diff --git a/docs/en/helm/component.md b/docs/en/helm/component.md index 30d95f156..5f8ea9d08 100644 --- a/docs/en/helm/component.md +++ b/docs/en/helm/component.md @@ -68,8 +68,20 @@ spec: tag: "5.1.2" ``` Helm module workload will use data in `settings` as [Helm chart values](https://github.com/captainroy-hy/podinfo/blob/master/charts/podinfo/values.yaml). -Currently, you can learn the schema of settings by reading the `README.md` of the Helm chart, and the schema are totally align with [`values.yaml`](https://github.com/captainroy-hy/podinfo/blob/master/charts/podinfo/values.yaml) of the chart. -We will integerate the values and generate it's [openapi-v3-json-schema](https://kubevela.io/#/en/platform-engineers/openapi-v3-json-schema.md) soon. +You can learn the schema of settings by reading the `README.md` of the Helm +chart, and the schema are totally align with +[`values.yaml`](https://github.com/captainroy-hy/podinfo/blob/master/charts/podinfo/values.yaml) +of the chart. + +Helm v3 has [support to validate +values](https://helm.sh/docs/topics/charts/#schema-files) in a chart's +values.yaml file with JSON schemas. +Vela will try to fetch the `values.schema.json` file from the Chart archive and +[save the schema into a +ConfigMap](https://kubevela.io/#/en/platform-engineers/openapi-v3-json-schema.md) +which can be consumed latter through UI or CLI. +If `values.schema.json` is not provided by the Chart author, Vela will generate a +OpenAPI-v3 JSON schema based on the `values.yaml` file automatically. Deploy the application and after several minutes (it takes time to fetch Helm chart from the repo, render and install), you can check the Helm release is installed. ```shell diff --git a/docs/examples/helm-module/webapp-chart-cd.yaml b/docs/examples/helm-module/webapp-chart-cd.yaml new file mode 100644 index 000000000..65688c45b --- /dev/null +++ b/docs/examples/helm-module/webapp-chart-cd.yaml @@ -0,0 +1,21 @@ +apiVersion: core.oam.dev/v1alpha2 +kind: ComponentDefinition +metadata: + name: webapp-chart + namespace: vela-system + annotations: + definition.oam.dev/description: helm chart for webapp +spec: + workload: + definition: + apiVersion: apps/v1 + kind: Deployment + schematic: + helm: + release: + chart: + spec: + chart: "podinfo" + version: "5.1.4" + repository: + url: "http://oam.dev/catalog/" diff --git a/pkg/appfile/helm/helm.go b/pkg/appfile/helm/helm.go index 3e7cface5..a12ceff4d 100644 --- a/pkg/appfile/helm/helm.go +++ b/pkg/appfile/helm/helm.go @@ -5,13 +5,12 @@ import ( "fmt" "time" + "github.com/pkg/errors" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" - "github.com/pkg/errors" - "github.com/oam-dev/kubevela/apis/core.oam.dev/v1alpha2" helmapi "github.com/oam-dev/kubevela/pkg/appfile/helm/flux2apis" ) @@ -23,17 +22,13 @@ var ( // RenderHelmReleaseAndHelmRepo constructs HelmRelease and HelmRepository in unstructured format func RenderHelmReleaseAndHelmRepo(helmSpec *v1alpha2.Helm, compName, appName, ns string, values map[string]interface{}) (*unstructured.Unstructured, *unstructured.Unstructured, error) { - releaseSpec := &helmapi.HelmReleaseSpec{} - if err := json.Unmarshal(helmSpec.Release.Raw, releaseSpec); err != nil { - return nil, nil, err + releaseSpec, repoSpec, err := decodeHelmSpec(helmSpec) + if err != nil { + return nil, nil, errors.WithMessage(err, "Helm spec is invalid") } if releaseSpec.Interval == nil { releaseSpec.Interval = DefaultIntervalDuration } - repoSpec := &helmapi.HelmRepositorySpec{} - if err := json.Unmarshal(helmSpec.Repository.Raw, repoSpec); err != nil { - return nil, nil, err - } if repoSpec.Interval == nil { repoSpec.Interval = DefaultIntervalDuration } @@ -102,3 +97,15 @@ func setSpecObjIntoUnstructuredObj(spec interface{}, u *unstructured.Unstructure _ = unstructured.SetNestedMap(u.Object, data, "spec") return nil } + +func decodeHelmSpec(h *v1alpha2.Helm) (*helmapi.HelmReleaseSpec, *helmapi.HelmRepositorySpec, error) { + releaseSpec := &helmapi.HelmReleaseSpec{} + if err := json.Unmarshal(h.Release.Raw, releaseSpec); err != nil { + return nil, nil, errors.Wrap(err, "Helm release spec is invalid") + } + repoSpec := &helmapi.HelmRepositorySpec{} + if err := json.Unmarshal(h.Repository.Raw, repoSpec); err != nil { + return nil, nil, errors.Wrap(err, "Helm repository spec is invalid") + } + return releaseSpec, repoSpec, nil +} diff --git a/pkg/appfile/helm/helm_test.go b/pkg/appfile/helm/helm_test.go index d2520ec58..ecbcefdfe 100644 --- a/pkg/appfile/helm/helm_test.go +++ b/pkg/appfile/helm/helm_test.go @@ -1,6 +1,7 @@ package helm import ( + "fmt" "testing" "github.com/ghodss/yaml" @@ -12,7 +13,7 @@ import ( ) func TestRenderHelmReleaseAndHelmRepo(t *testing.T) { - h := testData() + h := testData("podinfo", "1.0.0", "test.com") chartValues := map[string]interface{}{ "image": map[string]interface{}{ "tag": "1.0.1", @@ -61,13 +62,13 @@ func TestRenderHelmReleaseAndHelmRepo(t *testing.T) { } } -func testData() *v1alpha2.Helm { - rlsStr := +func testData(chart, version, repoURL string) *v1alpha2.Helm { + rlsStr := fmt.Sprintf( `chart: spec: - chart: "podinfo" - version: "1.0.0"` - repoStr := `url: "test.com"` + chart: "%s" + version: "%s"`, chart, version) + repoStr := fmt.Sprintf(`url: "%s"`, repoURL) rlsJson, _ := yaml.YAMLToJSON([]byte(rlsStr)) repoJson, _ := yaml.YAMLToJSON([]byte(repoStr)) diff --git a/pkg/appfile/helm/schema.go b/pkg/appfile/helm/schema.go new file mode 100644 index 000000000..90e60c14b --- /dev/null +++ b/pkg/appfile/helm/schema.go @@ -0,0 +1,200 @@ +package helm + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + + "cuelang.org/go/cue" + "cuelang.org/go/cue/format" + "cuelang.org/go/encoding/openapi" + "cuelang.org/go/encoding/yaml" + "github.com/getkin/kin-openapi/openapi3" + "github.com/pkg/errors" + "helm.sh/helm/v3/pkg/chart/loader" + "helm.sh/helm/v3/pkg/getter" + "helm.sh/helm/v3/pkg/repo" + + "github.com/oam-dev/kubevela/apis/core.oam.dev/v1alpha2" +) + +var ( + getters = getter.Providers{ + getter.Provider{ + Schemes: []string{"http", "https"}, + New: getter.NewHTTPGetter, + }, + } +) + +// GetChartValuesJSONSchema fetched the Chart bundle and get JSON schema of Values +// file. If the Chart provides a 'values.json.schema' file, use it directly. +// Otherwise, try to generate a JSON schema based on the Values file. +func GetChartValuesJSONSchema(ctx context.Context, h *v1alpha2.Helm) ([]byte, error) { + releaseSpec, repoSpec, err := decodeHelmSpec(h) + if err != nil { + return nil, errors.WithMessage(err, "Helm spec is invalid") + } + chartSpec := releaseSpec.Chart.Spec + files, err := loadChartFiles(ctx, repoSpec.URL, chartSpec.Chart, chartSpec.Version) + if err != nil { + return nil, errors.WithMessage(err, "cannot load Chart files") + } + var values *loader.BufferedFile + for _, f := range files { + switch f.Name { + case "values.yaml", "values.yml": + values = f + case "values.schema.json": + // use the JSON schema file if exists + return f.Data, nil + default: + continue + } + } + if values == nil { + return nil, errors.New("cannot find 'values.schema.json' or 'values.yaml' file in the Chart") + } + // try to generate a schema based on Values file + generatedSchema, err := generateSchemaFromValues(values.Data) + if err != nil { + return nil, errors.WithMessage(err, "cannot generate schema from Values file") + } + return generatedSchema, nil +} + +// generateSchemaFromValues generate OpenAPIv3 schema based on Chart Values +// file. +func generateSchemaFromValues(values []byte) ([]byte, error) { + valuesIdentifier := "values" + r := cue.Runtime{} + // convert Values yaml to CUE + ins, err := yaml.Decode(&r, "", string(values)) + if err != nil { + return nil, errors.Wrap(err, "cannot decode Values.yaml to CUE") + } + // get the streamed CUE including the comments which will be used as + // 'description' in the schema + c, err := format.Node(ins.Value().Syntax(cue.Docs(true)), format.Simplify()) + if err != nil { + return nil, errors.Wrap(err, "cannot format CUE generated from Values.yaml") + } + // cue openapi encoder only works on top-level identifier, we have to add + // an identifier manually + valuesStr := fmt.Sprintf("#%s:{\n%s\n}", valuesIdentifier, string(c)) + + r = cue.Runtime{} + ins, err = r.Compile("-", valuesStr) + if err != nil { + return nil, errors.Wrap(err, "cannot compile CUE generated from Values.yaml") + } + if ins.Err != nil { + return nil, errors.Wrap(ins.Err, "cannot compile CUE generated from Values.yaml") + } + // generate OpenAPIv3 schema through cue openapi encoder + rawSchema, err := openapi.Gen(ins, &openapi.Config{}) + if err != nil { + return nil, errors.Wrap(ins.Err, "cannot generate OpenAPIv3 schema") + } + rawSchema, err = makeSwaggerCompatible(rawSchema) + if err != nil { + return nil, errors.WithMessage(err, "cannot make CUE-generated schema compatible with Swagger") + } + + var out = &bytes.Buffer{} + _ = json.Indent(out, rawSchema, "", " ") + // load schema into Swagger to validate it compatible with Swagger OpenAPIv3 + fullSchemaBySwagger, err := openapi3.NewSwaggerLoader().LoadSwaggerFromData(out.Bytes()) + if err != nil { + return nil, errors.Wrap(err, "cannot load schema by SwaggerLoader") + } + valuesSchema := fullSchemaBySwagger.Components.Schemas[valuesIdentifier].Value + changeEnumToDefault(valuesSchema) + + b, err := valuesSchema.MarshalJSON() + if err != nil { + return nil, errors.Wrap(err, "cannot marshall Values schema") + } + return b, nil +} + +func loadChartFiles(ctx context.Context, repoURL, chart, version string) ([]*loader.BufferedFile, error) { + url, err := repo.FindChartInRepoURL(repoURL, chart, version, "", "", "", getters) + if err != nil { + return nil, errors.Wrap(err, "cannot find Chart URL") + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, errors.Wrapf(err, "cannot fetch Chart from remote URL:%s", url) + } + //nolint:errcheck + defer resp.Body.Close() + files, err := loader.LoadArchiveFiles(resp.Body) + if err != nil { + return nil, errors.Wrap(err, "cannot load Chart files") + } + return files, nil +} + +// cue openapi encoder converts default in Chart Values as enum in schema +// changing enum to default makes the schema consistent with Chart Values +func changeEnumToDefault(schema *openapi3.Schema) { + t := schema.Type + switch t { + case "object": + for _, v := range schema.Properties { + s := v.Value + changeEnumToDefault(s) + } + case "array": + if schema.Items != nil { + changeEnumToDefault(schema.Items.Value) + } + } + // change enum to default + if len(schema.Enum) > 0 { + schema.Default = schema.Enum[0] + schema.Enum = nil + } + // remove all required fields, because fields in Values.yml are all optional + schema.Required = nil +} + +// cue openapi encoder converts 'items' field in an array type field into array, +// that's not compatible with OpenAPIv3. 'items' field should be an object. +func makeSwaggerCompatible(d []byte) ([]byte, error) { + m := map[string]interface{}{} + err := json.Unmarshal(d, &m) + if err != nil { + return nil, errors.Wrap(err, "cannot unmarshall schema") + } + handleItemsOfArrayType(m) + b, err := json.Marshal(m) + if err != nil { + return nil, errors.Wrap(err, "cannot marshall schema") + } + return b, nil +} + +// handleItemsOfArrayType will convert all 'items' of array type from array to object +// and remove enum in the items +func handleItemsOfArrayType(t map[string]interface{}) { + for _, v := range t { + if next, ok := v.(map[string]interface{}); ok { + handleItemsOfArrayType(next) + } + } + if t["type"] == "array" { + if i, ok := t["items"].([]interface{}); ok { + itemSpec, _ := i[0].(map[string]interface{}) + itemSpec["enum"] = nil + t["items"] = itemSpec + } + } +} diff --git a/pkg/appfile/helm/schema_test.go b/pkg/appfile/helm/schema_test.go new file mode 100644 index 000000000..83978d829 --- /dev/null +++ b/pkg/appfile/helm/schema_test.go @@ -0,0 +1,248 @@ +package helm + +import ( + "context" + "encoding/json" + "fmt" + "io/ioutil" + "testing" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/google/go-cmp/cmp" +) + +func TestGenerateSchemaFromValues(t *testing.T) { + testValues, err := ioutil.ReadFile("./testdata/values.yaml") + if err != nil { + t.Error(err, "cannot load test data") + } + wantSchema, err := ioutil.ReadFile("./testdata/values.schema.json") + if err != nil { + t.Error(err, "cannot load expected data") + } + wantSchemaMap := map[string]interface{}{} + // convert bytes to map for diff converience + _ = json.Unmarshal(wantSchema, &wantSchemaMap) + result, err := generateSchemaFromValues(testValues) + if err != nil { + t.Error(err, "failed generate schema from values") + } + resultMap := map[string]interface{}{} + if err := json.Unmarshal(result, &resultMap); err != nil { + t.Error(err, "cannot unmarshal result bytes") + } + if diff := cmp.Diff(resultMap, wantSchemaMap); diff != "" { + t.Fatalf("\ngenerateSchemaFromValues(...)(...) -want +get \n%s", diff) + } +} + +func TestGetChartValuesJSONSchema(t *testing.T) { + testHelm := testData("podinfo", "5.1.4", "http://oam.dev/catalog") + wantSchema, err := ioutil.ReadFile("./testdata/values.schema.json") + if err != nil { + t.Error(err, "cannot load expected data") + } + wantSchemaMap := map[string]interface{}{} + // convert bytes to map for diff converience + _ = json.Unmarshal(wantSchema, &wantSchemaMap) + result, err := GetChartValuesJSONSchema(context.Background(), testHelm) + if err != nil { + t.Error(err, "failed get schema") + } + resultMap := map[string]interface{}{} + if err := json.Unmarshal(result, &resultMap); err != nil { + t.Error(err, "cannot unmarshal result bytes") + } + if diff := cmp.Diff(resultMap, wantSchemaMap); diff != "" { + t.Fatalf("\nGetChartValuesJSONSchema(...)(...) -want +get \n%s", diff) + } +} + +func TestChangeEnumToDefault(t *testing.T) { + // testData contains object, string, integer, bool, and array type fields + // with enum and required values + testData := `{"properties":{"array":{"enum":[["a","b","c"]],"items":{"type":"string"},"type":"array"},"bool":{"enum":[false],"type":"boolean"},"integer":{"enum":[1],"type":"integer"},"obj":{"properties":{"f0":{"enum":["v0"],"type":"string"},"f1":{"enum":["v1"],"type":"string"},"f2":{"enum":["v2"],"type":"string"}},"required":["f0","f1","f2"],"type":"object"},"string":{"enum":["a"],"type":"string"}},"required":["bool","string","obj","array","integer"],"type":"object"}` + + s := fmt.Sprintf(`{"components":{"schemas":{"values":%s}}}`, testData) + testSwagger, err := openapi3.NewSwaggerLoader().LoadSwaggerFromData([]byte(s)) + if err != nil { + t.Error(err) + } + testSchema := testSwagger.Components.Schemas["values"].Value + changeEnumToDefault(testSchema) + result, err := testSchema.MarshalJSON() + if err != nil { + t.Error(err) + } + resultMap := map[string]interface{}{} + err = json.Unmarshal(result, &resultMap) + if err != nil { + t.Error(err) + } + want := `{"properties":{"array":{"default":["a","b","c"],"items":{"type":"string"},"type":"array"},"bool":{"default":false,"type":"boolean"},"integer":{"default":1,"type":"integer"},"obj":{"properties":{"f0":{"default":"v0","type":"string"},"f1":{"default":"v1","type":"string"},"f2":{"default":"v2","type":"string"}},"type":"object"},"string":{"default":"a","type":"string"}},"type":"object"}` + wantMap := map[string]interface{}{} + _ = json.Unmarshal([]byte(want), &wantMap) + if diff := cmp.Diff(resultMap, wantMap); diff != "" { + t.Fatalf("\nchangeEnumToDefault(...) -want +get %s\n", diff) + } +} + +func TestMakeSwaggerCompatible(t *testing.T) { + tests := []struct { + caseName string + testdata string + want string + }{ + { + caseName: "integer type array", + testdata: `{"integerArray": { + "type": "array", + "items": [ + { + "type": "integer", + "enum": [ + 0 + ] + }, + { + "type": "integer", + "enum": [ + 1 + ] + }, + { + "type": "integer", + "enum": [ + 2 + ] + } + ], + "enum": [ + [ + 0, + 1, + 2 + ] + ] + }}`, + want: `{"integerArray":{"enum":[[0,1,2]],"items":{"enum":null,"type":"integer"},"type":"array"}}`, + }, + { + caseName: "string type array", + testdata: `{"stringArray": { + "type": "array", + "items": [ + { + "type": "string", + "enum": [ + "a" + ] + }, + { + "type": "string", + "enum": [ + "b" + ] + }, + { + "type": "string", + "enum": [ + "c" + ] + } + ], + "enum": [ + [ + "a", + "b", + "c" + ] + ] +}}`, + want: `{"stringArray":{"enum":[["a","b","c"]],"items":{"enum":null,"type":"string"},"type":"array"}}`, + }, + { + caseName: "bool type array", + testdata: `{"boolArray": { + "type": "array", + "items": [ + { + "type": "boolean", + "enum": [ + true + ] + }, + { + "type": "boolean", + "enum": [ + false + ] + } + ], + "enum": [ + [ + true, + false + ] + ] +}}`, + want: `{"boolArray":{"enum":[[true,false]],"items":{"enum":null,"type":"boolean"},"type":"array"}}`, + }, + { + caseName: "object type array", + testdata: `{"objectArray": { + "type": "array", + "items": [ + { + "type": "object", + "required": [ + "f0", + "f1", + "f2" + ], + "properties": { + "f0": { + "type": "string", + "enum": [ + "v0" + ] + }, + "f1": { + "type": "string", + "enum": [ + "v1" + ] + }, + "f2": { + "type": "string", + "enum": [ + "v2" + ] + } + } + } + ] +}}`, + want: `{"objectArray":{"items":{"enum":null,"properties":{"f0":{"enum":["v0"],"type":"string"},"f1":{"enum":["v1"],"type":"string"},"f2":{"enum":["v2"],"type":"string"}},"required":["f0","f1","f2"],"type":"object"},"type":"array"}}`, + }, + } + + for _, tc := range tests { + t.Run(tc.caseName, func(t *testing.T) { + result, err := makeSwaggerCompatible([]byte(tc.testdata)) + if err != nil { + t.Error(err) + } + resultMap := map[string]interface{}{} + err = json.Unmarshal(result, &resultMap) + if err != nil { + t.Error(err) + } + wantMap := map[string]interface{}{} + _ = json.Unmarshal([]byte(tc.want), &wantMap) + if diff := cmp.Diff(resultMap, wantMap); diff != "" { + t.Fatalf("\nmakeSwaggerCompatible(...) -want +get %s\n", diff) + } + }) + } +} diff --git a/pkg/appfile/helm/testdata.yaml b/pkg/appfile/helm/testdata.yaml deleted file mode 100644 index eca25eb7c..000000000 --- a/pkg/appfile/helm/testdata.yaml +++ /dev/null @@ -1,5 +0,0 @@ -chart: - spec: - chart: "podinfo" - version: "1.0.0" - url: "test.com" diff --git a/pkg/appfile/helm/testdata/values.schema.json b/pkg/appfile/helm/testdata/values.schema.json new file mode 100644 index 000000000..ac637b138 --- /dev/null +++ b/pkg/appfile/helm/testdata/values.schema.json @@ -0,0 +1,345 @@ +{ + "properties": { + "affinity": { + "type": "object" + }, + "backend": { + "nullable": true + }, + "backends": { + "default": [], + "type": "array" + }, + "cache": { + "default": "", + "description": "Redis address in the format :", + "type": "string" + }, + "certificate": { + "description": "create a certificate manager certificate", + "properties": { + "create": { + "default": false, + "type": "boolean" + }, + "dnsNames": { + "default": [ + "podinfo" + ], + "description": "the hostname / subject alternative names for the certificate", + "items": { + "type": "string" + }, + "type": "array" + }, + "issuerRef": { + "description": "the issuer used to issue the certificate", + "properties": { + "kind": { + "default": "ClusterIssuer", + "type": "string" + }, + "name": { + "default": "self-signed", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "faults": { + "properties": { + "delay": { + "default": false, + "type": "boolean" + }, + "error": { + "default": false, + "type": "boolean" + }, + "testFail": { + "default": false, + "type": "boolean" + }, + "testTimeout": { + "default": false, + "type": "boolean" + }, + "unhealthy": { + "default": false, + "type": "boolean" + }, + "unready": { + "default": false, + "type": "boolean" + } + }, + "type": "object" + }, + "h2c": { + "properties": { + "enabled": { + "default": false, + "type": "boolean" + } + }, + "type": "object" + }, + "hpa": { + "description": "metrics-server add-on required", + "properties": { + "cpu": { + "description": "average total CPU usage per pod (1-100)", + "nullable": true + }, + "enabled": { + "default": false, + "type": "boolean" + }, + "maxReplicas": { + "default": 10, + "type": "integer" + }, + "memory": { + "description": "average memory usage per pod (100Mi-1Gi)", + "nullable": true + }, + "requests": { + "description": "average http requests per second per pod (k8s-prometheus-adapter)", + "nullable": true + } + }, + "type": "object" + }, + "image": { + "properties": { + "pullPolicy": { + "default": "IfNotPresent", + "type": "string" + }, + "repository": { + "default": "ghcr.io/stefanprodan/podinfo", + "type": "string" + }, + "tag": { + "default": "5.1.4", + "type": "string" + } + }, + "type": "object" + }, + "ingress": { + "properties": { + "annotations": { + "type": "object" + }, + "enabled": { + "default": false, + "type": "boolean" + }, + "hosts": { + "default": [], + "type": "array" + }, + "path": { + "default": "/*", + "description": "kubernetes.io/ingress.class: nginx\nkubernetes.io/tls-acme: \"true\"", + "type": "string" + }, + "tls": { + "default": [], + "description": "- podinfo.local", + "type": "array" + } + }, + "type": "object" + }, + "linkerd": { + "properties": { + "profile": { + "properties": { + "enabled": { + "default": false, + "type": "boolean" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "logLevel": { + "default": "info", + "type": "string" + }, + "nodeSelector": { + "type": "object" + }, + "podAnnotations": { + "type": "object" + }, + "redis": { + "description": "Redis deployment", + "properties": { + "enabled": { + "default": false, + "type": "boolean" + }, + "repository": { + "default": "redis", + "type": "string" + }, + "tag": { + "default": "6.0.8", + "type": "string" + } + }, + "type": "object" + }, + "replicaCount": { + "default": 1, + "type": "integer" + }, + "resources": { + "properties": { + "limits": { + "nullable": true + }, + "requests": { + "properties": { + "cpu": { + "default": "1m", + "type": "string" + }, + "memory": { + "default": "16Mi", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "service": { + "properties": { + "enabled": { + "default": true, + "type": "boolean" + }, + "externalPort": { + "default": 9898, + "type": "integer" + }, + "grpcPort": { + "default": 9999, + "type": "integer" + }, + "grpcService": { + "default": "podinfo", + "type": "string" + }, + "hostPort": { + "description": "the port used to bind the http port to the host\nNOTE: requires privileged container with NET_BIND_SERVICE capability -- this is useful for testing\nin local clusters such as kind without port forwarding", + "nullable": true + }, + "httpPort": { + "default": 9898, + "type": "integer" + }, + "metricsPort": { + "default": 9797, + "type": "integer" + }, + "nodePort": { + "default": 31198, + "type": "integer" + }, + "type": { + "default": "ClusterIP", + "type": "string" + } + }, + "type": "object" + }, + "serviceAccount": { + "properties": { + "enabled": { + "default": false, + "description": "Specifies whether a service account should be created", + "type": "boolean" + }, + "name": { + "description": "The name of the service account to use.\nIf not set and create is true, a name is generated using the fullname template", + "nullable": true + } + }, + "type": "object" + }, + "serviceMonitor": { + "properties": { + "enabled": { + "default": false, + "type": "boolean" + }, + "interval": { + "default": "15s", + "type": "string" + } + }, + "type": "object" + }, + "tls": { + "description": "enable tls on the podinfo service", + "properties": { + "certPath": { + "default": "/data/cert", + "description": "the path where the certificate key pair will be mounted", + "type": "string" + }, + "enabled": { + "default": false, + "type": "boolean" + }, + "hostPort": { + "description": "the port used to bind the tls port to the host\nNOTE: requires privileged container with NET_BIND_SERVICE capability -- this is useful for testing\nin local clusters such as kind without port forwarding", + "nullable": true + }, + "port": { + "default": 9899, + "description": "the port used to host the tls endpoint on the service", + "type": "integer" + }, + "secretName": { + "description": "the name of the secret used to mount the certificate key pair", + "nullable": true + } + }, + "type": "object" + }, + "tolerations": { + "default": [], + "type": "array" + }, + "ui": { + "properties": { + "color": { + "default": "#34577c", + "type": "string" + }, + "logo": { + "default": "", + "type": "string" + }, + "message": { + "default": "", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" +} diff --git a/pkg/appfile/helm/testdata/values.yaml b/pkg/appfile/helm/testdata/values.yaml new file mode 100644 index 000000000..42ae5057c --- /dev/null +++ b/pkg/appfile/helm/testdata/values.yaml @@ -0,0 +1,127 @@ +# Default values for podinfo. + +replicaCount: 1 +logLevel: info +backend: #http://backend-podinfo:9898/echo +backends: [] + +ui: + color: "#34577c" + message: "" + logo: "" + +faults: + delay: false + error: false + unhealthy: false + unready: false + testFail: false + testTimeout: false + +h2c: + enabled: false + +image: + repository: ghcr.io/stefanprodan/podinfo + tag: 5.1.4 + pullPolicy: IfNotPresent + +service: + enabled: true + type: ClusterIP + metricsPort: 9797 + httpPort: 9898 + externalPort: 9898 + grpcPort: 9999 + grpcService: podinfo + nodePort: 31198 + # the port used to bind the http port to the host + # NOTE: requires privileged container with NET_BIND_SERVICE capability -- this is useful for testing + # in local clusters such as kind without port forwarding + hostPort: + +# enable tls on the podinfo service +tls: + enabled: false + # the name of the secret used to mount the certificate key pair + secretName: + # the path where the certificate key pair will be mounted + certPath: /data/cert + # the port used to host the tls endpoint on the service + port: 9899 + # the port used to bind the tls port to the host + # NOTE: requires privileged container with NET_BIND_SERVICE capability -- this is useful for testing + # in local clusters such as kind without port forwarding + hostPort: + +# create a certificate manager certificate +certificate: + create: false + # the issuer used to issue the certificate + issuerRef: + kind: ClusterIssuer + name: self-signed + # the hostname / subject alternative names for the certificate + dnsNames: + - podinfo + +# metrics-server add-on required +hpa: + enabled: false + maxReplicas: 10 + # average total CPU usage per pod (1-100) + cpu: + # average memory usage per pod (100Mi-1Gi) + memory: + # average http requests per second per pod (k8s-prometheus-adapter) + requests: + +# Redis address in the format : +cache: "" +# Redis deployment +redis: + enabled: false + repository: redis + tag: 6.0.8 + +serviceAccount: + # Specifies whether a service account should be created + enabled: false + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: + +linkerd: + profile: + enabled: false + +serviceMonitor: + enabled: false + interval: 15s + +ingress: + enabled: false + annotations: {} + # kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: "true" + path: /* + hosts: [] +# - podinfo.local + tls: [] + # - secretName: chart-example-tls + # hosts: + # - chart-example.local + +resources: + limits: + requests: + cpu: 1m + memory: 16Mi + +nodeSelector: {} + +tolerations: [] + +affinity: {} + +podAnnotations: {} diff --git a/pkg/controller/core.oam.dev/v1alpha2/core/components/componentdefinition/componentdefinition_controller.go b/pkg/controller/core.oam.dev/v1alpha2/core/components/componentdefinition/componentdefinition_controller.go index 330fe223b..796564a46 100644 --- a/pkg/controller/core.oam.dev/v1alpha2/core/components/componentdefinition/componentdefinition_controller.go +++ b/pkg/controller/core.oam.dev/v1alpha2/core/components/componentdefinition/componentdefinition_controller.go @@ -83,8 +83,13 @@ func (r *Reconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) { var def utils.CapabilityComponentDefinition def.Name = req.NamespacedName.Name def.WorkloadType = workloadType - if workloadType == util.ReferWorkload { + switch workloadType { + case util.ReferWorkload: def.WorkloadDefName = componentDefinition.Spec.Workload.Type + case util.HELMDef: + def.Helm = componentDefinition.Spec.Schematic.HELM + def.ComponentDefinition = componentDefinition + default: } err = def.StoreOpenAPISchema(ctx, r, req.Namespace, req.Name) if err != nil { diff --git a/pkg/controller/core.oam.dev/v1alpha2/core/components/componentdefinition/componentdefinition_controller_test.go b/pkg/controller/core.oam.dev/v1alpha2/core/components/componentdefinition/componentdefinition_controller_test.go index cc1656e3d..e77b555fe 100644 --- a/pkg/controller/core.oam.dev/v1alpha2/core/components/componentdefinition/componentdefinition_controller_test.go +++ b/pkg/controller/core.oam.dev/v1alpha2/core/components/componentdefinition/componentdefinition_controller_test.go @@ -479,6 +479,49 @@ spec: }) }) + Context("When the ComponentDefinition contains Helm schematic", func() { + var componentDefinitionName = "cd-with-helm-schematic" + var namespace = "default" + req := reconcile.Request{NamespacedName: client.ObjectKey{Name: componentDefinitionName, Namespace: namespace}} + + It("Applying ComponentDefinition with Helm schematic", func() { + cd := v1alpha2.ComponentDefinition{} + cd.SetName(componentDefinitionName) + cd.SetNamespace(namespace) + cd.Spec.Workload.Definition = v1alpha2.WorkloadGVK{APIVersion: "apps/v1", Kind: "Deployment"} + cd.Spec.Schematic = &v1alpha2.Schematic{ + HELM: &v1alpha2.Helm{ + Release: util.Object2RawExtension(map[string]interface{}{ + "chart": map[string]interface{}{ + "spec": map[string]interface{}{ + "chart": "podinfo", + "version": "5.1.4", + }, + }, + }), + Repository: util.Object2RawExtension(map[string]interface{}{ + "url": "http://oam.dev/catalog/", + }), + }, + } + By("Create ComponentDefinition") + Expect(k8sClient.Create(ctx, &cd)).Should(Succeed()) + + By("Check whether WorkloadDefinition is created") + reconcileRetry(&r, req) + var wd v1alpha2.WorkloadDefinition + var wdName = componentDefinitionName + Eventually(func() bool { + err := k8sClient.Get(ctx, client.ObjectKey{Namespace: namespace, Name: wdName}, &wd) + return err == nil + }, 10*time.Second, time.Second).Should(BeTrue()) + Expect(wd.Name).Should(Equal(cd.Name)) + Expect(wd.Namespace).Should(Equal(cd.Namespace)) + Expect(wd.Annotations).Should(Equal(cd.Annotations)) + Expect(wd.Spec.Schematic).Should(Equal(cd.Spec.Schematic)) + }) + }) + Context("When the ComponentDefinition contain Workload.Type, shouldn't create a WorkloadDefinition", func() { var componentDefinitionName = "cd-with-workload-type" var namespace = "default" @@ -576,4 +619,5 @@ spec: }, 10*time.Second, time.Second).Should(Equal(name)) }) }) + }) diff --git a/pkg/controller/utils/capability.go b/pkg/controller/utils/capability.go index af3d09717..5d8e2afe9 100644 --- a/pkg/controller/utils/capability.go +++ b/pkg/controller/utils/capability.go @@ -33,6 +33,7 @@ import ( "github.com/oam-dev/kubevela/apis/core.oam.dev/v1alpha2" "github.com/oam-dev/kubevela/apis/types" + "github.com/oam-dev/kubevela/pkg/appfile/helm" mycue "github.com/oam-dev/kubevela/pkg/cue" "github.com/oam-dev/kubevela/pkg/oam/util" "github.com/oam-dev/kubevela/pkg/utils/common" @@ -105,7 +106,14 @@ func (def *CapabilityComponentDefinition) GetOpenAPISchema(ctx context.Context, // StoreOpenAPISchema stores OpenAPI v3 schema in ConfigMap from WorkloadDefinition func (def *CapabilityComponentDefinition) StoreOpenAPISchema(ctx context.Context, k8sClient client.Client, namespace, name string) error { - jsonSchema, err := def.GetOpenAPISchema(ctx, k8sClient, namespace, name) + var jsonSchema []byte + var err error + switch def.WorkloadType { + case util.HELMDef: + jsonSchema, err = helm.GetChartValuesJSONSchema(ctx, def.Helm) + default: + jsonSchema, err = def.GetOpenAPISchema(ctx, k8sClient, namespace, name) + } if err != nil { return fmt.Errorf("failed to generate OpenAPI v3 JSON schema for capability %s: %w", def.Name, err) } diff --git a/pkg/oam/util/helper.go b/pkg/oam/util/helper.go index b3e086259..a25a0cfd6 100644 --- a/pkg/oam/util/helper.go +++ b/pkg/oam/util/helper.go @@ -90,7 +90,6 @@ const ( ComponentDef WorkloadType = "ComponentDef" // HELMDef describe a workload refer to HELM - // TODO(yangsoon): we need store helm capability schema in configMap HELMDef WorkloadType = "HelmDef" // ReferWorkload describe an existing workload diff --git a/test/e2e-test/component_version_test.go b/test/e2e-test/component_version_test.go index 1cd4f3f03..0294a2850 100644 --- a/test/e2e-test/component_version_test.go +++ b/test/e2e-test/component_version_test.go @@ -340,6 +340,7 @@ var _ = Describe("Versioning mechanism of components", func() { var w1 unstructured.Unstructured Eventually( func() error { + reconcileAppConfigNow(ctx, &appconfig) w1.SetAPIVersion("example.com/v1") w1.SetKind("Bar") return k8sClient.Get(ctx, client.ObjectKey{Namespace: namespace, Name: revisionNameV1}, &w1) diff --git a/test/e2e-test/helm_app_test.go b/test/e2e-test/helm_app_test.go index 7e42e61a6..dd3f66b51 100644 --- a/test/e2e-test/helm_app_test.go +++ b/test/e2e-test/helm_app_test.go @@ -2,6 +2,7 @@ package controllers_test import ( "context" + "errors" "fmt" "strings" "time" @@ -285,4 +286,18 @@ var _ = Describe("Test application containing helm module", func() { }, 120*time.Second, 10*time.Second).Should(BeTrue()) }) + It("Test store JSON schema of Helm Chart in ConfigMap", func() { + By("Get the ConfigMap") + cmName := fmt.Sprintf("schema-%s", cdName) + Eventually(func() error { + cm := &corev1.ConfigMap{} + if err := k8sClient.Get(ctx, client.ObjectKey{Name: cmName, Namespace: namespace}, cm); err != nil { + return err + } + if cm.Data["openapi-v3-json-schema"] == "" { + return errors.New("json schema is not found in the ConfigMap") + } + return nil + }, 60*time.Second, 5*time.Second).Should(Succeed()) + }) })