Compare commits

...

9 Commits

Author SHA1 Message Date
github-actions[bot]
25473ac9fc Fix: rebuild appliedResources from ResourceTracker instead of filtering by name (#7083) (#7086)
appliedResources entries use resource names, not component names, so filtering
them against component names incorrectly dropped valid entries. Rebuild directly
from the current ResourceTracker each reconcile - already in memory, no extra
API calls.


(cherry picked from commit 73e4c791a9)

Signed-off-by: Brian Kane <briankane1@gmail.com>
Co-authored-by: Brian Kane <briankane1@gmail.com>
2026-03-30 08:53:30 -07:00
Brian Kane
e494c0adab Fix: Allow status.details field to support dynamic keys and option to disable validation (#7056) (#7062)
(cherry picked from commit 3c74ac68bf)

Signed-off-by: Brian Kane <briankane1@gmail.com>
2026-03-18 21:24:19 -07:00
github-actions[bot]
3b49347a78 Fix: update debug-webhook-setup.sh for k3d on macOS with Docker Desktop (#7060) (#7063)
- Use host.docker.internal instead of bridge gateway IP on macOS, which
  is unreachable from the host when using Docker Desktop
- Make webhook port configurable via first argument (default 9445) to
  avoid conflicts with other processes


(cherry picked from commit 9a83c932a4)

Signed-off-by: Brian Kane <briankane1@gmail.com>
Co-authored-by: Brian Kane <briankane1@gmail.com>
2026-03-18 21:19:57 -07:00
github-actions[bot]
471a0d55e2 Fix: do not recreate externally deleted apply-once resources on reconciliation (#7051) (#7054)
* skip ext deleted resources from state keep



* state keep test



* re-trigger CI



---------


(cherry picked from commit b9a44ebfaa)

Signed-off-by: Oana Schipor <oana.schipor@vortexa.com>
Co-authored-by: Oana Schipor <36938341+oanasc@users.noreply.github.com>
2026-03-03 07:39:35 -08:00
Brian Kane
a2fe0b9fdc Fix: Backport commits to 1.10 (#7040) 2026-02-12 09:09:48 -08:00
github-actions[bot]
217a71e598 Fix: 7018 Ensure Component removals are correctly persisted and reflected in status (#7027) (#7029)
(cherry picked from commit 555e4416f4)

Signed-off-by: Brian Kane <briankane1@gmail.com>
Co-authored-by: Brian Kane <briankane1@gmail.com>
2026-02-09 05:12:32 -08:00
github-actions[bot]
bbbdd0d299 Fix: Enhance shared resource handling to avoid last-applied-configuration pollution (#6998) (#7001)
Some checks failed
Webhook Upgrade Validation / webhook-upgrade-check (push) Failing after 16m4s
(cherry picked from commit 552764d48f)

Signed-off-by: Brian Kane <briankane1@gmail.com>
Co-authored-by: Ayush Kumar <65535504+roguepikachu@users.noreply.github.com>
2025-11-26 08:06:58 -08:00
github-actions[bot]
f89622eec7 Fix: Fix issue with imports/packages in status validations (#6963) (#6971)
(cherry picked from commit 8e3749f970)

Signed-off-by: Brian Kane <briankane1@gmail.com>
Co-authored-by: Brian Kane <briankane1@gmail.com>
2025-11-06 18:56:43 -08:00
Ayush Kumar
8401ff4d85 Fix: Prevent namespace admins from accessing vela-system definitions without explicit permissions (#6967)
* fix: webhook validation to check definition existence in namespaces and privilege checks

Signed-off-by: Reetika Malhotra <malhotra.reetika25@gmail.com>
Signed-off-by: Ayush Kumar <ayushshyamkumar888@gmail.com>

* fix: make reviewable changes

Signed-off-by: Ayush Kumar <ayushshyamkumar888@gmail.com>

* Update Ingress API version and enhance output validation tests

- Changed Ingress API version from v1beta1 to v1 in multiple test files to align with Kubernetes API updates.
- Added pathType specification to Ingress rules for better compatibility.
- Introduced a new e2e test for validating outputs in ComponentDefinition, TraitDefinition, PolicyDefinition, and WorkflowStepDefinition, ensuring proper handling of valid and invalid resources.
- Enhanced existing tests to check for non-existent CRDs in outputs and validate definitions with mixed valid and invalid resources.

Signed-off-by: Ayush Kumar <ayushshyamkumar888@gmail.com>

* fix: update comment for expected error count in definition permissions test

Signed-off-by: Ayush Kumar <ayushshyamkumar888@gmail.com>

* fix: improve error handling message in definitionExistsInNamespace function

Signed-off-by: Ayush Kumar <ayushshyamkumar888@gmail.com>

* fix: enhance definition permission checks and add corresponding test cases

Signed-off-by: Ayush Kumar <ayushshyamkumar888@gmail.com>

* fix: clarify comment for definition permission check in ValidateComponents

Signed-off-by: Ayush Kumar <ayushshyamkumar888@gmail.com>

* fix: add existing definitions to validation permissions tests for improved coverage

Signed-off-by: Ayush Kumar <ayushshyamkumar888@gmail.com>

---------

Signed-off-by: Reetika Malhotra <malhotra.reetika25@gmail.com>
Signed-off-by: Ayush Kumar <ayushshyamkumar888@gmail.com>
Co-authored-by: Reetika Malhotra <malhotra.reetika25@gmail.com>
2025-11-06 06:31:05 -08:00
19 changed files with 3008 additions and 85 deletions

View File

@@ -9,6 +9,7 @@ CERT_DIR="k8s-webhook-server/serving-certs"
NAMESPACE="vela-system"
SECRET_NAME="webhook-server-cert"
WEBHOOK_CONFIG_NAME="kubevela-vela-core-admission"
WEBHOOK_PORT="${1:-9445}"
# Colors for output
RED='\033[0;31m'
@@ -75,12 +76,68 @@ generate_certificates() {
# Generate server private key
openssl genrsa -out ${CERT_DIR}/tls.key 2048
# Get host IP for Docker internal network
# NOTE: 192.168.5.2 is the standard k3d host gateway IP that allows containers to reach the host machine
# Auto-detect host IP for Docker/k3d internal network
# This is only for local k3d development environments - DO NOT use this script in production
# With failurePolicy: Fail, an unreachable webhook can block CRD operations cluster-wide
HOST_IP="192.168.5.2"
LOCAL_IP=$(ifconfig | grep "inet " | grep -v 127.0.0.1 | head -1 | awk '{print $2}')
# Try to detect k3d cluster
K3D_CLUSTER=$(kubectl config current-context | grep -o 'k3d-[^@]*' | sed 's/k3d-//' || echo "")
if [ -n "$K3D_CLUSTER" ]; then
echo "Detected k3d cluster: $K3D_CLUSTER"
# Check if k3d is using host network
NETWORK_MODE=$(docker inspect "k3d-${K3D_CLUSTER}-server-0" 2>/dev/null | grep -o '"NetworkMode": "[^"]*"' | cut -d'"' -f4 || echo "")
if [ "$NETWORK_MODE" = "host" ]; then
# Host network mode - detect OS
if [ "$(uname)" = "Darwin" ]; then
# macOS with Docker Desktop - use host.docker.internal
echo "Detected k3d with --network host on macOS, using host.docker.internal"
HOST_IP="host.docker.internal"
else
# Linux - true host networking works
echo "Detected k3d with --network host, using localhost"
HOST_IP="127.0.0.1"
fi
else
# Bridge network mode
if [ "$(uname)" = "Darwin" ]; then
# macOS with Docker Desktop - host.docker.internal is always reachable from containers
echo "Detected k3d with bridge network on macOS, using host.docker.internal"
HOST_IP="host.docker.internal"
else
# Linux bridge - get gateway IP
NETWORK_NAME="k3d-${K3D_CLUSTER}"
HOST_IP=$(docker network inspect "$NETWORK_NAME" -f '{{range .IPAM.Config}}{{.Gateway}}{{end}}' 2>/dev/null || echo "")
if [ -z "$HOST_IP" ]; then
# Fallback to common k3d gateway IPs
echo "Could not detect gateway IP, trying common defaults..."
if docker exec "k3d-${K3D_CLUSTER}-server-0" getent hosts host.k3d.internal 2>/dev/null | awk '{print $1}' | grep -q .; then
HOST_IP=$(docker exec "k3d-${K3D_CLUSTER}-server-0" cat /etc/hosts | grep host.k3d.internal | awk '{print $1}')
else
HOST_IP="172.18.0.1"
fi
fi
echo "Detected k3d with bridge network, using gateway IP: $HOST_IP"
fi
fi
else
# Not k3d, use default
echo "Not using k3d, defaulting to 192.168.5.2"
HOST_IP="192.168.5.2"
fi
# Get local machine IP for SANs (optional, for reference)
if command -v ifconfig &> /dev/null; then
LOCAL_IP=$(ifconfig | grep "inet " | grep -v 127.0.0.1 | head -1 | awk '{print $2}')
elif command -v ip &> /dev/null; then
LOCAL_IP=$(ip -4 addr show | grep -oP '(?<=inet\s)\d+(\.\d+){3}' | grep -v 127.0.0.1 | head -1)
else
LOCAL_IP=""
fi
# Create certificate config with SANs
cat > /tmp/webhook.conf << EOF
@@ -98,11 +155,26 @@ DNS.2 = vela-webhook.${NAMESPACE}.svc
DNS.3 = vela-webhook.${NAMESPACE}.svc.cluster.local
DNS.4 = *.${NAMESPACE}.svc
DNS.5 = *.${NAMESPACE}.svc.cluster.local
DNS.6 = host.k3d.internal
DNS.7 = host.docker.internal
DNS.8 = host.lima.internal
IP.1 = 127.0.0.1
IP.2 = ${HOST_IP}
IP.3 = ${LOCAL_IP}
EOF
# Add HOST_IP - check if it's a hostname or IP
if [[ "$HOST_IP" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
# It's an IP address
echo "IP.2 = ${HOST_IP}" >> /tmp/webhook.conf
else
# It's a hostname - already covered by DNS SANs above
echo "# HOST_IP is hostname: ${HOST_IP} (already in DNS SANs)" >> /tmp/webhook.conf
fi
# Add LOCAL_IP to SANs only if detected and is an IP
if [ -n "$LOCAL_IP" ] && [[ "$LOCAL_IP" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "IP.3 = ${LOCAL_IP}" >> /tmp/webhook.conf
fi
# Generate certificate request
openssl req -new -key ${CERT_DIR}/tls.key -out /tmp/server.csr \
-subj "/CN=vela-webhook.${NAMESPACE}.svc" -config /tmp/webhook.conf
@@ -153,7 +225,7 @@ metadata:
webhooks:
- name: componentdefinition.core.oam.dev
clientConfig:
url: https://${HOST_IP}:9445/validating-core-oam-dev-v1beta1-componentdefinitions
url: https://${HOST_IP}:${WEBHOOK_PORT}/validating-core-oam-dev-v1beta1-componentdefinitions
caBundle: ${CA_BUNDLE}
rules:
- apiGroups: ["core.oam.dev"]
@@ -165,7 +237,7 @@ webhooks:
failurePolicy: Fail
- name: traitdefinition.core.oam.dev
clientConfig:
url: https://${HOST_IP}:9445/validating-core-oam-dev-v1beta1-traitdefinitions
url: https://${HOST_IP}:${WEBHOOK_PORT}/validating-core-oam-dev-v1beta1-traitdefinitions
caBundle: ${CA_BUNDLE}
rules:
- apiGroups: ["core.oam.dev"]
@@ -177,7 +249,7 @@ webhooks:
failurePolicy: Fail
- name: policydefinition.core.oam.dev
clientConfig:
url: https://${HOST_IP}:9445/validating-core-oam-dev-v1beta1-policydefinitions
url: https://${HOST_IP}:${WEBHOOK_PORT}/validating-core-oam-dev-v1beta1-policydefinitions
caBundle: ${CA_BUNDLE}
rules:
- apiGroups: ["core.oam.dev"]
@@ -189,7 +261,7 @@ webhooks:
failurePolicy: Fail
- name: workflowstepdefinition.core.oam.dev
clientConfig:
url: https://${HOST_IP}:9445/validating-core-oam-dev-v1beta1-workflowstepdefinitions
url: https://${HOST_IP}:${WEBHOOK_PORT}/validating-core-oam-dev-v1beta1-workflowstepdefinitions
caBundle: ${CA_BUNDLE}
rules:
- apiGroups: ["core.oam.dev"]
@@ -199,6 +271,18 @@ webhooks:
admissionReviewVersions: ["v1", "v1beta1"]
sideEffects: None
failurePolicy: Fail
- name: applications.core.oam.dev
clientConfig:
url: https://${HOST_IP}:${WEBHOOK_PORT}/validating-core-oam-dev-v1beta1-applications
caBundle: ${CA_BUNDLE}
rules:
- apiGroups: ["core.oam.dev"]
apiVersions: ["v1beta1"]
resources: ["applications"]
operations: ["CREATE", "UPDATE"]
admissionReviewVersions: ["v1", "v1beta1"]
sideEffects: None
failurePolicy: Fail
EOF
kubectl apply -f /tmp/webhook-config.yaml
@@ -214,19 +298,38 @@ show_next_steps() {
echo "Webhook debugging setup complete!"
echo "========================================="
echo -e "${NC}"
echo "Configuration:"
echo " - Webhook URL: https://${HOST_IP}:${WEBHOOK_PORT}"
echo " - Certificate directory: ${CERT_DIR}"
if [ -n "$K3D_CLUSTER" ]; then
echo " - k3d cluster: ${K3D_CLUSTER}"
if [ "$NETWORK_MODE" = "host" ]; then
echo " - Network mode: host (using ${HOST_IP})"
else
echo " - Network mode: bridge (using gateway ${HOST_IP})"
fi
fi
echo ""
echo "Next steps:"
echo "1. Open VS Code"
echo "1. Open your IDE (VS Code, GoLand, etc.)"
echo "2. Set breakpoints in webhook validation code:"
echo " - pkg/webhook/utils/utils.go:141"
echo " - pkg/webhook/core.oam.dev/v1beta1/componentdefinition/validating_handler.go:74"
echo "3. Press F5 and select 'Debug Webhook Validation'"
echo "4. Wait for webhook server to start (port 9445)"
echo " - pkg/webhook/core.oam.dev/v1beta1/application/validating_handler.go:66"
echo " - pkg/webhook/core.oam.dev/v1beta1/componentdefinition/component_definition_validating_handler.go:74"
echo "3. Start debugging cmd/core/main.go with arguments:"
echo " --use-webhook=true"
echo " --webhook-port=${WEBHOOK_PORT}"
echo " --webhook-cert-dir=${CERT_DIR}"
echo " --leader-elect=false"
echo "4. Wait for webhook server to start"
echo "5. Test with kubectl apply commands"
echo ""
echo -e "${YELLOW}Test command (should be rejected):${NC}"
echo 'kubectl apply -f test/webhook-test-invalid.yaml'
echo -e "${YELLOW}Test command:${NC}"
echo 'kubectl apply -f <your-application.yaml>'
echo ""
echo -e "${GREEN}The webhook will reject ComponentDefinitions with non-existent CRDs${NC}"
echo -e "${GREEN}Your breakpoints will hit when kubectl applies resources!${NC}"
}
# Main execution

View File

@@ -55,7 +55,24 @@ func (p *Parser) ValidateCUESchematicAppfile(a *Appfile) error {
}
}
// Collect workflow-supplied params for this component upfront
workflowParams := getWorkflowAndPolicySuppliedParams(a)
// Only augment if component has traits AND workflow supplies params (issue 7022)
originalParams := wl.Params
if len(wl.Traits) > 0 && len(workflowParams) > 0 {
shouldSkip, augmented := p.augmentComponentParamsForValidation(wl, workflowParams, ctxData)
if shouldSkip {
// Component has complex validation that can't be handled, skip trait validation
fmt.Printf("INFO: Skipping trait validation for component %q due to workflow-supplied parameters with complex validation\n", wl.Name)
continue
}
wl.Params = augmented
}
pCtx, err := newValidationProcessContext(wl, ctxData)
wl.Params = originalParams // Restore immediately
if err != nil {
return errors.WithMessagef(err, "cannot create the validation process context of app=%s in namespace=%s", a.Name, a.Namespace)
}
@@ -329,3 +346,200 @@ func validateAuxiliaryNameUnique() process.AuxiliaryHook {
return nil
})
}
// getWorkflowAndPolicySuppliedParams returns a set of parameter keys that will be
// supplied by workflow steps or override policies at runtime.
func getWorkflowAndPolicySuppliedParams(app *Appfile) map[string]bool {
result := make(map[string]bool)
// Collect from workflow step inputs
for _, step := range app.WorkflowSteps {
for _, in := range step.Inputs {
result[in.ParameterKey] = true
}
}
// Collect from override policies
for _, p := range app.Policies {
if p.Type != "override" {
continue
}
var spec overrideSpec
if err := json.Unmarshal(p.Properties.Raw, &spec); err != nil {
continue // Skip if we can't parse
}
for _, c := range spec.Components {
if len(c.Properties) == 0 {
continue
}
flat, err := flatten.Flatten(c.Properties, "", flatten.DotStyle)
if err != nil {
continue // Skip if we can't flatten
}
for k := range flat {
result[k] = true
}
}
}
return result
}
// getDefaultForMissingParameter checks if a parameter can be defaulted for validation
// and returns an appropriate placeholder value.
func getDefaultForMissingParameter(v cue.Value) (bool, any) {
if v.IsConcrete() {
return true, nil
}
if defaultVal, hasDefault := v.Default(); hasDefault {
return true, defaultVal
}
// Use Expr() to inspect the operation tree for complex validation
op, args := v.Expr()
switch op {
case cue.NoOp, cue.SelectorOp:
// No operation or field selector - simple type
// Use IncompleteKind for non-concrete values to get the correct type
return true, getTypeDefault(v.IncompleteKind())
case cue.AndOp:
// Conjunction (e.g., int & >0 & <100)
if len(args) > 1 {
// Check if any arg is NOT just a basic kind (indicates complex validation)
for _, arg := range args {
if arg.Kind() == cue.BottomKind {
return false, nil
}
}
}
return true, getTypeDefault(v.IncompleteKind())
case cue.OrOp:
// Disjunction (e.g., "value1" | "value2" | "value3") - likely an enum
if len(args) > 0 {
firstVal := args[0]
if firstVal.IsConcrete() {
var result any
if err := firstVal.Decode(&result); err == nil {
return true, result
}
}
}
return false, nil
default:
return false, nil
}
}
// getTypeDefault returns a simple default value based on the CUE Kind.
func getTypeDefault(kind cue.Kind) any {
switch kind {
case cue.StringKind:
return "__workflow_supplied__"
case cue.FloatKind:
return 0.0
case cue.IntKind, cue.NumberKind:
return 0
case cue.BoolKind:
return false
case cue.ListKind:
return []any{}
case cue.StructKind:
return map[string]any{}
default:
return "__workflow_supplied__"
}
}
// augmentComponentParamsForValidation checks if workflow-supplied parameters
// need to be augmented for trait validation. Returns (shouldSkip, augmentedParams).
// If shouldSkip=true, the component has complex validation and should skip trait validation.
// If shouldSkip=false, augmentedParams contains the original params plus simple defaults.
func (p *Parser) augmentComponentParamsForValidation(wl *Component, workflowParams map[string]bool, ctxData velaprocess.ContextData) (bool, map[string]any) {
// Build CUE value to inspect the component's parameter schema
ctx := velaprocess.NewContext(ctxData)
baseCtx, err := ctx.BaseContextFile()
if err != nil {
return false, wl.Params // Can't inspect, proceed normally
}
paramSnippet, err := cueParamBlock(wl.Params)
if err != nil {
return false, wl.Params
}
cueSrc := strings.Join([]string{
renderTemplate(wl.FullTemplate.TemplateStr),
paramSnippet,
baseCtx,
}, "\n")
val, err := cuex.DefaultCompiler.Get().CompileString(ctx.GetCtx(), cueSrc)
if err != nil {
return false, wl.Params // Can't compile, proceed normally
}
// Get the parameter schema
paramVal := val.LookupPath(value.FieldPath(velaprocess.ParameterFieldName))
// Collect default values for workflow-supplied params that are missing
workflowParamDefaults := make(map[string]any)
for paramKey := range workflowParams {
// Skip if already provided
if _, exists := wl.Params[paramKey]; exists {
continue
}
// Check the field in the schema
fieldVal := paramVal.LookupPath(cue.ParsePath(paramKey))
if !fieldVal.Exists() {
continue // Not a parameter field
}
canDefault, defaultVal := getDefaultForMissingParameter(fieldVal)
if !canDefault {
// complex validation - skip
return true, nil
}
if defaultVal != nil {
workflowParamDefaults[paramKey] = defaultVal
}
}
if len(workflowParamDefaults) == 0 {
return false, wl.Params
}
// Create augmented params map
augmented := make(map[string]any)
for k, v := range wl.Params {
augmented[k] = v
}
for k, v := range workflowParamDefaults {
augmented[k] = v
}
fmt.Printf("INFO: Augmented component %q with workflow-supplied defaults for trait validation: %v\n",
wl.Name, getMapKeys(workflowParamDefaults))
return false, augmented
}
// getMapKeys returns the keys from a map as a slice
func getMapKeys(m map[string]any) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}

View File

@@ -17,11 +17,20 @@ limitations under the License.
package appfile
import (
"testing"
"cuelang.org/go/cue"
workflowv1alpha1 "github.com/kubevela/workflow/api/v1alpha1"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/stretchr/testify/assert"
"k8s.io/apimachinery/pkg/runtime"
utilfeature "k8s.io/apiserver/pkg/util/feature"
"github.com/oam-dev/kubevela/apis/core.oam.dev/v1beta1"
"github.com/oam-dev/kubevela/apis/types"
"github.com/oam-dev/kubevela/pkg/cue/definition"
"github.com/oam-dev/kubevela/pkg/features"
)
var _ = Describe("Test validate CUE schematic Appfile", func() {
@@ -262,3 +271,806 @@ var _ = Describe("Test ValidateComponentParams", func() {
}),
)
})
func TestValidationHelpers(t *testing.T) {
t.Run("renderTemplate", func(t *testing.T) {
tmpl := "output: {}"
expected := "output: {}\ncontext: _\nparameter: _\n"
assert.Equal(t, expected, renderTemplate(tmpl))
})
t.Run("cueParamBlock", func(t *testing.T) {
t.Run("should handle empty params", func(t *testing.T) {
out, err := cueParamBlock(map[string]any{})
assert.NoError(t, err)
assert.Equal(t, "parameter: {}", out)
})
t.Run("should handle valid params", func(t *testing.T) {
params := map[string]any{"key": "value"}
out, err := cueParamBlock(params)
assert.NoError(t, err)
assert.Equal(t, `parameter: {"key":"value"}`, out)
})
t.Run("should return error for unmarshallable params", func(t *testing.T) {
params := map[string]any{"key": make(chan int)}
_, err := cueParamBlock(params)
assert.Error(t, err)
})
})
t.Run("filterMissing", func(t *testing.T) {
t.Run("should filter missing keys", func(t *testing.T) {
keys := []string{"a", "b.c", "d"}
provided := map[string]any{
"a": 1,
"b": map[string]any{
"c": 2,
},
}
out, err := filterMissing(keys, provided)
assert.NoError(t, err)
assert.Equal(t, []string{"d"}, out)
})
t.Run("should handle no missing keys", func(t *testing.T) {
keys := []string{"a"}
provided := map[string]any{"a": 1}
out, err := filterMissing(keys, provided)
assert.NoError(t, err)
assert.Empty(t, out)
})
})
t.Run("requiredFields", func(t *testing.T) {
t.Run("should identify required fields", func(t *testing.T) {
cueStr := `
parameter: {
name: string
age: int
nested: {
field1: string
field2: bool
}
}
`
var r cue.Runtime
inst, err := r.Compile("", cueStr)
assert.NoError(t, err)
val := inst.Value()
paramVal := val.LookupPath(cue.ParsePath("parameter"))
fields, err := requiredFields(paramVal)
assert.NoError(t, err)
assert.ElementsMatch(t, []string{"name", "age", "nested.field1", "nested.field2"}, fields)
})
t.Run("should ignore optional and default fields", func(t *testing.T) {
cueStr := `
parameter: {
name: string
age?: int
location: string | *"unknown"
nested: {
field1: string
field2?: bool
}
}
`
var r cue.Runtime
inst, err := r.Compile("", cueStr)
assert.NoError(t, err)
val := inst.Value()
paramVal := val.LookupPath(cue.ParsePath("parameter"))
fields, err := requiredFields(paramVal)
assert.NoError(t, err)
assert.ElementsMatch(t, []string{"name", "nested.field1"}, fields)
})
})
}
func TestEnforceRequiredParams(t *testing.T) {
var r cue.Runtime
cueStr := `
parameter: {
image: string
replicas: int
port: int
data: {
key: string
value: string
}
}
`
inst, err := r.Compile("", cueStr)
assert.NoError(t, err)
root := inst.Value()
t.Run("should pass if all params are provided directly", func(t *testing.T) {
params := map[string]any{
"image": "nginx",
"replicas": 2,
"port": 80,
"data": map[string]any{
"key": "k",
"value": "v",
},
}
app := &Appfile{}
err := enforceRequiredParams(root, params, app)
assert.NoError(t, err)
})
t.Run("should fail if params are missing", func(t *testing.T) {
params := map[string]any{
"image": "nginx",
}
app := &Appfile{}
err := enforceRequiredParams(root, params, app)
assert.Error(t, err)
assert.Contains(t, err.Error(), "missing parameters: replicas,port,data.key,data.value")
})
}
func TestParser_ValidateCUESchematicAppfile(t *testing.T) {
assert.NoError(t, utilfeature.DefaultMutableFeatureGate.Set(string(features.EnableCueValidation)+"=true"))
t.Cleanup(func() {
assert.NoError(t, utilfeature.DefaultMutableFeatureGate.Set(string(features.EnableCueValidation)+"=false"))
})
t.Run("should validate a valid CUE schematic appfile", func(t *testing.T) {
appfile := &Appfile{
Name: "test-app",
Namespace: "test-ns",
ParsedComponents: []*Component{
{
Name: "my-comp",
Type: "worker",
CapabilityCategory: types.CUECategory,
Params: map[string]any{
"image": "nginx",
},
FullTemplate: &Template{
TemplateStr: `
parameter: {
image: string
}
output: {
apiVersion: "apps/v1"
kind: "Deployment"
spec: {
template: {
spec: {
containers: [{
name: "my-container"
image: parameter.image
}]
}
}
}
}
`,
},
engine: definition.NewWorkloadAbstractEngine("my-comp"),
Traits: []*Trait{
{
Name: "my-trait",
CapabilityCategory: types.CUECategory,
Template: `
parameter: {
domain: string
}
patch: {}
`,
Params: map[string]any{
"domain": "example.com",
},
engine: definition.NewTraitAbstractEngine("my-trait"),
},
},
},
},
}
p := &Parser{}
err := p.ValidateCUESchematicAppfile(appfile)
assert.NoError(t, err)
})
t.Run("should return error for invalid trait evaluation", func(t *testing.T) {
appfile := &Appfile{
Name: "test-app",
Namespace: "test-ns",
ParsedComponents: []*Component{
{
Name: "my-comp",
Type: "worker",
CapabilityCategory: types.CUECategory,
Params: map[string]any{
"image": "nginx",
},
FullTemplate: &Template{
TemplateStr: `
parameter: {
image: string
}
output: {
apiVersion: "apps/v1"
kind: "Deployment"
}
`,
},
engine: definition.NewWorkloadAbstractEngine("my-comp"),
Traits: []*Trait{
{
Name: "my-trait",
CapabilityCategory: types.CUECategory,
Template: `
// invalid CUE template
parameter: {
domain: string
}
patch: {
invalid: {
}
`,
Params: map[string]any{
"domain": "example.com",
},
engine: definition.NewTraitAbstractEngine("my-trait"),
},
},
},
},
}
p := &Parser{}
err := p.ValidateCUESchematicAppfile(appfile)
assert.Error(t, err)
assert.Contains(t, err.Error(), "cannot evaluate trait \"my-trait\"")
})
t.Run("should return error for missing parameters", func(t *testing.T) {
appfile := &Appfile{
Name: "test-app",
Namespace: "test-ns",
ParsedComponents: []*Component{
{
Name: "my-comp",
Type: "worker",
CapabilityCategory: types.CUECategory,
Params: map[string]any{}, // no params provided
FullTemplate: &Template{
TemplateStr: `
parameter: {
image: string
}
output: {
apiVersion: "apps/v1"
kind: "Deployment"
}
`,
},
engine: definition.NewWorkloadAbstractEngine("my-comp"),
},
},
}
p := &Parser{}
err := p.ValidateCUESchematicAppfile(appfile)
assert.Error(t, err)
assert.Contains(t, err.Error(), "missing parameters: image")
})
t.Run("should skip non-CUE components", func(t *testing.T) {
appfile := &Appfile{
Name: "test-app",
Namespace: "test-ns",
ParsedComponents: []*Component{
{
Name: "my-comp",
Type: "helm",
CapabilityCategory: types.TerraformCategory,
},
},
}
p := &Parser{}
err := p.ValidateCUESchematicAppfile(appfile)
assert.NoError(t, err)
})
}
// TestValidateCUESchematicAppfile_WorkflowSuppliedParams tests validation with workflow-supplied parameters (issue #7022)
func TestValidateCUESchematicAppfile_WorkflowSuppliedParams(t *testing.T) {
assert.NoError(t, utilfeature.DefaultMutableFeatureGate.Set(string(features.EnableCueValidation)+"=true"))
t.Cleanup(func() {
assert.NoError(t, utilfeature.DefaultMutableFeatureGate.Set(string(features.EnableCueValidation)+"=false"))
})
componentTemplate := `
parameter: {
image: string
port: int | *80
}
output: {
apiVersion: "apps/v1"
kind: "Deployment"
spec: {
template: {
spec: {
containers: [{
name: "main"
image: parameter.image
ports: [{
containerPort: parameter.port
}]
}]
}
}
}
}
`
traitTemplate := `
parameter: {
key: string
value: string
}
patch: {
metadata: {
labels: {
(parameter.key): parameter.value
}
}
}
`
t.Run("workflow supplies param - NO traits - should PASS", func(t *testing.T) {
appfile := &Appfile{
Name: "test-app",
Namespace: "test-ns",
ParsedComponents: []*Component{
{
Name: "my-webservice",
Type: "webservice",
CapabilityCategory: types.CUECategory,
Params: map[string]any{
"port": 80,
},
FullTemplate: &Template{
TemplateStr: componentTemplate,
},
engine: definition.NewWorkloadAbstractEngine("my-webservice"),
},
},
WorkflowSteps: []workflowv1alpha1.WorkflowStep{
{
WorkflowStepBase: workflowv1alpha1.WorkflowStepBase{
Name: "apply-microservice",
Type: "apply-component",
Inputs: workflowv1alpha1.StepInputs{
{
From: "dynamicValue",
ParameterKey: "image",
},
},
},
},
},
}
p := &Parser{}
err := p.ValidateCUESchematicAppfile(appfile)
assert.NoError(t, err, "Should pass when workflow supplies missing param and NO traits present")
})
t.Run("workflow supplies param - WITH traits - should PASS", func(t *testing.T) {
appfile := &Appfile{
Name: "test-app",
Namespace: "test-ns",
ParsedComponents: []*Component{
{
Name: "my-webservice",
Type: "webservice",
CapabilityCategory: types.CUECategory,
Params: map[string]any{
"port": 80,
},
FullTemplate: &Template{
TemplateStr: componentTemplate,
},
engine: definition.NewWorkloadAbstractEngine("my-webservice"),
Traits: []*Trait{
{
Name: "labels",
CapabilityCategory: types.CUECategory,
Template: traitTemplate,
Params: map[string]any{
"key": "release",
"value": "stable",
},
engine: definition.NewTraitAbstractEngine("labels"),
},
},
},
},
WorkflowSteps: []workflowv1alpha1.WorkflowStep{
{
WorkflowStepBase: workflowv1alpha1.WorkflowStepBase{
Name: "apply-microservice",
Type: "apply-component",
Inputs: workflowv1alpha1.StepInputs{
{
From: "dynamicValue",
ParameterKey: "image",
},
},
},
},
},
}
p := &Parser{}
err := p.ValidateCUESchematicAppfile(appfile)
assert.NoError(t, err, "Should pass when workflow supplies missing param even WITH traits")
})
t.Run("workflow supplies param with ENUM - should use first enum value", func(t *testing.T) {
enumComponentTemplate := `
parameter: {
image: "nginx:latest" | "apache:latest" | "httpd:latest"
port: int | *80
}
output: {
apiVersion: "apps/v1"
kind: "Deployment"
spec: {
template: {
spec: {
containers: [{
name: "main"
image: parameter.image
ports: [{
containerPort: parameter.port
}]
}]
}
}
}
}
`
appfile := &Appfile{
Name: "test-app",
Namespace: "test-ns",
ParsedComponents: []*Component{
{
Name: "my-webservice",
Type: "webservice",
CapabilityCategory: types.CUECategory,
Params: map[string]any{
"port": 80,
},
FullTemplate: &Template{
TemplateStr: enumComponentTemplate,
},
engine: definition.NewWorkloadAbstractEngine("my-webservice"),
Traits: []*Trait{
{
Name: "labels",
CapabilityCategory: types.CUECategory,
Template: traitTemplate,
Params: map[string]any{
"key": "release",
"value": "stable",
},
engine: definition.NewTraitAbstractEngine("labels"),
},
},
},
},
WorkflowSteps: []workflowv1alpha1.WorkflowStep{
{
WorkflowStepBase: workflowv1alpha1.WorkflowStepBase{
Name: "apply-microservice",
Type: "apply-component",
Inputs: workflowv1alpha1.StepInputs{
{
From: "dynamicValue",
ParameterKey: "image",
},
},
},
},
},
}
p := &Parser{}
err := p.ValidateCUESchematicAppfile(appfile)
assert.NoError(t, err, "Should use first enum value as default")
})
t.Run("param missing everywhere - should FAIL", func(t *testing.T) {
appfile := &Appfile{
Name: "test-app",
Namespace: "test-ns",
ParsedComponents: []*Component{
{
Name: "my-webservice",
Type: "webservice",
CapabilityCategory: types.CUECategory,
Params: map[string]any{
"port": 80,
},
FullTemplate: &Template{
TemplateStr: componentTemplate,
},
engine: definition.NewWorkloadAbstractEngine("my-webservice"),
Traits: []*Trait{
{
Name: "labels",
CapabilityCategory: types.CUECategory,
Template: traitTemplate,
Params: map[string]any{
"key": "release",
"value": "stable",
},
engine: definition.NewTraitAbstractEngine("labels"),
},
},
},
},
}
p := &Parser{}
err := p.ValidateCUESchematicAppfile(appfile)
assert.Error(t, err, "Should fail when param is missing everywhere")
assert.Contains(t, err.Error(), "missing parameters: image")
})
t.Run("override policy supplies param - WITH traits - should PASS", func(t *testing.T) {
policyJSON := `{
"components": [{
"properties": {
"image": "nginx:1.20"
}
}]
}`
appfile := &Appfile{
Name: "test-app",
Namespace: "test-ns",
ParsedComponents: []*Component{
{
Name: "my-webservice",
Type: "webservice",
CapabilityCategory: types.CUECategory,
Params: map[string]any{
"port": 80,
},
FullTemplate: &Template{
TemplateStr: componentTemplate,
},
engine: definition.NewWorkloadAbstractEngine("my-webservice"),
Traits: []*Trait{
{
Name: "labels",
CapabilityCategory: types.CUECategory,
Template: traitTemplate,
Params: map[string]any{
"key": "release",
"value": "stable",
},
engine: definition.NewTraitAbstractEngine("labels"),
},
},
},
},
Policies: []v1beta1.AppPolicy{
{
Name: "override-policy",
Type: "override",
Properties: &runtime.RawExtension{
Raw: []byte(policyJSON),
},
},
},
}
p := &Parser{}
err := p.ValidateCUESchematicAppfile(appfile)
assert.NoError(t, err, "Should pass when override policy supplies missing param")
})
t.Run("workflow supplies different param types - should use correct defaults", func(t *testing.T) {
multiTypeTemplate := `
parameter: {
count: int
enabled: bool
tags: [...string]
port: int | *80
}
output: {
apiVersion: "v1"
kind: "ConfigMap"
data: {
count: "\(parameter.count)"
enabled: "\(parameter.enabled)"
port: "\(parameter.port)"
}
metadata: {
labels: {
for i, tag in parameter.tags {
"tag-\(i)": tag
}
}
}
}
`
appfile := &Appfile{
Name: "test-app",
Namespace: "test-ns",
ParsedComponents: []*Component{
{
Name: "my-config",
Type: "raw",
CapabilityCategory: types.CUECategory,
Params: map[string]any{
"port": 80,
},
FullTemplate: &Template{
TemplateStr: multiTypeTemplate,
},
engine: definition.NewWorkloadAbstractEngine("my-config"),
Traits: []*Trait{
{
Name: "labels",
CapabilityCategory: types.CUECategory,
Template: traitTemplate,
Params: map[string]any{
"key": "env",
"value": "test",
},
engine: definition.NewTraitAbstractEngine("labels"),
},
},
},
},
WorkflowSteps: []workflowv1alpha1.WorkflowStep{
{
WorkflowStepBase: workflowv1alpha1.WorkflowStepBase{
Name: "apply-config",
Type: "apply-component",
Inputs: workflowv1alpha1.StepInputs{
{From: "dynamicCount", ParameterKey: "count"},
{From: "dynamicEnabled", ParameterKey: "enabled"},
{From: "dynamicTags", ParameterKey: "tags"},
},
},
},
},
}
p := &Parser{}
err := p.ValidateCUESchematicAppfile(appfile)
assert.NoError(t, err, "Should handle int, bool, list types with correct defaults")
})
t.Run("workflow supplies param with numeric bounds - should skip validation", func(t *testing.T) {
// Component with complex validation that can't be easily defaulted
complexTemplate := `
parameter: {
port: int & >1024 & <65535
image: string
}
output: {
apiVersion: "v1"
kind: "Service"
spec: {
ports: [{
port: parameter.port
}]
}
}
`
appfile := &Appfile{
Name: "test-app",
Namespace: "test-ns",
ParsedComponents: []*Component{
{
Name: "my-service",
Type: "service",
CapabilityCategory: types.CUECategory,
Params: map[string]any{
"image": "nginx:latest",
},
FullTemplate: &Template{
TemplateStr: complexTemplate,
},
engine: definition.NewWorkloadAbstractEngine("my-service"),
Traits: []*Trait{
{
Name: "labels",
CapabilityCategory: types.CUECategory,
Template: traitTemplate,
Params: map[string]any{
"key": "version",
"value": "v1",
},
engine: definition.NewTraitAbstractEngine("labels"),
},
},
},
},
WorkflowSteps: []workflowv1alpha1.WorkflowStep{
{
WorkflowStepBase: workflowv1alpha1.WorkflowStepBase{
Name: "apply-service",
Type: "apply-component",
Inputs: workflowv1alpha1.StepInputs{
{From: "dynamicPort", ParameterKey: "port"},
},
},
},
},
}
p := &Parser{}
err := p.ValidateCUESchematicAppfile(appfile)
// Should pass by skipping validation due to complex constraints
assert.NoError(t, err, "Should skip validation when complex constraints cannot be satisfied")
})
t.Run("workflow param already provided in component - should not augment", func(t *testing.T) {
appfile := &Appfile{
Name: "test-app",
Namespace: "test-ns",
ParsedComponents: []*Component{
{
Name: "my-webservice",
Type: "webservice",
CapabilityCategory: types.CUECategory,
Params: map[string]any{
"image": "custom-image:v1.0",
"port": 8080,
},
FullTemplate: &Template{
TemplateStr: componentTemplate,
},
engine: definition.NewWorkloadAbstractEngine("my-webservice"),
Traits: []*Trait{
{
Name: "labels",
CapabilityCategory: types.CUECategory,
Template: traitTemplate,
Params: map[string]any{
"key": "app",
"value": "myapp",
},
engine: definition.NewTraitAbstractEngine("labels"),
},
},
},
},
WorkflowSteps: []workflowv1alpha1.WorkflowStep{
{
WorkflowStepBase: workflowv1alpha1.WorkflowStepBase{
Name: "apply-webservice",
Type: "apply-component",
Inputs: workflowv1alpha1.StepInputs{
{From: "dynamicImage", ParameterKey: "image"},
},
},
},
},
}
p := &Parser{}
err := p.ValidateCUESchematicAppfile(appfile)
assert.NoError(t, err, "Should use existing param value, not augment from workflow")
})
}

View File

@@ -215,9 +215,23 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
}
handler.addServiceStatus(false, app.Status.Services...)
app.Status.Services = handler.services
handler.addAppliedResource(true, app.Status.AppliedResources...)
app.Status.AppliedResources = handler.appliedResources
app.Status.Services = handler.services
// Remove services[] entries for components that no longer exist in spec
filteredServices, componentsRemoved := filterRemovedComponentsFromStatus(
app.Spec.Components,
app.Status.Services,
)
app.Status.Services = filteredServices
handler.services = filteredServices
if componentsRemoved {
logCtx.Info("Removed deleted components from status")
}
workflowUpdated := app.Status.Workflow.Message != "" && workflowInstance.Status.Message == ""
workflowInstance.Status.Phase = workflowState
app.Status.Workflow = workflow.ConvertWorkflowStatus(workflowInstance.Status, app.Status.Workflow.AppRevision)
@@ -254,6 +268,10 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
default:
}
// Rebuild appliedResources from the ResourceTracker now that the workflow has finished
// dispatching. The RT is the authoritative source
app.Status.AppliedResources = handler.resourceKeeper.GetAppliedResources()
var phase = common.ApplicationRunning
isHealthy := evalStatus(logCtx, handler, appFile, appParser)
if !isHealthy {
@@ -285,7 +303,8 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
Reason: condition.ReasonReconcileSuccess,
})
r.Recorder.Event(app, event.Normal(velatypes.ReasonDeployed, velatypes.MessageDeployed))
return r.gcResourceTrackers(logCtx, handler, phase, true, false)
// Use Update instead of Patch when components were removed to properly clear status arrays
return r.gcResourceTrackers(logCtx, handler, phase, true, componentsRemoved)
}
func (r *Reconciler) stateKeep(logCtx monitorContext.Context, handler *AppHandler, app *v1beta1.Application) {
@@ -696,6 +715,29 @@ func setVelaVersion(app *v1beta1.Application) {
}
}
// filterRemovedComponentsFromStatus removes services[] entries for components no longer in spec.
// Returns filtered services and whether any were removed (used to determine Update vs Patch).
func filterRemovedComponentsFromStatus(
components []common.ApplicationComponent,
services []common.ApplicationComponentStatus,
) (filteredServices []common.ApplicationComponentStatus, removed bool) {
componentMap := make(map[string]struct{}, len(components))
for _, comp := range components {
componentMap[comp.Name] = struct{}{}
}
filteredServices = make([]common.ApplicationComponentStatus, 0, len(services))
for _, svc := range services {
if _, found := componentMap[svc.Name]; found {
filteredServices = append(filteredServices, svc)
} else {
removed = true
}
}
return filteredServices, removed
}
func evalStatus(ctx monitorContext.Context, handler *AppHandler, appFile *appfile.Appfile, appParser *appfile.Parser) bool {
healthCheck := handler.checkComponentHealth(appParser, appFile)
if !hasHealthCheckPolicy(appFile.ParsedPolicies) {

View File

@@ -42,7 +42,7 @@ func (r *Reconciler) updateMetricsAndLog(_ context.Context, app *v1beta1.Applica
updatePhaseMetrics(app)
workflowStatus := buildWorkflowStatus(app.Status.Workflow)
serviceDetails := buildServiceDetails(app.Status.Services)
serviceDetails := buildServiceDetails(app, app.Status.Services)
logApplicationStatus(app, healthStatus, workflowStatus, serviceDetails)
}
@@ -106,13 +106,24 @@ func buildWorkflowStatus(workflow *common.WorkflowStatus) map[string]interface{}
}
}
// getComponentType looks up the component type from the application spec
func getComponentType(app *v1beta1.Application, componentName string) string {
for _, comp := range app.Spec.Components {
if comp.Name == componentName {
return comp.Type
}
}
return ""
}
// buildServiceDetails builds service details for logging
func buildServiceDetails(services []common.ApplicationComponentStatus) []map[string]interface{} {
func buildServiceDetails(app *v1beta1.Application, services []common.ApplicationComponentStatus) []map[string]interface{} {
serviceDetails := make([]map[string]interface{}, 0, len(services))
for _, svc := range services {
svcDetails := map[string]interface{}{
"name": svc.Name,
"type": getComponentType(app, svc.Name),
"namespace": svc.Namespace,
"cluster": svc.Cluster,
"healthy": svc.Healthy,
@@ -121,6 +132,23 @@ func buildServiceDetails(services []common.ApplicationComponentStatus) []map[str
if len(svc.Details) > 0 {
svcDetails["details"] = svc.Details
}
if len(svc.Traits) > 0 {
traits := make([]map[string]interface{}, 0, len(svc.Traits))
for _, trait := range svc.Traits {
traitDetails := map[string]interface{}{
"type": trait.Type,
"healthy": trait.Healthy,
}
if trait.Message != "" {
traitDetails["message"] = trait.Message
}
if len(trait.Details) > 0 {
traitDetails["details"] = trait.Details
}
traits = append(traits, traitDetails)
}
svcDetails["traits"] = traits
}
serviceDetails = append(serviceDetails, svcDetails)
}

View File

@@ -184,16 +184,28 @@ func TestBuildWorkflowStatus(t *testing.T) {
func TestBuildServiceDetails(t *testing.T) {
tests := []struct {
name string
app *v1beta1.Application
services []common.ApplicationComponentStatus
want []map[string]interface{}
}{
{
name: "empty services",
name: "empty services",
app: &v1beta1.Application{
Spec: v1beta1.ApplicationSpec{},
},
services: []common.ApplicationComponentStatus{},
want: []map[string]interface{}{},
},
{
name: "services with details",
app: &v1beta1.Application{
Spec: v1beta1.ApplicationSpec{
Components: []common.ApplicationComponent{
{Name: "web", Type: "webservice"},
{Name: "db", Type: "worker"},
},
},
},
services: []common.ApplicationComponentStatus{
{
Name: "web",
@@ -214,6 +226,7 @@ func TestBuildServiceDetails(t *testing.T) {
want: []map[string]interface{}{
{
"name": "web",
"type": "webservice",
"namespace": "default",
"cluster": "local",
"healthy": true,
@@ -222,6 +235,7 @@ func TestBuildServiceDetails(t *testing.T) {
},
{
"name": "db",
"type": "worker",
"namespace": "default",
"cluster": "local",
"healthy": false,
@@ -229,11 +243,66 @@ func TestBuildServiceDetails(t *testing.T) {
},
},
},
{
name: "services with traits",
app: &v1beta1.Application{
Spec: v1beta1.ApplicationSpec{
Components: []common.ApplicationComponent{
{Name: "web", Type: "webservice"},
},
},
},
services: []common.ApplicationComponentStatus{
{
Name: "web",
Namespace: "default",
Cluster: "local",
Healthy: true,
Message: "Running",
Traits: []common.ApplicationTraitStatus{
{
Type: "ingress",
Healthy: true,
Message: "Ingress ready",
Details: map[string]string{"host": "example.com"},
},
{
Type: "autoscaler",
Healthy: false,
Message: "Scaling",
},
},
},
},
want: []map[string]interface{}{
{
"name": "web",
"type": "webservice",
"namespace": "default",
"cluster": "local",
"healthy": true,
"message": "Running",
"traits": []map[string]interface{}{
{
"type": "ingress",
"healthy": true,
"message": "Ingress ready",
"details": map[string]string{"host": "example.com"},
},
{
"type": "autoscaler",
"healthy": false,
"message": "Scaling",
},
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := buildServiceDetails(tt.services)
got := buildServiceDetails(tt.app, tt.services)
assert.Equal(t, tt.want, got)
})
}
@@ -463,6 +532,12 @@ func TestUpdateMetricsAndLogFunction(t *testing.T) {
Namespace: "default",
UID: "12345",
},
Spec: v1beta1.ApplicationSpec{
Components: []common.ApplicationComponent{
{Name: "web", Type: "webservice"},
{Name: "db", Type: "worker"},
},
},
Status: common.AppStatus{
Phase: common.ApplicationRunning,
Services: []common.ApplicationComponentStatus{

View File

@@ -181,3 +181,68 @@ func Test_applyComponentHealthToServices(t *testing.T) {
})
}
}
func TestFilterRemovedComponentsFromStatus(t *testing.T) {
tests := []struct {
name string
components []common.ApplicationComponent
statusServices []common.ApplicationComponentStatus
expectedServices []string
servicesRemoved bool
}{
{
name: "removed component is filtered from services",
components: []common.ApplicationComponent{
{Name: "backend", Type: "webservice"},
},
statusServices: []common.ApplicationComponentStatus{
{Name: "frontend", Namespace: "default"},
{Name: "backend", Namespace: "default"},
},
expectedServices: []string{"backend"},
servicesRemoved: true,
},
{
name: "all components removed results in empty services",
components: []common.ApplicationComponent{},
statusServices: []common.ApplicationComponentStatus{
{Name: "frontend", Namespace: "default"},
{Name: "backend", Namespace: "default"},
},
expectedServices: []string{},
servicesRemoved: true,
},
{
name: "no components removed keeps all services",
components: []common.ApplicationComponent{
{Name: "frontend", Type: "webservice"},
{Name: "backend", Type: "webservice"},
},
statusServices: []common.ApplicationComponentStatus{
{Name: "frontend", Namespace: "default"},
{Name: "backend", Namespace: "default"},
},
expectedServices: []string{"frontend", "backend"},
servicesRemoved: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
filteredServices, servicesRemoved := filterRemovedComponentsFromStatus(
tt.components,
tt.statusServices,
)
assert.Equal(t, tt.servicesRemoved, servicesRemoved,
"servicesRemoved flag should match expected value")
assert.Equal(t, len(tt.expectedServices), len(filteredServices),
"filtered services count should match expected")
for i, expectedName := range tt.expectedServices {
assert.Equal(t, expectedName, filteredServices[i].Name,
fmt.Sprintf("service at index %d should be %s", i, expectedName))
}
})
}
}

View File

@@ -781,3 +781,227 @@ func TestContextPassing(t *testing.T) {
})
}
}
func TestGetStatusWithDynamicKeys(t *testing.T) {
cases := map[string]struct {
tpContext map[string]interface{}
parameter interface{}
statusCue string
expStatus map[string]string
}{
"root-comprehension-generates-dynamic-keys-from-list": {
tpContext: map[string]interface{}{
"outputs": map[string]interface{}{
"ingress": map[string]interface{}{
"spec": map[string]interface{}{
"rules": []interface{}{
map[string]interface{}{"host": "foo.example.com"},
map[string]interface{}{"host": "bar.example.com"},
},
},
},
},
},
parameter: make(map[string]interface{}),
statusCue: strings.TrimSpace(`
{for _, rule in context.outputs.ingress.spec.rules {
"host.\(rule.host)": rule.host
}}
`),
expStatus: map[string]string{
"host.foo.example.com": "foo.example.com",
"host.bar.example.com": "bar.example.com",
},
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
_, status, err := getStatusMap(tc.tpContext, tc.statusCue, tc.parameter)
assert.NoError(t, err)
assert.Equal(t, tc.expStatus, status)
})
}
}
func TestGetStatusWithDefinitionAndHiddenLabels(t *testing.T) {
testCases := []struct {
name string
templateContext map[string]interface{}
statusFields string
wantNoErr bool
description string
}{
{
name: "handles definition labels without panic",
templateContext: map[string]interface{}{
"output": map[string]interface{}{
"metadata": map[string]interface{}{
"name": "test",
},
},
},
statusFields: `
#SomeDefinition: {
name: string
type: string
}
status: #SomeDefinition & {
name: "test"
type: "healthy"
}
`,
wantNoErr: true,
description: "Should handle definition labels (#SomeDefinition) without panicking",
},
{
name: "handles hidden labels without panic",
templateContext: map[string]interface{}{
"output": map[string]interface{}{
"metadata": map[string]interface{}{
"name": "test",
},
},
},
statusFields: `
_hiddenField: "internal"
status: {
name: "test"
internal: _hiddenField
}
`,
wantNoErr: true,
description: "Should handle hidden labels (_hiddenField) without panicking",
},
{
name: "handles pattern labels without panic",
templateContext: map[string]interface{}{
"output": map[string]interface{}{
"metadata": map[string]interface{}{
"name": "test",
},
},
},
statusFields: `
[string]: _
status: {
name: "test"
healthy: true
}
`,
wantNoErr: true,
description: "Should handle pattern labels ([string]: _) without panicking",
},
{
name: "handles mixed label types without panic",
templateContext: map[string]interface{}{
"output": map[string]interface{}{
"metadata": map[string]interface{}{
"name": "test",
},
},
},
statusFields: `
#Definition: {
field: string
}
_hidden: "value"
normalField: "visible"
status: {
name: normalField
type: _hidden
def: #Definition & {field: "test"}
}
`,
wantNoErr: true,
description: "Should handle mixed label types without panicking",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
request := &StatusRequest{
Details: tc.statusFields,
Parameter: map[string]interface{}{},
}
// This should not panic even with definition or hidden labels
result, err := GetStatus(tc.templateContext, request)
if tc.wantNoErr {
// We expect no panic and a valid result
assert.NotNil(t, result, tc.description)
// The function may return an error for invalid CUE, but it shouldn't panic
if err != nil {
t.Logf("Got expected error (non-panic): %v", err)
}
} else {
assert.Error(t, err, tc.description)
}
})
}
}
func TestGetStatusMapWithComplexSelectors(t *testing.T) {
// Test that getStatusMap doesn't panic with various selector types
testCases := []struct {
name string
statusFields string
templateContext map[string]interface{}
shouldNotPanic bool
}{
{
name: "definition selector in context",
statusFields: `
#Config: {
enabled: bool
}
config: #Config & {
enabled: true
}
`,
templateContext: map[string]interface{}{},
shouldNotPanic: true,
},
{
name: "hidden field selector",
statusFields: `
_internal: {
secret: "hidden"
}
public: _internal.secret
`,
templateContext: map[string]interface{}{},
shouldNotPanic: true,
},
{
name: "optional field selector",
statusFields: `
optional?: string
required: string | *"default"
`,
templateContext: map[string]interface{}{},
shouldNotPanic: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
if tc.shouldNotPanic {
// The function should not panic
assert.NotPanics(t, func() {
_, _, _ = getStatusMap(tc.templateContext, tc.statusFields, nil)
}, "getStatusMap should not panic with %s", tc.name)
}
})
}
}

View File

@@ -21,7 +21,6 @@ import (
"strings"
"cuelang.org/go/cue/ast"
"cuelang.org/go/cue/parser"
"cuelang.org/go/cue/token"
)
@@ -32,8 +31,74 @@ const (
customStatus = "attributes.status.customStatus"
// localFieldPrefix is the prefix for local fields not output to the status
localFieldPrefix = "$"
// disableValidationAttr is the CUE field attribute that bypasses validation for status fields.
// Usage of this indicates validation is too restrictive and a bug should be opened.
disableValidationAttr = "@disableValidation()"
// cueAttrPrefix is the line prefix used to persist CUE field attributes
// e.g. "// cue-attr:@disableValidation()".
cueAttrPrefix = "// cue-attr:"
)
// injectAttrs serialises all attributes from cue attrs as cue-attr comment lines
func injectAttrs(litValue string, attrs []*ast.Attribute) string {
if len(attrs) == 0 {
return litValue
}
trimmed := strings.TrimSpace(litValue)
var openDelim, closeDelim string
switch {
case strings.HasPrefix(trimmed, `#"""`) && strings.HasSuffix(trimmed, `"""#`):
openDelim, closeDelim = `#"""`, `"""#`
case strings.HasPrefix(trimmed, `"""`) && strings.HasSuffix(trimmed, `"""`) && !strings.HasSuffix(trimmed, `"""#`):
openDelim, closeDelim = `"""`, `"""`
default:
return litValue
}
inner := strings.TrimPrefix(trimmed, openDelim)
inner = strings.TrimSuffix(inner, closeDelim)
indent := ""
if idx := strings.LastIndex(inner, "\n"); idx >= 0 {
indent = inner[idx+1:]
}
var injected strings.Builder
for _, a := range attrs {
injected.WriteString(indent + cueAttrPrefix + a.Text + "\n")
}
return openDelim + "\n" + injected.String() + inner + closeDelim
}
// extractAttrs scans unquoted CUE content for cue-attr comment lines, returns the
// reconstructed attributes and the content with those lines removed.
func extractAttrs(content string) ([]*ast.Attribute, string) {
var attrs []*ast.Attribute
var remaining strings.Builder
for _, line := range strings.Split(content, "\n") {
trimmed := strings.TrimSpace(line)
if text, ok := strings.CutPrefix(trimmed, cueAttrPrefix); ok {
attrs = append(attrs, &ast.Attribute{Text: text})
} else {
remaining.WriteString(line + "\n")
}
}
return attrs, remaining.String()
}
// hasDisableValidation reports whether a field carries the @disableValidation() attribute.
func hasDisableValidation(field *ast.Field) bool {
for _, a := range field.Attrs {
if a.Text == disableValidationAttr {
return true
}
}
return false
}
// EncodeMetadata encodes native CUE in the metadata fields to a CUE string literal
func EncodeMetadata(field *ast.Field) error {
if err := marshalField[*ast.StructLit](field, healthPolicy, validateHealthPolicyField); err != nil {
@@ -64,6 +129,22 @@ func DecodeMetadata(field *ast.Field) error {
func marshalField[T ast.Node](field *ast.Field, key string, validator func(T) error) error {
if statusField, ok := GetFieldByPath(field, key); ok {
if hasDisableValidation(statusField) {
switch expr := statusField.Value.(type) {
case *ast.StructLit:
strLit, err := StringifyStructLitAsCueString(expr)
if err != nil {
return err
}
strLit.Value = injectAttrs(strLit.Value, statusField.Attrs)
UpdateNodeByPath(field, key, strLit)
case *ast.BasicLit:
if expr.Kind == token.STRING && !strings.Contains(expr.Value, cueAttrPrefix) {
expr.Value = injectAttrs(expr.Value, statusField.Attrs)
}
}
return nil
}
switch expr := statusField.Value.(type) {
case *ast.BasicLit:
if expr.Kind != token.STRING {
@@ -105,20 +186,28 @@ func unmarshalField[T ast.Node](field *ast.Field, key string, validator func(T)
return fmt.Errorf("%s field is not a string literal", key)
}
err := ValidateCueStringLiteral[T](basicLit, validator)
if err != nil {
return fmt.Errorf("%s field failed validation: %w", key, err)
}
unquoted := strings.TrimSpace(TrimCueRawString(basicLit.Value))
expr, err := parser.ParseExpr("-", WrapCueStruct(unquoted))
if err != nil {
return fmt.Errorf("unexpected error re-parsing validated %s string: %w", key, err)
// Re-hydrate any attributes that were persisted as cue-attr comment lines
if restoredAttrs, cleaned := extractAttrs(unquoted); len(restoredAttrs) > 0 {
statusField.Attrs = append(statusField.Attrs, restoredAttrs...)
unquoted = strings.TrimSpace(cleaned)
}
structLit, ok := expr.(*ast.StructLit)
if !ok {
return fmt.Errorf("expected struct after validation in field %s", key)
if !hasDisableValidation(statusField) {
if err := ValidateCueStringLiteral[T](basicLit, validator); err != nil {
return fmt.Errorf("%s field failed validation: %w", key, err)
}
}
structLit, hasImports, hasPackage, parseErr := ParseCueContent(unquoted)
if parseErr != nil {
return fmt.Errorf("unexpected error re-parsing validated %s string: %w", key, parseErr)
}
if hasImports || hasPackage {
// Keep as string literal to preserve imports/package
return nil
}
statusField.Value = structLit
@@ -127,32 +216,130 @@ func unmarshalField[T ast.Node](field *ast.Field, key string, validator func(T)
}
func validateStatusField(sl *ast.StructLit) error {
localFields := map[string]*ast.StructLit{}
for _, elt := range sl.Elts {
f, ok := elt.(*ast.Field)
if !ok {
return fmt.Errorf("status.details contains non-field element")
continue
}
label := GetFieldLabel(f.Label)
if strings.HasPrefix(label, localFieldPrefix) {
continue
if structVal, ok := f.Value.(*ast.StructLit); ok {
localFields[label] = structVal
}
}
}
for _, elt := range sl.Elts {
switch e := elt.(type) {
case *ast.Field:
label := GetFieldLabel(e.Label)
if strings.HasPrefix(label, localFieldPrefix) {
continue
}
if err := validateStatusFieldValue(label, e.Value); err != nil {
return err
}
case *ast.Comprehension:
if err := validateStatusComprehension(e); err != nil {
return err
}
case *ast.EmbedDecl:
if err := validateStatusEmbed(e, localFields); err != nil {
return err
}
switch f.Value.(type) {
case *ast.BasicLit,
*ast.Ident,
*ast.SelectorExpr,
*ast.CallExpr,
*ast.BinaryExpr:
continue
default:
return fmt.Errorf("status.details field %q contains unsupported expression type %T", label, f.Value)
return fmt.Errorf("status.details contains unsupported element type %T", elt)
}
}
return nil
}
// validateStatusFieldValue checks that a named details field has a scalar-compatible value.
func validateStatusFieldValue(label string, val ast.Expr) error {
switch val.(type) {
case *ast.BasicLit,
*ast.Ident,
*ast.SelectorExpr,
*ast.CallExpr,
*ast.BinaryExpr,
*ast.UnaryExpr,
*ast.Interpolation,
*ast.IndexExpr:
return nil
default:
return fmt.Errorf("status.details field %q contains unsupported expression type %T", label, val)
}
}
// validateStatusComprehension checks that a root-level comprehension in details
// yields string-compatible key/value pairs.
func validateStatusComprehension(c *ast.Comprehension) error {
structVal, ok := c.Value.(*ast.StructLit)
if !ok {
return fmt.Errorf("status.details comprehension must yield a struct, got %T", c.Value)
}
for _, elt := range structVal.Elts {
f, ok := elt.(*ast.Field)
if !ok {
return fmt.Errorf("status.details comprehension yields non-field element %T", elt)
}
// Keys may be interpolations (e.g. "host.\(rule.host)") — use a placeholder
label := GetFieldLabel(f.Label)
if label == "" {
label = "<dynamic>"
}
if err := validateStatusFieldValue(label, f.Value); err != nil {
return fmt.Errorf("status.details comprehension: %w", err)
}
}
return nil
}
// validateStatusEmbed checks that an embedded expression in details is either a
// $-prefixed local field or a comprehension block (the {for ...} pattern).
func validateStatusEmbed(e *ast.EmbedDecl, localFields map[string]*ast.StructLit) error {
switch expr := e.Expr.(type) {
case *ast.Ident:
if !strings.HasPrefix(expr.Name, localFieldPrefix) {
return fmt.Errorf("status.details embed must reference a %s-prefixed local field, got identifier %q", localFieldPrefix, expr.Name)
}
structVal, declared := localFields[expr.Name]
if !declared {
return nil
}
for _, elt := range structVal.Elts {
f, ok := elt.(*ast.Field)
if !ok {
continue
}
label := GetFieldLabel(f.Label)
if err := validateStatusFieldValue(label, f.Value); err != nil {
return fmt.Errorf("status.details embedded field %q: %w", expr.Name, err)
}
}
return nil
case *ast.StructLit:
for _, elt := range expr.Elts {
comp, ok := elt.(*ast.Comprehension)
if !ok {
return fmt.Errorf("status.details embedded struct contains unsupported element %T", elt)
}
if err := validateStatusComprehension(comp); err != nil {
return err
}
}
return nil
default:
return fmt.Errorf("status.details contains unsupported embed expression type %T", e.Expr)
}
}
func validateCustomStatusField(sl *ast.StructLit) error {
validator := func(expr ast.Expr) error {
switch v := expr.(type) {

View File

@@ -17,11 +17,13 @@ limitations under the License.
package ast
import (
"strings"
"testing"
"cuelang.org/go/cue/ast"
"cuelang.org/go/cue/format"
"cuelang.org/go/cue/parser"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -76,6 +78,19 @@ func TestMarshalAndUnmarshalMetadata(t *testing.T) {
`,
expectContains: "selector",
},
{
name: "unary expression value is valid",
input: `
attributes: {
status: {
details: {
notFailing: !context.output.status.failing
}
}
}
`,
expectContains: "notFailing",
},
{
name: "struct value is invalid",
input: `
@@ -176,6 +191,50 @@ func TestMarshalAndUnmarshalMetadata(t *testing.T) {
`,
expectContains: "$local",
},
{
name: "status details with import statement should work",
input: `
attributes: {
status: {
details: #"""
import "strconv"
replicas: strconv.Atoi(context.output.status.replicas)
"""#
}
}
`,
expectContains: "import \"strconv\"",
},
{
name: "status details with package declaration",
input: `
attributes: {
status: {
details: #"""
package status
ready: true
phase: "Running"
"""#
}
}
`,
expectContains: "package status",
},
{
name: "status details with import cannot bypass validation",
input: `
attributes: {
status: {
details: #"""
import "strings"
data: { nested: "structure" }
"""#
}
}
`,
expectMarshalErr: "unsupported expression type",
},
}
for _, tt := range tests {
@@ -230,6 +289,484 @@ func TestMarshalAndUnmarshalMetadata(t *testing.T) {
}
}
func TestHasDisableValidation(t *testing.T) {
cases := []struct {
name string
input string
expected bool
}{
{
name: "field with @disableValidation()",
input: `details: { foo: "bar" } @disableValidation()`,
expected: true,
},
{
name: "field without any attributes",
input: `details: { foo: "bar" }`,
expected: false,
},
{
name: "field with a different attribute",
input: `details: { foo: "bar" } @someOtherAttr()`,
expected: false,
},
{
name: "field with multiple attributes including @disableValidation()",
input: `details: { foo: "bar" } @someOtherAttr() @disableValidation()`,
expected: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
file, err := parser.ParseFile("-", tc.input)
require.NoError(t, err)
var field *ast.Field
for _, decl := range file.Decls {
if f, ok := decl.(*ast.Field); ok {
field = f
break
}
}
require.NotNil(t, field)
assert.Equal(t, tc.expected, hasDisableValidation(field))
})
}
}
func TestDisableValidationAttribute(t *testing.T) {
tests := []struct {
name string
input string
expectContains string
}{
{
name: "details with @disableValidation() bypasses validation and is stringified",
input: `
attributes: {
status: {
details: {
{for _, rule in context.outputs.ingress.spec.rules {
"host.\(rule.host)": rule.host
}}
} @disableValidation()
}
}
`,
expectContains: "host.",
},
{
name: "healthPolicy with @disableValidation() bypasses validation and is stringified",
input: `
attributes: {
status: {
healthPolicy: {
someComplexField: [for c in context.output.status.conditions { c.status }][0] == "True"
isHealth: someComplexField
} @disableValidation()
}
}
`,
expectContains: "isHealth",
},
{
name: "details stringified format with @disableValidation() bypasses validation on encode and decode",
input: `
attributes: {
status: {
details: #"""
import "strings"
{for _, rule in context.outputs.ingress.spec.rules {
"host.\(rule.host)": rule.host
}}
"""# @disableValidation()
}
}
`,
expectContains: "import",
},
{
name: "customStatus with @disableValidation() bypasses validation and is stringified",
input: `
attributes: {
status: {
customStatus: {
message: [for c in context.output.status.conditions { c.message }][0]
} @disableValidation()
}
}
`,
expectContains: "message",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
file, err := parser.ParseFile("-", tt.input)
require.NoError(t, err)
var rootField *ast.Field
for _, decl := range file.Decls {
if f, ok := decl.(*ast.Field); ok {
rootField = f
break
}
}
require.NotNil(t, rootField)
err = EncodeMetadata(rootField)
require.NoError(t, err)
// After encoding, the field value should be a string literal
// containing the original content.
var fieldPath string
switch {
case strings.Contains(tt.input, "details:"):
fieldPath = "attributes.status.details"
case strings.Contains(tt.input, "healthPolicy:"):
fieldPath = "attributes.status.healthPolicy"
case strings.Contains(tt.input, "customStatus:"):
fieldPath = "attributes.status.customStatus"
}
statusField, ok := GetFieldByPath(rootField, fieldPath)
require.True(t, ok)
basicLit, ok := statusField.Value.(*ast.BasicLit)
require.True(t, ok, "expected field to be stringified to *ast.BasicLit after encoding, got %T", statusField.Value)
require.Contains(t, basicLit.Value, tt.expectContains)
})
}
}
func TestDisableValidationDecodeRoundTrip(t *testing.T) {
// Verifies that @disableValidation() skips validation on DecodeMetadata too,
// so a stringified field with otherwise-invalid content survives a round trip.
input := `
attributes: {
status: {
details: #"""
data: { nested: "structure" }
"""# @disableValidation()
}
}
`
file, err := parser.ParseFile("-", input)
require.NoError(t, err)
var rootField *ast.Field
for _, decl := range file.Decls {
if f, ok := decl.(*ast.Field); ok {
rootField = f
break
}
}
require.NotNil(t, rootField)
// Encode: should pass despite nested struct (validation disabled)
require.NoError(t, EncodeMetadata(rootField))
require.NoError(t, DecodeMetadata(rootField))
}
func TestDisableValidationYAMLRoundTrip(t *testing.T) {
// Simulates the full YAML storage round-trip: encode strips field attributes
// (as YAML/gocodec does), but the cue-attr sentinel in the string value means
// decode still skips validation correctly.
tests := []struct {
name string
input string
}{
{
name: "details with struct value and @disableValidation()",
input: `
attributes: {
status: {
details: {
data: { nested: "structure" }
} @disableValidation()
}
}
`,
},
{
name: "details with stringified value and @disableValidation()",
input: `
attributes: {
status: {
details: #"""
data: { nested: "structure" }
"""# @disableValidation()
}
}
`,
},
{
name: "healthPolicy with @disableValidation()",
input: `
attributes: {
status: {
healthPolicy: {
isHealth: [for c in context.output.status.conditions { c.status }][0] == "True"
} @disableValidation()
}
}
`,
},
{
name: "customStatus with @disableValidation()",
input: `
attributes: {
status: {
customStatus: {
message: [for c in context.output.status.conditions { c.message }][0]
} @disableValidation()
}
}
`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
file, err := parser.ParseFile("-", tt.input)
require.NoError(t, err)
var rootField *ast.Field
for _, decl := range file.Decls {
if f, ok := decl.(*ast.Field); ok {
rootField = f
break
}
}
require.NotNil(t, rootField)
require.NoError(t, EncodeMetadata(rootField))
// Simulate YAML storage stripping field attributes from all status sub-fields.
for _, path := range []string{
"attributes.status.details",
"attributes.status.healthPolicy",
"attributes.status.customStatus",
} {
if f, ok := GetFieldByPath(rootField, path); ok {
f.Attrs = nil
}
}
// Decode must still succeed — the cue-attr sentinel in the string carries
// the @disableValidation() intent across the storage boundary.
require.NoError(t, DecodeMetadata(rootField))
})
}
}
func TestInjectAttrsMultipleAndIdempotent(t *testing.T) {
t.Run("multiple attributes are all persisted and restored", func(t *testing.T) {
// Use two attributes on the same field; both should survive the YAML round-trip.
input := `
attributes: {
status: {
details: {
data: { nested: "structure" }
} @disableValidation() @someOtherAttr(value)
}
}
`
file, err := parser.ParseFile("-", input)
require.NoError(t, err)
var rootField *ast.Field
for _, decl := range file.Decls {
if f, ok := decl.(*ast.Field); ok {
rootField = f
break
}
}
require.NotNil(t, rootField)
require.NoError(t, EncodeMetadata(rootField))
// Verify both cue-attr lines are present in the stored string.
f, ok := GetFieldByPath(rootField, "attributes.status.details")
require.True(t, ok)
bl, ok := f.Value.(*ast.BasicLit)
require.True(t, ok)
require.Contains(t, bl.Value, "// cue-attr:@disableValidation()")
require.Contains(t, bl.Value, "// cue-attr:@someOtherAttr(value)")
// Strip field attributes to simulate YAML storage.
f.Attrs = nil
// Decode must restore both attributes and succeed.
require.NoError(t, DecodeMetadata(rootField))
require.Len(t, f.Attrs, 2)
})
t.Run("encoding an already-stringified field with cue-attr does not double-inject", func(t *testing.T) {
// Simulates a second EncodeMetadata call on an already-encoded field.
input := `
attributes: {
status: {
details: #"""
// cue-attr:@disableValidation()
data: { nested: "structure" }
"""# @disableValidation()
}
}
`
file, err := parser.ParseFile("-", input)
require.NoError(t, err)
var rootField *ast.Field
for _, decl := range file.Decls {
if f, ok := decl.(*ast.Field); ok {
rootField = f
break
}
}
require.NotNil(t, rootField)
require.NoError(t, EncodeMetadata(rootField))
f, ok := GetFieldByPath(rootField, "attributes.status.details")
require.True(t, ok)
bl, ok := f.Value.(*ast.BasicLit)
require.True(t, ok)
// Should appear exactly once, not twice.
count := strings.Count(bl.Value, "// cue-attr:@disableValidation()")
require.Equal(t, 1, count, "cue-attr sentinel should not be duplicated on re-encode")
})
}
func TestComprehensionDynamicKeyErrorMessage(t *testing.T) {
// Verifies that a comprehension with an invalid value type reports a
// meaningful error rather than an empty label.
input := `
attributes: {
status: {
details: {
{for _, rule in context.outputs.ingress.spec.rules {
"host.\(rule.host)": { nested: "invalid" }
}}
}
}
}
`
file, err := parser.ParseFile("-", input)
require.NoError(t, err)
var rootField *ast.Field
for _, decl := range file.Decls {
if f, ok := decl.(*ast.Field); ok {
rootField = f
break
}
}
require.NotNil(t, rootField)
err = EncodeMetadata(rootField)
require.Error(t, err)
require.Contains(t, err.Error(), "<dynamic>")
}
func TestStatusDetailsWithDynamicKeys(t *testing.T) {
tests := []struct {
name string
input string
expectMarshalErr string
expectContains string
}{
{
name: "root comprehension generates dynamic keys from list",
input: `
attributes: {
status: {
details: {
{for _, rule in context.outputs.ingress.spec.rules {
"host.\(rule.host)": rule.host
}}
}
}
}
`,
expectContains: "host.",
},
{
name: "call expression value with list comprehension arg",
input: `
attributes: {
status: {
details: {
hosts: strings.Join([for rule in context.outputs.ingress.spec.rules { rule.host }], ",")
}
}
}
`,
expectContains: "hosts",
},
{
name: "local field embedded at root of details",
input: `
attributes: {
status: {
details: {
$local: { key: "value" }
$local
}
}
}
`,
expectContains: "key",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
file, err := parser.ParseFile("-", tt.input)
require.NoError(t, err)
var rootField *ast.Field
for _, decl := range file.Decls {
if f, ok := decl.(*ast.Field); ok {
rootField = f
break
}
}
require.NotNil(t, rootField)
err = EncodeMetadata(rootField)
if tt.expectMarshalErr != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tt.expectMarshalErr)
return
}
require.NoError(t, err)
err = DecodeMetadata(rootField)
require.NoError(t, err)
if tt.expectContains != "" {
statusField, ok := GetFieldByPath(rootField, "attributes.status.details")
require.True(t, ok)
switch v := statusField.Value.(type) {
case *ast.BasicLit:
require.Contains(t, v.Value, tt.expectContains)
case *ast.StructLit:
out, err := format.Node(v)
require.NoError(t, err)
require.Contains(t, string(out), tt.expectContains)
default:
t.Fatalf("unexpected status value type: %T", v)
}
}
})
}
}
func TestMarshalAndUnmarshalHealthPolicy(t *testing.T) {
tests := []struct {
name string
@@ -379,6 +916,21 @@ func TestMarshalAndUnmarshalHealthPolicy(t *testing.T) {
`,
expectContains: "isHealth",
},
{
name: "healthPolicy with package declaration",
input: `
attributes: {
status: {
healthPolicy: #"""
package health
isHealth: context.output.status.phase == "Running"
"""#
}
}
`,
expectContains: "package health",
},
}
for _, tt := range tests {
@@ -610,6 +1162,96 @@ func TestMarshalAndUnmarshalCustomStatus(t *testing.T) {
`,
expectContains: "message",
},
{
name: "customStatus with import statement should work",
input: `
attributes: {
status: {
customStatus: #"""
import "strings"
message: strings.Join(["hello", "world"], ",")
"""#
}
}
`,
expectContains: "import \"strings\"",
},
{
name: "customStatus with multiple imports",
input: `
attributes: {
status: {
customStatus: #"""
import "strings"
import "strconv"
count: strconv.Atoi("42")
message: strings.Join(["Count", strconv.FormatInt(count, 10)], ": ")
"""#
}
}
`,
expectContains: "import \"strconv\"",
},
{
name: "customStatus with import alias",
input: `
attributes: {
status: {
customStatus: #"""
import str "strings"
message: str.ToUpper(str.Join(["hello", "world"], " "))
"""#
}
}
`,
expectContains: "import str \"strings\"",
},
{
name: "customStatus with package declaration",
input: `
attributes: {
status: {
customStatus: #"""
package mytest
message: "Package test"
"""#
}
}
`,
expectContains: "package mytest",
},
{
name: "customStatus with package and imports",
input: `
attributes: {
status: {
customStatus: #"""
package mytest
import "strings"
message: strings.ToUpper("hello world")
"""#
}
}
`,
expectContains: "package mytest",
},
{
name: "customStatus with import still requires message field",
input: `
attributes: {
status: {
customStatus: #"""
import "strings"
someOtherField: "value"
"""#
}
}
`,
expectMarshalErr: "customStatus must contain a 'message' field",
},
}
for _, tt := range tests {
@@ -951,6 +1593,57 @@ func TestCustomStatusEdgeCases(t *testing.T) {
}
}
func TestMixedFieldsWithAndWithoutImports(t *testing.T) {
input := `
attributes: {
status: {
healthPolicy: #"""
isHealth: context.output.status.phase == "Running"
"""#
customStatus: #"""
import "strings"
message: strings.ToLower(context.output.status.phase)
"""#
}
}
`
file, err := parser.ParseFile("-", input)
require.NoError(t, err)
var rootField *ast.Field
for _, decl := range file.Decls {
if f, ok := decl.(*ast.Field); ok {
rootField = f
break
}
}
require.NotNil(t, rootField)
// Encode (struct -> string)
err = EncodeMetadata(rootField)
require.NoError(t, err)
// Decode (string -> struct/string based on imports)
err = DecodeMetadata(rootField)
require.NoError(t, err)
// Check healthPolicy (no imports) - should be decoded to struct
healthField, ok := GetFieldByPath(rootField, "attributes.status.healthPolicy")
require.True(t, ok)
_, isStruct := healthField.Value.(*ast.StructLit)
assert.True(t, isStruct, "healthPolicy without imports should be decoded to struct")
// Check customStatus (has imports) - should remain as string
customField, ok := GetFieldByPath(rootField, "attributes.status.customStatus")
require.True(t, ok)
basicLit, isString := customField.Value.(*ast.BasicLit)
assert.True(t, isString, "customStatus with imports should remain as string")
if isString {
assert.Contains(t, basicLit.Value, "import \"strings\"")
}
}
func TestBackwardCompatibility(t *testing.T) {
tests := []struct {
name string

View File

@@ -152,18 +152,15 @@ func ValidateCueStringLiteral[T ast.Node](lit *ast.BasicLit, validator func(T) e
return nil
}
wrapped := WrapCueStruct(raw)
expr, err := parser.ParseExpr("-", wrapped)
structLit, _, _, err := ParseCueContent(raw)
if err != nil {
return fmt.Errorf("invalid cue content in string literal: %w", err)
}
node, ok := expr.(T)
node, ok := ast.Node(structLit).(T)
if !ok {
return fmt.Errorf("parsed expression is not of expected type %T", *new(T))
}
return validator(node)
}
@@ -197,6 +194,36 @@ func WrapCueStruct(s string) string {
return fmt.Sprintf("{\n%s\n}", s)
}
// ParseCueContent parses CUE content and extracts struct fields, skipping imports/packages
func ParseCueContent(content string) (*ast.StructLit, bool, bool, error) {
if strings.TrimSpace(content) == "" {
return &ast.StructLit{Elts: []ast.Decl{}}, false, false, nil
}
file, err := parser.ParseFile("-", content)
if err != nil {
return nil, false, false, err
}
hasImports := len(file.Imports) > 0
hasPackage := file.PackageName() != ""
structLit := &ast.StructLit{
Elts: []ast.Decl{},
}
for _, decl := range file.Decls {
switch decl.(type) {
case *ast.ImportDecl, *ast.Package:
// Skip imports and package declarations
default:
structLit.Elts = append(structLit.Elts, decl)
}
}
return structLit, hasImports, hasPackage, nil
}
// FindAndValidateField searches for a field at the top level or within top-level if statements
func FindAndValidateField(sl *ast.StructLit, fieldName string, validator fieldValidator) (found bool, err error) {
// First check top-level fields

View File

@@ -26,6 +26,7 @@ import (
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"sigs.k8s.io/controller-runtime/pkg/client"
"github.com/oam-dev/kubevela/apis/core.oam.dev/common"
"github.com/oam-dev/kubevela/apis/core.oam.dev/v1alpha1"
"github.com/oam-dev/kubevela/apis/core.oam.dev/v1beta1"
"github.com/oam-dev/kubevela/pkg/multicluster"
@@ -45,6 +46,9 @@ type ResourceKeeper interface {
DispatchComponentRevision(context.Context, *appsv1.ControllerRevision) error
DeleteComponentRevision(context.Context, *appsv1.ControllerRevision) error
// GetAppliedResources returns the current applied resources from the ResourceTracker.
GetAppliedResources() []common.ClusterObjectReference
}
type resourceKeeper struct {
@@ -125,6 +129,20 @@ func (h *resourceKeeper) loadResourceTrackers(ctx context.Context) (err error) {
return err
}
// GetAppliedResources returns all resources from the current ResourceTracker as ClusterObjectReferences.
// Resources pending deletion (Deleted=true) are included as they still exist in the cluster.
// Returns an empty slice if no current ResourceTracker is loaded.
func (h *resourceKeeper) GetAppliedResources() []common.ClusterObjectReference {
if h._currentRT == nil {
return []common.ClusterObjectReference{}
}
refs := make([]common.ClusterObjectReference, 0, len(h._currentRT.Spec.ManagedResources))
for _, mr := range h._currentRT.Spec.ManagedResources {
refs = append(refs, mr.ClusterObjectReference)
}
return refs
}
// NewResourceKeeper create a handler for dispatching and deleting resources
func NewResourceKeeper(ctx context.Context, cli client.Client, app *v1beta1.Application) (_ ResourceKeeper, err error) {
h := &resourceKeeper{

View File

@@ -21,11 +21,14 @@ import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
oamcommon "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/pkg/oam"
"github.com/oam-dev/kubevela/pkg/oam/util"
@@ -97,3 +100,62 @@ func TestNewResourceKeeper(t *testing.T) {
r.NotNil(currentRT)
r.Equal(3, len(rk._historyRTs))
}
func TestGetAppliedResources(t *testing.T) {
ref := func(name, kind, component string, deleted bool) v1beta1.ManagedResource {
return v1beta1.ManagedResource{
ClusterObjectReference: oamcommon.ClusterObjectReference{
Creator: oamcommon.WorkflowResourceCreator,
ObjectReference: corev1.ObjectReference{
Name: name,
Namespace: "default",
Kind: kind,
APIVersion: "v1",
},
},
OAMObjectReference: oamcommon.OAMObjectReference{Component: component},
Deleted: deleted,
}
}
tests := []struct {
name string
managedRes []v1beta1.ManagedResource
expectedNames []string
}{
{
name: "returns all resources including pending-delete",
managedRes: []v1beta1.ManagedResource{
ref("shared-config", "ConfigMap", "my-component", false),
ref("old-config", "ConfigMap", "removed-component", true),
},
expectedNames: []string{"shared-config", "old-config"},
},
{
name: "returns empty when no current RT",
managedRes: nil,
expectedNames: []string{},
},
{
name: "returns empty when RT has no resources",
managedRes: []v1beta1.ManagedResource{},
expectedNames: []string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rk := &resourceKeeper{}
if tt.managedRes != nil {
rk._currentRT = &v1beta1.ResourceTracker{
Spec: v1beta1.ResourceTrackerSpec{ManagedResources: tt.managedRes},
}
}
result := rk.GetAppliedResources()
require.Equal(t, len(tt.expectedNames), len(result))
for i, name := range tt.expectedNames {
assert.Equal(t, name, result[i].Name)
}
})
}
}

View File

@@ -80,6 +80,9 @@ func (h *resourceKeeper) StateKeep(ctx context.Context) error {
if err != nil {
return errors.Wrapf(err, "failed to apply once resource %s from resourcetracker %s", mr.ResourceKey(), rt.Name)
}
if manifest == nil {
return nil
}
ao := []apply.ApplyOption{apply.MustBeControlledByApp(h.app)}
if h.isShared(manifest) {
ao = append([]apply.ApplyOption{apply.SharedByApp(h.app)}, ao...)
@@ -117,6 +120,9 @@ func ApplyStrategies(ctx context.Context, h *resourceKeeper, manifest *unstructu
err := h.Get(ctx, types.NamespacedName{Name: manifest.GetName(), Namespace: manifest.GetNamespace()}, un)
if err != nil {
if kerrors.IsNotFound(err) {
if matchedAffectStage == v1alpha1.ApplyOnceStrategyOnAppStateKeep {
return nil, nil
}
return manifest, nil
}
return nil, err

View File

@@ -25,6 +25,7 @@ import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
corev1 "k8s.io/api/core/v1"
kerrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
@@ -170,6 +171,57 @@ var _ = Describe("Test ResourceKeeper StateKeep", func() {
Expect(err.Error()).Should(ContainSubstring("failed to re-apply"))
})
It("Test StateKeep apply-once does not re-create externally deleted resource", func() {
cli := testClient
// Resource tracked in the resource tracker but not present in the cluster,
// simulating a resource that was externally deleted (e.g. Job garbage-collected by TTL)
// With an apply-once policy matching this resource, state-keep should skip it
cm := createConfigMap("cm-apply-once-gc", "value")
cm.SetLabels(map[string]string{
oam.LabelAppName: "app-apply-once-gc",
oam.LabelAppNamespace: "default",
oam.LabelAppComponent: "my-task",
})
cmRaw, err := json.Marshal(cm)
Expect(err).Should(Succeed())
app := &v1beta1.Application{ObjectMeta: metav1.ObjectMeta{Name: "app-apply-once-gc", Namespace: "default"}}
h := &resourceKeeper{
Client: cli,
app: app,
applicator: apply.NewAPIApplicator(cli),
cache: newResourceCache(cli, app),
applyOncePolicy: &v1alpha1.ApplyOncePolicySpec{
Enable: true,
Rules: []v1alpha1.ApplyOncePolicyRule{{
Selector: v1alpha1.ResourcePolicyRuleSelector{
CompNames: []string{"my-task"},
},
Strategy: &v1alpha1.ApplyOnceStrategy{Path: []string{"*"}},
}},
},
}
h._currentRT = &v1beta1.ResourceTracker{
Spec: v1beta1.ResourceTrackerSpec{
ManagedResources: []v1beta1.ManagedResource{{
ClusterObjectReference: createConfigMapClusterObjectReference("cm-apply-once-gc"),
OAMObjectReference: common.NewOAMObjectReferenceFromObject(cm),
Data: &runtime.RawExtension{Raw: cmRaw},
}},
},
}
Expect(h.StateKeep(context.Background())).Should(Succeed())
// Verify the resource was not re-created
got := &unstructured.Unstructured{}
got.SetGroupVersionKind(corev1.SchemeGroupVersion.WithKind("ConfigMap"))
err = cli.Get(context.Background(), client.ObjectKeyFromObject(cm), got)
Expect(err).Should(HaveOccurred())
Expect(kerrors.IsNotFound(err)).Should(BeTrue())
})
It("Test StateKeep for shared resources", func() {
cli := testClient
ctx := context.Background()

View File

@@ -18,7 +18,6 @@ package apply
import (
"context"
"encoding/json"
"fmt"
"reflect"
"strconv"
@@ -207,6 +206,20 @@ func (a *APIApplicator) Apply(ctx context.Context, desired client.Object, ao ...
return nil
}
// Short-circuit for shared resources: only patch the shared-by annotation
// This avoids the three-way merge which could pollute last-applied-configuration
if applyAct.isShared {
loggingApply("patching shared resource annotation only", desired, applyAct.quiet)
sharedBy := desired.GetAnnotations()[oam.AnnotationAppSharedBy]
patch := []byte(fmt.Sprintf(`{"metadata":{"annotations":{"%s":"%s"}}}`, oam.AnnotationAppSharedBy, sharedBy))
var patchOpts []client.PatchOption
if applyAct.dryRun {
patchOpts = append(patchOpts, client.DryRunAll)
}
return errors.Wrapf(a.c.Patch(ctx, existing, client.RawPatch(types.MergePatchType, patch), patchOpts...),
"cannot patch shared resource annotation")
}
strategy := applyAct.updateStrategy
if strategy.Op == "" {
if utilfeature.DefaultMutableFeatureGate.Enabled(features.ApplyResourceByReplace) && isUpdatableResource(desired) {
@@ -478,39 +491,39 @@ func DisableUpdateAnnotation() ApplyOption {
// SharedByApp let the resource be sharable
func SharedByApp(app *v1beta1.Application) ApplyOption {
return func(act *applyAction, existing, desired client.Object) error {
// calculate the shared-by annotation
// if resource exists, add the current application into the resource shared-by field
// Calculate the shared-by annotation value
var sharedBy string
if existing != nil && existing.GetAnnotations() != nil {
sharedBy = existing.GetAnnotations()[oam.AnnotationAppSharedBy]
}
sharedBy = AddSharer(sharedBy, app)
// Always add the shared-by annotation to desired (for create case)
util.AddAnnotations(desired, map[string]string{oam.AnnotationAppSharedBy: sharedBy})
if existing == nil {
return nil
}
// resource exists and controlled by current application
// Resource exists - check if controlled by current application
appKey, controlledBy := GetAppKey(app), GetControlledBy(existing)
if controlledBy == "" || appKey == controlledBy {
// Owner app - use normal three-way merge flow
return nil
}
// resource exists but not controlled by current application
// Resource exists but not controlled by current application
if existing.GetAnnotations() == nil || existing.GetAnnotations()[oam.AnnotationAppSharedBy] == "" {
// if the application that controls the resource does not allow sharing, return error
// Owner doesn't allow sharing
return fmt.Errorf("application is controlled by %s but is not sharable", controlledBy)
}
// the application that controls the resource allows sharing, then only mutate the shared-by annotation
// Non-owner sharer: set flags for short-circuit in Apply()
// The short-circuit will only patch the shared-by annotation, avoiding
// any manipulation of the resource spec or last-applied-configuration
act.isShared = true
bs, err := json.Marshal(existing)
if err != nil {
return err
}
if err = json.Unmarshal(bs, desired); err != nil {
return err
}
util.AddAnnotations(desired, map[string]string{oam.AnnotationAppSharedBy: sharedBy})
act.updateAnnotation = false
return nil
}
}

View File

@@ -445,10 +445,11 @@ func TestSharedByApp(t *testing.T) {
app := &v1beta1.Application{ObjectMeta: metav1.ObjectMeta{Name: "app"}}
ao := SharedByApp(app)
testCases := map[string]struct {
existing client.Object
desired client.Object
output client.Object
hasError bool
existing client.Object
desired client.Object
output client.Object
hasError bool
expectIsShared bool
}{
"create new resource": {
existing: nil,
@@ -492,17 +493,17 @@ func TestSharedByApp(t *testing.T) {
"kind": "ConfigMap",
"data": "y",
}},
// Non-owner sharer: desired only gets the shared-by annotation added
// The actual resource content is NOT modified - the short-circuit in Apply()
// will patch only the annotation on the server
output: &unstructured.Unstructured{Object: map[string]interface{}{
"kind": "ConfigMap",
"metadata": map[string]interface{}{
"labels": map[string]interface{}{
oam.LabelAppName: "example",
oam.LabelAppNamespace: "default",
},
"annotations": map[string]interface{}{oam.AnnotationAppSharedBy: "x/y,default/app"},
},
"data": "x",
"data": "y",
}},
expectIsShared: true,
},
"add sharer to existing sharing resource owned by self": {
existing: &unstructured.Unstructured{Object: map[string]interface{}{
@@ -554,16 +555,102 @@ func TestSharedByApp(t *testing.T) {
}},
hasError: true,
},
"non-owner sharer sets short-circuit flags": {
existing: &unstructured.Unstructured{Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "Pod",
"metadata": map[string]interface{}{
"name": "test-pod",
"resourceVersion": "12345",
"labels": map[string]interface{}{
oam.LabelAppName: "app1",
oam.LabelAppNamespace: "default",
},
"annotations": map[string]interface{}{
oam.AnnotationAppSharedBy: "default/app1",
},
},
}},
desired: &unstructured.Unstructured{Object: map[string]interface{}{
"kind": "Pod",
"metadata": map[string]interface{}{
"name": "test-pod",
},
}},
// For non-owner sharers, desired only gets the shared-by annotation added
// The actual patching happens in Apply() via short-circuit
output: &unstructured.Unstructured{Object: map[string]interface{}{
"kind": "Pod",
"metadata": map[string]interface{}{
"name": "test-pod",
"annotations": map[string]interface{}{
oam.AnnotationAppSharedBy: "default/app1,default/app",
},
},
}},
// These flags are checked in the test loop
expectIsShared: true,
},
"non-owner sharer works without last-applied-configuration": {
existing: &unstructured.Unstructured{Object: map[string]interface{}{
"kind": "ConfigMap",
"metadata": map[string]interface{}{
"name": "test-cm",
"labels": map[string]interface{}{
oam.LabelAppName: "app1",
oam.LabelAppNamespace: "default",
},
"annotations": map[string]interface{}{
oam.AnnotationAppSharedBy: "default/app1",
},
},
"data": map[string]interface{}{
"key": "value",
},
}},
desired: &unstructured.Unstructured{Object: map[string]interface{}{
"kind": "ConfigMap",
"metadata": map[string]interface{}{
"name": "test-cm",
},
}},
// For non-owner sharers, desired only gets the shared-by annotation added
output: &unstructured.Unstructured{Object: map[string]interface{}{
"kind": "ConfigMap",
"metadata": map[string]interface{}{
"name": "test-cm",
"annotations": map[string]interface{}{
oam.AnnotationAppSharedBy: "default/app1,default/app",
},
},
}},
expectIsShared: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
r := require.New(t)
err := ao(&applyAction{}, tc.existing, tc.desired)
act := &applyAction{}
err := ao(act, tc.existing, tc.desired)
if tc.hasError {
r.Error(err)
} else {
r.NoError(err)
r.Equal(tc.output, tc.desired)
// Verify short-circuit flags for non-owner sharers
if tc.expectIsShared {
r.True(act.isShared, "isShared should be true for non-owner sharers")
r.False(act.updateAnnotation, "updateAnnotation should be false for non-owner sharers")
}
// Legacy check: When a resource is shared by another app, updateAnnotation should be false
if tc.existing != nil && tc.existing.GetAnnotations() != nil && tc.existing.GetAnnotations()[oam.AnnotationAppSharedBy] != "" {
existingController := GetControlledBy(tc.existing)
if existingController != "" && existingController != GetAppKey(app) {
r.False(act.updateAnnotation, "updateAnnotation should be false when sharing resource controlled by another app")
}
}
}
})
}

View File

@@ -25,6 +25,7 @@ import (
"github.com/kubevela/pkg/controller/sharding"
"github.com/kubevela/pkg/util/singleton"
authv1 "k8s.io/api/authorization/v1"
"k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/util/validation/field"
utilfeature "k8s.io/apiserver/pkg/util/feature"
"k8s.io/klog/v2"
@@ -114,7 +115,7 @@ func (h *ValidatingHandler) ValidateComponents(ctx context.Context, app *v1beta1
// checkDefinitionPermission checks if user has permission to access a definition in either system namespace or app namespace
func (h *ValidatingHandler) checkDefinitionPermission(ctx context.Context, req admission.Request, resource, definitionType, appNamespace string) (bool, error) {
// Check permission in system namespace (vela-system) first since most definitions are there
// Check permission in vela-system namespace first since most definitions are there
// This optimizes for the common case and reduces API calls
systemNsSar := &authv1.SubjectAccessReview{
Spec: authv1.SubjectAccessReviewSpec{
@@ -136,8 +137,19 @@ func (h *ValidatingHandler) checkDefinitionPermission(ctx context.Context, req a
}
if systemNsSar.Status.Allowed {
// User has permission in system namespace - no need to check app namespace
return true, nil
// User has permission in system namespace
// Verify the definition actually exists in vela-system
if exists, err := h.definitionExistsInNamespace(ctx, resource, definitionType, oam.SystemDefinitionNamespace); err != nil {
klog.V(4).Infof("Failed to check if %s %q exists in vela-system: %v", resource, definitionType, err)
// On error checking existence, deny access for safety
return false, nil
} else if !exists {
klog.V(4).Infof("%s %q does not exist in vela-system, checking app namespace", resource, definitionType)
// Definition doesn't exist in vela-system, fall through to check app namespace
} else {
// Definition exists in vela-system and user has permission
return true, nil
}
}
// If not in system namespace and app namespace is different, check app namespace
@@ -163,6 +175,17 @@ func (h *ValidatingHandler) checkDefinitionPermission(ctx context.Context, req a
if appNsSar.Status.Allowed {
// User has permission in app namespace
// But we need to verify the definition actually exists in the app namespace
// to prevent users with wildcard permissions from using definitions that only exist in vela-system
if exists, err := h.definitionExistsInNamespace(ctx, resource, definitionType, appNamespace); err != nil {
klog.V(4).Infof("Failed to check if %s %q exists in namespace %q: %v", resource, definitionType, appNamespace, err)
// On error checking existence, deny access for safety
return false, nil
} else if !exists {
klog.V(4).Infof("%s %q does not exist in namespace %q, denying access", resource, definitionType, appNamespace)
return false, nil
}
// Definition exists and user has permission
return true, nil
}
}
@@ -171,6 +194,38 @@ func (h *ValidatingHandler) checkDefinitionPermission(ctx context.Context, req a
return false, nil
}
// definitionExistsInNamespace checks if a definition actually exists in the specified namespace
func (h *ValidatingHandler) definitionExistsInNamespace(ctx context.Context, resource, name, namespace string) (bool, error) {
// Determine the object type based on the resource
var obj client.Object
switch resource {
case "componentdefinitions":
obj = &v1beta1.ComponentDefinition{}
case "traitdefinitions":
obj = &v1beta1.TraitDefinition{}
case "policydefinitions":
obj = &v1beta1.PolicyDefinition{}
case "workflowstepdefinitions":
obj = &v1beta1.WorkflowStepDefinition{}
default:
return false, fmt.Errorf("unknown resource type: %s", resource)
}
// Try to get the definition from the namespace
key := client.ObjectKey{Name: name, Namespace: namespace}
if err := h.Client.Get(ctx, key, obj); err != nil {
if !errors.IsNotFound(err) {
// Handle other errors than not found
return false, err
}
// Definition not found
return false, nil
}
// Definition exists
return true, nil
}
// workflowStepLocation represents the location of a workflow step
type workflowStepLocation struct {
StepIndex int

View File

@@ -26,6 +26,7 @@ import (
admissionv1 "k8s.io/api/admission/v1"
authenticationv1 "k8s.io/api/authentication/v1"
authv1 "k8s.io/api/authorization/v1"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
utilfeature "k8s.io/apiserver/pkg/util/feature"
@@ -55,6 +56,7 @@ func TestValidateDefinitionPermissions(t *testing.T) {
app *v1beta1.Application
userInfo authenticationv1.UserInfo
allowedDefinitions map[string]bool // resource/namespace/name -> allowed
existingDefinitions map[string]bool // namespace/name -> exists
expectedErrorCount int
expectedErrorFields []string
expectedErrorMsgs []string
@@ -101,6 +103,12 @@ func TestValidateDefinitionPermissions(t *testing.T) {
"policydefinitions/vela-system/topology": true,
"workflowstepdefinitions/vela-system/deploy": true,
},
existingDefinitions: map[string]bool{
"vela-system/webservice": true,
"vela-system/scaler": true,
"vela-system/topology": true,
"vela-system/deploy": true,
},
expectedErrorCount: 0,
},
{
@@ -166,6 +174,9 @@ func TestValidateDefinitionPermissions(t *testing.T) {
"traitdefinitions/vela-system/gateway": false,
"traitdefinitions/test-ns/gateway": false,
},
existingDefinitions: map[string]bool{
"vela-system/webservice": true,
},
expectedErrorCount: 2,
expectedErrorFields: []string{"spec.components[0].traits[1].type", "spec.components[0].traits[0].type"},
expectedErrorMsgs: []string{"cannot get TraitDefinition \"gateway\"", "cannot get TraitDefinition \"scaler\""},
@@ -206,6 +217,10 @@ func TestValidateDefinitionPermissions(t *testing.T) {
"policydefinitions/vela-system/override": false,
"policydefinitions/test-ns/override": false,
},
existingDefinitions: map[string]bool{
"vela-system/topology": true,
"test-ns/topology": true,
},
expectedErrorCount: 1,
expectedErrorFields: []string{"spec.policies[1].type"},
expectedErrorMsgs: []string{"cannot get PolicyDefinition \"override\""},
@@ -251,6 +266,10 @@ func TestValidateDefinitionPermissions(t *testing.T) {
"componentdefinitions/vela-system/webservice": true,
"componentdefinitions/test-ns/webservice": true,
},
existingDefinitions: map[string]bool{
"vela-system/webservice": true,
"test-ns/webservice": true,
},
expectedErrorCount: 0,
},
{
@@ -325,6 +344,12 @@ func TestValidateDefinitionPermissions(t *testing.T) {
"workflowstepdefinitions/vela-system/notification": false,
"workflowstepdefinitions/test-ns/notification": false,
},
existingDefinitions: map[string]bool{
"vela-system/webservice": true,
"vela-system/ingress": true,
"vela-system/topology": true,
"vela-system/deploy": true,
},
expectedErrorCount: 4,
expectedErrorFields: []string{
"spec.components[0].traits[0].type",
@@ -418,7 +443,11 @@ func TestValidateDefinitionPermissions(t *testing.T) {
"componentdefinitions/vela-system/custom-comp": false,
"componentdefinitions/test-ns/custom-comp": true, // Allowed in app namespace
},
expectedErrorCount: 0, // Should pass as user has permission in app namespace
existingDefinitions: map[string]bool{
// Definition exists in app namespace
"test-ns/custom-comp": true,
},
expectedErrorCount: 0, // Should pass as user has permission in their namespace
},
{
name: "user has permission in system namespace but not app namespace",
@@ -444,6 +473,9 @@ func TestValidateDefinitionPermissions(t *testing.T) {
"componentdefinitions/vela-system/webservice": true, // Allowed in system namespace
"componentdefinitions/test-ns/webservice": false,
},
existingDefinitions: map[string]bool{
"vela-system/webservice": true,
},
expectedErrorCount: 0, // Should pass as user has permission in system namespace
},
{
@@ -487,6 +519,9 @@ func TestValidateDefinitionPermissions(t *testing.T) {
"workflowstepdefinitions/vela-system/notification": false,
"workflowstepdefinitions/test-ns/notification": false,
},
existingDefinitions: map[string]bool{
"vela-system/deploy": true,
},
expectedErrorCount: 2,
expectedErrorFields: []string{"spec.workflow.steps[0].subSteps[0].type", "spec.workflow.steps[0].subSteps[1].type"},
expectedErrorMsgs: []string{"cannot get WorkflowStepDefinition \"suspend\"", "cannot get WorkflowStepDefinition \"notification\""},
@@ -561,6 +596,108 @@ func TestValidateDefinitionPermissions(t *testing.T) {
expectedErrorCount: 1,
expectedErrorFields: []string{"spec.components[0].type"},
},
{
name: "namespace admin cannot use vela-system definitions without explicit access",
app: &v1beta1.Application{
ObjectMeta: metav1.ObjectMeta{
Name: "test-app",
Namespace: "test",
},
Spec: v1beta1.ApplicationSpec{
Components: []common.ApplicationComponent{
{
Name: "hello",
Type: "hello-cm", // This definition exists only in vela-system
},
},
},
},
userInfo: authenticationv1.UserInfo{
Username: "system:serviceaccount:test:app-writer",
Groups: []string{"system:serviceaccounts", "system:serviceaccounts:test"},
},
allowedDefinitions: map[string]bool{
// User has wildcard permissions in test namespace
"componentdefinitions/test/hello-cm": true,
// But no explicit access to vela-system
"componentdefinitions/vela-system/hello-cm": false,
},
existingDefinitions: map[string]bool{
// Definition exists in vela-system but not in test namespace
"vela-system/hello-cm": true,
"test/hello-cm": false,
},
expectedErrorCount: 1,
expectedErrorFields: []string{"spec.components[0].type"},
expectedErrorMsgs: []string{"cannot get ComponentDefinition \"hello-cm\""},
},
{
name: "user has vela-system permission but definition does not exist there",
app: &v1beta1.Application{
ObjectMeta: metav1.ObjectMeta{
Name: "test-app",
Namespace: "test",
},
Spec: v1beta1.ApplicationSpec{
Components: []common.ApplicationComponent{
{
Name: "phantom",
Type: "phantom-def", // User has permission but doesn't exist
},
},
},
},
userInfo: authenticationv1.UserInfo{
Username: "phantom-user",
Groups: []string{"phantom-group"},
},
allowedDefinitions: map[string]bool{
// User has explicit permission to phantom-def in vela-system
"componentdefinitions/vela-system/phantom-def": true,
// And also in test namespace
"componentdefinitions/test/phantom-def": true,
},
existingDefinitions: map[string]bool{
// But definition doesn't exist in either namespace
"vela-system/phantom-def": false,
"test/phantom-def": false,
},
expectedErrorCount: 1,
expectedErrorFields: []string{"spec.components[0].type"},
expectedErrorMsgs: []string{"cannot get ComponentDefinition \"phantom-def\""},
},
{
name: "user has vela-system permission but definition only exists in app namespace",
app: &v1beta1.Application{
ObjectMeta: metav1.ObjectMeta{
Name: "test-app",
Namespace: "test",
},
Spec: v1beta1.ApplicationSpec{
Components: []common.ApplicationComponent{
{
Name: "local-only",
Type: "local-only-def", // Exists only in app namespace
},
},
},
},
userInfo: authenticationv1.UserInfo{
Username: "mixed-user",
Groups: []string{"mixed-group"},
},
allowedDefinitions: map[string]bool{
// User has permission in both namespaces
"componentdefinitions/vela-system/local-only-def": true,
"componentdefinitions/test/local-only-def": true,
},
existingDefinitions: map[string]bool{
// Definition only exists in app namespace
"vela-system/local-only-def": false,
"test/local-only-def": true,
},
expectedErrorCount: 0, // Should succeed using test namespace version
},
}
for _, tc := range testCases {
@@ -571,8 +708,9 @@ func TestValidateDefinitionPermissions(t *testing.T) {
_ = authv1.AddToScheme(scheme)
fakeClient := &mockSARClient{
Client: fake.NewClientBuilder().WithScheme(scheme).Build(),
allowedDefinitions: tc.allowedDefinitions,
Client: fake.NewClientBuilder().WithScheme(scheme).Build(),
allowedDefinitions: tc.allowedDefinitions,
existingDefinitions: tc.existingDefinitions,
}
handler := &ValidatingHandler{
@@ -696,6 +834,10 @@ func TestValidateDefinitionPermissions_AuthenticationDisabled(t *testing.T) {
"componentdefinitions/vela-system/webservice": true,
"componentdefinitions/test-ns/webservice": true,
},
existingDefinitions: map[string]bool{
"vela-system/webservice": true,
"test-ns/webservice": true,
},
}
handler := &ValidatingHandler{
@@ -865,7 +1007,8 @@ func TestGetWorkflowStepFieldPath(t *testing.T) {
// mockSARClient is a mock client that simulates SubjectAccessReview responses
type mockSARClient struct {
client.Client
allowedDefinitions map[string]bool
allowedDefinitions map[string]bool
existingDefinitions map[string]bool // namespace/name -> exists
}
func (m *mockSARClient) Create(ctx context.Context, obj client.Object, opts ...client.CreateOption) error {
@@ -893,3 +1036,20 @@ func (m *mockSARClient) Create(ctx context.Context, obj client.Object, opts ...c
}
return m.Client.Create(ctx, obj, opts...)
}
func (m *mockSARClient) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error {
// Handle definition existence checks
switch obj.(type) {
case *v1beta1.ComponentDefinition, *v1beta1.TraitDefinition, *v1beta1.PolicyDefinition, *v1beta1.WorkflowStepDefinition:
defKey := fmt.Sprintf("%s/%s", key.Namespace, key.Name)
if m.existingDefinitions != nil {
if exists, ok := m.existingDefinitions[defKey]; ok && exists {
// Definition exists - return success
return nil
}
}
// Definition not found
return errors.NewNotFound(v1beta1.SchemeGroupVersion.WithResource("componentdefinitions").GroupResource(), key.Name)
}
return m.Client.Get(ctx, key, obj, opts...)
}