diff --git a/docs/examples/custom-trait/README.md b/docs/examples/custom-trait/README.md new file mode 100644 index 000000000..2557b6c68 --- /dev/null +++ b/docs/examples/custom-trait/README.md @@ -0,0 +1,103 @@ +# How to use + +1. define a stateful component with StatefulSet as output + +```shell +$ vela def apply stateful.cue +ComponentDefinition test-stateful created in namespace vela-system. +``` + +2. define a custom trait with patch volume + +```shell +$ vela def apply volume-trait.cue +TraitDefinition storageclass created in namespace vela-system. +``` + +3. You can validate it by: +``` +$ vela def vet volume-trait.cue +Validation succeed. +``` + + + +4. try dry run your app: +``` +vela dry-run -f app.yaml +``` + +```yaml +# Application(website) -- Component(custom-component) +--- + +apiVersion: apps/v1 +kind: StatefulSet +metadata: + annotations: {} + labels: + app.oam.dev/appRevision: "" + app.oam.dev/component: custom-component + app.oam.dev/name: website + app.oam.dev/namespace: default + app.oam.dev/resourceType: WORKLOAD + workload.oam.dev/type: test-stateful + name: custom-component + namespace: default +spec: + minReadySeconds: 10 + replicas: 1 + selector: + matchLabels: + app: custom-component + serviceName: custom-component + template: + metadata: + labels: + app: custom-component + spec: + containers: + - image: nginx:latest + name: nginx + ports: + - containerPort: 80 + name: web + volumeMounts: + - mountPath: /usr/share/nginx/html + name: test + terminationGracePeriodSeconds: 10 + volumeClaimTemplates: + - metadata: + name: test + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 10Gi + storageClassName: cbs + +--- +apiVersion: v1 +kind: Service +metadata: + annotations: {} + labels: + app: custom-component + app.oam.dev/appRevision: "" + app.oam.dev/component: custom-component + app.oam.dev/name: website + app.oam.dev/namespace: default + app.oam.dev/resourceType: TRAIT + trait.oam.dev/resource: web + trait.oam.dev/type: AuxiliaryWorkload + name: custom-component + namespace: default +spec: + clusterIP: None + ports: + - name: web + port: 80 + selector: + app: custom-component +``` \ No newline at end of file diff --git a/docs/examples/custom-trait/app.yaml b/docs/examples/custom-trait/app.yaml new file mode 100644 index 000000000..e18f1c4ac --- /dev/null +++ b/docs/examples/custom-trait/app.yaml @@ -0,0 +1,20 @@ +apiVersion: core.oam.dev/v1beta1 +kind: Application +metadata: + name: website + namespace: default +spec: + components: + - name: custom-component + type: test-stateful + properties: + image: nginx:latest + replicas: 1 + traits: + - type: storageclass + properties: + volumeClaimTemplates: + - name: test + requests: 10Gi + storageClassName: cbs + mountPath: /usr/share/nginx/html \ No newline at end of file diff --git a/docs/examples/custom-trait/stateful.cue b/docs/examples/custom-trait/stateful.cue new file mode 100644 index 000000000..cea6e8dc1 --- /dev/null +++ b/docs/examples/custom-trait/stateful.cue @@ -0,0 +1,58 @@ +"test-stateful": { + annotations: {} + attributes: workload: definition: { + apiVersion: "apps/v1" + kind: "StatefulSet" + } + description: "StatefulSet component." + labels: {} + type: "component" +} + +template: { + output: { + apiVersion: "apps/v1" + kind: "StatefulSet" + metadata: name: context.name + spec: { + selector: matchLabels: app: context.name + minReadySeconds: 10 + replicas: parameter.replicas + serviceName: context.name + template: { + metadata: labels: app: context.name + spec: { + containers: [{ + name: "nginx" + ports: [{ + name: "web" + containerPort: 80 + }] + image: parameter.image + }] + terminationGracePeriodSeconds: 10 + } + } + } + } + outputs: web: { + apiVersion: "v1" + kind: "Service" + metadata: { + name: context.name + labels: app: context.name + } + spec: { + clusterIP: "None" + ports: [{ + name: "web" + port: 80 + }] + selector: app: context.name + } + } + parameter: { + image: string + replicas: int + } +} diff --git a/docs/examples/custom-trait/volume-trait.cue b/docs/examples/custom-trait/volume-trait.cue new file mode 100644 index 000000000..b7061e1cd --- /dev/null +++ b/docs/examples/custom-trait/volume-trait.cue @@ -0,0 +1,56 @@ +storageclass: { + type: "trait" + annotations: {} + labels: {} + description: "Add storageclass on K8s pod for your workload which follows the pod spec in path 'spec.template'." + attributes: { + appliesToWorkloads: ["*"] + } +} +template: { + + volumeClaimTemplatesList: *[ + for v in parameter.volumeClaimTemplates { + { + metadata: name: v.name + spec: { + accessModes: ["ReadWriteOnce"] + resources: requests: storage: v.requests + storageClassName: v.storageClassName + } + } + }, + ] | [] + + volumeClaimTemplateVolumeMountsList: *[ + for v in parameter.volumeClaimTemplates { + { + name: v.name + mountPath: v.mountPath + } + }, + ] | [] + + patch: { + // +patchKey=name + spec: { + template: spec: { + containers: [...{ + // +patchKey=name + volumeMounts: volumeClaimTemplateVolumeMountsList + }] + } + // +patchKey=name + volumeClaimTemplates: volumeClaimTemplatesList + } + } + + parameter: { + volumeClaimTemplates?: [...{ + name: string + requests: string + storageClassName: string + mountPath: string + }] + } +} diff --git a/pkg/apiserver/rest/usecase/application.go b/pkg/apiserver/rest/usecase/application.go index c5104c38d..d36a8f9f6 100644 --- a/pkg/apiserver/rest/usecase/application.go +++ b/pkg/apiserver/rest/usecase/application.go @@ -1657,7 +1657,7 @@ func dryRunApplication(ctx context.Context, app *v1beta1.Application) (bytes.Buf if err != nil { return buff, err } - dryRunOpt := dryrun.NewDryRunOption(newClient, dm, pd, objs) + dryRunOpt := dryrun.NewDryRunOption(newClient, config, dm, pd, objs) comps, err := dryRunOpt.ExecuteDryRun(ctx, app) if err != nil { return buff, errors.New("generate OAM objects") @@ -1735,7 +1735,7 @@ func compare(ctx context.Context, newApp *v1beta1.Application, oldApp *v1beta1.A if err != nil { return nil, buff, err } - liveDiffOption := dryrun.NewLiveDiffOption(client, dm, pd, objs) + liveDiffOption := dryrun.NewLiveDiffOption(client, config, dm, pd, objs) diffResult, err := liveDiffOption.DiffApps(ctx, newApp, oldApp) if err != nil { return nil, buff, err diff --git a/pkg/cue/definition/template.go b/pkg/cue/definition/template.go index 25cd82fd0..26cbb128d 100644 --- a/pkg/cue/definition/template.go +++ b/pkg/cue/definition/template.go @@ -353,7 +353,6 @@ func (td *traitDef) Complete(ctx process.Context, abstractTemplate string, param if err := base.Unify(p, sets.CreateUnifyOptionsForPatcher(patcher)...); err != nil { return errors.WithMessagef(err, "invalid patch trait %s into workload", td.name) } - for _, auxiliary := range auxiliaries { target := patcher.Lookup("context", model.OutputsFieldName, auxiliary.Name) if target.Exists() { diff --git a/references/appfile/dryrun/diff.go b/references/appfile/dryrun/diff.go index b52992bfd..20eaaf3ad 100644 --- a/references/appfile/dryrun/diff.go +++ b/references/appfile/dryrun/diff.go @@ -23,6 +23,7 @@ import ( "github.com/aryann/difflib" "github.com/pkg/errors" + "k8s.io/client-go/rest" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/yaml" @@ -35,9 +36,9 @@ import ( ) // NewLiveDiffOption creates a live-diff option -func NewLiveDiffOption(c client.Client, dm discoverymapper.DiscoveryMapper, pd *packages.PackageDiscover, as []oam.Object) *LiveDiffOption { +func NewLiveDiffOption(c client.Client, cfg *rest.Config, dm discoverymapper.DiscoveryMapper, pd *packages.PackageDiscover, as []oam.Object) *LiveDiffOption { parser := appfile.NewApplicationParser(c, dm, pd) - return &LiveDiffOption{DryRun: NewDryRunOption(c, dm, pd, as), Parser: parser} + return &LiveDiffOption{DryRun: NewDryRunOption(c, cfg, dm, pd, as), Parser: parser} } // ManifestKind enums the kind of OAM objects diff --git a/references/appfile/dryrun/dryrun.go b/references/appfile/dryrun/dryrun.go index f319f1f53..f4e404c23 100644 --- a/references/appfile/dryrun/dryrun.go +++ b/references/appfile/dryrun/dryrun.go @@ -18,10 +18,20 @@ package dryrun import ( "context" + "encoding/json" + "os" + "path/filepath" "github.com/pkg/errors" corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/client-go/discovery" + "k8s.io/client-go/rest" + "k8s.io/kubectl/pkg/util/openapi" + "k8s.io/kubectl/pkg/util/openapi/validation" + kval "k8s.io/kubectl/pkg/validation" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/yaml" "github.com/oam-dev/kubevela/apis/core.oam.dev/v1beta1" "github.com/oam-dev/kubevela/apis/types" @@ -38,8 +48,8 @@ type DryRun interface { } // NewDryRunOption creates a dry-run option -func NewDryRunOption(c client.Client, dm discoverymapper.DiscoveryMapper, pd *packages.PackageDiscover, as []oam.Object) *Option { - return &Option{c, dm, pd, as} +func NewDryRunOption(c client.Client, cfg *rest.Config, dm discoverymapper.DiscoveryMapper, pd *packages.PackageDiscover, as []oam.Object) *Option { + return &Option{c, dm, pd, cfg, as} } // Option contains options to execute dry-run @@ -47,12 +57,67 @@ type Option struct { Client client.Client DiscoveryMapper discoverymapper.DiscoveryMapper PackageDiscover *packages.PackageDiscover + + cfg *rest.Config // Auxiliaries are capability definitions used to parse application. // DryRun will use capabilities in Auxiliaries as higher priority than // getting one from cluster. Auxiliaries []oam.Object } +// validateObjectFromFile will read file into Unstructured object +func (d *Option) validateObjectFromFile(filename string) (*unstructured.Unstructured, error) { + fileContent, err := os.ReadFile(filepath.Clean(filename)) + if err != nil { + return nil, err + } + + fileType := filepath.Ext(filename) + switch fileType { + case ".yaml", ".yml": + fileContent, err = yaml.YAMLToJSON(fileContent) + if err != nil { + return nil, err + } + } + + dc, err := discovery.NewDiscoveryClientForConfig(d.cfg) + if err != nil { + return nil, err + } + openAPIGetter := openapi.NewOpenAPIGetter(dc) + resources, err := openapi.NewOpenAPIParser(openAPIGetter).Parse() + if err != nil { + return nil, err + } + + valids := kval.ConjunctiveSchema{validation.NewSchemaValidation(resources), kval.NoDoubleKeySchema{}} + if err = valids.ValidateBytes(fileContent); err != nil { + return nil, err + } + + app := new(unstructured.Unstructured) + err = json.Unmarshal(fileContent, app) + return app, err +} + +// ValidateApp will validate app with client schema check and server side dry-run +func (d *Option) ValidateApp(ctx context.Context, filename string) error { + app, err := d.validateObjectFromFile(filename) + if err != nil { + return err + } + + app2 := app.DeepCopy() + + err = d.Client.Get(ctx, client.ObjectKey{Namespace: app.GetNamespace(), Name: app.GetName()}, app2) + if err == nil { + app.SetResourceVersion(app2.GetResourceVersion()) + return d.Client.Update(ctx, app, client.DryRunAll) + } + return d.Client.Create(ctx, app, client.DryRunAll) +} + // ExecuteDryRun simulates applying an application into cluster and returns rendered // resources but not persist them into cluster. func (d *Option) ExecuteDryRun(ctx context.Context, app *v1beta1.Application) ([]*types.ComponentManifest, error) { diff --git a/references/appfile/dryrun/suit_test.go b/references/appfile/dryrun/suit_test.go index a66addb53..17665b827 100644 --- a/references/appfile/dryrun/suit_test.go +++ b/references/appfile/dryrun/suit_test.go @@ -107,7 +107,7 @@ var _ = BeforeSuite(func(done Done) { tdMyScaler, err := oamutil.Object2Unstructured(myscalerDef) Expect(err).Should(BeNil()) - dryrunOpt = NewDryRunOption(k8sClient, dm, pd, []oam.Object{cdMyWorker, tdMyIngress, tdMyScaler}) + dryrunOpt = NewDryRunOption(k8sClient, cfg, dm, pd, []oam.Object{cdMyWorker, tdMyIngress, tdMyScaler}) diffOpt = &LiveDiffOption{DryRun: dryrunOpt, Parser: appfile.NewApplicationParser(k8sClient, dm, pd)} close(done) diff --git a/references/appfile/dryrun/testdata/invalid-app-format.yaml b/references/appfile/dryrun/testdata/invalid-app-format.yaml new file mode 100644 index 000000000..3f1ae3d77 --- /dev/null +++ b/references/appfile/dryrun/testdata/invalid-app-format.yaml @@ -0,0 +1,20 @@ +apiVersion: core.oam.dev/v1beta1 +kind: Application +metadata: + name: website + namespace: default +spec: + components: + - name: custom-component + type: test-stateful + properties: + image: nginx:latest + replicas: 1 + traits: + - type: storageclass + properties: + volumeClaimTemplates: + - name: test + requests: 10Gi + storageClassName: cbs + mountPath: /usr/share/nginx/html \ No newline at end of file diff --git a/references/cli/dryrun.go b/references/cli/dryrun.go index e9922509d..27adc095a 100644 --- a/references/cli/dryrun.go +++ b/references/cli/dryrun.go @@ -107,13 +107,19 @@ func DryRunApplication(cmdOption *DryRunCmdOptions, c common.Args, namespace str return buff, err } + dryRunOpt := dryrun.NewDryRunOption(newClient, config, dm, pd, objs) + ctx := oamutil.SetNamespaceInCtx(context.Background(), namespace) + + err = dryRunOpt.ValidateApp(ctx, cmdOption.ApplicationFile) + if err != nil { + return buff, errors.WithMessagef(err, "validate application: %s by dry-run", cmdOption.ApplicationFile) + } + app, err := readApplicationFromFile(cmdOption.ApplicationFile) if err != nil { return buff, errors.WithMessagef(err, "read application file: %s", cmdOption.ApplicationFile) } - dryRunOpt := dryrun.NewDryRunOption(newClient, dm, pd, objs) - ctx := oamutil.SetNamespaceInCtx(context.Background(), namespace) comps, err := dryRunOpt.ExecuteDryRun(ctx, app) if err != nil { return buff, errors.WithMessage(err, "generate OAM objects") diff --git a/references/cli/livediff.go b/references/cli/livediff.go index 585de656e..4759aae8c 100644 --- a/references/cli/livediff.go +++ b/references/cli/livediff.go @@ -143,7 +143,7 @@ func LiveDiffApplication(cmdOption *LiveDiffCmdOptions, c common.Args, namespace } } - liveDiffOption := dryrun.NewLiveDiffOption(newClient, dm, pd, objs) + liveDiffOption := dryrun.NewLiveDiffOption(newClient, config, dm, pd, objs) diffResult, err := liveDiffOption.Diff(context.Background(), app, appRevision) if err != nil { return buff, errors.WithMessage(err, "cannot calculate diff")