diff --git a/go.mod b/go.mod index 1974ec5..b6e38c8 100644 --- a/go.mod +++ b/go.mod @@ -6,10 +6,10 @@ require ( github.com/golang/mock v1.6.0 github.com/imdario/mergo v0.3.13 github.com/mitchellh/mapstructure v1.5.0 - github.com/score-spec/score-go v0.0.0-20221019054335-3510902b5f8b + github.com/score-spec/score-go v0.0.0-20230417150859-c1bf3fbe372b github.com/sendgrid/rest v2.6.9+incompatible github.com/spf13/cobra v1.6.0 - github.com/stretchr/testify v1.8.0 + github.com/stretchr/testify v1.8.1 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index ab96424..d7f0e40 100644 --- a/go.sum +++ b/go.sum @@ -13,8 +13,8 @@ github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RR 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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/score-spec/score-go v0.0.0-20221019054335-3510902b5f8b h1:Ws6TNwu+OuoR+K7C3fKLCuaeyAiGU0G7Xkaz9UUygWA= -github.com/score-spec/score-go v0.0.0-20221019054335-3510902b5f8b/go.mod h1:eNU0evgibNfV6ESUfRKjWcfGPmd92dI8dsUN/GBouZs= +github.com/score-spec/score-go v0.0.0-20230417150859-c1bf3fbe372b h1:s6DuDF4QC/jjXvBYYJfYKaRgsTGc3dTNwbT43eJCC04= +github.com/score-spec/score-go v0.0.0-20230417150859-c1bf3fbe372b/go.mod h1:eNU0evgibNfV6ESUfRKjWcfGPmd92dI8dsUN/GBouZs= github.com/sendgrid/rest v2.6.9+incompatible h1:1EyIcsNdn9KIisLW50MKwmSRSK+ekueiEMJ7NEoxJo0= github.com/sendgrid/rest v2.6.9+incompatible/go.mod h1:kXX7q3jZtJXK5c5qK83bSGMdV6tsOE70KbHoqJls4lE= github.com/spf13/cobra v1.6.0 h1:42a0n6jwCot1pUmomAp4T7DeMD+20LFv4Q54pxLf2LI= @@ -23,9 +23,11 @@ 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/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 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= diff --git a/internal/humanitec/convert.go b/internal/humanitec/convert.go index b217dd9..5378ceb 100644 --- a/internal/humanitec/convert.go +++ b/internal/humanitec/convert.go @@ -9,6 +9,7 @@ package humanitec import ( "fmt" + "log" "strings" mergo "github.com/imdario/mergo" @@ -17,6 +18,48 @@ import ( humanitec "github.com/score-spec/score-humanitec/internal/humanitec_go/types" ) +const ( + AnnotationLabelResourceId = "score.humanitec.io/resId" +) + +// parseResourceId extracts resource ID details from a resource reference string. +// Supported reference string formants: +// +// {resId} +// {externals|shared}.{resId} +// modules.{workloadId}.{externals|shared}.{resId} +func parseResourceId(ref string) (workload, scope, resId string, err error) { + var segments = strings.SplitN(ref, ".", 4) + switch len(segments) { + case 4: + if segments[0] != "modules" { + err = fmt.Errorf("invalid resource reference '%s': not supported", ref) + return + } + workload = segments[1] + scope = segments[2] + resId = segments[3] + case 3: + if segments[0] != "modules" { + err = fmt.Errorf("invalid resource reference '%s': not supported", ref) + return + } + case 2: + workload = "" + scope = segments[0] + resId = segments[1] + case 1: + workload = "" + scope = "" + resId = segments[0] + default: + workload = "" + scope = "" + resId = "" + } + return +} + // getProbeDetails extracts an httpGet probe details from the source spec. // Returns nil if the source spec is empty. func getProbeDetails(probe *score.ContainerProbeSpec) map[string]interface{} { @@ -157,12 +200,57 @@ func ConvertSpec(name, envID string, spec *score.WorkloadSpec, ext *extensions.H "spec": workloadSpec, } - var externals = map[string]interface{}{} + var externals = make(map[string]interface{}) + var shared = make([]humanitec.UpdateAction, 0) for name, res := range spec.Resources { - if meta, exists := ext.Resources[name]; !exists || meta.Scope == "" || meta.Scope == "external" { - if res.Type != "service" && res.Type != "environment" { - externals[name] = map[string]interface{}{ - "type": res.Type, + switch res.Type { + + case "service", "environment": + continue + + default: + resId, hasAnnotation := res.Metadata.Annotations[AnnotationLabelResourceId] + if resId == "" { + resId = fmt.Sprintf("externals.%s", name) + } + + // DEPRECATED: Should use resource annotations instead + if meta, hasMeta := ext.Resources[name]; hasMeta { + log.Printf("Warning: Extensions for resources has been deprecated. Use '%s' resource annotation instead. Extensions are still configured for '%s'.\n", AnnotationLabelResourceId, name) + if !hasAnnotation && (meta.Scope == "" || meta.Scope == "externals") { + resId = fmt.Sprintf("externals.%s", name) + } else if !hasAnnotation && meta.Scope == "shared" { + resId = fmt.Sprintf("shared.%s", name) + } + } + // END (DEPRECATED) + + if mod, scope, resName, err := parseResourceId(resId); err != nil { + log.Printf("Warning: %v.\n", err) + } else if mod == "" || mod == spec.Metadata.Name { + if scope == "externals" { + var extRes = map[string]interface{}{ + "type": res.Type, + } + if len(res.Params) > 0 { + extRes["params"] = res.Params + } + externals[resName] = extRes + } else if scope == "shared" { + var resName = strings.Replace(resId, "shared.", "", 1) + var sharedRes = map[string]interface{}{ + "type": res.Type, + } + if len(res.Params) > 0 { + sharedRes["params"] = res.Params + } + shared = append(shared, humanitec.UpdateAction{ + Operation: "add", + Path: "/" + resName, + Value: sharedRes, + }) + } else { + log.Printf("Warning: invalid resource reference '%s': not supported.\n", resId) } } } @@ -171,22 +259,6 @@ func ConvertSpec(name, envID string, spec *score.WorkloadSpec, ext *extensions.H workload["externals"] = externals } - var shared []humanitec.UpdateAction - for name, res := range spec.Resources { - if meta, exists := ext.Resources[name]; exists && meta.Scope == "shared" { - if shared == nil { - shared = make([]humanitec.UpdateAction, 0) - } - shared = append(shared, humanitec.UpdateAction{ - Operation: "add", - Path: "/" + name, - Value: map[string]interface{}{ - "type": res.Type, - }, - }) - } - } - var res = humanitec.CreateDeploymentDeltaRequest{ Metadata: humanitec.DeltaMetadata{ Name: name, @@ -197,7 +269,9 @@ func ConvertSpec(name, envID string, spec *score.WorkloadSpec, ext *extensions.H spec.Metadata.Name: workload, }, }, - Shared: shared, + } + if len(shared) > 0 { + res.Shared = shared } return &res, nil diff --git a/internal/humanitec/convert_test.go b/internal/humanitec/convert_test.go index 4d386d5..1e11064 100644 --- a/internal/humanitec/convert_test.go +++ b/internal/humanitec/convert_test.go @@ -8,6 +8,7 @@ The Apache Software Foundation (http://www.apache.org/). package humanitec import ( + "errors" "testing" score "github.com/score-spec/score-go/types" @@ -16,6 +17,86 @@ import ( "github.com/stretchr/testify/assert" ) +func TestParseResourceId(t *testing.T) { + var tests = []struct { + Name string + ResourceReference string + ExpectedModuleId string + ExpectedScope string + ExpectedResourceId string + ExpectedError error + }{ + // Success path + // + { + Name: "Should accept empty string", + ResourceReference: "", + ExpectedResourceId: "", + ExpectedError: nil, + }, + { + Name: "Should accept resource ID only", + ResourceReference: "test-res-id", + ExpectedResourceId: "test-res-id", + ExpectedError: nil, + }, + { + Name: "Should accept external resource reference", + ResourceReference: "externals.test-res-id", + ExpectedScope: "externals", + ExpectedResourceId: "test-res-id", + ExpectedError: nil, + }, + { + Name: "Should accept shared resource reference", + ResourceReference: "shared.test-res-id", + ExpectedScope: "shared", + ExpectedResourceId: "test-res-id", + ExpectedError: nil, + }, + { + Name: "Should accept foreighn module resource reference", + ResourceReference: "modules.test-module.externals.test-res-id", + ExpectedModuleId: "test-module", + ExpectedScope: "externals", + ExpectedResourceId: "test-res-id", + ExpectedError: nil, + }, + + // Errors handling + // + { + Name: "Should reject incomplete resource reference", + ResourceReference: "test-module.externals.test-res-id", + ExpectedError: errors.New("not supported"), + }, + { + Name: "Should reject non-module resource reference", + ResourceReference: "something.test-something.externals.test-res-id", + ExpectedError: errors.New("not supported"), + }, + } + + for _, tt := range tests { + t.Run(tt.Name, func(t *testing.T) { + mod, scope, resId, err := parseResourceId(tt.ResourceReference) + + if tt.ExpectedError != nil { + // On Error + // + assert.ErrorContains(t, err, tt.ExpectedError.Error()) + } else { + // On Success + // + assert.NoError(t, err) + assert.Equal(t, tt.ExpectedModuleId, mod) + assert.Equal(t, tt.ExpectedScope, scope) + assert.Equal(t, tt.ExpectedResourceId, resId) + } + }) + } +} + func TestScoreConvert(t *testing.T) { const ( envID = "test" @@ -168,6 +249,7 @@ func TestScoreConvert(t *testing.T) { "ORDERS_SERVICE": "http://${resources.orders.name}:${resources.orders.port}/api", "CONNECTION_STRING": "postgresql://${resources.db.host}:${resources.db.port}/${resources.db.name}", "DOMAIN_NAME": "${resources.dns.domain}", + "EXTERNAL_RESOURCE": "${resources.external-resource.name}", }, Files: []score.FileMountSpec{ { @@ -191,6 +273,11 @@ func TestScoreConvert(t *testing.T) { }, Resources: map[string]score.ResourceSpec{ "env": { + Metadata: score.ResourceMeta{ + Annotations: map[string]string{ + AnnotationLabelResourceId: "externals.should-ignore-this-one", + }, + }, Type: "environment", Properties: map[string]score.ResourcePropertySpec{ "DEBUG": {Default: false, Required: false}, @@ -202,11 +289,19 @@ func TestScoreConvert(t *testing.T) { Properties: map[string]score.ResourcePropertySpec{ "domain": {}, }, + Params: map[string]interface{}{ + "test": "value", + }, }, "data": { Type: "volume", }, "db": { + Metadata: score.ResourceMeta{ + Annotations: map[string]string{ + AnnotationLabelResourceId: "externals.annotations-db-id", + }, + }, Type: "postgres", Properties: map[string]score.ResourcePropertySpec{ "host": {Default: "localhost", Required: true}, @@ -215,6 +310,14 @@ func TestScoreConvert(t *testing.T) { "user_name": {Required: true, Secret: true}, "password": {Required: true, Secret: true}, }, + Params: map[string]interface{}{ + "extensions": map[string]interface{}{ + "uuid-ossp": map[string]interface{}{ + "schema": "uuid_schema", + "version": "1.1", + }, + }, + }, }, "orders": { Type: "service", @@ -223,6 +326,17 @@ func TestScoreConvert(t *testing.T) { "port": {}, }, }, + "external-resource": { + Metadata: score.ResourceMeta{ + Annotations: map[string]string{ + AnnotationLabelResourceId: "modules.test-module.externals.test-resource", + }, + }, + Type: "some-type", + Properties: map[string]score.ResourcePropertySpec{ + "name": {Required: false}, + }, + }, }, }, Extensions: &extensions.HumanitecExtensionsSpec{ @@ -245,6 +359,9 @@ func TestScoreConvert(t *testing.T) { }, }, Resources: extensions.HumanitecResourcesSpecs{ + "db": extensions.HumanitecResourceSpec{ + Scope: "shared", + }, "dns": extensions.HumanitecResourceSpec{ Scope: "shared", }, @@ -264,8 +381,9 @@ func TestScoreConvert(t *testing.T) { "DEBUG": "${values.DEBUG}", "LOGS_LEVEL": "${pod.debug.level}", "ORDERS_SERVICE": "http://${modules.orders.service.name}:${modules.orders.service.port}/api", - "CONNECTION_STRING": "postgresql://${externals.db.host}:${externals.db.port}/${externals.db.name}", + "CONNECTION_STRING": "postgresql://${externals.annotations-db-id.host}:${externals.annotations-db-id.port}/${externals.annotations-db-id.name}", "DOMAIN_NAME": "${shared.dns.domain}", + "EXTERNAL_RESOURCE": "${modules.test-module.externals.test-resource.name}", }, "files": map[string]interface{}{ "/etc/backend/config.yaml": map[string]interface{}{ @@ -302,8 +420,16 @@ func TestScoreConvert(t *testing.T) { "data": map[string]interface{}{ "type": "volume", }, - "db": map[string]interface{}{ + "annotations-db-id": map[string]interface{}{ "type": "postgres", + "params": map[string]interface{}{ + "extensions": map[string]interface{}{ + "uuid-ossp": map[string]interface{}{ + "schema": "uuid_schema", + "version": "1.1", + }, + }, + }, }, }, }, @@ -315,6 +441,9 @@ func TestScoreConvert(t *testing.T) { Path: "/dns", Value: map[string]interface{}{ "type": "dns", + "params": map[string]interface{}{ + "test": "value", + }, }, }, }, diff --git a/internal/humanitec/extensions/types.go b/internal/humanitec/extensions/types.go index 3b2cf8e..4112685 100644 --- a/internal/humanitec/extensions/types.go +++ b/internal/humanitec/extensions/types.go @@ -29,10 +29,12 @@ package extensions // dns: // scope: shared type HumanitecExtensionsSpec struct { - ApiVersion string `mapstructure:"apiVersion"` - Profile string `mapstructure:"profile"` - Spec map[string]interface{} `mapstructure:"spec"` - Resources HumanitecResourcesSpecs `mapstructure:"resources"` + ApiVersion string `mapstructure:"apiVersion"` + Profile string `mapstructure:"profile"` + Spec map[string]interface{} `mapstructure:"spec"` + + // DEPRECATED: Should use score resources annotations instead + Resources HumanitecResourcesSpecs `mapstructure:"resources"` } // HumanitecResourcesSpecs is a map of workload resources specifications. diff --git a/internal/humanitec/templates.go b/internal/humanitec/templates.go index 2a580d3..1cb195a 100644 --- a/internal/humanitec/templates.go +++ b/internal/humanitec/templates.go @@ -53,8 +53,19 @@ func buildContext(metadata score.WorkloadMeta, resources score.ResourcesSpecs, e if res.Type == "workload" { log.Println("Warning: 'workload' is a reserved resource type. Its usage may lead to compatibility issues with future releases of this application.") } - if resExt, exists := ext[resName]; exists && resExt.Scope == "shared" { - source = fmt.Sprintf("shared.%s", resName) + resId, hasAnnotation := res.Metadata.Annotations[AnnotationLabelResourceId] + // DEPRECATED: Should use resource annotations instead + if resExt, hasMeta := ext[resName]; hasMeta && !hasAnnotation { + if resExt.Scope == "" || resExt.Scope == "external" { + resId = fmt.Sprintf("externals.%s", resName) + } else if resExt.Scope == "shared" { + resId = fmt.Sprintf("shared.%s", resName) + } + } + // END (DEPRECATED) + + if resId != "" { + source = resId } else { source = fmt.Sprintf("externals.%s", resName) }