mirror of
https://github.com/kubevela/kubevela.git
synced 2026-02-14 18:10:21 +00:00
Compare commits
5 Commits
master
...
release-1.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2fe0b9fdc | ||
|
|
217a71e598 | ||
|
|
bbbdd0d299 | ||
|
|
f89622eec7 | ||
|
|
8401ff4d85 |
@@ -75,12 +75,61 @@ 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 - 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
|
||||
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 +147,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
|
||||
@@ -199,6 +263,18 @@ webhooks:
|
||||
admissionReviewVersions: ["v1", "v1beta1"]
|
||||
sideEffects: None
|
||||
failurePolicy: Fail
|
||||
- name: applications.core.oam.dev
|
||||
clientConfig:
|
||||
url: https://${HOST_IP}:9445/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 +290,38 @@ show_next_steps() {
|
||||
echo "Webhook debugging setup complete!"
|
||||
echo "========================================="
|
||||
echo -e "${NC}"
|
||||
|
||||
echo "Configuration:"
|
||||
echo " - Webhook URL: https://${HOST_IP}:9445"
|
||||
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=9445"
|
||||
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -218,6 +218,22 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
|
||||
handler.addAppliedResource(true, app.Status.AppliedResources...)
|
||||
app.Status.AppliedResources = handler.appliedResources
|
||||
app.Status.Services = handler.services
|
||||
|
||||
// Remove status entries for components that no longer exist in spec
|
||||
filteredServices, filteredResources, componentsRemoved := filterRemovedComponentsFromStatus(
|
||||
app.Spec.Components,
|
||||
app.Status.Services,
|
||||
app.Status.AppliedResources,
|
||||
)
|
||||
app.Status.Services = filteredServices
|
||||
app.Status.AppliedResources = filteredResources
|
||||
handler.services = filteredServices
|
||||
handler.appliedResources = filteredResources
|
||||
|
||||
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)
|
||||
@@ -285,7 +301,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 +713,39 @@ func setVelaVersion(app *v1beta1.Application) {
|
||||
}
|
||||
}
|
||||
|
||||
// filterRemovedComponentsFromStatus removes status entries for components no longer in spec.
|
||||
// Returns filtered lists and whether any components were removed (used to determine Update vs Patch).
|
||||
func filterRemovedComponentsFromStatus(
|
||||
components []common.ApplicationComponent,
|
||||
services []common.ApplicationComponentStatus,
|
||||
appliedResources []common.ClusterObjectReference,
|
||||
) (filteredServices []common.ApplicationComponentStatus, filteredResources []common.ClusterObjectReference, 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
|
||||
}
|
||||
}
|
||||
|
||||
filteredResources = make([]common.ClusterObjectReference, 0, len(appliedResources))
|
||||
for _, res := range appliedResources {
|
||||
if _, found := componentMap[res.Name]; found {
|
||||
filteredResources = append(filteredResources, res)
|
||||
} else {
|
||||
removed = true
|
||||
}
|
||||
}
|
||||
|
||||
return filteredServices, filteredResources, removed
|
||||
}
|
||||
|
||||
func evalStatus(ctx monitorContext.Context, handler *AppHandler, appFile *appfile.Appfile, appParser *appfile.Parser) bool {
|
||||
healthCheck := handler.checkComponentHealth(appParser, appFile)
|
||||
if !hasHealthCheckPolicy(appFile.ParsedPolicies) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
|
||||
"cuelang.org/go/cue"
|
||||
"github.com/stretchr/testify/assert"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
|
||||
@@ -181,3 +182,129 @@ func Test_applyComponentHealthToServices(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterRemovedComponentsFromStatus(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
components []common.ApplicationComponent
|
||||
statusServices []common.ApplicationComponentStatus
|
||||
statusResources []common.ClusterObjectReference
|
||||
expectedServices []string
|
||||
expectedResources []string
|
||||
componentsRemoved bool
|
||||
}{
|
||||
{
|
||||
name: "removed components are filtered from status",
|
||||
components: []common.ApplicationComponent{
|
||||
{Name: "backend", Type: "webservice"},
|
||||
},
|
||||
statusServices: []common.ApplicationComponentStatus{
|
||||
{Name: "frontend", Namespace: "default"},
|
||||
{Name: "backend", Namespace: "default"},
|
||||
},
|
||||
statusResources: []common.ClusterObjectReference{
|
||||
{
|
||||
ObjectReference: corev1.ObjectReference{
|
||||
Name: "frontend",
|
||||
Namespace: "default",
|
||||
Kind: "Deployment",
|
||||
},
|
||||
},
|
||||
{
|
||||
ObjectReference: corev1.ObjectReference{
|
||||
Name: "backend",
|
||||
Namespace: "default",
|
||||
Kind: "Deployment",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedServices: []string{"backend"},
|
||||
expectedResources: []string{"backend"},
|
||||
componentsRemoved: true,
|
||||
},
|
||||
{
|
||||
name: "all components removed results in empty status",
|
||||
components: []common.ApplicationComponent{},
|
||||
statusServices: []common.ApplicationComponentStatus{
|
||||
{Name: "frontend", Namespace: "default"},
|
||||
{Name: "backend", Namespace: "default"},
|
||||
},
|
||||
statusResources: []common.ClusterObjectReference{
|
||||
{
|
||||
ObjectReference: corev1.ObjectReference{
|
||||
Name: "frontend",
|
||||
Namespace: "default",
|
||||
Kind: "Deployment",
|
||||
},
|
||||
},
|
||||
{
|
||||
ObjectReference: corev1.ObjectReference{
|
||||
Name: "backend",
|
||||
Namespace: "default",
|
||||
Kind: "Deployment",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedServices: []string{},
|
||||
expectedResources: []string{},
|
||||
componentsRemoved: true,
|
||||
},
|
||||
{
|
||||
name: "no components removed keeps all status entries",
|
||||
components: []common.ApplicationComponent{
|
||||
{Name: "frontend", Type: "webservice"},
|
||||
{Name: "backend", Type: "webservice"},
|
||||
},
|
||||
statusServices: []common.ApplicationComponentStatus{
|
||||
{Name: "frontend", Namespace: "default"},
|
||||
{Name: "backend", Namespace: "default"},
|
||||
},
|
||||
statusResources: []common.ClusterObjectReference{
|
||||
{
|
||||
ObjectReference: corev1.ObjectReference{
|
||||
Name: "frontend",
|
||||
Namespace: "default",
|
||||
Kind: "Deployment",
|
||||
},
|
||||
},
|
||||
{
|
||||
ObjectReference: corev1.ObjectReference{
|
||||
Name: "backend",
|
||||
Namespace: "default",
|
||||
Kind: "Deployment",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedServices: []string{"frontend", "backend"},
|
||||
expectedResources: []string{"frontend", "backend"},
|
||||
componentsRemoved: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
filteredServices, filteredResources, componentsRemoved := filterRemovedComponentsFromStatus(
|
||||
tt.components,
|
||||
tt.statusServices,
|
||||
tt.statusResources,
|
||||
)
|
||||
|
||||
assert.Equal(t, tt.componentsRemoved, componentsRemoved,
|
||||
"componentsRemoved 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))
|
||||
}
|
||||
|
||||
assert.Equal(t, len(tt.expectedResources), len(filteredResources),
|
||||
"filtered resources count should match expected")
|
||||
for i, expectedName := range tt.expectedResources {
|
||||
assert.Equal(t, expectedName, filteredResources[i].Name,
|
||||
fmt.Sprintf("resource at index %d should be %s", i, expectedName))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"cuelang.org/go/cue/ast"
|
||||
"cuelang.org/go/cue/parser"
|
||||
"cuelang.org/go/cue/token"
|
||||
)
|
||||
|
||||
@@ -111,14 +110,15 @@ func unmarshalField[T ast.Node](field *ast.Field, key string, validator func(T)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
structLit, hasImports, hasPackage, parseErr := ParseCueContent(unquoted)
|
||||
if parseErr != nil {
|
||||
return fmt.Errorf("unexpected error re-parsing validated %s string: %w", key, parseErr)
|
||||
}
|
||||
|
||||
structLit, ok := expr.(*ast.StructLit)
|
||||
if !ok {
|
||||
return fmt.Errorf("expected struct after validation in field %s", key)
|
||||
if hasImports || hasPackage {
|
||||
// Keep as string literal to preserve imports/package
|
||||
return nil
|
||||
}
|
||||
|
||||
statusField.Value = structLit
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -176,6 +177,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 {
|
||||
@@ -379,6 +424,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 +670,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 +1101,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user