diff --git a/modules/mysql/example/dev/kcl.mod b/modules/mysql/example/dev/kcl.mod new file mode 100644 index 0000000..a74d7a7 --- /dev/null +++ b/modules/mysql/example/dev/kcl.mod @@ -0,0 +1,11 @@ +[package] +name = "example" + +[dependencies] +kam = { git = "https://github.com/KusionStack/kam.git", tag = "0.1.0" } +network = { oci = "oci://ghcr.io/kusionstack/network", tag = "0.1.0" } +mysql = { oci = "oci://ghcr.io/kusionstack/mysql", tag = "0.1.0" } + +[profile] +entries = ["main.k"] + diff --git a/modules/mysql/example/dev/main.k b/modules/mysql/example/dev/main.k new file mode 100644 index 0000000..fc14d2e --- /dev/null +++ b/modules/mysql/example/dev/main.k @@ -0,0 +1,34 @@ +# The configuration codes in perspective of developers. +import kam.v1.app_configuration as ac +import kam.v1.workload as wl +import kam.v1.workload.container as c +import network as n +import mysql.mysql + +quickstart: ac.AppConfiguration { + workload: wl.Service { + containers: { + quickstart: c.Container { + image: "kusionstack/kusion-quickstart:latest" + env: { + "DB_HOST": "$(KUSION_DB_HOST_TEST_DATABASE)" + "DB_USERNAME": "$(KUSION_DB_USERNAME_TEST_DATABASE)" + "DB_PASSWORD": "$(KUSION_DB_PASSWORD_TEST_DATABASE)" + } + } + } + } + accessories: { + "network": n.Network { + ports: [ + n.Port { + port: 8080 + } + ] + } + "mysql": mysql.MySQL { + type: "local" + version: "8.0" + } + } +} diff --git a/modules/mysql/example/dev/stack.yaml b/modules/mysql/example/dev/stack.yaml new file mode 100644 index 0000000..19e96e3 --- /dev/null +++ b/modules/mysql/example/dev/stack.yaml @@ -0,0 +1 @@ +name: dev diff --git a/modules/mysql/example/project.yaml b/modules/mysql/example/project.yaml new file mode 100644 index 0000000..2551cb1 --- /dev/null +++ b/modules/mysql/example/project.yaml @@ -0,0 +1 @@ +name: example diff --git a/modules/mysql/kcl.mod b/modules/mysql/kcl.mod new file mode 100644 index 0000000..f031675 --- /dev/null +++ b/modules/mysql/kcl.mod @@ -0,0 +1,3 @@ +[package] +name = "mysql" +version = "0.1.0" diff --git a/modules/mysql/mysql.k b/modules/mysql/mysql.k index cb0ee38..9c49359 100644 --- a/modules/mysql/mysql.k +++ b/modules/mysql/mysql.k @@ -16,9 +16,11 @@ schema MySQL: import catalog.models.schema.v1.accessories.mysql - mysql: mysql.MySQL { - type: "local" - version: "5.7" + accessories: { + "mysql": mysql.MySQL { + type: "local" + version: "8.0" + } } """ diff --git a/modules/mysql/src/Makefile b/modules/mysql/src/Makefile new file mode 100644 index 0000000..5a0c8f6 --- /dev/null +++ b/modules/mysql/src/Makefile @@ -0,0 +1,36 @@ +TEST?=$$(go list ./... | grep -v 'vendor') +###### chang variables below according to your own modules ### +NAMESPACE=kusionstack +NAME=mysql +VERSION=0.1.0 +BINARY=../bin/kusion-module-${NAME}_${VERSION} + +LOCAL_ARCH := $(shell uname -m) +ifeq ($(LOCAL_ARCH),x86_64) +GOARCH_LOCAL := amd64 +else +GOARCH_LOCAL := $(LOCAL_ARCH) +endif +export GOOS_LOCAL := $(shell uname|tr 'A-Z' 'a-z') +export OS_ARCH ?= $(GOARCH_LOCAL) + +default: install + +build-darwin: + GOOS=darwin GOARCH=arm64 go build -o ${BINARY} ./${NAME} + +install: build-darwin +# copy module binary to $KUSION_HOME. e.g. ~/.kusion/modules/kusionstack/mysql/v0.1.0/darwin/arm64/kusion-module-mysql_0.1.0 + mkdir -p ${KUSION_HOME}/modules/${NAMESPACE}/${NAME}/${VERSION}/${GOOS_LOCAL}/${OS_ARCH} + cp ${BINARY} ${KUSION_HOME}/modules/${NAMESPACE}/${NAME}/${VERSION}/${GOOS_LOCAL}/${OS_ARCH} + +release: + GOOS=darwin GOARCH=arm64 go build -o ${BINARY}_darwin_arm64 ./${NAME} + GOOS=darwin GOARCH=amd64 go build -o ${BINARY}_darwin_amd64 ./${NAME} + GOOS=linux GOARCH=arm64 go build -o ${BINARY}_linux_arm64 ./${NAME} + GOOS=linux GOARCH=amd64 go build -o ${BINARY}_linux_amd64 ./${NAME} + GOOS=windows GOARCH=amd64 go build -o ${BINARY}_windows_amd64 ./${NAME} + GOOS=windows GOARCH=386 go build -o ${BINARY}_windows_386 ./${NAME} + +test: + TF_ACC=1 go test $(TEST) -v $(TESTARGS) -timeout 5m diff --git a/modules/mysql/src/alicloud_rds.go b/modules/mysql/src/alicloud_rds.go new file mode 100644 index 0000000..9c5f436 --- /dev/null +++ b/modules/mysql/src/alicloud_rds.go @@ -0,0 +1,217 @@ +package main + +import ( + "errors" + "os" + "strings" + + "kusionstack.io/kusion-module-framework/pkg/module" + apiv1 "kusionstack.io/kusion/pkg/apis/core/v1" + "kusionstack.io/kusion/pkg/modules" +) + +var ErrEmptyAlicloudProviderRegion = errors.New("empty alicloud provider region") + +var ( + alicloudRegionEnv = "ALICLOUD_REGION" + alicloudDBInstance = "alicloud_db_instance" + alicloudDBConnection = "alicloud_db_connection" + alicloudRDSAccount = "alicloud_rds_account" +) + +var defaultAlicloudProviderCfg = apiv1.ProviderConfig{ + Source: "aliyun/alicloud", + Version: "1.209.1", +} + +type alicloudServerlessConfig struct { + AutoPause bool `yaml:"auto_pause" json:"auto_pause"` + SwitchForce bool `yaml:"switch_force" json:"switch_force"` + MaxCapacity int `yaml:"max_capacity,omitempty" json:"max_capacity,omitempty"` + MinCapacity int `yaml:"min_capacity,omitempty" json:"min_capacity,omitempty"` +} + +// GenerateAlicloudResources generates Alicloud provided MySQL database instance. +func (mysql *MySQL) GenerateAlicloudResources(request *module.GeneratorRequest) ([]apiv1.Resource, []apiv1.Patcher, error) { + var resources []apiv1.Resource + var patchers []apiv1.Patcher + + // Set the Alicloud provider with the default provider config. + alicloudProviderCfg := defaultAlicloudProviderCfg + + // Get the Alicloud Terraform provider region, which should not be empty. + var region string + if region = module.TerraformProviderRegion(alicloudProviderCfg); region == "" { + region = os.Getenv(alicloudRegionEnv) + } + if region == "" { + return nil, nil, ErrEmptyAlicloudProviderRegion + } + + // Build random_password resource. + randomPasswordRes, randomPasswordID, err := mysql.GenerateTFRandomPassword(request) + if err != nil { + return nil, nil, err + } + resources = append(resources, *randomPasswordRes) + + // Build alicloud_db_instance resource. + alicloudDBInstanceRes, alicloudDBInstanceID, err := mysql.generateAlicloudDBInstance( + alicloudProviderCfg, region, + ) + if err != nil { + return nil, nil, err + } + resources = append(resources, *alicloudDBInstanceRes) + + // Build alicloud_db_connection resource. + var alicloudDBConnectionRes *apiv1.Resource + var alicloudDBConnectionID string + if IsPublicAccessible(mysql.SecurityIPs) { + alicloudDBConnectionRes, alicloudDBConnectionID, err = mysql.generateAlicloudDBConnection( + alicloudProviderCfg, + region, alicloudDBInstanceID, + ) + if err != nil { + return nil, nil, err + } + + resources = append(resources, *alicloudDBConnectionRes) + } + + // Build alicloud_rds_account resuorce. + alicloudRDSAccountRes, err := mysql.generateAlicloudRDSAccount( + alicloudProviderCfg, + region, mysql.Username, randomPasswordID, alicloudDBInstanceID, + ) + if err != nil { + return nil, nil, err + } + resources = append(resources, *alicloudRDSAccountRes) + + hostAddress := modules.KusionPathDependency(alicloudDBInstanceID, "connection_string") + if !mysql.PrivateRouting { + // Set the public network connection string as the host address. + hostAddress = modules.KusionPathDependency(alicloudDBConnectionID, "connection_string") + } + password := modules.KusionPathDependency(randomPasswordID, "result") + + // Build Kubernetes Secret with the hostAddress, username and password of the Alicloud provided MySQL instance, + // and inject the credentials as the environment variable patcher. + dbSecret, pathcer, err := mysql.GenerateDBSecret(request, hostAddress, mysql.Username, password) + if err != nil { + return nil, nil, err + } + resources = append(resources, *dbSecret) + patchers = append(patchers, *pathcer) + + return resources, patchers, nil +} + +// generateAlicloudDBInstance generates alicloud_db_instance resource +// for the Alicloud provided MySQL database instance. +func (mysql *MySQL) generateAlicloudDBInstance(alicloudProviderCfg apiv1.ProviderConfig, + region string, +) (*apiv1.Resource, string, error) { + resAttrs := map[string]interface{}{ + "category": mysql.Category, + "engine": "MySQL", + "engine_version": mysql.Version, + "instance_storage": mysql.Size, + "instance_type": mysql.InstanceType, + "security_ips": mysql.SecurityIPs, + "vswitch_id": mysql.SubnetID, + "instance_name": mysql.DatabaseName, + } + + // Set the serverless-specific attributes of the alicloud_db_instance resource. + if strings.Contains(mysql.Category, "serverless") { + resAttrs["db_instance_storage_type"] = "cloud_essd" + resAttrs["instance_charge_type"] = "Serverless" + + serverlessConfig := alicloudServerlessConfig{ + MaxCapacity: 8, + MinCapacity: 1, + } + serverlessConfig.AutoPause = false + serverlessConfig.SwitchForce = false + + resAttrs["serverless_config"] = []alicloudServerlessConfig{ + serverlessConfig, + } + } + + id, err := module.TerraformResourceID(alicloudProviderCfg, alicloudDBInstance, mysql.DatabaseName) + if err != nil { + return nil, "", err + } + + resExts, err := module.TerraformProviderExtensions(alicloudProviderCfg, map[string]any{"region": region}, alicloudDBInstance) + if err != nil { + return nil, "", err + } + + resource, err := module.WrapTFResourceToKusionResource(id, resAttrs, resExts, nil) + if err != nil { + return nil, "", err + } + + return resource, id, nil +} + +// generateAlicloudDBConnection generates alicloud_db_connection resource +// for the Alicloud provided MySQL database instance. +func (mysql *MySQL) generateAlicloudDBConnection(alicloudProviderCfg apiv1.ProviderConfig, + region, dbInstanceID string, +) (*apiv1.Resource, string, error) { + resAttrs := map[string]interface{}{ + "instance_id": modules.KusionPathDependency(dbInstanceID, "id"), + } + + id, err := module.TerraformResourceID(alicloudProviderCfg, alicloudDBConnection, mysql.DatabaseName) + if err != nil { + return nil, "", err + } + + resExts, err := module.TerraformProviderExtensions(alicloudProviderCfg, map[string]any{"region": region}, alicloudDBConnection) + if err != nil { + return nil, "", err + } + + resource, err := module.WrapTFResourceToKusionResource(id, resAttrs, resExts, nil) + if err != nil { + return nil, "", err + } + + return resource, id, nil +} + +// generateAlicloudRDSAccount generates alicloud_rds_account resource +// for the Alicloud provided MySQL database instance. +func (mysql *MySQL) generateAlicloudRDSAccount(alicloudProviderCfg apiv1.ProviderConfig, + region, accountName, randomPasswordID, dbInstanceID string, +) (*apiv1.Resource, error) { + resAttrs := map[string]interface{}{ + "account_name": accountName, + "account_password": modules.KusionPathDependency(randomPasswordID, "result"), + "account_type": "Super", + "db_instance_id": modules.KusionPathDependency(dbInstanceID, "id"), + } + + id, err := module.TerraformResourceID(alicloudProviderCfg, alicloudRDSAccount, mysql.DatabaseName) + if err != nil { + return nil, err + } + + resExts, err := module.TerraformProviderExtensions(alicloudProviderCfg, map[string]any{"region": region}, alicloudRDSAccount) + if err != nil { + return nil, err + } + + resource, err := module.WrapTFResourceToKusionResource(id, resAttrs, resExts, nil) + if err != nil { + return nil, err + } + + return resource, nil +} diff --git a/modules/mysql/src/alicloud_rds_test.go b/modules/mysql/src/alicloud_rds_test.go new file mode 100644 index 0000000..45e0b10 --- /dev/null +++ b/modules/mysql/src/alicloud_rds_test.go @@ -0,0 +1,111 @@ +package main + +import ( + "os" + "testing" + + "github.com/bytedance/mockey" + "github.com/stretchr/testify/assert" + "kusionstack.io/kusion-module-framework/pkg/module" + "kusionstack.io/kusion/pkg/apis/core/v1/workload" +) + +func TestMySQLModule_GenerateAlicloudResources(t *testing.T) { + r := &module.GeneratorRequest{ + Project: "test-project", + Stack: "test-stack", + App: "test-app", + Workload: &workload.Workload{ + Header: workload.Header{ + Type: "Service", + }, + Service: &workload.Service{}, + }, + } + + mysql := &MySQL{ + Type: "local", + Version: "8.0", + DatabaseName: "test-database", + Username: defaultUsername, + SecurityIPs: defaultSecurityIPs, + PrivateRouting: false, + Size: defaultSize, + InstanceType: "mysql.n2.serverless.1c", + Category: "serverless_basic", + SubnetID: "test-subnet-id", + } + + mockey.PatchConvey("set alicloud region env", t, func() { + mockey.Mock(os.Getenv).Return("test-region").Build() + + resources, patchers, err := mysql.GenerateAlicloudResources(r) + + assert.Equal(t, 5, len(resources)) + assert.NotNil(t, patchers) + assert.NoError(t, err) + }) +} + +func TestMySQLModule_GenerateAlicloudDBInstance(t *testing.T) { + mysql := &MySQL{ + Type: "local", + Version: "8.0", + DatabaseName: "test-database", + Username: defaultUsername, + SecurityIPs: defaultSecurityIPs, + PrivateRouting: false, + Size: defaultSize, + InstanceType: "mysql.n2.serverless.1c", + Category: "serverless_basic", + SubnetID: "test-subnet-id", + } + + res, id, err := mysql.generateAlicloudDBInstance(defaultAlicloudProviderCfg, "test-region") + + assert.NotNil(t, res) + assert.NotEqual(t, id, "") + assert.NoError(t, err) +} + +func TestMySQLModule_GenerateAlicloudDBConnection(t *testing.T) { + mysql := &MySQL{ + Type: "local", + Version: "8.0", + DatabaseName: "test-database", + Username: defaultUsername, + SecurityIPs: defaultSecurityIPs, + PrivateRouting: false, + Size: defaultSize, + InstanceType: "mysql.n2.serverless.1c", + Category: "serverless_basic", + SubnetID: "test-subnet-id", + } + + res, id, err := mysql.generateAlicloudDBConnection(defaultAlicloudProviderCfg, "test-region", "db_instance_id") + + assert.NotNil(t, res) + assert.NotEqual(t, id, "") + assert.NoError(t, err) +} + +func TestMySQLModule_GenerateAlicloudRDSAccount(t *testing.T) { + mysql := &MySQL{ + Type: "local", + Version: "8.0", + DatabaseName: "test-database", + Username: defaultUsername, + SecurityIPs: defaultSecurityIPs, + PrivateRouting: false, + Size: defaultSize, + InstanceType: "mysql.n2.serverless.1c", + Category: "serverless_basic", + SubnetID: "test-subnet-id", + } + + res, err := mysql.generateAlicloudRDSAccount(defaultAlicloudProviderCfg, "test-region", + "account_name", "random_password_id", "db_instance_id") + + assert.NotNil(t, res) + assert.NoError(t, err) +} diff --git a/modules/mysql/src/aws_rds.go b/modules/mysql/src/aws_rds.go new file mode 100644 index 0000000..9fc52f8 --- /dev/null +++ b/modules/mysql/src/aws_rds.go @@ -0,0 +1,175 @@ +package main + +import ( + "errors" + "fmt" + "os" + + "kusionstack.io/kusion-module-framework/pkg/module" + apiv1 "kusionstack.io/kusion/pkg/apis/core/v1" + "kusionstack.io/kusion/pkg/modules" +) + +var ErrEmptyAWSProviderRegion = errors.New("empty aws provider region") + +var ( + awsRegionEnv = "AWS_REGION" + awsSecurityGroup = "aws_security_group" + awsDBInstance = "aws_db_instance" +) + +var defaultAWSProviderCfg = apiv1.ProviderConfig{ + Source: "hashicorp/aws", + Version: "5.0.1", +} + +type awsSecurityGroupTraffic struct { + CidrBlocks []string `yaml:"cidr_blocks" json:"cidr_blocks"` + Description string `yaml:"description" json:"description"` + FromPort int `yaml:"from_port" json:"from_port"` + IPv6CIDRBlocks []string `yaml:"ipv6_cidr_blocks" json:"ipv6_cidr_blocks"` + PrefixListIDs []string `yaml:"prefix_list_ids" json:"prefix_list_ids"` + Protocol string `yaml:"protocol" json:"protocol"` + SecurityGroups []string `yaml:"security_groups" json:"security_groups"` + Self bool `yaml:"self" json:"self"` + ToPort int `yaml:"to_port" json:"to_port"` +} + +// GenerateAWSResources generates the AWS provided MySQL database instance. +func (mysql *MySQL) GenerateAWSResources(request *module.GeneratorRequest) ([]apiv1.Resource, []apiv1.Patcher, error) { + var resources []apiv1.Resource + var pathcers []apiv1.Patcher + + // Set the AWS provider with the default provider config. + awsProviderCfg := defaultAWSProviderCfg + + // Get the AWS Terraform provider region, which should not be empty. + var region string + if region = module.TerraformProviderRegion(awsProviderCfg); region == "" { + region = os.Getenv(awsRegionEnv) + } + if region == "" { + return nil, nil, ErrEmptyAWSProviderRegion + } + + // Build random_password resource. + randomPasswordRes, randomPasswordID, err := mysql.GenerateTFRandomPassword(request) + if err != nil { + return nil, nil, err + } + resources = append(resources, *randomPasswordRes) + + // Build aws_security_group resource. + awsSecurityGroupRes, awsSecurityGroupID, err := mysql.generateAWSSecurityGroup(awsProviderCfg, region) + if err != nil { + return nil, nil, err + } + resources = append(resources, *awsSecurityGroupRes) + + // Build aws_db_instance resource. + awsDBInstance, awsDBInstanceID, err := mysql.generateAWSDBInstance(awsProviderCfg, region, randomPasswordID, awsSecurityGroupID) + if err != nil { + return nil, nil, err + } + resources = append(resources, *awsDBInstance) + + hostAddress := modules.KusionPathDependency(awsDBInstanceID, "address") + password := modules.KusionPathDependency(randomPasswordID, "result") + + // Build Kubernetes Secret with the hostAddress, username and password of the AWS provided MySQL instance, + // and inject the credentials as the environment variable patcher. + dbSecret, patcher, err := mysql.GenerateDBSecret(request, hostAddress, mysql.Username, password) + if err != nil { + return nil, nil, err + } + resources = append(resources, *dbSecret) + pathcers = append(pathcers, *patcher) + + return resources, pathcers, nil +} + +// generateAWSSecurityGroup generates aws_security_group resource for the AWS provided MySQL database instance. +func (mysql *MySQL) generateAWSSecurityGroup(awsProviderCfg apiv1.ProviderConfig, region string) (*apiv1.Resource, string, error) { + // SecurityIPs should be in the format of IP address or Classes Inter-Domain + // Routing (CIDR) mode. + for _, ip := range mysql.SecurityIPs { + if !IsIPAddress(ip) && !IsCIDR(ip) { + return nil, "", fmt.Errorf("illegal security ip format: %s", ip) + } + } + + resAttrs := map[string]interface{}{ + "egress": []awsSecurityGroupTraffic{ + { + CidrBlocks: []string{"0.0.0.0/0"}, + Protocol: "-1", + FromPort: 0, + ToPort: 0, + }, + }, + "ingress": []awsSecurityGroupTraffic{ + { + CidrBlocks: mysql.SecurityIPs, + Protocol: "tcp", + FromPort: 3306, + ToPort: 3306, + }, + }, + } + + id, err := module.TerraformResourceID(awsProviderCfg, awsSecurityGroup, mysql.DatabaseName+dbResSuffix) + if err != nil { + return nil, "", err + } + + resExts, err := module.TerraformProviderExtensions(awsProviderCfg, map[string]any{"region": region}, awsSecurityGroup) + if err != nil { + return nil, "", err + } + + resource, err := module.WrapTFResourceToKusionResource(id, resAttrs, resExts, nil) + if err != nil { + return nil, "", err + } + + return resource, id, nil +} + +// generateAWSDBInstance generates aws_db_instance resource for the AWS provided MySQL database instance. +func (mysql *MySQL) generateAWSDBInstance(awsProviderCfg apiv1.ProviderConfig, region, randomPasswordID, awsSecurityGroupID string) (*apiv1.Resource, string, error) { + resAttrs := map[string]interface{}{ + "allocated_storage": mysql.Size, + "engine": dbEngine, + "engine_version": mysql.Version, + "identifier": mysql.DatabaseName, + "instance_class": mysql.InstanceType, + "password": modules.KusionPathDependency(randomPasswordID, "result"), + "publicly_accessible": IsPublicAccessible(mysql.SecurityIPs), + "skip_final_snapshot": true, + "username": mysql.Username, + "vpc_security_group_ids": []string{ + modules.KusionPathDependency(awsSecurityGroupID, "id"), + }, + } + + if mysql.SubnetID != "" { + resAttrs["db_subnet_group_name"] = mysql.SubnetID + } + + id, err := module.TerraformResourceID(awsProviderCfg, awsDBInstance, mysql.DatabaseName) + if err != nil { + return nil, "", err + } + + resExts, err := module.TerraformProviderExtensions(awsProviderCfg, map[string]any{"region": region}, awsDBInstance) + if err != nil { + return nil, "", err + } + + resource, err := module.WrapTFResourceToKusionResource(id, resAttrs, resExts, nil) + if err != nil { + return nil, "", err + } + + return resource, id, nil +} diff --git a/modules/mysql/src/aws_rds_test.go b/modules/mysql/src/aws_rds_test.go new file mode 100644 index 0000000..5621f18 --- /dev/null +++ b/modules/mysql/src/aws_rds_test.go @@ -0,0 +1,88 @@ +package main + +import ( + "os" + "testing" + + "github.com/bytedance/mockey" + "github.com/stretchr/testify/assert" + "kusionstack.io/kusion-module-framework/pkg/module" + "kusionstack.io/kusion/pkg/apis/core/v1/workload" +) + +func TestMySQLModule_GenerateAWSResources(t *testing.T) { + r := &module.GeneratorRequest{ + Project: "test-project", + Stack: "test-stack", + App: "test-app", + Workload: &workload.Workload{ + Header: workload.Header{ + Type: "Service", + }, + Service: &workload.Service{}, + }, + } + + mysql := &MySQL{ + Type: "local", + Version: "8.0", + DatabaseName: "test-database", + Username: defaultUsername, + Category: defaultCategory, + SecurityIPs: defaultSecurityIPs, + PrivateRouting: false, + Size: defaultSize, + InstanceType: "db.t3.micro", + } + + mockey.PatchConvey("set aws region env", t, func() { + mockey.Mock(os.Getenv).Return("test-region").Build() + + resources, patchers, err := mysql.GenerateAWSResources(r) + + assert.Equal(t, 4, len(resources)) + assert.NotNil(t, patchers) + assert.NoError(t, err) + }) +} + +func TestMySQLModule_GenerateAWSSecurityGroup(t *testing.T) { + mysql := &MySQL{ + Type: "local", + Version: "8.0", + DatabaseName: "test-database", + Username: defaultUsername, + Category: defaultCategory, + SecurityIPs: defaultSecurityIPs, + PrivateRouting: false, + Size: defaultSize, + InstanceType: "db.t3.micro", + } + + res, id, err := mysql.generateAWSSecurityGroup(defaultAWSProviderCfg, "test-region") + + assert.NotNil(t, res) + assert.NotEqual(t, id, "") + assert.NoError(t, err) +} + +func TestMySQLModule_GenerateAWSDBInstance(t *testing.T) { + mysql := &MySQL{ + Type: "local", + Version: "8.0", + DatabaseName: "test-database", + Username: defaultUsername, + Category: defaultCategory, + SecurityIPs: defaultSecurityIPs, + PrivateRouting: false, + Size: defaultSize, + InstanceType: "db.t3.micro", + } + + res, id, err := mysql.generateAWSDBInstance(defaultAWSProviderCfg, "test-region", + "random_password_id", "aws_security_group_id") + + assert.NotNil(t, res) + assert.NotEqual(t, id, "") + assert.NoError(t, err) +} diff --git a/modules/mysql/src/go.mod b/modules/mysql/src/go.mod new file mode 100644 index 0000000..fbf26d2 --- /dev/null +++ b/modules/mysql/src/go.mod @@ -0,0 +1,63 @@ +module mysql + +go 1.22.1 + +require ( + github.com/bytedance/mockey v1.2.10 + github.com/stretchr/testify v1.9.0 + k8s.io/api v0.29.3 + k8s.io/apimachinery v0.29.3 + kusionstack.io/kusion v0.10.1-0.20240326060146-3f01e9416ff6 + kusionstack.io/kusion-module-framework v0.0.0-20240326063408-807a5a4e4682 +) + +require ( + github.com/blang/semver/v4 v4.0.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/fatih/color v1.13.0 // indirect + github.com/go-logr/logr v1.3.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/go-github/v50 v50.0.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/google/gofuzz v1.2.0 // indirect + github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 // indirect + github.com/hashicorp/go-hclog v0.16.2 // indirect + github.com/hashicorp/go-plugin v1.6.0 // indirect + github.com/hashicorp/go-version v1.6.0 // indirect + github.com/hashicorp/yamux v0.1.1 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/jtolds/gls v4.20.0+incompatible // indirect + github.com/mattn/go-colorable v0.1.12 // indirect + github.com/mattn/go-isatty v0.0.14 // indirect + github.com/mitchellh/go-testing-interface v1.14.1 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/oklog/run v1.0.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d // indirect + github.com/smartystreets/goconvey v1.6.4 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.6.0 // indirect + go.uber.org/zap v1.24.0 // indirect + golang.org/x/arch v0.1.0 // indirect + golang.org/x/crypto v0.18.0 // indirect + golang.org/x/net v0.20.0 // indirect + golang.org/x/oauth2 v0.16.0 // indirect + golang.org/x/sys v0.16.0 // indirect + golang.org/x/text v0.14.0 // indirect + google.golang.org/appengine v1.6.8 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240123012728-ef4313101c80 // indirect + google.golang.org/grpc v1.62.0 // indirect + google.golang.org/protobuf v1.33.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/klog/v2 v2.110.1 // indirect + k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect + kcl-lang.io/kcl-plugin v0.5.0 // indirect + sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect +) diff --git a/modules/mysql/src/go.sum b/modules/mysql/src/go.sum new file mode 100644 index 0000000..a83fbf4 --- /dev/null +++ b/modules/mysql/src/go.sum @@ -0,0 +1,206 @@ +github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= +github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA= +github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8= +github.com/bytedance/mockey v1.2.10 h1:4JlMpkm7HMXmTUtItid+iCu2tm61wvq+ca1X2u7ymzE= +github.com/bytedance/mockey v1.2.10/go.mod h1:bNrUnI1u7+pAc0TYDgPATM+wF2yzHxmNH+iDXg4AOCU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY= +github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-github/v50 v50.0.0 h1:gdO1AeuSZZK4iYWwVbjni7zg8PIQhp7QfmPunr016Jk= +github.com/google/go-github/v50 v50.0.0/go.mod h1:Ev4Tre8QoKiolvbpOSG3FIi4Mlon3S2Nt9W5JYqKiwA= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/hashicorp/go-hclog v0.16.2 h1:K4ev2ib4LdQETX5cSZBG0DVLk1jwGqSPXBjdah3veNs= +github.com/hashicorp/go-hclog v0.16.2/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= +github.com/hashicorp/go-plugin v1.6.0 h1:wgd4KxHJTVGGqWBq4QPB1i5BZNEx9BR8+OFmHDmTk8A= +github.com/hashicorp/go-plugin v1.6.0/go.mod h1:lBS5MtSSBZk0SHc66KACcjjlU6WzEVP/8pwz68aMkCI= +github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= +github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= +github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= +github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c= +github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= +github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw= +github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A= +go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4= +go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60= +go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= +golang.org/x/arch v0.1.0 h1:oMxhUYsO9VsR1dcoVUjJjIGhx1LXol3989T/yZ59Xsw= +golang.org/x/arch v0.1.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/oauth2 v0.16.0 h1:aDkGMBSYxElaoP81NpoUoz2oo2R2wHdZpGToUxfyQrQ= +golang.org/x/oauth2 v0.16.0/go.mod h1:hqZ+0LWXsiVoZpeld6jVt06P3adbS2Uu911W1SsJv2o= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= +google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240123012728-ef4313101c80 h1:AjyfHzEPEFp/NpvfN5g+KDla3EMojjhRVZc1i7cj+oM= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240123012728-ef4313101c80/go.mod h1:PAREbraiVEVGVdTZsVWjSbbTtSyGbAgIIvni8a8CD5s= +google.golang.org/grpc v1.62.0 h1:HQKZ/fa1bXkX1oFOvSjmZEUL8wLSaZTjCcLAlmZRtdk= +google.golang.org/grpc v1.62.0/go.mod h1:IWTG0VlJLCh1SkC58F7np9ka9mx/WNkjl4PGJaiq+QE= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/natefinch/lumberjack.v2 v2.0.0 h1:1Lc07Kr7qY4U2YPouBjpCLxpiyxIVoxqXgkXLknAOE8= +gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.29.3 h1:2ORfZ7+bGC3YJqGpV0KSDDEVf8hdGQ6A03/50vj8pmw= +k8s.io/api v0.29.3/go.mod h1:y2yg2NTyHUUkIoTC+phinTnEa3KFM6RZ3szxt014a80= +k8s.io/apimachinery v0.29.3 h1:2tbx+5L7RNvqJjn7RIuIKu9XTsIZ9Z5wX2G22XAa5EU= +k8s.io/apimachinery v0.29.3/go.mod h1:hx/S4V2PNW4OMg3WizRrHutyB5la0iCUbZym+W0EQIU= +k8s.io/klog/v2 v2.110.1 h1:U/Af64HJf7FcwMcXyKm2RPM22WZzyR7OSpYj5tg3cL0= +k8s.io/klog/v2 v2.110.1/go.mod h1:YGtd1984u+GgbuZ7e08/yBuAfKLSO0+uR1Fhi6ExXjo= +k8s.io/utils v0.0.0-20230726121419-3b25d923346b h1:sgn3ZU783SCgtaSJjpcVVlRqd6GSnlTLKgpAAttJvpI= +k8s.io/utils v0.0.0-20230726121419-3b25d923346b/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +kcl-lang.io/kcl-plugin v0.5.0 h1:eoh6y4l81rwA8yhJXU4hN7YmJeTUNB1nfYCP9OffSxc= +kcl-lang.io/kcl-plugin v0.5.0/go.mod h1:QnZ5OLcyBw5nOnHpChRHtvBq8wvjwiHu/ZZ8j1dfz48= +kusionstack.io/kusion v0.10.1-0.20240326060146-3f01e9416ff6 h1:66DJEK1NZyA7C/Geh9f2MCeZx54R5y6quHJXWpoxJX4= +kusionstack.io/kusion v0.10.1-0.20240326060146-3f01e9416ff6/go.mod h1:xI/6cDT0cZAaWFdKVnpV/U5TKpSQnLMnUJAEy/uRpL8= +kusionstack.io/kusion-module-framework v0.0.0-20240326063408-807a5a4e4682 h1:X8zSDNh5Sa6FsTbwgohdu6tQ9lDQ3lZs9mTnXvk9GYo= +kusionstack.io/kusion-module-framework v0.0.0-20240326063408-807a5a4e4682/go.mod h1:ynITUHw3Cke7aLhzQXXFvXgxQcLF/z4uFxn8O87K4mA= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= +sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo= +sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= diff --git a/modules/mysql/src/local_db.go b/modules/mysql/src/local_db.go new file mode 100644 index 0000000..9ca48dd --- /dev/null +++ b/modules/mysql/src/local_db.go @@ -0,0 +1,302 @@ +package main + +import ( + "crypto/md5" + "encoding/hex" + "strconv" + + "kusionstack.io/kusion-module-framework/pkg/module" + + appsv1 "k8s.io/api/apps/v1" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + apiv1 "kusionstack.io/kusion/pkg/apis/core/v1" +) + +// GenerateLocalResources generates the resources of locally deployed MySQL database instance. +func (mysql *MySQL) GenerateLocalResources(request *module.GeneratorRequest) ([]apiv1.Resource, []apiv1.Patcher, error) { + var resources []apiv1.Resource + var patchers []apiv1.Patcher + + // Build Kubernetes Secret for the random password of the local MySQL instance. + password := mysql.generateLocalPassword(request) + localSecret, err := mysql.generateLocalSecret(request, password) + if err != nil { + return nil, nil, err + } + resources = append(resources, *localSecret) + + // Build Kubernetes Deployment for the local MySQL instance. + localDeployment, err := mysql.generateLocalDeployment(request) + if err != nil { + return nil, nil, err + } + resources = append(resources, *localDeployment) + + // Build Kubernetes Persistent Volume Claim for the lcoal MySQL instance. + localPVC, err := mysql.generateLocalPVC(request) + if err != nil { + return nil, nil, err + } + resources = append(resources, *localPVC) + + // Build Kubernetes Service for the local MySQL instance. + localSvc, hostAddress, err := mysql.generateLocalService(request) + if err != nil { + return nil, nil, err + } + resources = append(resources, *localSvc) + + // Build Kubernetes Secret with the hostAddress, username and password of the local MySQL instance, + // and inject the credentials as the environment variable patcher. + dbSecret, patcher, err := mysql.GenerateDBSecret(request, hostAddress, mysql.Username, password) + if err != nil { + return nil, nil, err + } + resources = append(resources, *dbSecret) + patchers = append(patchers, *patcher) + + return resources, patchers, nil +} + +// generateLocalSecret generates the Kubernetes Secret resource for the local MySQL instance. +func (mysql *MySQL) generateLocalSecret(request *module.GeneratorRequest, password string) (*apiv1.Resource, error) { + // Set the password string. + data := make(map[string]string) + data["password"] = password + + // Construct the Kubernetes Secret resource. + secret := &v1.Secret{ + TypeMeta: metav1.TypeMeta{ + Kind: "Secret", + APIVersion: v1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: mysql.DatabaseName + localSecretSuffix, + Namespace: request.Project, + }, + StringData: data, + } + + resourceID := module.KubernetesResourceID(secret.TypeMeta, secret.ObjectMeta) + resource, err := module.WrapK8sResourceToKusionResource(resourceID, secret) + if err != nil { + return nil, err + } + + return resource, nil +} + +// generateLocalDeployment generates the Kubernetes Deployment resource for the local MySQL instance. +func (mysql *MySQL) generateLocalDeployment(request *module.GeneratorRequest) (*apiv1.Resource, error) { + // Prepare the Pod Spec for the local MySQL instance. + podSpec, err := mysql.generateLocalPodSpec(request) + if err != nil { + return nil, nil + } + + // Create the Kubernetes Deployment for the local MySQL instance. + deployment := &appsv1.Deployment{ + TypeMeta: metav1.TypeMeta{ + Kind: "Deployment", + APIVersion: appsv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: mysql.DatabaseName + localDeploymentSuffix, + Namespace: request.Project, + }, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: mysql.generateLocalMatchLabels(), + }, + Template: v1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: mysql.generateLocalMatchLabels(), + }, + Spec: podSpec, + }, + }, + } + + resourceID := module.KubernetesResourceID(deployment.TypeMeta, deployment.ObjectMeta) + resource, err := module.WrapK8sResourceToKusionResource(resourceID, deployment) + if err != nil { + return nil, err + } + + return resource, nil +} + +// generateLocalPodSpec generates the Kubernetes PodSpec for the local MySQL instance. +func (mysql *MySQL) generateLocalPodSpec(_ *module.GeneratorRequest) (v1.PodSpec, error) { + image := dbEngine + ":" + mysql.Version + secretName := mysql.DatabaseName + localSecretSuffix + ports := []v1.ContainerPort{ + { + Name: mysql.DatabaseName, + ContainerPort: int32(3306), + }, + } + volumes := []v1.Volume{ + { + Name: mysql.DatabaseName, + VolumeSource: v1.VolumeSource{ + PersistentVolumeClaim: &v1.PersistentVolumeClaimVolumeSource{ + ClaimName: mysql.DatabaseName + localPVCSuffix, + }, + }, + }, + } + volumeMounts := []v1.VolumeMount{ + { + Name: mysql.DatabaseName, + MountPath: "/var/lib/mysql", + }, + } + + var env []v1.EnvVar + if mysql.Username != "root" { + env = []v1.EnvVar{ + { + Name: "MYSQL_USER", + Value: mysql.Username, + }, + { + Name: "MYSQL_PASSWORD", + ValueFrom: &v1.EnvVarSource{ + SecretKeyRef: &v1.SecretKeySelector{ + LocalObjectReference: v1.LocalObjectReference{ + Name: secretName, + }, + Key: "password", + }, + }, + }, + } + } else { + env = []v1.EnvVar{ + { + Name: "MYSQL_ROOT_PASSWORD", + ValueFrom: &v1.EnvVarSource{ + SecretKeyRef: &v1.SecretKeySelector{ + LocalObjectReference: v1.LocalObjectReference{ + Name: secretName, + }, + Key: "password", + }, + }, + }, + } + } + + podSpec := v1.PodSpec{ + Containers: []v1.Container{ + { + Name: mysql.DatabaseName, + Image: image, + Env: env, + Ports: ports, + VolumeMounts: volumeMounts, + }, + }, + Volumes: volumes, + } + + return podSpec, nil +} + +// generateLocalPVC generates the Kubernetes Persistent Volume Claim resource for the local MySQL instance. +func (mysql *MySQL) generateLocalPVC(request *module.GeneratorRequest) (*apiv1.Resource, error) { + // Create the Kubernetes PVC with the storage size of `mysql.Size`. + pvc := &v1.PersistentVolumeClaim{ + TypeMeta: metav1.TypeMeta{ + Kind: "PersistentVolumeClaim", + APIVersion: v1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: mysql.DatabaseName + localPVCSuffix, + Namespace: request.Project, + Labels: mysql.generateLocalMatchLabels(), + }, + Spec: v1.PersistentVolumeClaimSpec{ + AccessModes: []v1.PersistentVolumeAccessMode{ + v1.ReadWriteOnce, + }, + Resources: v1.VolumeResourceRequirements{ + Requests: map[v1.ResourceName]resource.Quantity{ + v1.ResourceStorage: resource.MustParse(strconv.Itoa(mysql.Size) + "Gi"), + }, + }, + }, + } + + resourceID := module.KubernetesResourceID(pvc.TypeMeta, pvc.ObjectMeta) + resource, err := module.WrapK8sResourceToKusionResource(resourceID, pvc) + if err != nil { + return nil, err + } + + return resource, nil +} + +// generateLocalService generates the Kubernetes Service resource for the local MySQL instance. +func (mysql *MySQL) generateLocalService(request *module.GeneratorRequest) (*apiv1.Resource, string, error) { + // Prepare the service port for the local MySQL instance. + svcPort := mysql.generateLocalSvcPort() + svcName := mysql.DatabaseName + localServiceSuffix + + // Create the Kubernetes service for local MySQL instance. + service := &v1.Service{ + TypeMeta: metav1.TypeMeta{ + Kind: "Service", + APIVersion: v1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: svcName, + Namespace: request.Project, + Labels: mysql.generateLocalMatchLabels(), + }, + Spec: v1.ServiceSpec{ + ClusterIP: "None", + Ports: svcPort, + Selector: mysql.generateLocalMatchLabels(), + }, + } + + resourceID := module.KubernetesResourceID(service.TypeMeta, service.ObjectMeta) + resource, err := module.WrapK8sResourceToKusionResource(resourceID, service) + if err != nil { + return nil, "", err + } + + return resource, svcName, nil +} + +// generateLocalSvcPort generates the Kubernetes ServicePort resource of the local MySQL instance. +func (mysql *MySQL) generateLocalSvcPort() []v1.ServicePort { + svcPort := []v1.ServicePort{ + { + Port: int32(3306), + }, + } + + return svcPort +} + +// generateLocalMatchLabels generates the match labels for the Kubernetes resources of the local MySQL instance. +func (mysql *MySQL) generateLocalMatchLabels() map[string]string { + return map[string]string{ + "accessory": mysql.DatabaseName, + } +} + +// generateLocalPassword generates a fixed password string with the specified length for the local MySQL instance. +func (mysql *MySQL) generateLocalPassword(request *module.GeneratorRequest) string { + hashInput := request.Project + request.Stack + request.App + mysql.DatabaseName + hash := md5.Sum([]byte(hashInput)) + + hashString := hex.EncodeToString(hash[:]) + + return hashString[:16] +} diff --git a/modules/mysql/src/local_db_test.go b/modules/mysql/src/local_db_test.go new file mode 100644 index 0000000..a3761f5 --- /dev/null +++ b/modules/mysql/src/local_db_test.go @@ -0,0 +1,191 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "kusionstack.io/kusion-module-framework/pkg/module" + "kusionstack.io/kusion/pkg/apis/core/v1/workload" +) + +func TestMySQLModule_GenerateLocalResources(t *testing.T) { + r := &module.GeneratorRequest{ + Project: "test-project", + Stack: "test-stack", + App: "test-app", + Workload: &workload.Workload{ + Header: workload.Header{ + Type: "Service", + }, + Service: &workload.Service{}, + }, + } + + mysql := &MySQL{ + Type: "local", + Version: "8.0", + DatabaseName: "test-database", + Username: defaultUsername, + Category: defaultCategory, + SecurityIPs: defaultSecurityIPs, + PrivateRouting: defaultPrivateRouting, + Size: defaultSize, + } + + resources, patchers, err := mysql.GenerateLocalResources(r) + + assert.Equal(t, 5, len(resources)) + assert.NotNil(t, patchers) + assert.NoError(t, err) +} + +func TestMySQLModule_GenerateLocalSecret(t *testing.T) { + r := &module.GeneratorRequest{ + Project: "test-project", + Stack: "test-stack", + App: "test-app", + Workload: &workload.Workload{ + Header: workload.Header{ + Type: "Service", + }, + Service: &workload.Service{}, + }, + } + + mysql := &MySQL{ + Type: "local", + Version: "8.0", + DatabaseName: "test-database", + Username: defaultUsername, + Category: defaultCategory, + SecurityIPs: defaultSecurityIPs, + PrivateRouting: defaultPrivateRouting, + Size: defaultSize, + } + + res, err := mysql.generateLocalSecret(r, "123456") + + assert.NotNil(t, res) + assert.NoError(t, err) +} + +func TestMySQLModule_GenerateLocalDeployment(t *testing.T) { + r := &module.GeneratorRequest{ + Project: "test-project", + Stack: "test-stack", + App: "test-app", + Workload: &workload.Workload{ + Header: workload.Header{ + Type: "Service", + }, + Service: &workload.Service{}, + }, + } + + mysql := &MySQL{ + Type: "local", + Version: "8.0", + DatabaseName: "test-database", + Username: defaultUsername, + Category: defaultCategory, + SecurityIPs: defaultSecurityIPs, + PrivateRouting: defaultPrivateRouting, + Size: defaultSize, + } + + res, err := mysql.generateLocalDeployment(r) + + assert.NotNil(t, res) + assert.NoError(t, err) +} + +func TestMySQLModule_GenerateLocalPodSpec(t *testing.T) { + r := &module.GeneratorRequest{ + Project: "test-project", + Stack: "test-stack", + App: "test-app", + Workload: &workload.Workload{ + Header: workload.Header{ + Type: "Service", + }, + Service: &workload.Service{}, + }, + } + + mysql := &MySQL{ + Type: "local", + Version: "8.0", + DatabaseName: "test-database", + Username: defaultUsername, + Category: defaultCategory, + SecurityIPs: defaultSecurityIPs, + PrivateRouting: defaultPrivateRouting, + Size: defaultSize, + } + + res, err := mysql.generateLocalPodSpec(r) + + assert.NotNil(t, res) + assert.NoError(t, err) +} + +func TestMySQLModule_GenerateLocalPVC(t *testing.T) { + r := &module.GeneratorRequest{ + Project: "test-project", + Stack: "test-stack", + App: "test-app", + Workload: &workload.Workload{ + Header: workload.Header{ + Type: "Service", + }, + Service: &workload.Service{}, + }, + } + + mysql := &MySQL{ + Type: "local", + Version: "8.0", + DatabaseName: "test-database", + Username: defaultUsername, + Category: defaultCategory, + SecurityIPs: defaultSecurityIPs, + PrivateRouting: defaultPrivateRouting, + Size: defaultSize, + } + + res, err := mysql.generateLocalPVC(r) + + assert.NotNil(t, res) + assert.NoError(t, err) +} + +func TestMySQLModule_GenerateLocalService(t *testing.T) { + r := &module.GeneratorRequest{ + Project: "test-project", + Stack: "test-stack", + App: "test-app", + Workload: &workload.Workload{ + Header: workload.Header{ + Type: "Service", + }, + Service: &workload.Service{}, + }, + } + + mysql := &MySQL{ + Type: "local", + Version: "8.0", + DatabaseName: "test-database", + Username: defaultUsername, + Category: defaultCategory, + SecurityIPs: defaultSecurityIPs, + PrivateRouting: defaultPrivateRouting, + Size: defaultSize, + } + + res, svcName, err := mysql.generateLocalService(r) + + assert.NotNil(t, res) + assert.NotNil(t, svcName) + assert.NoError(t, err) +} diff --git a/modules/mysql/src/mysql.go b/modules/mysql/src/mysql.go new file mode 100644 index 0000000..1e82ef6 --- /dev/null +++ b/modules/mysql/src/mysql.go @@ -0,0 +1,385 @@ +package main + +import ( + "context" + "errors" + "fmt" + "net" + "strings" + + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "kusionstack.io/kusion-module-framework/pkg/module" + "kusionstack.io/kusion-module-framework/pkg/server" + apiv1 "kusionstack.io/kusion/pkg/apis/core/v1" + "kusionstack.io/kusion/pkg/log" + "kusionstack.io/kusion/pkg/workspace" +) + +const ( + CloudDBType = "cloud" + LocalDBType = "local" +) + +const ( + dbEngine = "mysql" + dbResSuffix = "-mysql" + dbHostAddressEnv = "KUSION_DB_HOST" + dbUsernameEnv = "KUSION_DB_USERNAME" + dbPasswordEnv = "KUSION_DB_PASSWORD" +) + +var ( + ErrEmptyInstanceTypeForCloudDB = errors.New("empty instance type for cloud managed mysql instance") + ErrEmptyCloudProviderType = errors.New("empty cloud provider type in mysql module config") +) + +var ( + localDeploymentSuffix = "-db-local-deployment" + localSecretSuffix = "-db-local-secret" + localPVCSuffix = "-db-local-pvc" + localServiceSuffix = "-db-local-service" +) + +var ( + defaultUsername string = "root" + defaultCategory string = "Basic" + defaultSecurityIPs []string = []string{"0.0.0.0/0"} + defaultPrivateRouting bool = true + defaultSize int = 10 +) + +var defaultRandomProviderCfg = apiv1.ProviderConfig{ + Source: "hashicorp/random", + Version: "3.6.0", +} + +var randomPassword = "random_password" + +// MySQL describes the attributes to locally deploy or create a cloud provider +// managed MySQL database instance for the workload. +type MySQL struct { + // The deployment mode of the MySQL database. + Type string `json:"type,omitempty" yaml:"type,omitempty"` + // The MySQL database version to use. + Version string `json:"version,omitempty" yaml:"version,omitempty"` + // The type of the MySQL instance. + InstanceType string `json:"instanceType,omitempty" yaml:"instanceType,omitempty"` + // The allocated storage size of the MySQL instance. + Size int `json:"size,omitempty" yaml:"size,omitempty"` + // The edition of the MySQL instance provided by the cloud vendor. + Category string `json:"category,omitempty" yaml:"category,omitempty"` + // The operation account for the MySQL database. + Username string `json:"username,omitempty" yaml:"username,omitempty"` + // The list of IP addresses allowed to access the MySQL instance provided by the cloud vendor. + SecurityIPs []string `json:"securityIPs,omitempty" yaml:"securityIPs,omitempty"` + // The virtual subnet ID associated with the VPC that the cloud MySQL instance will be created in. + SubnetID string `json:"subnetID,omitempty" yaml:"subnetID,omitempty"` + // Whether the host address of the cloud MySQL instance for the workload to connect with is via + // public network or private network of the cloud vendor. + PrivateRouting bool `json:"privateRouting,omitempty" yaml:"privateRouting,omitempty"` + // The specified name of the MySQL database instance. + DatabaseName string `json:"databaseName,omitempty" yaml:"databaseName,omitempty"` +} + +func (mysql *MySQL) Generate(_ context.Context, request *module.GeneratorRequest) (*module.GeneratorResponse, error) { + defer func() { + if r := recover(); r != nil { + log.Debugf("failed to generate mysql module: %v", r) + } + }() + + // MySQL does not exist in AppConfiguration configs. + if request.DevModuleConfig == nil { + log.Info("MySQL does not exist in AppConfig config") + + return nil, nil + } + + // Get the complete configs of the MySQL instance. + err := mysql.GetCompleteConfig(request.DevModuleConfig, request.PlatformModuleConfig) + if err != nil { + return nil, err + } + + // Set the database name. + if mysql.DatabaseName == "" { + mysql.DatabaseName = GenerateDefaultMySQLName(request.Project, request.Stack, request.App) + } + + // Generate the MySQL intance resources based on the type and the cloud provider config. + var resources []apiv1.Resource + var patchers []apiv1.Patcher + switch strings.ToLower(mysql.Type) { + case LocalDBType: + resources, patchers, err = mysql.GenerateLocalResources(request) + case CloudDBType: + providerType, err := GetCloudProviderType(request.PlatformModuleConfig) + if err != nil { + return nil, err + } + + switch strings.ToLower(providerType) { + case "aws": + resources, patchers, err = mysql.GenerateAWSResources(request) + case "alicloud": + resources, patchers, err = mysql.GenerateAlicloudResources(request) + default: + return nil, fmt.Errorf("unsupported cloud provider type: %s", providerType) + } + default: + return nil, fmt.Errorf("unsupported mysql type: %s", mysql.Type) + } + + if err != nil { + return nil, err + } + + return &module.GeneratorResponse{ + Resources: resources, + Patchers: patchers, + }, nil +} + +// GetCompleteConfig combines the configs in devModuleConfig and platformModuleConfig to form a complete +// configuration for the MySQL instance. +func (mysql *MySQL) GetCompleteConfig(devConfig apiv1.Accessory, platformConfig apiv1.GenericConfig) error { + // Set the default values for MySQL instance if platformConfig not exists. + if platformConfig == nil { + mysql.Username = defaultUsername + mysql.Category = defaultCategory + mysql.SecurityIPs = defaultSecurityIPs + mysql.PrivateRouting = defaultPrivateRouting + mysql.Size = defaultSize + } + + // Get the type and version of the MySQL instance in devConfig. + if mysqlType, ok := devConfig["type"]; ok { + mysql.Type = mysqlType.(string) + } + if mysqlVersion, ok := devConfig["version"]; ok { + mysql.Version = mysqlVersion.(string) + } + + // Get the other configs of the MySQL instance in platformConfig, + // and use the default values if some of them don't exist. + if username, ok := platformConfig["username"]; ok { + mysql.Username = username.(string) + } else { + mysql.Username = defaultUsername + } + + if category, ok := platformConfig["category"]; ok { + mysql.Category = category.(string) + } else { + mysql.Category = defaultCategory + } + + if securityIPs, ok := platformConfig["securityIPs"]; ok { + mysql.SecurityIPs = securityIPs.([]string) + } else { + mysql.SecurityIPs = defaultSecurityIPs + } + + if privateRouting, ok := platformConfig["privateRouting"]; ok { + mysql.PrivateRouting = privateRouting.(bool) + } else { + mysql.PrivateRouting = defaultPrivateRouting + } + + if size, ok := platformConfig["size"]; ok { + mysql.Size = size.(int) + } else { + mysql.Size = defaultSize + } + + if instanceType, ok := platformConfig["instanceType"]; ok { + mysql.InstanceType = instanceType.(string) + } + + if subnetID, ok := platformConfig["subnetID"]; ok { + mysql.SubnetID = subnetID.(string) + } + + if databaseName, ok := platformConfig["databaseName"]; ok { + mysql.DatabaseName = databaseName.(string) + } + + return mysql.Validate() +} + +// GenerateDBSecret generates Kubernetes Secret resource to store the host address, username +// and password of the local MySQL database instance. +func (mysql *MySQL) GenerateDBSecret(request *module.GeneratorRequest, hostAddress, username, password string) ( + *apiv1.Resource, *apiv1.Patcher, error, +) { + // Create the data map of Kubernetes Secret storing the database host address, username + // and password. + data := make(map[string]string) + data["hostAddress"] = hostAddress + data["username"] = username + data["password"] = password + + // Create the Kubernetes Secret. + secret := &v1.Secret{ + TypeMeta: metav1.TypeMeta{ + Kind: "Secret", + APIVersion: v1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: mysql.DatabaseName + dbResSuffix, + Namespace: request.Project, + }, + StringData: data, + } + + resourceID := module.KubernetesResourceID(secret.TypeMeta, secret.ObjectMeta) + resource, err := module.WrapK8sResourceToKusionResource(resourceID, secret) + if err != nil { + return nil, nil, err + } + + // Inject the database credentials into the workload as the environment variables with + // Kusion resource patcher. + hostAddressKey := dbHostAddressEnv + "_" + strings.ToUpper(strings.ReplaceAll(mysql.DatabaseName, "-", "_")) + usernameKey := dbUsernameEnv + "_" + strings.ToUpper(strings.ReplaceAll(mysql.DatabaseName, "-", "_")) + passwordKey := dbPasswordEnv + "_" + strings.ToUpper(strings.ReplaceAll(mysql.DatabaseName, "-", "_")) + + envVars := []v1.EnvVar{ + { + Name: hostAddressKey, + ValueFrom: &v1.EnvVarSource{ + SecretKeyRef: &v1.SecretKeySelector{ + LocalObjectReference: v1.LocalObjectReference{ + Name: secret.Name, + }, + Key: "hostAddress", + }, + }, + }, + { + Name: usernameKey, + ValueFrom: &v1.EnvVarSource{ + SecretKeyRef: &v1.SecretKeySelector{ + LocalObjectReference: v1.LocalObjectReference{ + Name: secret.Name, + }, + Key: "username", + }, + }, + }, + { + Name: passwordKey, + ValueFrom: &v1.EnvVarSource{ + SecretKeyRef: &v1.SecretKeySelector{ + LocalObjectReference: v1.LocalObjectReference{ + Name: secret.Name, + }, + Key: "password", + }, + }, + }, + } + + patcher := &apiv1.Patcher{ + Environments: envVars, + } + + return resource, patcher, nil +} + +// GenerateTFRandomPassword generates the terraform random_password resource as the password +// of the cloud provided MySQL database instance. +func (mysql *MySQL) GenerateTFRandomPassword(request *module.GeneratorRequest) (*apiv1.Resource, string, error) { + resAttrs := map[string]any{ + "length": 16, + "special": true, + "override_special": "_", + } + + // Set the random_password provider with the default provider config. + randomPasswordProvider := defaultRandomProviderCfg + + id, err := module.TerraformResourceID(randomPasswordProvider, randomPassword, mysql.DatabaseName+dbResSuffix) + if err != nil { + return nil, "", err + } + + resExts, err := module.TerraformProviderExtensions(randomPasswordProvider, nil, randomPassword) + if err != nil { + return nil, "", err + } + + resource, err := module.WrapTFResourceToKusionResource(id, resAttrs, resExts, nil) + if err != nil { + return nil, "", err + } + + return resource, id, nil +} + +// Validate validates whether the input of a MySQL database instance is valid. +func (mysql *MySQL) Validate() error { + if mysql.Type == CloudDBType && mysql.InstanceType == "" { + return ErrEmptyInstanceTypeForCloudDB + } + + return nil +} + +// GenerateDefaultMySQLName generates the default name of the MySQL instance. +func GenerateDefaultMySQLName(projectName, stackName, appName string) string { + strs := []string{projectName, stackName, appName, dbEngine} + + return strings.Join(strs, "-") +} + +// GetCloudProviderType returns the cloud provider type of the MySQL instance. +func GetCloudProviderType(platformConfig apiv1.GenericConfig) (string, error) { + if platformConfig == nil { + return "", workspace.ErrEmptyModuleConfigBlock + } + + if cloud, ok := platformConfig["cloud"]; ok { + return cloud.(string), nil + } + + return "", ErrEmptyCloudProviderType +} + +// IsPublicAccessible returns whether the mysql database instance is publicly +// accessible according to the securityIPs. +func IsPublicAccessible(securityIPs []string) bool { + var parsedIP net.IP + for _, ip := range securityIPs { + if IsIPAddress(ip) { + parsedIP = net.ParseIP(ip) + } else if IsCIDR(ip) { + parsedIP, _, _ = net.ParseCIDR(ip) + } + + if parsedIP != nil && !parsedIP.IsPrivate() { + return true + } + } + + return false +} + +// IsIPAddress returns whether the input string is a valid ip address. +func IsIPAddress(ipStr string) bool { + ip := net.ParseIP(ipStr) + + return ip != nil +} + +// IsCIDR returns whether the input string is a valid CIDR record. +func IsCIDR(cidrStr string) bool { + _, _, err := net.ParseCIDR(cidrStr) + + return err == nil +} + +func main() { + server.Start(&MySQL{}) +} diff --git a/modules/mysql/src/mysql_test.go b/modules/mysql/src/mysql_test.go new file mode 100644 index 0000000..5fe79e0 --- /dev/null +++ b/modules/mysql/src/mysql_test.go @@ -0,0 +1,411 @@ +package main + +import ( + "context" + "errors" + "testing" + + "github.com/bytedance/mockey" + "github.com/stretchr/testify/assert" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "kusionstack.io/kusion-module-framework/pkg/module" + apiv1 "kusionstack.io/kusion/pkg/apis/core/v1" + "kusionstack.io/kusion/pkg/apis/core/v1/workload" +) + +func TestMySQLModule_Generator(t *testing.T) { + r := &module.GeneratorRequest{ + Project: "test-project", + Stack: "test-stack", + App: "test-app", + Workload: &workload.Workload{ + Header: workload.Header{ + Type: "Service", + }, + Service: &workload.Service{}, + }, + } + + testcases := []struct { + name string + devModuleConfig apiv1.Accessory + platformConfig apiv1.GenericConfig + expectedErr error + }{ + { + name: "Generate local MySQL database", + devModuleConfig: apiv1.Accessory{ + "type": "local", + "version": "8.0", + }, + platformConfig: apiv1.GenericConfig{ + "databaseName": "test-mysql", + }, + expectedErr: nil, + }, + { + name: "Generate AWS MySQL RDS", + devModuleConfig: apiv1.Accessory{ + "type": "cloud", + "version": "8.0", + }, + platformConfig: apiv1.GenericConfig{ + "cloud": "aws", + "size": 20, + "instanceType": "db.t3.micro", + "privateRouting": false, + }, + expectedErr: nil, + }, + { + name: "Generate Alicloud MySQL RDS", + devModuleConfig: apiv1.Accessory{ + "type": "cloud", + "version": "8.0", + }, + platformConfig: apiv1.GenericConfig{ + "cloud": "alicloud", + "size": 20, + "instanceType": "mysql.n2.serverless.1c", + "category": "serverless_basic", + "privateRouting": false, + "subnetID": "test-subnet-id", + }, + expectedErr: nil, + }, + { + name: "Unsupported MySQL type", + devModuleConfig: apiv1.Accessory{ + "type": "unsupported-type", + "version": "8.0", + }, + platformConfig: apiv1.GenericConfig{ + "databaseName": "test-mysql", + }, + expectedErr: errors.New("unsupported mysql type"), + }, + { + name: "Unsupported Terraform provider type", + devModuleConfig: apiv1.Accessory{ + "type": "cloud", + "version": "8.0", + }, + platformConfig: apiv1.GenericConfig{ + "cloud": "unsupported-type", + "instanceType": "db.t3.micro", + }, + expectedErr: errors.New("unsupported cloud provider type"), + }, + { + name: "Empty cloud MySQL instance type", + devModuleConfig: apiv1.Accessory{ + "type": "cloud", + "version": "8.0", + }, + platformConfig: apiv1.GenericConfig{ + "cloud": "aws", + }, + expectedErr: ErrEmptyInstanceTypeForCloudDB, + }, + } + + for _, tc := range testcases { + mysql := &MySQL{} + t.Run(tc.name, func(t *testing.T) { + r.DevModuleConfig = tc.devModuleConfig + r.PlatformModuleConfig = tc.platformConfig + + res, err := mysql.Generate(context.Background(), r) + if tc.expectedErr != nil { + assert.ErrorContains(t, err, tc.expectedErr.Error()) + } else { + assert.NoError(t, err) + assert.NotNil(t, res) + } + }) + } +} + +func TestMySQLModule_GetCompleteConfig(t *testing.T) { + testcases := []struct { + name string + devModuleConfig apiv1.Accessory + platformConfig apiv1.GenericConfig + expectedMySQL *MySQL + }{ + { + name: "Empty platform config", + devModuleConfig: apiv1.Accessory{ + "type": "local", + "version": "8.0", + }, + platformConfig: nil, + expectedMySQL: &MySQL{ + Type: "local", + Version: "8.0", + Username: defaultUsername, + Category: defaultCategory, + SecurityIPs: defaultSecurityIPs, + PrivateRouting: defaultPrivateRouting, + Size: defaultSize, + }, + }, + { + name: "Default config with specified platform config", + devModuleConfig: apiv1.Accessory{ + "type": "cloud", + "version": "8.0", + }, + platformConfig: apiv1.GenericConfig{ + "size": 100, + "privateRouting": true, + "instanceType": "test-instance-type", + "subnetID": "test-subnet-id", + "databaseName": "test-database", + }, + expectedMySQL: &MySQL{ + Type: "cloud", + Version: "8.0", + Username: defaultUsername, + Category: defaultCategory, + SecurityIPs: defaultSecurityIPs, + PrivateRouting: true, + Size: 100, + InstanceType: "test-instance-type", + SubnetID: "test-subnet-id", + DatabaseName: "test-database", + }, + }, + } + + for _, tc := range testcases { + mysql := &MySQL{} + t.Run(tc.name, func(t *testing.T) { + mockey.PatchConvey("mock mysql validate", t, func() { + mockey.Mock(mysql.Validate).Return(nil).Build() + + _ = mysql.GetCompleteConfig(tc.devModuleConfig, tc.platformConfig) + assert.Equal(t, tc.expectedMySQL, mysql) + }) + }) + } +} + +func TestMySQLModule_GenerateDBSecret(t *testing.T) { + r := &module.GeneratorRequest{ + Project: "test-project", + Stack: "test-stack", + App: "test-app", + Workload: &workload.Workload{ + Header: workload.Header{ + Type: "Service", + }, + Service: &workload.Service{}, + }, + } + + mysql := &MySQL{ + Type: "local", + Version: "8.0", + DatabaseName: "test-database", + } + + hostAddress := "test-host-address" + username := "test-username" + password := "test-password" + + sec := &v1.Secret{ + TypeMeta: metav1.TypeMeta{ + Kind: "Secret", + APIVersion: v1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-database-mysql", + Namespace: "test-project", + }, + StringData: map[string]string{ + "hostAddress": "test-host-address", + "username": "test-username", + "password": "test-password", + }, + } + + resID := module.KubernetesResourceID(sec.TypeMeta, sec.ObjectMeta) + expectedResource, err := module.WrapK8sResourceToKusionResource(resID, sec) + if err != nil { + t.Fatalf("failed to wrap secret resource for unit test: %v", err) + } + + expectedPatcher := &apiv1.Patcher{ + Environments: []v1.EnvVar{ + { + Name: "KUSION_DB_HOST_TEST_DATABASE", + ValueFrom: &v1.EnvVarSource{ + SecretKeyRef: &v1.SecretKeySelector{ + LocalObjectReference: v1.LocalObjectReference{ + Name: "test-database-mysql", + }, + Key: "hostAddress", + }, + }, + }, + { + Name: "KUSION_DB_USERNAME_TEST_DATABASE", + ValueFrom: &v1.EnvVarSource{ + SecretKeyRef: &v1.SecretKeySelector{ + LocalObjectReference: v1.LocalObjectReference{ + Name: "test-database-mysql", + }, + Key: "username", + }, + }, + }, + { + Name: "KUSION_DB_PASSWORD_TEST_DATABASE", + ValueFrom: &v1.EnvVarSource{ + SecretKeyRef: &v1.SecretKeySelector{ + LocalObjectReference: v1.LocalObjectReference{ + Name: "test-database-mysql", + }, + Key: "password", + }, + }, + }, + }, + } + + actualResource, actualPatcher, err := mysql.GenerateDBSecret(r, hostAddress, username, password) + + assert.Nil(t, err) + assert.Equal(t, expectedResource, actualResource) + assert.Equal(t, expectedPatcher, actualPatcher) +} + +func TestMySQLModule_GenerateTFRandomPassword(t *testing.T) { + r := &module.GeneratorRequest{ + Project: "test-project", + Stack: "test-stack", + App: "test-app", + Workload: &workload.Workload{ + Header: workload.Header{ + Type: "Service", + }, + Service: &workload.Service{}, + }, + } + + mysql := &MySQL{ + Type: "local", + Version: "8.0", + DatabaseName: "test-database", + } + + t.Run("failed to generate tf resource id", func(t *testing.T) { + mockey.PatchConvey("failed to generate tf resource id", t, func() { + mockey.Mock(module.TerraformResourceID).Return("", errors.New("failed to generate tf resource id")).Build() + + res, id, err := mysql.GenerateTFRandomPassword(r) + + assert.Nil(t, res) + assert.Equal(t, id, "") + assert.ErrorContains(t, err, "failed to generate tf resource id") + }) + }) + + t.Run("failed to generate provider extensions", func(t *testing.T) { + mockey.PatchConvey("failed to generate provider extensions", t, func() { + mockey.Mock(module.TerraformResourceID).Return("", nil).Build() + mockey.Mock(module.TerraformProviderExtensions).Return(nil, errors.New("failed to generate provider extensions")).Build() + + res, id, err := mysql.GenerateTFRandomPassword(r) + + assert.Nil(t, res) + assert.Equal(t, id, "") + assert.ErrorContains(t, err, "failed to generate provider extensions") + }) + }) + + t.Run("failed to wrap tf resource to kusion resource", func(t *testing.T) { + mockey.PatchConvey("failed to wrap tf resource to kusion resource", t, func() { + mockey.Mock(module.TerraformResourceID).Return("", nil).Build() + mockey.Mock(module.TerraformProviderExtensions).Return(nil, nil).Build() + mockey.Mock(module.WrapTFResourceToKusionResource).Return(nil, errors.New("failed to wrap tf resource to kusion resource")).Build() + + res, id, err := mysql.GenerateTFRandomPassword(r) + + assert.Nil(t, res) + assert.Equal(t, id, "") + assert.ErrorContains(t, err, "failed to wrap tf resource to kusion resource") + }) + }) + + t.Run("successfully generate random_password resource", func(t *testing.T) { + res, id, err := mysql.GenerateTFRandomPassword(r) + + assert.NotNil(t, res) + assert.NotEqual(t, id, "") + assert.NoError(t, err) + }) +} + +func TestMySQLModule_Validate(t *testing.T) { + t.Run("cloud db with empty instanceType", func(t *testing.T) { + mysql := &MySQL{ + Type: "cloud", + Version: "8.0", + } + + err := mysql.Validate() + + assert.ErrorContains(t, err, ErrEmptyInstanceTypeForCloudDB.Error()) + }) + + t.Run("valid mysql config", func(t *testing.T) { + mysql := &MySQL{ + Type: "cloud", + Version: "8.0", + InstanceType: "test-instance-type", + } + + err := mysql.Validate() + + assert.NoError(t, err) + }) +} + +func TestIsPublicAccessible(t *testing.T) { + testcases := []struct { + name string + securityIPs []string + expected bool + }{ + { + name: "Public CIDR", + securityIPs: []string{ + "0.0.0.0/0", + }, + expected: true, + }, + { + name: "Private CIDR", + securityIPs: []string{ + "172.16.0.0/24", + }, + expected: false, + }, + { + name: "Private IP Address", + securityIPs: []string{ + "172.16.0.1", + }, + expected: false, + }, + } + + for _, tc := range testcases { + actual := IsPublicAccessible(tc.securityIPs) + + assert.Equal(t, tc.expected, actual) + } +} diff --git a/modules/network/example/dev/example_workspace.yaml b/modules/network/example/dev/example_workspace.yaml new file mode 100644 index 0000000..8f56b17 --- /dev/null +++ b/modules/network/example/dev/example_workspace.yaml @@ -0,0 +1,8 @@ +modules: + kusionstack/network@0.1.0: + default: + port: + type: alicloud + annotations: + service.beta.kubernetes.io/alibaba-cloud-loadbalancer-spec: slb.s1.small + \ No newline at end of file diff --git a/modules/network/example/dev/kcl.mod b/modules/network/example/dev/kcl.mod new file mode 100644 index 0000000..567c529 --- /dev/null +++ b/modules/network/example/dev/kcl.mod @@ -0,0 +1,10 @@ +[package] +name = "example" + +[dependencies] +kam = { git = "https://github.com/KusionStack/kam.git", tag = "0.1.0" } +network = { oci = "oci://ghcr.io/kusionstack/network", tag = "0.1.0" } + +[profile] +entries = ["main.k"] + diff --git a/modules/network/example/dev/main.k b/modules/network/example/dev/main.k new file mode 100644 index 0000000..ae78b90 --- /dev/null +++ b/modules/network/example/dev/main.k @@ -0,0 +1,25 @@ +# The configuration codes in perspective of developers. +import kam.v1.app_configuration as ac +import kam.v1.workload as wl +import kam.v1.workload.container as c +import network.network as n + +nginx: ac.AppConfiguration { + workload: wl.Service { + containers: { + nginx: c.Container { + image: "nginx:1.25.2" + } + } + } + accessories: { + "network": n.Network { + ports: [ + n.Port { + port: 80 + public: True + } + ] + } + } +} diff --git a/modules/network/example/dev/stack.yaml b/modules/network/example/dev/stack.yaml new file mode 100644 index 0000000..19e96e3 --- /dev/null +++ b/modules/network/example/dev/stack.yaml @@ -0,0 +1 @@ +name: dev diff --git a/modules/network/example/project.yaml b/modules/network/example/project.yaml new file mode 100644 index 0000000..2551cb1 --- /dev/null +++ b/modules/network/example/project.yaml @@ -0,0 +1 @@ +name: example diff --git a/modules/network/kcl.mod b/modules/network/kcl.mod new file mode 100644 index 0000000..790a34e --- /dev/null +++ b/modules/network/kcl.mod @@ -0,0 +1,3 @@ +[package] +name = "network" +version = "0.1.0" diff --git a/modules/network/network.k b/modules/network/network.k new file mode 100644 index 0000000..dd8e8db --- /dev/null +++ b/modules/network/network.k @@ -0,0 +1,73 @@ +schema Network: + """ Network describes the network accessories of Workload, which typically contains the exposed ports, load balancer + and other related resource configs. + + Attributes + ---------- + ports: [n.Port], default is Undefined, optional. + The list of ports which the Workload should get exposed. + + Examples + -------- + import catalog.models.schema.v1.network as n + + accessories: { + "network": n.Network { + ports: [ + n.Port { + port: 80 + public: True + } + n.Port { + port: 8080 + } + ] + } + } + """ + + # The list of ports getting exposed. + ports?: [Port] + +schema Port: + """ Port defines the exposed port of Workload, which can be used to describe how the Workload + get accessed. + + Attributes + ---------- + port: int, default is 80, required. + The exposed port of the Workload. + targetPort: int, default is Undefined, optional. + The backend container port. If empty, set it the same as the port. + protocol: "TCP" | "UDP", default is "TCP", required. + The protocol to access the port. + public: bool, default is False, required. + Public defines whether the port can be accessed through Internet. + + Examples + -------- + import catalog.models.schema.v1.network as n + + port = n.Port { + port: 80 + targetPort: 8080 + protocol: "TCP" + public: True + } + """ + + # The exposed port of the Service. + port: int = 80 + + # The backend container port. + targetPort?: int + + # The protocol of port. + protocol: "TCP" | "UDP" = "TCP" + + # Public defines whether to expose the port through Internet. + public: bool = False + + check: + 1 <= port <= 65535, "port must be between 1 and 65535, inclusive" + 1 <= targetPort <= 65535 if targetPort, "targetPort must be between 1 and 65535, inclusive" diff --git a/modules/network/src/Makefile b/modules/network/src/Makefile new file mode 100644 index 0000000..b55dc96 --- /dev/null +++ b/modules/network/src/Makefile @@ -0,0 +1,36 @@ +TEST?=$$(go list ./... | grep -v 'vendor') +###### chang variables below according to your own modules ### +NAMESPACE=kusionstack +NAME=network +VERSION=0.1.0 +BINARY=../bin/kusion-module-${NAME}_${VERSION} + +LOCAL_ARCH := $(shell uname -m) +ifeq ($(LOCAL_ARCH),x86_64) +GOARCH_LOCAL := amd64 +else +GOARCH_LOCAL := $(LOCAL_ARCH) +endif +export GOOS_LOCAL := $(shell uname|tr 'A-Z' 'a-z') +export OS_ARCH ?= $(GOARCH_LOCAL) + +default: install + +build-darwin: + GOOS=darwin GOARCH=arm64 go build -o ${BINARY} ./${NAME} + +install: build-darwin +# copy module binary to $KUSION_HOME. e.g. ~/.kusion/modules/kusionstack/network/v0.1.0/darwin/arm64/kusion-module-network_0.1.0 + mkdir -p ${KUSION_HOME}/modules/${NAMESPACE}/${NAME}/${VERSION}/${GOOS_LOCAL}/${OS_ARCH} + cp ${BINARY} ${KUSION_HOME}/modules/${NAMESPACE}/${NAME}/${VERSION}/${GOOS_LOCAL}/${OS_ARCH} + +release: + GOOS=darwin GOARCH=arm64 go build -o ${BINARY}_darwin_arm64 ./${NAME} + GOOS=darwin GOARCH=amd64 go build -o ${BINARY}_darwin_amd64 ./${NAME} + GOOS=linux GOARCH=arm64 go build -o ${BINARY}_linux_arm64 ./${NAME} + GOOS=linux GOARCH=amd64 go build -o ${BINARY}_linux_amd64 ./${NAME} + GOOS=windows GOARCH=amd64 go build -o ${BINARY}_windows_amd64 ./${NAME} + GOOS=windows GOARCH=386 go build -o ${BINARY}_windows_386 ./${NAME} + +test: + TF_ACC=1 go test $(TEST) -v $(TESTARGS) -timeout 5m diff --git a/modules/network/src/go.mod b/modules/network/src/go.mod new file mode 100644 index 0000000..a7fdab0 --- /dev/null +++ b/modules/network/src/go.mod @@ -0,0 +1,57 @@ +module network + +go 1.22.1 + +require ( + github.com/stretchr/testify v1.9.0 + gopkg.in/yaml.v3 v3.0.1 + k8s.io/api v0.29.3 + k8s.io/apimachinery v0.29.3 + kusionstack.io/kusion v0.10.1-0.20240326060146-3f01e9416ff6 + kusionstack.io/kusion-module-framework v0.0.0-20240326063408-807a5a4e4682 +) + +require ( + github.com/blang/semver/v4 v4.0.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/fatih/color v1.13.0 // indirect + github.com/go-logr/logr v1.3.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/go-github/v50 v50.0.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/google/gofuzz v1.2.0 // indirect + github.com/hashicorp/go-hclog v0.16.2 // indirect + github.com/hashicorp/go-plugin v1.6.0 // indirect + github.com/hashicorp/go-version v1.6.0 // indirect + github.com/hashicorp/yamux v0.1.1 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mattn/go-colorable v0.1.12 // indirect + github.com/mattn/go-isatty v0.0.14 // indirect + github.com/mitchellh/go-testing-interface v1.14.1 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/oklog/run v1.0.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.6.0 // indirect + go.uber.org/zap v1.24.0 // indirect + golang.org/x/crypto v0.18.0 // indirect + golang.org/x/net v0.20.0 // indirect + golang.org/x/oauth2 v0.16.0 // indirect + golang.org/x/sys v0.16.0 // indirect + golang.org/x/text v0.14.0 // indirect + google.golang.org/appengine v1.6.8 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240123012728-ef4313101c80 // indirect + google.golang.org/grpc v1.62.0 // indirect + google.golang.org/protobuf v1.33.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + k8s.io/klog/v2 v2.110.1 // indirect + k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect + kcl-lang.io/kcl-plugin v0.5.0 // indirect + sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect +) diff --git a/modules/network/src/go.sum b/modules/network/src/go.sum new file mode 100644 index 0000000..065ab0f --- /dev/null +++ b/modules/network/src/go.sum @@ -0,0 +1,204 @@ +github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= +github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA= +github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8= +github.com/bytedance/mockey v1.2.10 h1:4JlMpkm7HMXmTUtItid+iCu2tm61wvq+ca1X2u7ymzE= +github.com/bytedance/mockey v1.2.10/go.mod h1:bNrUnI1u7+pAc0TYDgPATM+wF2yzHxmNH+iDXg4AOCU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY= +github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-github/v50 v50.0.0 h1:gdO1AeuSZZK4iYWwVbjni7zg8PIQhp7QfmPunr016Jk= +github.com/google/go-github/v50 v50.0.0/go.mod h1:Ev4Tre8QoKiolvbpOSG3FIi4Mlon3S2Nt9W5JYqKiwA= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/hashicorp/go-hclog v0.16.2 h1:K4ev2ib4LdQETX5cSZBG0DVLk1jwGqSPXBjdah3veNs= +github.com/hashicorp/go-hclog v0.16.2/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= +github.com/hashicorp/go-plugin v1.6.0 h1:wgd4KxHJTVGGqWBq4QPB1i5BZNEx9BR8+OFmHDmTk8A= +github.com/hashicorp/go-plugin v1.6.0/go.mod h1:lBS5MtSSBZk0SHc66KACcjjlU6WzEVP/8pwz68aMkCI= +github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= +github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= +github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= +github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c= +github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= +github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw= +github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A= +go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4= +go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60= +go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= +golang.org/x/arch v0.1.0 h1:oMxhUYsO9VsR1dcoVUjJjIGhx1LXol3989T/yZ59Xsw= +golang.org/x/arch v0.1.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/oauth2 v0.16.0 h1:aDkGMBSYxElaoP81NpoUoz2oo2R2wHdZpGToUxfyQrQ= +golang.org/x/oauth2 v0.16.0/go.mod h1:hqZ+0LWXsiVoZpeld6jVt06P3adbS2Uu911W1SsJv2o= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= +google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240123012728-ef4313101c80 h1:AjyfHzEPEFp/NpvfN5g+KDla3EMojjhRVZc1i7cj+oM= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240123012728-ef4313101c80/go.mod h1:PAREbraiVEVGVdTZsVWjSbbTtSyGbAgIIvni8a8CD5s= +google.golang.org/grpc v1.62.0 h1:HQKZ/fa1bXkX1oFOvSjmZEUL8wLSaZTjCcLAlmZRtdk= +google.golang.org/grpc v1.62.0/go.mod h1:IWTG0VlJLCh1SkC58F7np9ka9mx/WNkjl4PGJaiq+QE= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/natefinch/lumberjack.v2 v2.0.0 h1:1Lc07Kr7qY4U2YPouBjpCLxpiyxIVoxqXgkXLknAOE8= +gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.29.3 h1:2ORfZ7+bGC3YJqGpV0KSDDEVf8hdGQ6A03/50vj8pmw= +k8s.io/api v0.29.3/go.mod h1:y2yg2NTyHUUkIoTC+phinTnEa3KFM6RZ3szxt014a80= +k8s.io/apimachinery v0.29.3 h1:2tbx+5L7RNvqJjn7RIuIKu9XTsIZ9Z5wX2G22XAa5EU= +k8s.io/apimachinery v0.29.3/go.mod h1:hx/S4V2PNW4OMg3WizRrHutyB5la0iCUbZym+W0EQIU= +k8s.io/klog/v2 v2.110.1 h1:U/Af64HJf7FcwMcXyKm2RPM22WZzyR7OSpYj5tg3cL0= +k8s.io/klog/v2 v2.110.1/go.mod h1:YGtd1984u+GgbuZ7e08/yBuAfKLSO0+uR1Fhi6ExXjo= +k8s.io/utils v0.0.0-20230726121419-3b25d923346b h1:sgn3ZU783SCgtaSJjpcVVlRqd6GSnlTLKgpAAttJvpI= +k8s.io/utils v0.0.0-20230726121419-3b25d923346b/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +kcl-lang.io/kcl-plugin v0.5.0 h1:eoh6y4l81rwA8yhJXU4hN7YmJeTUNB1nfYCP9OffSxc= +kcl-lang.io/kcl-plugin v0.5.0/go.mod h1:QnZ5OLcyBw5nOnHpChRHtvBq8wvjwiHu/ZZ8j1dfz48= +kusionstack.io/kusion v0.10.1-0.20240326060146-3f01e9416ff6 h1:66DJEK1NZyA7C/Geh9f2MCeZx54R5y6quHJXWpoxJX4= +kusionstack.io/kusion v0.10.1-0.20240326060146-3f01e9416ff6/go.mod h1:xI/6cDT0cZAaWFdKVnpV/U5TKpSQnLMnUJAEy/uRpL8= +kusionstack.io/kusion-module-framework v0.0.0-20240326063408-807a5a4e4682 h1:X8zSDNh5Sa6FsTbwgohdu6tQ9lDQ3lZs9mTnXvk9GYo= +kusionstack.io/kusion-module-framework v0.0.0-20240326063408-807a5a4e4682/go.mod h1:ynITUHw3Cke7aLhzQXXFvXgxQcLF/z4uFxn8O87K4mA= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= +sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo= +sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= diff --git a/modules/network/src/network.go b/modules/network/src/network.go new file mode 100644 index 0000000..cd5dd9a --- /dev/null +++ b/modules/network/src/network.go @@ -0,0 +1,381 @@ +package main + +import ( + "context" + "errors" + "fmt" + "strings" + + "gopkg.in/yaml.v3" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + "kusionstack.io/kusion-module-framework/pkg/module" + "kusionstack.io/kusion-module-framework/pkg/server" + apiv1 "kusionstack.io/kusion/pkg/apis/core/v1" + "kusionstack.io/kusion/pkg/log" + "kusionstack.io/kusion/pkg/modules" + "kusionstack.io/kusion/pkg/workspace" +) + +const ( + FieldType = "type" + FieldLabels = "labels" + FieldAnnotations = "annotations" +) + +const ( + CSPAWS = "aws" + CSPAliCloud = "alicloud" +) + +const ( + ProtocolTCP = "TCP" + ProtocolUDP = "UDP" +) + +const ( + k8sKindService = "Service" + suffixPublic = "public" + suffixPrivate = "private" +) + +var ( + ErrEmptyPortConfig = errors.New("empty port config") + ErrEmptyType = errors.New("type must not be empty when public") + ErrUnsupportedType = errors.New("type only support alicloud and aws for now") + ErrInvalidPort = errors.New("port must be between 1 and 65535") + ErrInvalidTargetPort = errors.New("targetPort must be between 1 and 65535 if exist") + ErrInvalidProtocol = errors.New("protocol must be TCP or UDP") + ErrEmptySvcWorkload = errors.New("network port should be binded to a service workload") +) + +// Network describes the network accessories of workload, which typically contains the exposed +// ports, load balancer and other related resource configs. +type Network struct { + Ports []Port `yaml:"ports,omitempty" json:"ports,omitempty"` +} + +// Port defines the exposed port of workload, which can be used to describe how +// the workload get accessed. +type Port struct { + // Type is the specific cloud vendor that provides load balancer, works when Public + // is true, supports CSPAliCloud and CSPAWS for now. + Type string `yaml:"type,omitempty" json:"type,omitempty"` + + // Port is the exposed port of the workload. + Port int `yaml:"port,omitempty" json:"port,omitempty"` + + // TargetPort is the backend container.Container port. + TargetPort int `yaml:"targetPort,omitempty" json:"targetPort,omitempty"` + + // Protocol is protocol used to expose the port, support ProtocolTCP and ProtocolUDP. + Protocol string `yaml:"protocol,omitempty" json:"protocol,omitempty"` + + // Public defines whether to expose the port through Internet. + Public bool `yaml:"public,omitempty" json:"public,omitempty"` + + // Labels are the attached labels of the port, works only when the Public is true. + Labels map[string]string `yaml:"labels,omitempty" json:"labels,omitempty"` + + // Annotations are the attached annotations of the port, works only when the Public is true. + Annotations map[string]string `yaml:"annotations,omitempty" json:"annotations,omitempty"` +} + +func (network *Network) Generate(_ context.Context, request *module.GeneratorRequest) (*module.GeneratorResponse, error) { + defer func() { + if r := recover(); r != nil { + log.Debugf("failed to generate network module: %v", r) + } + }() + + // Network does not exist in AppConfiguration configs. + if request.DevModuleConfig == nil { + log.Info("Network does not exist in AppConfig config") + + return nil, nil + } + + // Get the complete configs of the Network accessory. + if err := network.GetCompleteConfig(request.DevModuleConfig, request.PlatformModuleConfig); err != nil { + return nil, err + } + if len(network.Ports) != 0 && request.Workload.Service == nil { + return nil, ErrEmptySvcWorkload + } + + var resources []apiv1.Resource + // Generate network port related resources. + res, err := network.GeneratePortResources(request) + if err != nil { + return nil, err + } + resources = append(resources, res...) + + return &module.GeneratorResponse{ + Resources: resources, + }, nil +} + +// GetCompleteConfig combines the configs in devModuleConfig and platformModuleConfig to form a complete +// configuration for the Network accessory. +func (network *Network) GetCompleteConfig(devConfig apiv1.Accessory, platformConfig apiv1.GenericConfig) error { + // Get the complete port config. + if err := network.CompletePortConfig(devConfig, platformConfig); err != nil { + return err + } + + return network.Validate() +} + +// CompletePortConfig completes the network port related config. +func (network *Network) CompletePortConfig(devConfig apiv1.Accessory, platformConfig apiv1.GenericConfig) error { + if devConfig != nil { + ports, ok := devConfig["ports"] + if ok { + for _, port := range ports.([]interface{}) { + // Retrieve port configs from the devConfig based on the result + // of the type assertion. + mp, err := toMapStringInterface(port) + if err != nil { + return fmt.Errorf("failed to retrieve port from dev config: %v", err) + } + + yamlStr, err := yaml.Marshal(mp) + if err != nil { + return err + } + + var p Port + if err := yaml.Unmarshal(yamlStr, &p); err != nil { + return err + } + + network.Ports = append(network.Ports, p) + } + } + } + + var portConfig apiv1.GenericConfig + if platformConfig != nil { + pc, ok := platformConfig["port"] + if ok { + // Retrieve port configs from the platformConfig based on the result + // of the type assertion. + mpc, err := toMapStringInterface(pc) + if err != nil { + return fmt.Errorf("failed to retrieve port from platform config: %v", err) + } + + yamlStr, err := yaml.Marshal(mpc) + if err != nil { + return err + } + + if err := yaml.Unmarshal(yamlStr, &portConfig); err != nil { + return err + } + } + } + + for i := range network.Ports { + if network.Ports[i].TargetPort == 0 { + network.Ports[i].TargetPort = network.Ports[i].Port + } + if network.Ports[i].Public { + // Get port type from platform config. + if portConfig == nil { + return ErrEmptyPortConfig + } + portType, err := workspace.GetStringFromGenericConfig(portConfig, FieldType) + if err != nil { + return err + } + if portType == "" { + return ErrEmptyType + } + if portType != CSPAWS && portType != CSPAliCloud { + return ErrUnsupportedType + } + network.Ports[i].Type = portType + + // Get labels from platform config. + labels, err := workspace.GetStringMapFromGenericConfig(portConfig, FieldLabels) + if err != nil { + return err + } + network.Ports[i].Labels = labels + + // Get annotations from platform config. + annotations, err := workspace.GetStringMapFromGenericConfig(portConfig, FieldAnnotations) + if err != nil { + return err + } + network.Ports[i].Annotations = annotations + } + } + + return nil +} + +// Validate validates whether the input of a Network accessory is valid. +func (network *Network) Validate() error { + // Validate the port config. + if err := network.ValidatePortConfig(); err != nil { + return err + } + + return nil +} + +// ValidatePortConfig validates whether the port configs are valid or not. +func (network *Network) ValidatePortConfig() error { + for _, port := range network.Ports { + if port.Port < 1 || port.Port > 65535 { + return ErrInvalidPort + } + if port.TargetPort < 1 || port.TargetPort > 65535 { + return ErrInvalidTargetPort + } + if port.Protocol != ProtocolTCP && port.Protocol != ProtocolUDP { + return ErrInvalidProtocol + } + } + + return nil +} + +// GeneratePortResources generates the resources related to the network port. +func (network *Network) GeneratePortResources(request *module.GeneratorRequest) ([]apiv1.Resource, error) { + var resources []apiv1.Resource + privatePorts, publicPorts := splitPorts(network.Ports) + if len(privatePorts) != 0 { + svc := generatePortK8sSvc(request, false, privatePorts) + resourceID := module.KubernetesResourceID(svc.TypeMeta, svc.ObjectMeta) + resource, err := module.WrapK8sResourceToKusionResource(resourceID, svc) + if err != nil { + return nil, err + } + resources = append(resources, *resource) + } + if len(publicPorts) != 0 { + svc := generatePortK8sSvc(request, true, publicPorts) + resourceID := module.KubernetesResourceID(svc.TypeMeta, svc.ObjectMeta) + resource, err := module.WrapK8sResourceToKusionResource(resourceID, svc) + if err != nil { + return nil, err + } + resources = append(resources, *resource) + } + + return resources, nil +} + +// generatePortK8sSvc generates the Kubernetes Service resource for the network port. +func generatePortK8sSvc(request *module.GeneratorRequest, public bool, ports []Port) *v1.Service { + appUname := modules.UniqueAppName(request.Project, request.Stack, request.App) + var name string + if public { + name = fmt.Sprintf("%s-%s", appUname, suffixPublic) + } else { + name = fmt.Sprintf("%s-%s", appUname, suffixPrivate) + } + svcType := v1.ServiceTypeClusterIP + if public { + svcType = v1.ServiceTypeLoadBalancer + } + + labels := modules.MergeMaps(modules.UniqueAppLabels(request.Project, request.App), request.Workload.Service.Labels) + annotations := modules.MergeMaps(request.Workload.Service.Annotations) + selector := modules.UniqueAppLabels(request.Project, request.App) + + svc := &v1.Service{ + TypeMeta: metav1.TypeMeta{ + APIVersion: v1.SchemeGroupVersion.String(), + Kind: k8sKindService, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: request.Project, + Labels: labels, + Annotations: annotations, + }, + Spec: v1.ServiceSpec{ + Ports: toSvcPorts(name, ports), + Selector: selector, + Type: svcType, + }, + } + + if public { + if len(svc.Labels) == 0 { + svc.Labels = make(map[string]string) + } + if len(svc.Annotations) == 0 { + svc.Annotations = make(map[string]string) + } + + labels := ports[0].Labels + for k, v := range labels { + svc.Labels[k] = v + } + annotations := ports[0].Annotations + for k, v := range annotations { + svc.Annotations[k] = v + } + } + + return svc +} + +// splitPorts splits the network ports into private ports and public ports. +func splitPorts(ports []Port) ([]Port, []Port) { + var privatePorts, publicPorts []Port + for _, port := range ports { + if port.Public { + publicPorts = append(publicPorts, port) + } else { + privatePorts = append(privatePorts, port) + } + } + return privatePorts, publicPorts +} + +// toSvcPorts returns the Kubernetes ServicePort resource. +func toSvcPorts(name string, ports []Port) []v1.ServicePort { + svcPorts := make([]v1.ServicePort, len(ports)) + for i, port := range ports { + svcPorts[i] = v1.ServicePort{ + Name: fmt.Sprintf("%s-%d-%s", name, port.Port, strings.ToLower(port.Protocol)), + Port: int32(port.Port), + TargetPort: intstr.FromInt(port.TargetPort), + Protocol: v1.Protocol(port.Protocol), + } + } + return svcPorts +} + +// toMapStringInterface changes the input interface (usually map[interface{}]interface{}) +// into map[string]interface{}. +func toMapStringInterface(i any) (map[string]interface{}, error) { + m := make(map[string]interface{}) + if p, ok := i.(map[interface{}]interface{}); ok { + for k, v := range p { + m[fmt.Sprintf("%v", k)] = v + } + } else if p, ok := i.(map[string]interface{}); ok { + m = p + } else if p, ok := i.(apiv1.Accessory); ok { + m = map[string]interface{}(p) + } else if p, ok := i.(apiv1.GenericConfig); ok { + m = map[string]interface{}(p) + } else { + return nil, fmt.Errorf("unexpected type: %T", i) + } + + return m, nil +} + +func main() { + server.Start(&Network{}) +} diff --git a/modules/network/src/network_test.go b/modules/network/src/network_test.go new file mode 100644 index 0000000..8500af0 --- /dev/null +++ b/modules/network/src/network_test.go @@ -0,0 +1,208 @@ +package main + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "kusionstack.io/kusion-module-framework/pkg/module" + apiv1 "kusionstack.io/kusion/pkg/apis/core/v1" + "kusionstack.io/kusion/pkg/apis/core/v1/workload" +) + +func TestNetworkModule_Generator(t *testing.T) { + r := &module.GeneratorRequest{ + Project: "test-project", + Stack: "test-stack", + App: "test-app", + Workload: &workload.Workload{ + Header: workload.Header{ + Type: "Service", + }, + Service: &workload.Service{}, + }, + } + + testcases := []struct { + name string + devModuleConfig apiv1.Accessory + platformModuleConfig apiv1.GenericConfig + expectedErr error + }{ + { + name: "Generate private port service", + devModuleConfig: apiv1.Accessory{ + "ports": []interface{}{ + map[string]any{ + "port": 8080, + "protocol": "TCP", + }, + }, + }, + platformModuleConfig: nil, + expectedErr: nil, + }, + { + name: "Generate public port service", + devModuleConfig: apiv1.Accessory{ + "ports": []interface{}{ + map[string]any{ + "port": 8080, + "public": true, + "protocol": "TCP", + }, + }, + }, + platformModuleConfig: apiv1.GenericConfig{ + "port": map[string]any{ + "type": "alicloud", + "annotations": map[string]string{ + "service.beta.kubernetes.io/alibaba-cloud-loadbalancer-spec": "slb.s1.small", + }, + }, + }, + expectedErr: nil, + }, + } + + for _, tc := range testcases { + network := &Network{} + t.Run(tc.name, func(t *testing.T) { + r.DevModuleConfig = tc.devModuleConfig + r.PlatformModuleConfig = tc.platformModuleConfig + + res, err := network.Generate(context.Background(), r) + if tc.expectedErr != nil { + assert.ErrorContains(t, err, tc.expectedErr.Error()) + } else { + assert.NoError(t, err) + assert.NotNil(t, res) + } + }) + } +} + +func TestNetworkModule_GetCompleteConfig(t *testing.T) { + testcases := []struct { + name string + devModuleConfig apiv1.Accessory + platformModuleConfig apiv1.GenericConfig + expectedErr error + }{ + { + name: "Generate private port service", + devModuleConfig: apiv1.Accessory{ + "ports": []interface{}{ + map[string]any{ + "port": 8080, + "protocol": "TCP", + }, + }, + }, + platformModuleConfig: nil, + expectedErr: nil, + }, + { + name: "Generate public port service", + devModuleConfig: apiv1.Accessory{ + "ports": []interface{}{ + map[string]any{ + "port": 8080, + "public": true, + "protocol": "TCP", + }, + }, + }, + platformModuleConfig: apiv1.GenericConfig{ + "port": map[string]any{ + "type": "alicloud", + "annotations": map[string]string{ + "service.beta.kubernetes.io/alibaba-cloud-loadbalancer-spec": "slb.s1.small", + }, + }, + }, + expectedErr: nil, + }, + } + + for _, tc := range testcases { + network := &Network{} + t.Run(tc.name, func(t *testing.T) { + err := network.GetCompleteConfig(tc.devModuleConfig, tc.platformModuleConfig) + if tc.expectedErr != nil { + assert.ErrorContains(t, err, tc.expectedErr.Error()) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestNetworkModule_Validate(t *testing.T) { + testcases := []struct { + name string + network *Network + expectedErr error + }{ + { + name: "Invalid port", + network: &Network{ + Ports: []Port{ + { + Port: 0, + }, + }, + }, + expectedErr: ErrInvalidPort, + }, + { + name: "Invalid target port", + network: &Network{ + Ports: []Port{ + { + Port: 80, + TargetPort: 0, + }, + }, + }, + expectedErr: ErrInvalidTargetPort, + }, + { + name: "Invalid protocol", + network: &Network{ + Ports: []Port{ + { + Port: 80, + TargetPort: 80, + Protocol: "InvalidProtocol", + }, + }, + }, + expectedErr: ErrInvalidProtocol, + }, + { + name: "Valid port", + network: &Network{ + Ports: []Port{ + { + Port: 80, + TargetPort: 80, + Protocol: "TCP", + }, + }, + }, + expectedErr: nil, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + err := tc.network.Validate() + if tc.expectedErr != nil { + assert.ErrorContains(t, err, tc.expectedErr.Error()) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/modules/postgres/example/dev/kcl.mod b/modules/postgres/example/dev/kcl.mod new file mode 100644 index 0000000..3cff3fe --- /dev/null +++ b/modules/postgres/example/dev/kcl.mod @@ -0,0 +1,11 @@ +[package] +name = "example" + +[dependencies] +postgres = { oci = "oci://ghcr.io/kusionstack/postgres", tag = "0.1.0" } +kam = { git = "https://github.com/KusionStack/kam.git", tag = "0.1.0" } +network = { oci = "oci://ghcr.io/kusionstack/network", tag = "0.1.0" } + +[profile] +entries = ["main.k"] + diff --git a/modules/postgres/example/dev/main.k b/modules/postgres/example/dev/main.k new file mode 100644 index 0000000..dc580d3 --- /dev/null +++ b/modules/postgres/example/dev/main.k @@ -0,0 +1,34 @@ +# The configuration codes in perspective of developers. +import kam.v1.app_configuration as ac +import kam.v1.workload as wl +import kam.v1.workload.container as c +import network as n +import postgres.postgres + +pgadmin: ac.AppConfiguration { + workload: wl.Service { + containers: { + pgadmin: c.Container { + image: "dpage/pgadmin4:latest" + env: { + "PGADMIN_DEFAULT_EMAIL": "admin@email.com" + "PGADMIN_DEFAULT_PASSWORD": "123456" + "PGADMIN_PORT": "80" + } + } + } + } + accessories: { + "network": n.Network { + ports: [ + n.Port { + port: 80 + } + ] + } + "postgres": postgres.PostgreSQL { + type: "local" + version: "14.0" + } + } +} diff --git a/modules/postgres/example/dev/stack.yaml b/modules/postgres/example/dev/stack.yaml new file mode 100644 index 0000000..19e96e3 --- /dev/null +++ b/modules/postgres/example/dev/stack.yaml @@ -0,0 +1 @@ +name: dev diff --git a/modules/postgres/example/project.yaml b/modules/postgres/example/project.yaml new file mode 100644 index 0000000..2551cb1 --- /dev/null +++ b/modules/postgres/example/project.yaml @@ -0,0 +1 @@ +name: example diff --git a/modules/postgres/kcl.mod b/modules/postgres/kcl.mod new file mode 100644 index 0000000..99706b4 --- /dev/null +++ b/modules/postgres/kcl.mod @@ -0,0 +1,2 @@ +[package] +name = "postgres" diff --git a/modules/postgres/postgres.k b/modules/postgres/postgres.k index 8da0fd9..4c47f4e 100644 --- a/modules/postgres/postgres.k +++ b/modules/postgres/postgres.k @@ -8,7 +8,7 @@ schema PostgreSQL: Type defines whether the postgresql database is deployed locally or provided by cloud vendor. version: str, defaults to Undefined, required. - Version defines the mysql version to use. + Version defines the postgres version to use. Examples -------- @@ -16,9 +16,11 @@ schema PostgreSQL: import catalog.models.schema.v1.accessories.postgres - postgres: postgres.PostgreSQL { - type: "local" - version: "14.0" + accessories: { + "postgres": postgres.PostgreSQL { + type: "local" + version: "14.0" + } } """ diff --git a/modules/postgres/src/Makefile b/modules/postgres/src/Makefile new file mode 100644 index 0000000..21f460f --- /dev/null +++ b/modules/postgres/src/Makefile @@ -0,0 +1,36 @@ +TEST?=$$(go list ./... | grep -v 'vendor') +###### chang variables below according to your own modules ### +NAMESPACE=kusionstack +NAME=postgres +VERSION=0.1.0 +BINARY=../bin/kusion-module-${NAME}_${VERSION} + +LOCAL_ARCH := $(shell uname -m) +ifeq ($(LOCAL_ARCH),x86_64) +GOARCH_LOCAL := amd64 +else +GOARCH_LOCAL := $(LOCAL_ARCH) +endif +export GOOS_LOCAL := $(shell uname|tr 'A-Z' 'a-z') +export OS_ARCH ?= $(GOARCH_LOCAL) + +default: install + +build-darwin: + GOOS=darwin GOARCH=arm64 go build -o ${BINARY} ./${NAME} + +install: build-darwin +# copy module binary to $KUSION_HOME. e.g. ~/.kusion/modules/kusionstack/postgres/v0.1.0/darwin/arm64/kusion-module-postgres_0.1.0 + mkdir -p ${KUSION_HOME}/modules/${NAMESPACE}/${NAME}/${VERSION}/${GOOS_LOCAL}/${OS_ARCH} + cp ${BINARY} ${KUSION_HOME}/modules/${NAMESPACE}/${NAME}/${VERSION}/${GOOS_LOCAL}/${OS_ARCH} + +release: + GOOS=darwin GOARCH=arm64 go build -o ${BINARY}_darwin_arm64 ./${NAME} + GOOS=darwin GOARCH=amd64 go build -o ${BINARY}_darwin_amd64 ./${NAME} + GOOS=linux GOARCH=arm64 go build -o ${BINARY}_linux_arm64 ./${NAME} + GOOS=linux GOARCH=amd64 go build -o ${BINARY}_linux_amd64 ./${NAME} + GOOS=windows GOARCH=amd64 go build -o ${BINARY}_windows_amd64 ./${NAME} + GOOS=windows GOARCH=386 go build -o ${BINARY}_windows_386 ./${NAME} + +test: + TF_ACC=1 go test $(TEST) -v $(TESTARGS) -timeout 5m diff --git a/modules/postgres/src/alicloud_rds.go b/modules/postgres/src/alicloud_rds.go new file mode 100644 index 0000000..d23a522 --- /dev/null +++ b/modules/postgres/src/alicloud_rds.go @@ -0,0 +1,218 @@ +package main + +import ( + "errors" + "os" + "strings" + + "kusionstack.io/kusion-module-framework/pkg/module" + apiv1 "kusionstack.io/kusion/pkg/apis/core/v1" + "kusionstack.io/kusion/pkg/modules" +) + +var ErrEmptyAlicloudProviderRegion = errors.New("empty alicloud provider region") + +var ( + alicloudRegionEnv = "ALICLOUD_REGION" + alicloudDBInstance = "alicloud_db_instance" + alicloudDBConnection = "alicloud_db_connection" + alicloudRDSAccount = "alicloud_rds_account" +) + +var defaultAlicloudProviderCfg = apiv1.ProviderConfig{ + Source: "aliyun/alicloud", + Version: "1.209.1", +} + +type alicloudServerlessConfig struct { + AutoPause bool `yaml:"auto_pause" json:"auto_pause"` + SwitchForce bool `yaml:"switch_force" json:"switch_force"` + MaxCapacity int `yaml:"max_capacity,omitempty" json:"max_capacity,omitempty"` + MinCapacity int `yaml:"min_capacity,omitempty" json:"min_capacity,omitempty"` +} + +// GenerateAlicloudResources generates Alicloud provided PostgreSQL database instance. +func (postgres *PostgreSQL) GenerateAlicloudResources(request *module.GeneratorRequest) ([]apiv1.Resource, []apiv1.Patcher, error) { + var resources []apiv1.Resource + var patchers []apiv1.Patcher + + // Set the Alicloud provider with the default provider config. + alicloudProviderCfg := defaultAlicloudProviderCfg + + // Get the Alicloud Terraform provider region, which should not be empty. + var region string + if region = module.TerraformProviderRegion(alicloudProviderCfg); region == "" { + region = os.Getenv(alicloudRegionEnv) + } + if region == "" { + return nil, nil, ErrEmptyAlicloudProviderRegion + } + + // Build random_password resource. + randomPasswordRes, randomPasswordID, err := postgres.GenerateTFRandomPassword(request) + if err != nil { + return nil, nil, err + } + resources = append(resources, *randomPasswordRes) + + // Build alicloud_db_instance resource. + alicloudDBInstanceRes, alicloudDBInstanceID, err := postgres.generateAlicloudDBInstance( + alicloudProviderCfg, region, + ) + if err != nil { + return nil, nil, err + } + resources = append(resources, *alicloudDBInstanceRes) + + // Build alicloud_db_connection resource. + var alicloudDBConnectionRes *apiv1.Resource + var alicloudDBConnectionID string + if IsPublicAccessible(postgres.SecurityIPs) { + alicloudDBConnectionRes, alicloudDBConnectionID, err = postgres.generateAlicloudDBConnection( + alicloudProviderCfg, + region, alicloudDBInstanceID, + ) + if err != nil { + return nil, nil, err + } + + resources = append(resources, *alicloudDBConnectionRes) + } + + // Build alicloud_rds_account resuorce. + alicloudRDSAccountRes, err := postgres.generateAlicloudRDSAccount( + alicloudProviderCfg, + region, postgres.Username, randomPasswordID, alicloudDBInstanceID, + ) + if err != nil { + return nil, nil, err + } + resources = append(resources, *alicloudRDSAccountRes) + + hostAddress := modules.KusionPathDependency(alicloudDBInstanceID, "connection_string") + if !postgres.PrivateRouting { + // Set the public network connection string as the host address. + hostAddress = modules.KusionPathDependency(alicloudDBConnectionID, "connection_string") + } + password := modules.KusionPathDependency(randomPasswordID, "result") + + // Build Kubernetes Secret with the hostAddress, username and password of the Alicloud provided PostgreSQL instance, + // and inject the credentials as the environment variable patcher. + dbSecret, patcher, err := postgres.GenerateDBSecret(request, hostAddress, postgres.Username, password) + if err != nil { + return nil, nil, err + } + resources = append(resources, *dbSecret) + patchers = append(patchers, *patcher) + + return resources, patchers, nil +} + +// generateAlicloudDBInstance generates alicloud_db_instance resource +// for the Alicloud provided PostgreSQL database instance. +func (postgres *PostgreSQL) generateAlicloudDBInstance(alicloudProviderCfg apiv1.ProviderConfig, + region string, +) (*apiv1.Resource, string, error) { + resAttrs := map[string]interface{}{ + "category": postgres.Category, + "engine": "PostgreSQL", + "engine_version": postgres.Version, + "instance_storage": postgres.Size, + "instance_type": postgres.InstanceType, + "security_ips": postgres.SecurityIPs, + "vswitch_id": postgres.SubnetID, + "instance_name": postgres.DatabaseName, + } + + // Set the serverless-specific attributes of the alicloud_db_instance resource. + if strings.Contains(postgres.Category, "serverless") { + resAttrs["db_instance_storage_type"] = "cloud_essd" + resAttrs["instance_charge_type"] = "Serverless" + + serverlessConfig := alicloudServerlessConfig{ + MaxCapacity: 8, + MinCapacity: 1, + } + serverlessConfig.AutoPause = false + serverlessConfig.SwitchForce = false + + resAttrs["serverless_config"] = []alicloudServerlessConfig{ + serverlessConfig, + } + } + + id, err := module.TerraformResourceID(alicloudProviderCfg, alicloudDBInstance, postgres.DatabaseName) + if err != nil { + return nil, "", err + } + + resExts, err := module.TerraformProviderExtensions(alicloudProviderCfg, map[string]any{"region": region}, alicloudDBInstance) + if err != nil { + return nil, "", err + } + + resource, err := module.WrapTFResourceToKusionResource(id, resAttrs, resExts, nil) + if err != nil { + return nil, "", err + } + + return resource, id, nil +} + +// generateAlicloudDBConnection generates alicloud_db_connection resource +// for the Alicloud provided PostgreSQL database instance. +func (postgres *PostgreSQL) generateAlicloudDBConnection(alicloudProviderCfg apiv1.ProviderConfig, + region, dbInstanceID string, +) (*apiv1.Resource, string, error) { + resAttrs := map[string]interface{}{ + "instance_id": modules.KusionPathDependency(dbInstanceID, "id"), + "port": 5432, + } + + id, err := module.TerraformResourceID(alicloudProviderCfg, alicloudDBConnection, postgres.DatabaseName) + if err != nil { + return nil, "", err + } + + resExts, err := module.TerraformProviderExtensions(alicloudProviderCfg, map[string]any{"region": region}, alicloudDBConnection) + if err != nil { + return nil, "", err + } + + resource, err := module.WrapTFResourceToKusionResource(id, resAttrs, resExts, nil) + if err != nil { + return nil, "", err + } + + return resource, id, nil +} + +// generateAlicloudRDSAccount generates alicloud_rds_account resource +// for the Alicloud provided PostgreSQL database instance. +func (postgres *PostgreSQL) generateAlicloudRDSAccount(alicloudProviderCfg apiv1.ProviderConfig, + region, accountName, randomPasswordID, dbInstanceID string, +) (*apiv1.Resource, error) { + resAttrs := map[string]interface{}{ + "account_name": accountName, + "account_password": modules.KusionPathDependency(randomPasswordID, "result"), + "account_type": "Super", + "db_instance_id": modules.KusionPathDependency(dbInstanceID, "id"), + } + + id, err := module.TerraformResourceID(alicloudProviderCfg, alicloudRDSAccount, postgres.DatabaseName) + if err != nil { + return nil, err + } + + resExts, err := module.TerraformProviderExtensions(alicloudProviderCfg, map[string]any{"region": region}, alicloudRDSAccount) + if err != nil { + return nil, err + } + + resource, err := module.WrapTFResourceToKusionResource(id, resAttrs, resExts, nil) + if err != nil { + return nil, err + } + + return resource, nil +} diff --git a/modules/postgres/src/alicloud_rds_test.go b/modules/postgres/src/alicloud_rds_test.go new file mode 100644 index 0000000..0f2590f --- /dev/null +++ b/modules/postgres/src/alicloud_rds_test.go @@ -0,0 +1,111 @@ +package main + +import ( + "os" + "testing" + + "github.com/bytedance/mockey" + "github.com/stretchr/testify/assert" + "kusionstack.io/kusion-module-framework/pkg/module" + "kusionstack.io/kusion/pkg/apis/core/v1/workload" +) + +func TestPostgreSQLModule_GenerateAlicloudResources(t *testing.T) { + r := &module.GeneratorRequest{ + Project: "test-project", + Stack: "test-stack", + App: "test-app", + Workload: &workload.Workload{ + Header: workload.Header{ + Type: "Service", + }, + Service: &workload.Service{}, + }, + } + + postgres := &PostgreSQL{ + Type: "local", + Version: "14.0", + DatabaseName: "test-database", + Username: defaultUsername, + SecurityIPs: defaultSecurityIPs, + PrivateRouting: false, + Size: defaultSize, + InstanceType: "postgres.n2.serverless.1c", + Category: "serverless_basic", + SubnetID: "test-subnet-id", + } + + mockey.PatchConvey("set alicloud region env", t, func() { + mockey.Mock(os.Getenv).Return("test-region").Build() + + resources, patchers, err := postgres.GenerateAlicloudResources(r) + + assert.Equal(t, 5, len(resources)) + assert.NotNil(t, patchers) + assert.NoError(t, err) + }) +} + +func TestPostgreSQLModule_GenerateAlicloudDBInstance(t *testing.T) { + postgres := &PostgreSQL{ + Type: "local", + Version: "14.0", + DatabaseName: "test-database", + Username: defaultUsername, + SecurityIPs: defaultSecurityIPs, + PrivateRouting: false, + Size: defaultSize, + InstanceType: "postgres.n2.serverless.1c", + Category: "serverless_basic", + SubnetID: "test-subnet-id", + } + + res, id, err := postgres.generateAlicloudDBInstance(defaultAlicloudProviderCfg, "test-region") + + assert.NotNil(t, res) + assert.NotEqual(t, id, "") + assert.NoError(t, err) +} + +func TestPostgreSQLModule_GenerateAlicloudDBConnection(t *testing.T) { + postgres := &PostgreSQL{ + Type: "local", + Version: "14.0", + DatabaseName: "test-database", + Username: defaultUsername, + SecurityIPs: defaultSecurityIPs, + PrivateRouting: false, + Size: defaultSize, + InstanceType: "postgres.n2.serverless.1c", + Category: "serverless_basic", + SubnetID: "test-subnet-id", + } + + res, id, err := postgres.generateAlicloudDBConnection(defaultAlicloudProviderCfg, "test-region", "db_instance_id") + + assert.NotNil(t, res) + assert.NotEqual(t, id, "") + assert.NoError(t, err) +} + +func TestPostgreSQLModule_GenerateAlicloudRDSAccount(t *testing.T) { + postgres := &PostgreSQL{ + Type: "local", + Version: "14.0", + DatabaseName: "test-database", + Username: defaultUsername, + SecurityIPs: defaultSecurityIPs, + PrivateRouting: false, + Size: defaultSize, + InstanceType: "postgres.n2.serverless.1c", + Category: "serverless_basic", + SubnetID: "test-subnet-id", + } + + res, err := postgres.generateAlicloudRDSAccount(defaultAlicloudProviderCfg, "test-region", + "account_name", "random_password_id", "db_instance_id") + + assert.NotNil(t, res) + assert.NoError(t, err) +} diff --git a/modules/postgres/src/aws_rds.go b/modules/postgres/src/aws_rds.go new file mode 100644 index 0000000..dc24ae9 --- /dev/null +++ b/modules/postgres/src/aws_rds.go @@ -0,0 +1,175 @@ +package main + +import ( + "errors" + "fmt" + "os" + + "kusionstack.io/kusion-module-framework/pkg/module" + apiv1 "kusionstack.io/kusion/pkg/apis/core/v1" + "kusionstack.io/kusion/pkg/modules" +) + +var ErrEmptyAWSProviderRegion = errors.New("empty aws provider region") + +var ( + awsRegionEnv = "AWS_REGION" + awsSecurityGroup = "aws_security_group" + awsDBInstance = "aws_db_instance" +) + +var defaultAWSProviderCfg = apiv1.ProviderConfig{ + Source: "hashicorp/aws", + Version: "5.0.1", +} + +type awsSecurityGroupTraffic struct { + CidrBlocks []string `yaml:"cidr_blocks" json:"cidr_blocks"` + Description string `yaml:"description" json:"description"` + FromPort int `yaml:"from_port" json:"from_port"` + IPv6CIDRBlocks []string `yaml:"ipv6_cidr_blocks" json:"ipv6_cidr_blocks"` + PrefixListIDs []string `yaml:"prefix_list_ids" json:"prefix_list_ids"` + Protocol string `yaml:"protocol" json:"protocol"` + SecurityGroups []string `yaml:"security_groups" json:"security_groups"` + Self bool `yaml:"self" json:"self"` + ToPort int `yaml:"to_port" json:"to_port"` +} + +// GenerateAWSResources generates the AWS provided PostgreSQL database instance. +func (postgres *PostgreSQL) GenerateAWSResources(request *module.GeneratorRequest) ([]apiv1.Resource, []apiv1.Patcher, error) { + var resources []apiv1.Resource + var patchers []apiv1.Patcher + + // Set the AWS provider with the default provider config. + awsProviderCfg := defaultAWSProviderCfg + + // Get the AWS Terraform provider region, which should not be empty. + var region string + if region = module.TerraformProviderRegion(awsProviderCfg); region == "" { + region = os.Getenv(awsRegionEnv) + } + if region == "" { + return nil, nil, ErrEmptyAWSProviderRegion + } + + // Build random_password resource. + randomPasswordRes, randomPasswordID, err := postgres.GenerateTFRandomPassword(request) + if err != nil { + return nil, nil, err + } + resources = append(resources, *randomPasswordRes) + + // Build aws_security_group resource. + awsSecurityGroupRes, awsSecurityGroupID, err := postgres.generateAWSSecurityGroup(awsProviderCfg, region) + if err != nil { + return nil, nil, err + } + resources = append(resources, *awsSecurityGroupRes) + + // Build aws_db_instance resource. + awsDBInstance, awsDBInstanceID, err := postgres.generateAWSDBInstance(awsProviderCfg, region, randomPasswordID, awsSecurityGroupID) + if err != nil { + return nil, nil, err + } + resources = append(resources, *awsDBInstance) + + hostAddress := modules.KusionPathDependency(awsDBInstanceID, "address") + password := modules.KusionPathDependency(randomPasswordID, "result") + + // Build Kubernetes Secret with the hostAddress, username and password of the AWS provided PostgreSQL instance, + // and inject the credentials as the environment variable patcher. + dbSecret, patcher, err := postgres.GenerateDBSecret(request, hostAddress, postgres.Username, password) + if err != nil { + return nil, nil, err + } + resources = append(resources, *dbSecret) + patchers = append(patchers, *patcher) + + return resources, patchers, nil +} + +// generateAWSSecurityGroup generates aws_security_group resource for the AWS provided PostgreSQL database instance. +func (postgres *PostgreSQL) generateAWSSecurityGroup(awsProviderCfg apiv1.ProviderConfig, region string) (*apiv1.Resource, string, error) { + // SecurityIPs should be in the format of IP address or Classes Inter-Domain + // Routing (CIDR) mode. + for _, ip := range postgres.SecurityIPs { + if !IsIPAddress(ip) && !IsCIDR(ip) { + return nil, "", fmt.Errorf("illegal security ip format: %s", ip) + } + } + + resAttrs := map[string]interface{}{ + "egress": []awsSecurityGroupTraffic{ + { + CidrBlocks: []string{"0.0.0.0/0"}, + Protocol: "-1", + FromPort: 0, + ToPort: 0, + }, + }, + "ingress": []awsSecurityGroupTraffic{ + { + CidrBlocks: postgres.SecurityIPs, + Protocol: "tcp", + FromPort: 5432, + ToPort: 5432, + }, + }, + } + + id, err := module.TerraformResourceID(awsProviderCfg, awsSecurityGroup, postgres.DatabaseName+dbResSuffix) + if err != nil { + return nil, "", err + } + + resExts, err := module.TerraformProviderExtensions(awsProviderCfg, map[string]any{"region": region}, awsSecurityGroup) + if err != nil { + return nil, "", err + } + + resource, err := module.WrapTFResourceToKusionResource(id, resAttrs, resExts, nil) + if err != nil { + return nil, "", err + } + + return resource, id, nil +} + +// generateAWSDBInstance generates aws_db_instance resource for the AWS provided PostgreSQL database instance. +func (postgres *PostgreSQL) generateAWSDBInstance(awsProviderCfg apiv1.ProviderConfig, region, randomPasswordID, awsSecurityGroupID string) (*apiv1.Resource, string, error) { + resAttrs := map[string]interface{}{ + "allocated_storage": postgres.Size, + "engine": dbEngine, + "engine_version": postgres.Version, + "identifier": postgres.DatabaseName, + "instance_class": postgres.InstanceType, + "password": modules.KusionPathDependency(randomPasswordID, "result"), + "publicly_accessible": IsPublicAccessible(postgres.SecurityIPs), + "skip_final_snapshot": true, + "username": postgres.Username, + "vpc_security_group_ids": []string{ + modules.KusionPathDependency(awsSecurityGroupID, "id"), + }, + } + + if postgres.SubnetID != "" { + resAttrs["db_subnet_group_name"] = postgres.SubnetID + } + + id, err := module.TerraformResourceID(awsProviderCfg, awsDBInstance, postgres.DatabaseName) + if err != nil { + return nil, "", err + } + + resExts, err := module.TerraformProviderExtensions(awsProviderCfg, map[string]any{"region": region}, awsDBInstance) + if err != nil { + return nil, "", err + } + + resource, err := module.WrapTFResourceToKusionResource(id, resAttrs, resExts, nil) + if err != nil { + return nil, "", err + } + + return resource, id, nil +} diff --git a/modules/postgres/src/aws_rds_test.go b/modules/postgres/src/aws_rds_test.go new file mode 100644 index 0000000..d70a9de --- /dev/null +++ b/modules/postgres/src/aws_rds_test.go @@ -0,0 +1,88 @@ +package main + +import ( + "os" + "testing" + + "github.com/bytedance/mockey" + "github.com/stretchr/testify/assert" + "kusionstack.io/kusion-module-framework/pkg/module" + "kusionstack.io/kusion/pkg/apis/core/v1/workload" +) + +func TestPostgreSQLModule_GenerateAWSResources(t *testing.T) { + r := &module.GeneratorRequest{ + Project: "test-project", + Stack: "test-stack", + App: "test-app", + Workload: &workload.Workload{ + Header: workload.Header{ + Type: "Service", + }, + Service: &workload.Service{}, + }, + } + + postgres := &PostgreSQL{ + Type: "local", + Version: "14.0", + DatabaseName: "test-database", + Username: defaultUsername, + Category: defaultCategory, + SecurityIPs: defaultSecurityIPs, + PrivateRouting: false, + Size: defaultSize, + InstanceType: "db.t3.micro", + } + + mockey.PatchConvey("set aws region env", t, func() { + mockey.Mock(os.Getenv).Return("test-region").Build() + + resources, patchers, err := postgres.GenerateAWSResources(r) + + assert.Equal(t, 4, len(resources)) + assert.NotNil(t, patchers) + assert.NoError(t, err) + }) +} + +func TestPostgreSQLModule_GenerateAWSSecurityGroup(t *testing.T) { + postgres := &PostgreSQL{ + Type: "local", + Version: "14.0", + DatabaseName: "test-database", + Username: defaultUsername, + Category: defaultCategory, + SecurityIPs: defaultSecurityIPs, + PrivateRouting: false, + Size: defaultSize, + InstanceType: "db.t3.micro", + } + + res, id, err := postgres.generateAWSSecurityGroup(defaultAWSProviderCfg, "test-region") + + assert.NotNil(t, res) + assert.NotEqual(t, id, "") + assert.NoError(t, err) +} + +func TestPostgreSQLModule_GenerateAWSDBInstance(t *testing.T) { + postgres := &PostgreSQL{ + Type: "local", + Version: "14.0", + DatabaseName: "test-database", + Username: defaultUsername, + Category: defaultCategory, + SecurityIPs: defaultSecurityIPs, + PrivateRouting: false, + Size: defaultSize, + InstanceType: "db.t3.micro", + } + + res, id, err := postgres.generateAWSDBInstance(defaultAWSProviderCfg, "test-region", + "random_password_id", "aws_security_group_id") + + assert.NotNil(t, res) + assert.NotEqual(t, id, "") + assert.NoError(t, err) +} diff --git a/modules/postgres/src/go.mod b/modules/postgres/src/go.mod new file mode 100644 index 0000000..408a74b --- /dev/null +++ b/modules/postgres/src/go.mod @@ -0,0 +1,63 @@ +module postgres + +go 1.22.1 + +require ( + github.com/bytedance/mockey v1.2.10 + github.com/stretchr/testify v1.9.0 + k8s.io/api v0.29.3 + k8s.io/apimachinery v0.29.3 + kusionstack.io/kusion v0.10.1-0.20240326060146-3f01e9416ff6 + kusionstack.io/kusion-module-framework v0.0.0-20240326063408-807a5a4e4682 +) + +require ( + github.com/blang/semver/v4 v4.0.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/fatih/color v1.13.0 // indirect + github.com/go-logr/logr v1.3.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/go-github/v50 v50.0.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/google/gofuzz v1.2.0 // indirect + github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 // indirect + github.com/hashicorp/go-hclog v0.16.2 // indirect + github.com/hashicorp/go-plugin v1.6.0 // indirect + github.com/hashicorp/go-version v1.6.0 // indirect + github.com/hashicorp/yamux v0.1.1 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/jtolds/gls v4.20.0+incompatible // indirect + github.com/mattn/go-colorable v0.1.12 // indirect + github.com/mattn/go-isatty v0.0.14 // indirect + github.com/mitchellh/go-testing-interface v1.14.1 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/oklog/run v1.0.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d // indirect + github.com/smartystreets/goconvey v1.6.4 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.6.0 // indirect + go.uber.org/zap v1.24.0 // indirect + golang.org/x/arch v0.1.0 // indirect + golang.org/x/crypto v0.18.0 // indirect + golang.org/x/net v0.20.0 // indirect + golang.org/x/oauth2 v0.16.0 // indirect + golang.org/x/sys v0.16.0 // indirect + golang.org/x/text v0.14.0 // indirect + google.golang.org/appengine v1.6.8 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240123012728-ef4313101c80 // indirect + google.golang.org/grpc v1.62.0 // indirect + google.golang.org/protobuf v1.33.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/klog/v2 v2.110.1 // indirect + k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect + kcl-lang.io/kcl-plugin v0.5.0 // indirect + sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect +) diff --git a/modules/postgres/src/go.sum b/modules/postgres/src/go.sum new file mode 100644 index 0000000..a83fbf4 --- /dev/null +++ b/modules/postgres/src/go.sum @@ -0,0 +1,206 @@ +github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= +github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA= +github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8= +github.com/bytedance/mockey v1.2.10 h1:4JlMpkm7HMXmTUtItid+iCu2tm61wvq+ca1X2u7ymzE= +github.com/bytedance/mockey v1.2.10/go.mod h1:bNrUnI1u7+pAc0TYDgPATM+wF2yzHxmNH+iDXg4AOCU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY= +github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-github/v50 v50.0.0 h1:gdO1AeuSZZK4iYWwVbjni7zg8PIQhp7QfmPunr016Jk= +github.com/google/go-github/v50 v50.0.0/go.mod h1:Ev4Tre8QoKiolvbpOSG3FIi4Mlon3S2Nt9W5JYqKiwA= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/hashicorp/go-hclog v0.16.2 h1:K4ev2ib4LdQETX5cSZBG0DVLk1jwGqSPXBjdah3veNs= +github.com/hashicorp/go-hclog v0.16.2/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= +github.com/hashicorp/go-plugin v1.6.0 h1:wgd4KxHJTVGGqWBq4QPB1i5BZNEx9BR8+OFmHDmTk8A= +github.com/hashicorp/go-plugin v1.6.0/go.mod h1:lBS5MtSSBZk0SHc66KACcjjlU6WzEVP/8pwz68aMkCI= +github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= +github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= +github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= +github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c= +github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= +github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw= +github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A= +go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4= +go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60= +go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= +golang.org/x/arch v0.1.0 h1:oMxhUYsO9VsR1dcoVUjJjIGhx1LXol3989T/yZ59Xsw= +golang.org/x/arch v0.1.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/oauth2 v0.16.0 h1:aDkGMBSYxElaoP81NpoUoz2oo2R2wHdZpGToUxfyQrQ= +golang.org/x/oauth2 v0.16.0/go.mod h1:hqZ+0LWXsiVoZpeld6jVt06P3adbS2Uu911W1SsJv2o= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= +google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240123012728-ef4313101c80 h1:AjyfHzEPEFp/NpvfN5g+KDla3EMojjhRVZc1i7cj+oM= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240123012728-ef4313101c80/go.mod h1:PAREbraiVEVGVdTZsVWjSbbTtSyGbAgIIvni8a8CD5s= +google.golang.org/grpc v1.62.0 h1:HQKZ/fa1bXkX1oFOvSjmZEUL8wLSaZTjCcLAlmZRtdk= +google.golang.org/grpc v1.62.0/go.mod h1:IWTG0VlJLCh1SkC58F7np9ka9mx/WNkjl4PGJaiq+QE= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/natefinch/lumberjack.v2 v2.0.0 h1:1Lc07Kr7qY4U2YPouBjpCLxpiyxIVoxqXgkXLknAOE8= +gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.29.3 h1:2ORfZ7+bGC3YJqGpV0KSDDEVf8hdGQ6A03/50vj8pmw= +k8s.io/api v0.29.3/go.mod h1:y2yg2NTyHUUkIoTC+phinTnEa3KFM6RZ3szxt014a80= +k8s.io/apimachinery v0.29.3 h1:2tbx+5L7RNvqJjn7RIuIKu9XTsIZ9Z5wX2G22XAa5EU= +k8s.io/apimachinery v0.29.3/go.mod h1:hx/S4V2PNW4OMg3WizRrHutyB5la0iCUbZym+W0EQIU= +k8s.io/klog/v2 v2.110.1 h1:U/Af64HJf7FcwMcXyKm2RPM22WZzyR7OSpYj5tg3cL0= +k8s.io/klog/v2 v2.110.1/go.mod h1:YGtd1984u+GgbuZ7e08/yBuAfKLSO0+uR1Fhi6ExXjo= +k8s.io/utils v0.0.0-20230726121419-3b25d923346b h1:sgn3ZU783SCgtaSJjpcVVlRqd6GSnlTLKgpAAttJvpI= +k8s.io/utils v0.0.0-20230726121419-3b25d923346b/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +kcl-lang.io/kcl-plugin v0.5.0 h1:eoh6y4l81rwA8yhJXU4hN7YmJeTUNB1nfYCP9OffSxc= +kcl-lang.io/kcl-plugin v0.5.0/go.mod h1:QnZ5OLcyBw5nOnHpChRHtvBq8wvjwiHu/ZZ8j1dfz48= +kusionstack.io/kusion v0.10.1-0.20240326060146-3f01e9416ff6 h1:66DJEK1NZyA7C/Geh9f2MCeZx54R5y6quHJXWpoxJX4= +kusionstack.io/kusion v0.10.1-0.20240326060146-3f01e9416ff6/go.mod h1:xI/6cDT0cZAaWFdKVnpV/U5TKpSQnLMnUJAEy/uRpL8= +kusionstack.io/kusion-module-framework v0.0.0-20240326063408-807a5a4e4682 h1:X8zSDNh5Sa6FsTbwgohdu6tQ9lDQ3lZs9mTnXvk9GYo= +kusionstack.io/kusion-module-framework v0.0.0-20240326063408-807a5a4e4682/go.mod h1:ynITUHw3Cke7aLhzQXXFvXgxQcLF/z4uFxn8O87K4mA= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= +sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo= +sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= diff --git a/modules/postgres/src/local_db.go b/modules/postgres/src/local_db.go new file mode 100644 index 0000000..7dcd57e --- /dev/null +++ b/modules/postgres/src/local_db.go @@ -0,0 +1,305 @@ +package main + +import ( + "crypto/md5" + "encoding/hex" + "strconv" + + "kusionstack.io/kusion-module-framework/pkg/module" + + appsv1 "k8s.io/api/apps/v1" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + apiv1 "kusionstack.io/kusion/pkg/apis/core/v1" +) + +// GenerateLocalResources generates the resources of locally deployed PostgreSQL database instance. +func (postgres *PostgreSQL) GenerateLocalResources(request *module.GeneratorRequest) ([]apiv1.Resource, []apiv1.Patcher, error) { + var resources []apiv1.Resource + var patchers []apiv1.Patcher + + // Build Kubernetes Secret for the random password of the local PostgreSQL instance. + password := postgres.generateLocalPassword(request) + localSecret, err := postgres.generateLocalSecret(request, password) + if err != nil { + return nil, nil, err + } + resources = append(resources, *localSecret) + + // Build Kubernetes Deployment for the local PostgreSQL instance. + localDeployment, err := postgres.generateLocalDeployment(request) + if err != nil { + return nil, nil, err + } + resources = append(resources, *localDeployment) + + // Build Kubernetes Persistent Volume Claim for the lcoal PostgreSQL instance. + localPVC, err := postgres.generateLocalPVC(request) + if err != nil { + return nil, nil, err + } + resources = append(resources, *localPVC) + + // Build Kubernetes Service for the local PostgreSQL instance. + localSvc, hostAddress, err := postgres.generateLocalService(request) + if err != nil { + return nil, nil, err + } + resources = append(resources, *localSvc) + + // Build Kubernetes Secret with the hostAddress, username and password of the local PostgreSQL instance, + // and inject the credentials as the environment variable patcher. + dbSecret, patcher, err := postgres.GenerateDBSecret(request, hostAddress, postgres.Username, password) + if err != nil { + return nil, nil, err + } + resources = append(resources, *dbSecret) + patchers = append(patchers, *patcher) + + return resources, patchers, nil +} + +// generateLocalSecret generates the Kubernetes Secret resource for the local PostgreSQL instance. +func (postgres *PostgreSQL) generateLocalSecret(request *module.GeneratorRequest, password string) (*apiv1.Resource, error) { + // Set the password string. + data := make(map[string]string) + data["password"] = password + data["username"] = postgres.Username + data["database"] = postgres.DatabaseName + + // Construct the Kubernetes Secret resource. + secret := &v1.Secret{ + TypeMeta: metav1.TypeMeta{ + Kind: "Secret", + APIVersion: v1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: postgres.DatabaseName + localSecretSuffix, + Namespace: request.Project, + }, + StringData: data, + } + + resourceID := module.KubernetesResourceID(secret.TypeMeta, secret.ObjectMeta) + resource, err := module.WrapK8sResourceToKusionResource(resourceID, secret) + if err != nil { + return nil, err + } + + return resource, nil +} + +// generateLocalDeployment generates the Kubernetes Deployment resource for the local PostgreSQL instance. +func (postgres *PostgreSQL) generateLocalDeployment(request *module.GeneratorRequest) (*apiv1.Resource, error) { + // Prepare the Pod Spec for the local PostgreSQL instance. + podSpec, err := postgres.generateLocalPodSpec(request) + if err != nil { + return nil, nil + } + + // Create the Kubernetes Deployment for the local PostgreSQL instance. + deployment := &appsv1.Deployment{ + TypeMeta: metav1.TypeMeta{ + Kind: "Deployment", + APIVersion: appsv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: postgres.DatabaseName + localDeploymentSuffix, + Namespace: request.Project, + }, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: postgres.generateLocalMatchLabels(), + }, + Template: v1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: postgres.generateLocalMatchLabels(), + }, + Spec: podSpec, + }, + }, + } + + resourceID := module.KubernetesResourceID(deployment.TypeMeta, deployment.ObjectMeta) + resource, err := module.WrapK8sResourceToKusionResource(resourceID, deployment) + if err != nil { + return nil, err + } + + return resource, nil +} + +// generateLocalPodSpec generates the Kubernetes PodSpec for the local PostgreSQL instance. +func (postgres *PostgreSQL) generateLocalPodSpec(_ *module.GeneratorRequest) (v1.PodSpec, error) { + image := dbEngine + ":" + postgres.Version + secretName := postgres.DatabaseName + localSecretSuffix + ports := []v1.ContainerPort{ + { + Name: postgres.DatabaseName, + ContainerPort: int32(5432), + }, + } + volumes := []v1.Volume{ + { + Name: postgres.DatabaseName, + VolumeSource: v1.VolumeSource{ + PersistentVolumeClaim: &v1.PersistentVolumeClaimVolumeSource{ + ClaimName: postgres.DatabaseName + localPVCSuffix, + }, + }, + }, + } + volumeMounts := []v1.VolumeMount{ + { + Name: postgres.DatabaseName, + MountPath: "/var/lib/postgresql/data", + }, + } + + env := []v1.EnvVar{ + { + Name: "POSTGRES_USER", + ValueFrom: &v1.EnvVarSource{ + SecretKeyRef: &v1.SecretKeySelector{ + LocalObjectReference: v1.LocalObjectReference{ + Name: secretName, + }, + Key: "username", + }, + }, + }, + { + Name: "POSTGRES_PASSWORD", + ValueFrom: &v1.EnvVarSource{ + SecretKeyRef: &v1.SecretKeySelector{ + LocalObjectReference: v1.LocalObjectReference{ + Name: secretName, + }, + Key: "password", + }, + }, + }, + { + Name: "POSTGRES_DB", + ValueFrom: &v1.EnvVarSource{ + SecretKeyRef: &v1.SecretKeySelector{ + LocalObjectReference: v1.LocalObjectReference{ + Name: secretName, + }, + Key: "database", + }, + }, + }, + } + + podSpec := v1.PodSpec{ + Containers: []v1.Container{ + { + Name: postgres.DatabaseName, + Image: image, + Env: env, + Ports: ports, + VolumeMounts: volumeMounts, + }, + }, + Volumes: volumes, + } + + return podSpec, nil +} + +// generateLocalPVC generates the Kubernetes Persistent Volume Claim resource for the local PostgreSQL instance. +func (postgres *PostgreSQL) generateLocalPVC(request *module.GeneratorRequest) (*apiv1.Resource, error) { + // Create the Kubernetes PVC with the storage size of `postgres.Size`. + pvc := &v1.PersistentVolumeClaim{ + TypeMeta: metav1.TypeMeta{ + Kind: "PersistentVolumeClaim", + APIVersion: v1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: postgres.DatabaseName + localPVCSuffix, + Namespace: request.Project, + Labels: postgres.generateLocalMatchLabels(), + }, + Spec: v1.PersistentVolumeClaimSpec{ + AccessModes: []v1.PersistentVolumeAccessMode{ + v1.ReadWriteOnce, + }, + Resources: v1.VolumeResourceRequirements{ + Requests: map[v1.ResourceName]resource.Quantity{ + v1.ResourceStorage: resource.MustParse(strconv.Itoa(postgres.Size) + "Gi"), + }, + }, + }, + } + + resourceID := module.KubernetesResourceID(pvc.TypeMeta, pvc.ObjectMeta) + resource, err := module.WrapK8sResourceToKusionResource(resourceID, pvc) + if err != nil { + return nil, err + } + + return resource, nil +} + +// generateLocalService generates the Kubernetes Service resource for the local PostgreSQL instance. +func (postgres *PostgreSQL) generateLocalService(request *module.GeneratorRequest) (*apiv1.Resource, string, error) { + // Prepare the service port for the local PostgreSQL instance. + svcPort := postgres.generateLocalSvcPort() + svcName := postgres.DatabaseName + localServiceSuffix + + // Create the Kubernetes service for local PostgreSQL instance. + service := &v1.Service{ + TypeMeta: metav1.TypeMeta{ + Kind: "Service", + APIVersion: v1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: svcName, + Namespace: request.Project, + Labels: postgres.generateLocalMatchLabels(), + }, + Spec: v1.ServiceSpec{ + ClusterIP: "None", + Ports: svcPort, + Selector: postgres.generateLocalMatchLabels(), + }, + } + + resourceID := module.KubernetesResourceID(service.TypeMeta, service.ObjectMeta) + resource, err := module.WrapK8sResourceToKusionResource(resourceID, service) + if err != nil { + return nil, "", err + } + + return resource, svcName, nil +} + +// generateLocalSvcPort generates the Kubernetes ServicePort resource of the local PostgreSQL instance. +func (postgres *PostgreSQL) generateLocalSvcPort() []v1.ServicePort { + svcPort := []v1.ServicePort{ + { + Port: int32(5432), + }, + } + + return svcPort +} + +// generateLocalMatchLabels generates the match labels for the Kubernetes resources of the local PostgreSQL instance. +func (postgres *PostgreSQL) generateLocalMatchLabels() map[string]string { + return map[string]string{ + "accessory": postgres.DatabaseName, + } +} + +// generateLocalPassword generates a fixed password string with the specified length for the local PostgreSQL instance. +func (postgres *PostgreSQL) generateLocalPassword(request *module.GeneratorRequest) string { + hashInput := request.Project + request.Stack + request.App + postgres.DatabaseName + hash := md5.Sum([]byte(hashInput)) + + hashString := hex.EncodeToString(hash[:]) + + return hashString[:16] +} diff --git a/modules/postgres/src/local_db_test.go b/modules/postgres/src/local_db_test.go new file mode 100644 index 0000000..b9f5aa6 --- /dev/null +++ b/modules/postgres/src/local_db_test.go @@ -0,0 +1,191 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "kusionstack.io/kusion-module-framework/pkg/module" + "kusionstack.io/kusion/pkg/apis/core/v1/workload" +) + +func TestPostgreSQLModule_GenerateLocalResources(t *testing.T) { + r := &module.GeneratorRequest{ + Project: "test-project", + Stack: "test-stack", + App: "test-app", + Workload: &workload.Workload{ + Header: workload.Header{ + Type: "Service", + }, + Service: &workload.Service{}, + }, + } + + postgres := &PostgreSQL{ + Type: "local", + Version: "14.0", + DatabaseName: "test-database", + Username: defaultUsername, + Category: defaultCategory, + SecurityIPs: defaultSecurityIPs, + PrivateRouting: defaultPrivateRouting, + Size: defaultSize, + } + + resources, patchers, err := postgres.GenerateLocalResources(r) + + assert.Equal(t, 5, len(resources)) + assert.NotNil(t, patchers) + assert.NoError(t, err) +} + +func TestPostgreSQLModule_GenerateLocalSecret(t *testing.T) { + r := &module.GeneratorRequest{ + Project: "test-project", + Stack: "test-stack", + App: "test-app", + Workload: &workload.Workload{ + Header: workload.Header{ + Type: "Service", + }, + Service: &workload.Service{}, + }, + } + + postgres := &PostgreSQL{ + Type: "local", + Version: "14.0", + DatabaseName: "test-database", + Username: defaultUsername, + Category: defaultCategory, + SecurityIPs: defaultSecurityIPs, + PrivateRouting: defaultPrivateRouting, + Size: defaultSize, + } + + res, err := postgres.generateLocalSecret(r, "123456") + + assert.NotNil(t, res) + assert.NoError(t, err) +} + +func TestPostgreSQLModule_GenerateLocalDeployment(t *testing.T) { + r := &module.GeneratorRequest{ + Project: "test-project", + Stack: "test-stack", + App: "test-app", + Workload: &workload.Workload{ + Header: workload.Header{ + Type: "Service", + }, + Service: &workload.Service{}, + }, + } + + postgres := &PostgreSQL{ + Type: "local", + Version: "14.0", + DatabaseName: "test-database", + Username: defaultUsername, + Category: defaultCategory, + SecurityIPs: defaultSecurityIPs, + PrivateRouting: defaultPrivateRouting, + Size: defaultSize, + } + + res, err := postgres.generateLocalDeployment(r) + + assert.NotNil(t, res) + assert.NoError(t, err) +} + +func TestPostgreSQLModule_GenerateLocalPodSpec(t *testing.T) { + r := &module.GeneratorRequest{ + Project: "test-project", + Stack: "test-stack", + App: "test-app", + Workload: &workload.Workload{ + Header: workload.Header{ + Type: "Service", + }, + Service: &workload.Service{}, + }, + } + + postgres := &PostgreSQL{ + Type: "local", + Version: "14.0", + DatabaseName: "test-database", + Username: defaultUsername, + Category: defaultCategory, + SecurityIPs: defaultSecurityIPs, + PrivateRouting: defaultPrivateRouting, + Size: defaultSize, + } + + res, err := postgres.generateLocalPodSpec(r) + + assert.NotNil(t, res) + assert.NoError(t, err) +} + +func TestPostgreSQLModule_GenerateLocalPVC(t *testing.T) { + r := &module.GeneratorRequest{ + Project: "test-project", + Stack: "test-stack", + App: "test-app", + Workload: &workload.Workload{ + Header: workload.Header{ + Type: "Service", + }, + Service: &workload.Service{}, + }, + } + + postgres := &PostgreSQL{ + Type: "local", + Version: "14.0", + DatabaseName: "test-database", + Username: defaultUsername, + Category: defaultCategory, + SecurityIPs: defaultSecurityIPs, + PrivateRouting: defaultPrivateRouting, + Size: defaultSize, + } + + res, err := postgres.generateLocalPVC(r) + + assert.NotNil(t, res) + assert.NoError(t, err) +} + +func TestPostgreSQLModule_GenerateLocalService(t *testing.T) { + r := &module.GeneratorRequest{ + Project: "test-project", + Stack: "test-stack", + App: "test-app", + Workload: &workload.Workload{ + Header: workload.Header{ + Type: "Service", + }, + Service: &workload.Service{}, + }, + } + + postgres := &PostgreSQL{ + Type: "local", + Version: "14.0", + DatabaseName: "test-database", + Username: defaultUsername, + Category: defaultCategory, + SecurityIPs: defaultSecurityIPs, + PrivateRouting: defaultPrivateRouting, + Size: defaultSize, + } + + res, svcName, err := postgres.generateLocalService(r) + + assert.NotNil(t, res) + assert.NotNil(t, svcName) + assert.NoError(t, err) +} diff --git a/modules/postgres/src/postgres.go b/modules/postgres/src/postgres.go new file mode 100644 index 0000000..1f5a363 --- /dev/null +++ b/modules/postgres/src/postgres.go @@ -0,0 +1,387 @@ +package main + +import ( + "context" + "errors" + "fmt" + "net" + "strings" + + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "kusionstack.io/kusion-module-framework/pkg/module" + "kusionstack.io/kusion-module-framework/pkg/server" + apiv1 "kusionstack.io/kusion/pkg/apis/core/v1" + "kusionstack.io/kusion/pkg/log" + "kusionstack.io/kusion/pkg/workspace" +) + +const ( + CloudDBType = "cloud" + LocalDBType = "local" +) + +const ( + dbEngine = "postgres" + dbResSuffix = "-postgres" + dbHostAddressEnv = "KUSION_DB_HOST" + dbUsernameEnv = "KUSION_DB_USERNAME" + dbPasswordEnv = "KUSION_DB_PASSWORD" +) + +var ( + ErrEmptyInstanceTypeForCloudDB = errors.New("empty instance type for cloud managed postgres instance") + ErrEmptyCloudProviderType = errors.New("empty cloud provider type in postgres module config") +) + +var ( + localDeploymentSuffix = "-db-local-deployment" + localSecretSuffix = "-db-local-secret" + localPVCSuffix = "-db-local-pvc" + localServiceSuffix = "-db-local-service" +) + +var ( + // NOTE: the user name used as the account name of alicloud_rds should + // conform to certain standards. + defaultUsername string = "kusion_default" + defaultCategory string = "Basic" + defaultSecurityIPs []string = []string{"0.0.0.0/0"} + defaultPrivateRouting bool = true + defaultSize int = 10 +) + +var defaultRandomProviderCfg = apiv1.ProviderConfig{ + Source: "hashicorp/random", + Version: "3.6.0", +} + +var randomPassword = "random_password" + +// PostgreSQL describes the attributes to locally deploy or create a cloud provider +// managed PostgreSQL database instance for the workload. +type PostgreSQL struct { + // The deployment mode of the PostgreSQL database. + Type string `json:"type,omitempty" yaml:"type,omitempty"` + // The PostgreSQL database version to use. + Version string `json:"version,omitempty" yaml:"version,omitempty"` + // The type of the PostgreSQL instance. + InstanceType string `json:"instanceType,omitempty" yaml:"instanceType,omitempty"` + // The allocated storage size of the PostgreSQL instance. + Size int `json:"size,omitempty" yaml:"size,omitempty"` + // The edition of the PostgreSQL instance provided by the cloud vendor. + Category string `json:"category,omitempty" yaml:"category,omitempty"` + // The operation account for the PostgreSQL database. + Username string `json:"username,omitempty" yaml:"username,omitempty"` + // The list of IP addresses allowed to access the PostgreSQL instance provided by the cloud vendor. + SecurityIPs []string `json:"securityIPs,omitempty" yaml:"securityIPs,omitempty"` + // The virtual subnet ID associated with the VPC that the cloud PostgreSQL instance will be created in. + SubnetID string `json:"subnetID,omitempty" yaml:"subnetID,omitempty"` + // Whether the host address of the cloud PostgreSQL instance for the workload to connect with is via + // public network or private network of the cloud vendor. + PrivateRouting bool `json:"privateRouting,omitempty" yaml:"privateRouting,omitempty"` + // The specified name of the PostgreSQL database instance. + DatabaseName string `json:"databaseName,omitempty" yaml:"databaseName,omitempty"` +} + +func (postgres *PostgreSQL) Generate(_ context.Context, request *module.GeneratorRequest) (*module.GeneratorResponse, error) { + defer func() { + if r := recover(); r != nil { + log.Debugf("failed to generate postgres module: %v", r) + } + }() + + // PostgreSQL does not exist in AppConfiguration and workspace configs. + if request.DevModuleConfig == nil { + log.Info("PostgreSQL does not exist in AppConfig config") + + return nil, nil + } + + // Get the complete configs of the PostgreSQL instance. + err := postgres.GetCompleteConfig(request.DevModuleConfig, request.PlatformModuleConfig) + if err != nil { + return nil, err + } + + // Set the database name. + if postgres.DatabaseName == "" { + postgres.DatabaseName = GenerateDefaultPostgreSQLName(request.Project, request.Stack, request.App) + } + + // Generate the PostgreSQL intance resources based on the type and the cloud provider config. + var resources []apiv1.Resource + var patchers []apiv1.Patcher + switch strings.ToLower(postgres.Type) { + case LocalDBType: + resources, patchers, err = postgres.GenerateLocalResources(request) + case CloudDBType: + providerType, err := GetCloudProviderType(request.PlatformModuleConfig) + if err != nil { + return nil, err + } + + switch strings.ToLower(providerType) { + case "aws": + resources, patchers, err = postgres.GenerateAWSResources(request) + case "alicloud": + resources, patchers, err = postgres.GenerateAlicloudResources(request) + default: + return nil, fmt.Errorf("unsupported cloud provider type: %s", providerType) + } + default: + return nil, fmt.Errorf("unsupported postgres type: %s", postgres.Type) + } + + if err != nil { + return nil, err + } + + return &module.GeneratorResponse{ + Resources: resources, + Patchers: patchers, + }, nil +} + +// GetCompleteConfig combines the configs in devModuleConfig and platformModuleConfig to form a complete +// configuration for the PostgreSQL instance. +func (postgres *PostgreSQL) GetCompleteConfig(devConfig apiv1.Accessory, platformConfig apiv1.GenericConfig) error { + // Set the default values for PostgreSQL instance if platformConfig not exists. + if platformConfig == nil { + postgres.Username = defaultUsername + postgres.Category = defaultCategory + postgres.SecurityIPs = defaultSecurityIPs + postgres.PrivateRouting = defaultPrivateRouting + postgres.Size = defaultSize + } + + // Get the type and version of the PostgreSQL instance in devConfig. + if postgresType, ok := devConfig["type"]; ok { + postgres.Type = postgresType.(string) + } + if postgresVersion, ok := devConfig["version"]; ok { + postgres.Version = postgresVersion.(string) + } + + // Get the other configs of the PostgreSQL instance in platformConfig, + // and use the default values if some of them don't exist. + if username, ok := platformConfig["username"]; ok { + postgres.Username = username.(string) + } else { + postgres.Username = defaultUsername + } + + if category, ok := platformConfig["category"]; ok { + postgres.Category = category.(string) + } else { + postgres.Category = defaultCategory + } + + if securityIPs, ok := platformConfig["securityIPs"]; ok { + postgres.SecurityIPs = securityIPs.([]string) + } else { + postgres.SecurityIPs = defaultSecurityIPs + } + + if privateRouting, ok := platformConfig["privateRouting"]; ok { + postgres.PrivateRouting = privateRouting.(bool) + } else { + postgres.PrivateRouting = defaultPrivateRouting + } + + if size, ok := platformConfig["size"]; ok { + postgres.Size = size.(int) + } else { + postgres.Size = defaultSize + } + + if instanceType, ok := platformConfig["instanceType"]; ok { + postgres.InstanceType = instanceType.(string) + } + + if subnetID, ok := platformConfig["subnetID"]; ok { + postgres.SubnetID = subnetID.(string) + } + + if databaseName, ok := platformConfig["databaseName"]; ok { + postgres.DatabaseName = databaseName.(string) + } + + return postgres.Validate() +} + +// GenerateDBSecret generates Kubernetes Secret resource to store the host address, username +// and password of the local PostgreSQL database instance. +func (postgres *PostgreSQL) GenerateDBSecret(request *module.GeneratorRequest, hostAddress, username, password string) ( + *apiv1.Resource, *apiv1.Patcher, error, +) { + // Create the data map of Kubernetes Secret storing the database host address, username + // and password. + data := make(map[string]string) + data["hostAddress"] = hostAddress + data["username"] = username + data["password"] = password + + // Create the Kubernetes Secret. + secret := &v1.Secret{ + TypeMeta: metav1.TypeMeta{ + Kind: "Secret", + APIVersion: v1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: postgres.DatabaseName + dbResSuffix, + Namespace: request.Project, + }, + StringData: data, + } + + resourceID := module.KubernetesResourceID(secret.TypeMeta, secret.ObjectMeta) + resource, err := module.WrapK8sResourceToKusionResource(resourceID, secret) + if err != nil { + return nil, nil, err + } + + // Inject the database credentials into the workload as the environment variables with + // Kusion resource patcher. + hostAddressKey := dbHostAddressEnv + "_" + strings.ToUpper(strings.ReplaceAll(postgres.DatabaseName, "-", "_")) + usernameKey := dbUsernameEnv + "_" + strings.ToUpper(strings.ReplaceAll(postgres.DatabaseName, "-", "_")) + passwordKey := dbPasswordEnv + "_" + strings.ToUpper(strings.ReplaceAll(postgres.DatabaseName, "-", "_")) + + envVars := []v1.EnvVar{ + { + Name: hostAddressKey, + ValueFrom: &v1.EnvVarSource{ + SecretKeyRef: &v1.SecretKeySelector{ + LocalObjectReference: v1.LocalObjectReference{ + Name: secret.Name, + }, + Key: "hostAddress", + }, + }, + }, + { + Name: usernameKey, + ValueFrom: &v1.EnvVarSource{ + SecretKeyRef: &v1.SecretKeySelector{ + LocalObjectReference: v1.LocalObjectReference{ + Name: secret.Name, + }, + Key: "username", + }, + }, + }, + { + Name: passwordKey, + ValueFrom: &v1.EnvVarSource{ + SecretKeyRef: &v1.SecretKeySelector{ + LocalObjectReference: v1.LocalObjectReference{ + Name: secret.Name, + }, + Key: "password", + }, + }, + }, + } + + patcher := &apiv1.Patcher{ + Environments: envVars, + } + + return resource, patcher, nil +} + +// GenerateTFRandomPassword generates the terraform random_password resource as the password +// of the cloud provided PostgreSQL database instance. +func (postgres *PostgreSQL) GenerateTFRandomPassword(request *module.GeneratorRequest) (*apiv1.Resource, string, error) { + resAttrs := map[string]any{ + "length": 16, + "special": true, + "override_special": "_", + } + + // Set the random_password provider with the default provider config. + randomPasswordProvider := defaultRandomProviderCfg + + id, err := module.TerraformResourceID(randomPasswordProvider, randomPassword, postgres.DatabaseName+dbResSuffix) + if err != nil { + return nil, "", err + } + + resExts, err := module.TerraformProviderExtensions(randomPasswordProvider, nil, randomPassword) + if err != nil { + return nil, "", err + } + + resource, err := module.WrapTFResourceToKusionResource(id, resAttrs, resExts, nil) + if err != nil { + return nil, "", err + } + + return resource, id, nil +} + +// Validate validates whether the input of a PostgreSQL database instance is valid. +func (postgres *PostgreSQL) Validate() error { + if postgres.Type == CloudDBType && postgres.InstanceType == "" { + return ErrEmptyInstanceTypeForCloudDB + } + + return nil +} + +// GenerateDefaultPostgreSQLName generates the default name of the PostgreSQL instance. +func GenerateDefaultPostgreSQLName(projectName, stackName, appName string) string { + strs := []string{projectName, stackName, appName, dbEngine} + + return strings.Join(strs, "-") +} + +// GetCloudProviderType returns the cloud provider type of the PostgreSQL instance. +func GetCloudProviderType(platformConfig apiv1.GenericConfig) (string, error) { + if platformConfig == nil { + return "", workspace.ErrEmptyModuleConfigBlock + } + + if cloud, ok := platformConfig["cloud"]; ok { + return cloud.(string), nil + } + + return "", ErrEmptyCloudProviderType +} + +// IsPublicAccessible returns whether the postgres database instance is publicly +// accessible according to the securityIPs. +func IsPublicAccessible(securityIPs []string) bool { + var parsedIP net.IP + for _, ip := range securityIPs { + if IsIPAddress(ip) { + parsedIP = net.ParseIP(ip) + } else if IsCIDR(ip) { + parsedIP, _, _ = net.ParseCIDR(ip) + } + + if parsedIP != nil && !parsedIP.IsPrivate() { + return true + } + } + + return false +} + +// IsIPAddress returns whether the input string is a valid ip address. +func IsIPAddress(ipStr string) bool { + ip := net.ParseIP(ipStr) + + return ip != nil +} + +// IsCIDR returns whether the input string is a valid CIDR record. +func IsCIDR(cidrStr string) bool { + _, _, err := net.ParseCIDR(cidrStr) + + return err == nil +} + +func main() { + server.Start(&PostgreSQL{}) +} diff --git a/modules/postgres/src/postgres_test.go b/modules/postgres/src/postgres_test.go new file mode 100644 index 0000000..6135649 --- /dev/null +++ b/modules/postgres/src/postgres_test.go @@ -0,0 +1,411 @@ +package main + +import ( + "context" + "errors" + "testing" + + "github.com/bytedance/mockey" + "github.com/stretchr/testify/assert" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "kusionstack.io/kusion-module-framework/pkg/module" + apiv1 "kusionstack.io/kusion/pkg/apis/core/v1" + "kusionstack.io/kusion/pkg/apis/core/v1/workload" +) + +func TestPostgreSQLModule_Generator(t *testing.T) { + r := &module.GeneratorRequest{ + Project: "test-project", + Stack: "test-stack", + App: "test-app", + Workload: &workload.Workload{ + Header: workload.Header{ + Type: "Service", + }, + Service: &workload.Service{}, + }, + } + + testcases := []struct { + name string + devModuleConfig apiv1.Accessory + platformConfig apiv1.GenericConfig + expectedErr error + }{ + { + name: "Generate local PostgreSQL database", + devModuleConfig: apiv1.Accessory{ + "type": "local", + "version": "14.0", + }, + platformConfig: apiv1.GenericConfig{ + "databaseName": "test-postgres", + }, + expectedErr: nil, + }, + { + name: "Generate AWS PostgreSQL RDS", + devModuleConfig: apiv1.Accessory{ + "type": "cloud", + "version": "14.0", + }, + platformConfig: apiv1.GenericConfig{ + "cloud": "aws", + "size": 20, + "instanceType": "db.t3.micro", + "privateRouting": false, + }, + expectedErr: nil, + }, + { + name: "Generate Alicloud PostgreSQL RDS", + devModuleConfig: apiv1.Accessory{ + "type": "cloud", + "version": "14.0", + }, + platformConfig: apiv1.GenericConfig{ + "cloud": "alicloud", + "size": 20, + "instanceType": "postgres.n2.serverless.1c", + "category": "serverless_basic", + "privateRouting": false, + "subnetID": "test-subnet-id", + }, + expectedErr: nil, + }, + { + name: "Unsupported PostgreSQL type", + devModuleConfig: apiv1.Accessory{ + "type": "unsupported-type", + "version": "14.0", + }, + platformConfig: apiv1.GenericConfig{ + "databaseName": "test-postgres", + }, + expectedErr: errors.New("unsupported postgres type"), + }, + { + name: "Unsupported Terraform provider type", + devModuleConfig: apiv1.Accessory{ + "type": "cloud", + "version": "14.0", + }, + platformConfig: apiv1.GenericConfig{ + "cloud": "unsupported-type", + "instanceType": "db.t3.micro", + }, + expectedErr: errors.New("unsupported cloud provider type"), + }, + { + name: "Empty cloud PostgreSQL instance type", + devModuleConfig: apiv1.Accessory{ + "type": "cloud", + "version": "14.0", + }, + platformConfig: apiv1.GenericConfig{ + "cloud": "aws", + }, + expectedErr: ErrEmptyInstanceTypeForCloudDB, + }, + } + + for _, tc := range testcases { + postgres := &PostgreSQL{} + t.Run(tc.name, func(t *testing.T) { + r.DevModuleConfig = tc.devModuleConfig + r.PlatformModuleConfig = tc.platformConfig + + res, err := postgres.Generate(context.Background(), r) + if tc.expectedErr != nil { + assert.ErrorContains(t, err, tc.expectedErr.Error()) + } else { + assert.NoError(t, err) + assert.NotNil(t, res) + } + }) + } +} + +func TestPostgreSQLModule_GetCompleteConfig(t *testing.T) { + testcases := []struct { + name string + devModuleConfig apiv1.Accessory + platformConfig apiv1.GenericConfig + expectedPostgreSQL *PostgreSQL + }{ + { + name: "Empty platform config", + devModuleConfig: apiv1.Accessory{ + "type": "local", + "version": "14.0", + }, + platformConfig: nil, + expectedPostgreSQL: &PostgreSQL{ + Type: "local", + Version: "14.0", + Username: defaultUsername, + Category: defaultCategory, + SecurityIPs: defaultSecurityIPs, + PrivateRouting: defaultPrivateRouting, + Size: defaultSize, + }, + }, + { + name: "Default config with specified platform config", + devModuleConfig: apiv1.Accessory{ + "type": "cloud", + "version": "14.0", + }, + platformConfig: apiv1.GenericConfig{ + "size": 100, + "privateRouting": true, + "instanceType": "test-instance-type", + "subnetID": "test-subnet-id", + "databaseName": "test-database", + }, + expectedPostgreSQL: &PostgreSQL{ + Type: "cloud", + Version: "14.0", + Username: defaultUsername, + Category: defaultCategory, + SecurityIPs: defaultSecurityIPs, + PrivateRouting: true, + Size: 100, + InstanceType: "test-instance-type", + SubnetID: "test-subnet-id", + DatabaseName: "test-database", + }, + }, + } + + for _, tc := range testcases { + postgres := &PostgreSQL{} + t.Run(tc.name, func(t *testing.T) { + mockey.PatchConvey("mock postgres validate", t, func() { + mockey.Mock(postgres.Validate).Return(nil).Build() + + _ = postgres.GetCompleteConfig(tc.devModuleConfig, tc.platformConfig) + assert.Equal(t, tc.expectedPostgreSQL, postgres) + }) + }) + } +} + +func TestPostgreSQLModule_GenerateDBSecret(t *testing.T) { + r := &module.GeneratorRequest{ + Project: "test-project", + Stack: "test-stack", + App: "test-app", + Workload: &workload.Workload{ + Header: workload.Header{ + Type: "Service", + }, + Service: &workload.Service{}, + }, + } + + postgres := &PostgreSQL{ + Type: "local", + Version: "14.0", + DatabaseName: "test-database", + } + + hostAddress := "test-host-address" + username := "test-username" + password := "test-password" + + sec := &v1.Secret{ + TypeMeta: metav1.TypeMeta{ + Kind: "Secret", + APIVersion: v1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-database-postgres", + Namespace: "test-project", + }, + StringData: map[string]string{ + "hostAddress": "test-host-address", + "username": "test-username", + "password": "test-password", + }, + } + + resID := module.KubernetesResourceID(sec.TypeMeta, sec.ObjectMeta) + expectedResource, err := module.WrapK8sResourceToKusionResource(resID, sec) + if err != nil { + t.Fatalf("failed to wrap secret resource for unit test: %v", err) + } + + expectedPatcher := &apiv1.Patcher{ + Environments: []v1.EnvVar{ + { + Name: "KUSION_DB_HOST_TEST_DATABASE", + ValueFrom: &v1.EnvVarSource{ + SecretKeyRef: &v1.SecretKeySelector{ + LocalObjectReference: v1.LocalObjectReference{ + Name: "test-database-postgres", + }, + Key: "hostAddress", + }, + }, + }, + { + Name: "KUSION_DB_USERNAME_TEST_DATABASE", + ValueFrom: &v1.EnvVarSource{ + SecretKeyRef: &v1.SecretKeySelector{ + LocalObjectReference: v1.LocalObjectReference{ + Name: "test-database-postgres", + }, + Key: "username", + }, + }, + }, + { + Name: "KUSION_DB_PASSWORD_TEST_DATABASE", + ValueFrom: &v1.EnvVarSource{ + SecretKeyRef: &v1.SecretKeySelector{ + LocalObjectReference: v1.LocalObjectReference{ + Name: "test-database-postgres", + }, + Key: "password", + }, + }, + }, + }, + } + + actualResource, actualPatchers, err := postgres.GenerateDBSecret(r, hostAddress, username, password) + + assert.Nil(t, err) + assert.Equal(t, expectedPatcher, actualPatchers) + assert.Equal(t, expectedResource, actualResource) +} + +func TestPostgreSQLModule_GenerateTFRandomPassword(t *testing.T) { + r := &module.GeneratorRequest{ + Project: "test-project", + Stack: "test-stack", + App: "test-app", + Workload: &workload.Workload{ + Header: workload.Header{ + Type: "Service", + }, + Service: &workload.Service{}, + }, + } + + postgres := &PostgreSQL{ + Type: "local", + Version: "14.0", + DatabaseName: "test-database", + } + + t.Run("failed to generate tf resource id", func(t *testing.T) { + mockey.PatchConvey("failed to generate tf resource id", t, func() { + mockey.Mock(module.TerraformResourceID).Return("", errors.New("failed to generate tf resource id")).Build() + + res, id, err := postgres.GenerateTFRandomPassword(r) + + assert.Nil(t, res) + assert.Equal(t, id, "") + assert.ErrorContains(t, err, "failed to generate tf resource id") + }) + }) + + t.Run("failed to generate provider extensions", func(t *testing.T) { + mockey.PatchConvey("failed to generate provider extensions", t, func() { + mockey.Mock(module.TerraformResourceID).Return("", nil).Build() + mockey.Mock(module.TerraformProviderExtensions).Return(nil, errors.New("failed to generate provider extensions")).Build() + + res, id, err := postgres.GenerateTFRandomPassword(r) + + assert.Nil(t, res) + assert.Equal(t, id, "") + assert.ErrorContains(t, err, "failed to generate provider extensions") + }) + }) + + t.Run("failed to wrap tf resource to kusion resource", func(t *testing.T) { + mockey.PatchConvey("failed to wrap tf resource to kusion resource", t, func() { + mockey.Mock(module.TerraformResourceID).Return("", nil).Build() + mockey.Mock(module.TerraformProviderExtensions).Return(nil, nil).Build() + mockey.Mock(module.WrapTFResourceToKusionResource).Return(nil, errors.New("failed to wrap tf resource to kusion resource")).Build() + + res, id, err := postgres.GenerateTFRandomPassword(r) + + assert.Nil(t, res) + assert.Equal(t, id, "") + assert.ErrorContains(t, err, "failed to wrap tf resource to kusion resource") + }) + }) + + t.Run("successfully generate random_password resource", func(t *testing.T) { + res, id, err := postgres.GenerateTFRandomPassword(r) + + assert.NotNil(t, res) + assert.NotEqual(t, id, "") + assert.NoError(t, err) + }) +} + +func TestPostgreSQLModule_Validate(t *testing.T) { + t.Run("cloud db with empty instanceType", func(t *testing.T) { + postgres := &PostgreSQL{ + Type: "cloud", + Version: "14.0", + } + + err := postgres.Validate() + + assert.ErrorContains(t, err, ErrEmptyInstanceTypeForCloudDB.Error()) + }) + + t.Run("valid postgres config", func(t *testing.T) { + postgres := &PostgreSQL{ + Type: "cloud", + Version: "14.0", + InstanceType: "test-instance-type", + } + + err := postgres.Validate() + + assert.NoError(t, err) + }) +} + +func TestIsPublicAccessible(t *testing.T) { + testcases := []struct { + name string + securityIPs []string + expected bool + }{ + { + name: "Public CIDR", + securityIPs: []string{ + "0.0.0.0/0", + }, + expected: true, + }, + { + name: "Private CIDR", + securityIPs: []string{ + "172.16.0.0/24", + }, + expected: false, + }, + { + name: "Private IP Address", + securityIPs: []string{ + "172.16.0.1", + }, + expected: false, + }, + } + + for _, tc := range testcases { + actual := IsPublicAccessible(tc.securityIPs) + + assert.Equal(t, tc.expected, actual) + } +}