Files
kubevela/pkg/appfile/template.go
T
5a44b5f10c Feat: auto remediate cue issues (#7199)
* feat(cue/upgrade): auto-remediate legacy CUE syntax at render time

Transparently rewrite CUE templates that use deprecated list arithmetic
(+, *) and conflicting field names (error) so that older definitions
continue to work with CUE ≥ v0.14 (KubeVela ≥ 1.11).

- CUEUpgradeFunc registry with ID, CUE/KubeVela version guards, precheck,
  and upgrade function fields
- upgradeListConcatenation: rewrites list1+list2 → list.Concat([list1,list2])
  and list*n → list.Repeat(list, n); adds "list" import as needed
- collectAddChain + extractListConcatArgs: flatten left-associative + chains
  and existing list.Concat([...]) leaves into a single flat call, so both
  fresh chains (a+b+c+d) and partially-upgraded chains produce one
  list.Concat([a,b,c,d]) with no nesting across repeated passes
- upgradeErrorFieldLabel: rewrites unquoted `error` field labels to "error"
  to avoid conflict with the CUE 0.14 built-in; precheck uses a tighter
  \berror\s*: regex to avoid false positives on identifiers like errorMessage
- EnsureCueVersionCompatibility: single entry point used at render time;
  LRU cache with TTL eviction, Prometheus metrics, feature flag
- ParseVersion: regex anchored to reject garbage suffixes (e.g. "1.11foo")
  while accepting pre-release+build metadata (e.g. "v1.13.0-alpha.1+dev")

- template.go: call EnsureCueVersionCompatibility for every template area
  (main, health, custom status, status detail) with correct DefinitionKind
  derived from which definition pointer is non-nil
- validate.go: upgrade policy templates before compiling in
  validateNoRequiredParameters

- `vela def upgrade FILE [-o OUTPUT]`: upgrades a single .cue file
- `vela def upgrade FILE --validate [--quiet]`: exit 1 if upgrade needed
- `vela def compat definitions` / `vela def compat applications`: scan
  cluster definitions/apps for compat issues; output as table or YAML
- Cyclomatic complexity kept below threshold by extracting scanDefinitions,
  scanDefRevisions, buildDefCompatReport, scanApplications, scanAppRevision
  as standalone functions with options structs
- revisionNum() helper for numeric vN comparison (avoids lexicographic bugs)
- mergeImports() dedup helper shared by ToCUEString and formatCUEString
- ANSI escape sequences replaced with fatih/color for portability
- goconst: "yaml" → outputFormatYAML named constant throughout

- Component, trait, and policy definition validating handlers: removed
  spurious obj.Name argument from fmt.Sprintf in warning messages

- FromCUEString: only prepend importString to the stored template when
  imports are non-empty; empty importString ("\n") was causing a leading
  newline that made yaml.v3 use |2 block scalar on every generated YAML

- gen_sdk testdata: removed unused imports (vela/op, encoding/base64) from
  one_of.cue that were exposed by our importString+templateString change
- e2e test: fix flaky trait-order assertion using ContainElements instead
  of index-based equality

Upgraded all built-in .cue files that used deprecated list arithmetic:
- vela-templates/definitions/internal/component/cron-task.cue
- vela-templates/definitions/internal/trait/command.cue
- vela-templates/definitions/internal/trait/container-ports.cue
- vela-templates/definitions/internal/trait/env.cue
- vela-templates/definitions/internal/trait/init-container.cue

Removed unused stdlib imports that caused `def gen-api` to fail:
- vela-templates/definitions/internal/workflowstep/apply-deployment.cue
- vela-templates/definitions/internal/workflowstep/apply-terraform-provider.cue
- vela-templates/definitions/internal/workflowstep/build-push-image.cue

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Brian Kane <briankane1@gmail.com>

* fix(cue/upgrade): address PR review comments

- sync.atomic.Pointer for compatCache to fix data race on reinit
- SummaryVec → HistogramVec for both duration metrics (aggregatable
  across HA replicas); buckets tuned to sub-millisecond upgrade path
  and millisecond render path respectively
- errorFieldLabelRe: extend to match optional (?) and required (!)
  field constraint markers before the colon
- cue-compatibility-cache-size: clamp negative values to 0 (disabled)
  with warning log; document 0=disabled in flag help; cache put is
  no-op when capacity <= 0
- webhook: replace RequiresUpgrade+EnsureCueVersionCompatibility double
  parse with single EnsureCueVersionCompatibility call; use string
  comparison to detect upgrade and emit warning
- def compat: log warning when ApplicationRevision fetch fails instead
  of silently skipping (partial results are preserved)
- e2e: only delete definitions in DeferCleanup if this test created
  them (avoid deleting pre-existing shared resources)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Brian Kane <briankane1@gmail.com>

* fix(cue/upgrade): address further PR review comments

- EnsureCueVersionCompatibility: return (string, bool) where bool
  indicates semantic upgrades were applied (len(applied)>0), not
  string inequality — prevents false-positive warnings from
  formatting-only normalisation; update all call sites
- webhook handlers (component, trait, policy): switch from
  RequiresUpgrade+EnsureCueVersionCompatibility double-call to single
  EnsureCueVersionCompatibility call using wasUpgraded bool; remove
  now-unused strings imports
- cache: skip eviction goroutine when capacity==0 (disabled); set
  compatCacheCancel=nil on disabled path to avoid stale cancel on
  next InitCompatibilityCache call
- e2e: replace boolean ownership tracking with createAndTrack helper
  that checks pre-existence via Get before Create, eliminating both
  the ambiguous-create leak and the boilerplate booleans

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Brian Kane <briankane1@gmail.com>

* fix: address reviewer comments — cache determinism, e2e ownership race

- cache: store normalised string in compatEntry.upgraded even when no
  semantic fixes were applied, so cache-hit and cache-miss paths return
  identical output (fixes non-deterministic behaviour flagged in review)
- upgrade: return entry.upgraded on the requiresUpgrade=false cache-hit
  path instead of the raw input cueStr
- e2e: replace GET-then-CREATE ownership inference with atomic CREATE-
  first pattern; err==nil means we created it (register DeferCleanup),
  IsAlreadyExists means it pre-existed (skip cleanup), eliminating the
  GET/CREATE race window that could misattribute ownership

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Brian Kane <briankane1@gmail.com>

---------

Signed-off-by: Brian Kane <briankane1@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 05:57:23 -07:00

436 lines
15 KiB
Go

/*
Copyright 2021 The KubeVela Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package appfile
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/kubevela/pkg/multicluster"
"github.com/pkg/errors"
kerrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"github.com/oam-dev/kubevela/pkg/cue/definition/health"
"github.com/oam-dev/kubevela/pkg/cue/upgrade"
"github.com/oam-dev/kubevela/apis/core.oam.dev/common"
"github.com/oam-dev/kubevela/apis/core.oam.dev/v1beta1"
"github.com/oam-dev/kubevela/apis/types"
oamutil "github.com/oam-dev/kubevela/pkg/oam/util"
)
const (
// UsageTag is usage comment annotation
UsageTag = "+usage="
// ShortTag is the short alias annotation
ShortTag = "+short"
// ImmutableTag marks a parameter field as immutable
ImmutableTag = "+immutable"
)
// Template is a helper struct for processing capability including
// ComponentDefinition, TraitDefinition.
// It mainly collects schematic and status data of a capability definition.
type Template struct {
TemplateStr string
Health string
CustomStatus string
Details string
CapabilityCategory types.CapabilityCategory
Reference common.WorkloadTypeDescriptor
Terraform *common.Terraform
ComponentDefinition *v1beta1.ComponentDefinition
WorkloadDefinition *v1beta1.WorkloadDefinition
TraitDefinition *v1beta1.TraitDefinition
PolicyDefinition *v1beta1.PolicyDefinition
WorkflowStepDefinition *v1beta1.WorkflowStepDefinition
}
// LoadTemplate gets the capability definition from cluster and resolve it.
// It returns a helper struct, Template, which will be used for further
// processing.
func LoadTemplate(ctx context.Context, cli client.Client, capName string, capType types.CapType, annotations map[string]string) (*Template, error) {
ctx = multicluster.WithCluster(ctx, multicluster.Local)
// Application Controller only loads template from ComponentDefinition and TraitDefinition
switch capType {
case types.TypeComponentDefinition, types.TypeWorkload:
cd := new(v1beta1.ComponentDefinition)
err := oamutil.GetCapabilityDefinition(ctx, cli, cd, capName, annotations)
if err != nil {
if kerrors.IsNotFound(err) {
wd := new(v1beta1.WorkloadDefinition)
if err := oamutil.GetDefinition(ctx, cli, wd, capName); err != nil {
return nil, errors.WithMessagef(err, "load template from component definition [%s] ", capName)
}
tmpl, err := newTemplateOfWorkloadDefinition(wd)
if err != nil {
return nil, err
}
gvk, err := oamutil.GetGVKFromDefinition(cli.RESTMapper(), wd.Spec.Reference)
if err != nil {
return nil, errors.WithMessagef(err, "get group version kind from component definition [%s]", capName)
}
tmpl.Reference = common.WorkloadTypeDescriptor{
Definition: common.WorkloadGVK{
APIVersion: metav1.GroupVersion{
Group: gvk.Group,
Version: gvk.Version,
}.String(),
Kind: gvk.Kind,
},
}
return tmpl, nil
}
return nil, errors.WithMessagef(err, "load template from component definition [%s] ", capName)
}
tmpl, err := newTemplateOfCompDefinition(cd)
if err != nil {
return nil, err
}
return tmpl, nil
case types.TypeTrait:
td := new(v1beta1.TraitDefinition)
err := oamutil.GetCapabilityDefinition(ctx, cli, td, capName, annotations)
if err != nil {
return nil, errors.WithMessagef(err, "load template from trait definition [%s] ", capName)
}
tmpl, err := newTemplateOfTraitDefinition(td)
if err != nil {
return nil, err
}
return tmpl, nil
case types.TypePolicy:
d := new(v1beta1.PolicyDefinition)
err := oamutil.GetCapabilityDefinition(ctx, cli, d, capName, annotations)
if err != nil {
return nil, errors.WithMessagef(err, "load template from policy definition [%s] ", capName)
}
tmpl, err := newTemplateOfPolicyDefinition(d)
if err != nil {
return nil, err
}
return tmpl, nil
case types.TypeWorkflowStep:
d := new(v1beta1.WorkflowStepDefinition)
err := oamutil.GetCapabilityDefinition(ctx, cli, d, capName, annotations)
if err != nil {
return nil, errors.WithMessagef(err, "load template from workflow step definition [%s] ", capName)
}
tmpl, err := newTemplateOfWorkflowStepDefinition(d)
if err != nil {
return nil, err
}
return tmpl, nil
}
return nil, fmt.Errorf("kind(%s) of %s not supported", capType, capName)
}
// LoadTemplateFromRevision will load Definition template from app revision
func LoadTemplateFromRevision(capName string, capType types.CapType, apprev *v1beta1.ApplicationRevision, mapper meta.RESTMapper) (*Template, error) {
if apprev == nil {
return nil, errors.Errorf("fail to find template for %s as app revision is empty", capName)
}
capName = verifyRevisionName(capName, capType, apprev)
switch capType {
case types.TypeComponentDefinition:
cd, ok := apprev.Spec.ComponentDefinitions[capName]
if !ok {
wd, ok := apprev.Spec.WorkloadDefinitions[capName]
if !ok {
return nil, errors.Errorf("component definition [%s] not found in app revision %s", capName, apprev.Name)
}
tmpl, err := newTemplateOfWorkloadDefinition(&wd)
if err != nil {
return nil, err
}
gvk, err := oamutil.GetGVKFromDefinition(mapper, wd.Spec.Reference)
if err != nil {
return nil, errors.WithMessagef(err, "Get group version kind from component definition [%s]", capName)
}
tmpl.Reference = common.WorkloadTypeDescriptor{
Definition: common.WorkloadGVK{
APIVersion: metav1.GroupVersion{
Group: gvk.Group,
Version: gvk.Version,
}.String(),
Kind: gvk.Kind,
},
}
return tmpl, nil
}
tmpl, err := newTemplateOfCompDefinition(cd.DeepCopy())
if err != nil {
return nil, err
}
return tmpl, nil
case types.TypeTrait:
td, ok := apprev.Spec.TraitDefinitions[capName]
if !ok {
return nil, errors.Errorf("TraitDefinition [%s] not found in app revision %s", capName, apprev.Name)
}
tmpl, err := newTemplateOfTraitDefinition(td.DeepCopy())
if err != nil {
return nil, err
}
return tmpl, nil
case types.TypePolicy:
d, ok := apprev.Spec.PolicyDefinitions[capName]
if !ok {
return nil, errors.Errorf("PolicyDefinition [%s] not found in app revision %s", capName, apprev.Name)
}
tmpl, err := newTemplateOfPolicyDefinition(d.DeepCopy())
if err != nil {
return nil, err
}
return tmpl, nil
case types.TypeWorkflowStep:
w, ok := apprev.Spec.WorkflowStepDefinitions[capName]
if !ok {
return nil, errors.Errorf("WorkflowStepDefinition [%s] not found in app revision %s", capName, apprev.Name)
}
tmpl, err := newTemplateOfWorkflowStepDefinition(w.DeepCopy())
if err != nil {
return nil, err
}
return tmpl, nil
default:
return nil, fmt.Errorf("kind(%s) of %s not supported", capType, capName)
}
}
// IsNotFoundInAppRevision check if the error is `not found in app revision`
func IsNotFoundInAppRevision(err error) bool {
return err != nil && strings.Contains(err.Error(), "not found in app revision")
}
func verifyRevisionName(capName string, capType types.CapType, apprev *v1beta1.ApplicationRevision) string {
if strings.Contains(capName, "@") {
splitName := capName[0:strings.LastIndex(capName, "@")]
ok := false
switch capType {
case types.TypeComponentDefinition:
_, ok = apprev.Spec.ComponentDefinitions[splitName]
case types.TypeTrait:
_, ok = apprev.Spec.TraitDefinitions[splitName]
case types.TypePolicy:
_, ok = apprev.Spec.PolicyDefinitions[splitName]
case types.TypeWorkflowStep:
_, ok = apprev.Spec.WorkflowStepDefinitions[splitName]
default:
return capName
}
if ok {
return splitName
}
}
return capName
}
// DryRunTemplateLoader return a function that do the same work as
// LoadTemplate, but load template from provided ones before loading from
// cluster through LoadTemplate
func DryRunTemplateLoader(defs []*unstructured.Unstructured) TemplateLoaderFn {
return func(ctx context.Context, r client.Client, capName string, capType types.CapType, annotations map[string]string) (*Template, error) {
// retrieve provided cap definitions
for _, def := range defs {
if def.GetKind() == v1beta1.ComponentDefinitionKind &&
capType == types.TypeComponentDefinition && def.GetName() == capName {
compDef := &v1beta1.ComponentDefinition{}
if err := runtime.DefaultUnstructuredConverter.FromUnstructured(def.Object, compDef); err != nil {
return nil, errors.Wrap(err, "invalid component definition")
}
tmpl, err := newTemplateOfCompDefinition(compDef)
if err != nil {
return nil, errors.WithMessagef(err, "cannot load template of component definition %q", capName)
}
return tmpl, nil
}
if def.GetKind() == v1beta1.TraitDefinitionKind &&
capType == types.TypeTrait && def.GetName() == capName {
traitDef := &v1beta1.TraitDefinition{}
if err := runtime.DefaultUnstructuredConverter.FromUnstructured(def.Object, traitDef); err != nil {
return nil, errors.Wrap(err, "invalid trait definition")
}
tmpl, err := newTemplateOfTraitDefinition(traitDef)
if err != nil {
return nil, errors.WithMessagef(err, "cannot load template of trait definition %q", capName)
}
return tmpl, nil
}
}
// not found in provided cap definitions
// then try to retrieve from cluster
tmpl, err := LoadTemplate(ctx, r, capName, capType, annotations)
if err != nil {
return nil, errors.WithMessagef(err, "cannot load template %q from cluster and provided ones", capName)
}
return tmpl, nil
}
}
func newTemplateOfCompDefinition(compDef *v1beta1.ComponentDefinition) (*Template, error) {
tmpl := &Template{
Reference: compDef.Spec.Workload,
ComponentDefinition: compDef,
}
if err := loadSchematicToTemplate(tmpl, compDef.Spec.Status, compDef.Spec.Schematic, compDef.Spec.Extension); err != nil {
return nil, errors.WithMessage(err, "cannot load template")
}
if compDef.Annotations["type"] == string(types.TerraformCategory) {
tmpl.CapabilityCategory = types.TerraformCategory
}
return tmpl, nil
}
func newTemplateOfTraitDefinition(traitDef *v1beta1.TraitDefinition) (*Template, error) {
tmpl := &Template{
TraitDefinition: traitDef,
}
if err := loadSchematicToTemplate(tmpl, traitDef.Spec.Status, traitDef.Spec.Schematic, traitDef.Spec.Extension); err != nil {
return nil, errors.WithMessage(err, "cannot load template")
}
return tmpl, nil
}
func newTemplateOfWorkloadDefinition(wlDef *v1beta1.WorkloadDefinition) (*Template, error) {
tmpl := &Template{
Reference: common.WorkloadTypeDescriptor{Type: wlDef.Spec.Reference.Name},
WorkloadDefinition: wlDef,
}
if err := loadSchematicToTemplate(tmpl, wlDef.Spec.Status, wlDef.Spec.Schematic, wlDef.Spec.Extension); err != nil {
return nil, errors.WithMessage(err, "cannot load template")
}
return tmpl, nil
}
func newTemplateOfPolicyDefinition(def *v1beta1.PolicyDefinition) (*Template, error) {
tmpl := &Template{
PolicyDefinition: def,
}
if err := loadSchematicToTemplate(tmpl, nil, def.Spec.Schematic, nil); err != nil {
return nil, errors.WithMessage(err, "cannot load template")
}
return tmpl, nil
}
func newTemplateOfWorkflowStepDefinition(def *v1beta1.WorkflowStepDefinition) (*Template, error) {
tmpl := &Template{
WorkflowStepDefinition: def,
}
if err := loadSchematicToTemplate(tmpl, nil, def.Spec.Schematic, nil); err != nil {
return nil, errors.WithMessage(err, "cannot load template")
}
return tmpl, nil
}
// loadSchematicToTemplate loads common data that all kind definitions have.
func loadSchematicToTemplate(tmpl *Template, status *common.Status, schematic *common.Schematic, ext *runtime.RawExtension) error {
if status != nil {
tmpl.CustomStatus = status.CustomStatus
tmpl.Health = status.HealthPolicy
tmpl.Details = status.Details
}
if schematic != nil {
if schematic.CUE != nil {
tmpl.CapabilityCategory = types.CUECategory
tmpl.TemplateStr = schematic.CUE.Template
}
if schematic.Terraform != nil {
tmpl.CapabilityCategory = types.TerraformCategory
tmpl.Terraform = schematic.Terraform
return nil
}
}
if tmpl.TemplateStr == "" && ext != nil {
tmpl.CapabilityCategory = types.CUECategory
extension := map[string]interface{}{}
if err := json.Unmarshal(ext.Raw, &extension); err != nil {
return errors.Wrap(err, "cannot parse capability extension")
}
if extTemplate, ok := extension["template"]; ok {
if tmpStr, ok := extTemplate.(string); ok {
tmpl.TemplateStr = tmpStr
}
}
}
return nil
}
// ConvertTemplateJSON2Object convert spec.extension or spec.schematic to object
func ConvertTemplateJSON2Object(capabilityName string, in *runtime.RawExtension, schematic *common.Schematic) (types.Capability, error) {
var t types.Capability
t.Name = capabilityName
if in != nil && in.Raw != nil {
err := json.Unmarshal(in.Raw, &t)
if err != nil {
return t, errors.Wrapf(err, "parse extension fail")
}
}
capTemplate := &Template{}
if err := loadSchematicToTemplate(capTemplate, nil, schematic, in); err != nil {
return t, errors.WithMessage(err, "cannot resolve schematic")
}
if capTemplate.TemplateStr != "" {
t.CueTemplate = capTemplate.TemplateStr
}
return t, nil
}
// definitionKind returns the upgrade.DefinitionKind for this template, derived from
// whichever definition pointer is populated. Defaults to ComponentKind.
func (t *Template) definitionKind() upgrade.DefinitionKind {
switch {
case t.TraitDefinition != nil:
return upgrade.TraitKind
case t.PolicyDefinition != nil:
return upgrade.PolicyKind
case t.WorkflowStepDefinition != nil:
return upgrade.WorkflowStepKind
default:
return upgrade.ComponentKind
}
}
func (t *Template) AsStatusRequest(parameter map[string]interface{}) *health.StatusRequest {
kind := t.definitionKind()
healthCUE, _ := upgrade.EnsureCueVersionCompatibility(t.Health, "health", kind, upgrade.TemplateAreaHealth)
customCUE, _ := upgrade.EnsureCueVersionCompatibility(t.CustomStatus, "customStatus", kind, upgrade.TemplateAreaCustomStatus)
detailsCUE, _ := upgrade.EnsureCueVersionCompatibility(t.Details, "statusDetails", kind, upgrade.TemplateAreaStatusDetail)
return &health.StatusRequest{
Health: healthCUE,
Custom: customCUE,
Details: detailsCUE,
Parameter: parameter,
}
}