PackageVariantSet Reconciliation
Overview
The PackageVariantSet controller implements a declarative fan-out pattern where a single upstream package is automatically instantiated across multiple downstream targets using template-based generation with CEL expressions. It manages bulk creation of PackageVariant CRs based on target selectors and ensures the desired set of PackageVariants matches the actual set through set-based reconciliation.
High-Level Architecture
┌─────────────────────────────────────────────────────────┐
│ PackageVariantSet Reconciliation System │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Target │ │ Template │ │
│ │ Unrolling │ ──> │ Evaluation │ │
│ │ │ │ │ │
│ │ • Repository │ │ • CEL Exprs │ │
│ │ List │ │ • Context │ │
│ │ • Repository │ │ • Dynamic │ │
│ │ Selector │ │ Config │ │
│ │ • Object │ │ │ │
│ │ Selector │ │ │ │
│ └──────────────────┘ └──────────────────┘ │
│ │ │ │
│ └────────┬────────────────┘ │
│ ↓ │
│ ┌──────────────────┐ │
│ │ Set-Based │ │
│ │ Reconciliation │ │
│ │ │ │
│ │ • Desired Set │ │
│ │ • Existing Set │ │
│ │ • Create/Update │ │
│ │ • Delete │ │
│ └──────────────────┘ │
└─────────────────────────────────────────────────────────┘
Reconciliation Flow
The controller follows a structured reconciliation process:
Main Reconciliation Loop
Reconcile Triggered
↓
Get PackageVariantSet
↓
List PackageRevisions
↓
List Repositories
↓
Validate Spec
↓
Valid? ──No──> Set Stalled=True, return
│
Yes
↓
Find Upstream PR
↓
Found? ──No──> Set Stalled=True, return
│
Yes
↓
Unroll Downstream Targets
↓
Success? ──No──> Set Stalled=True, return
│
Yes
↓
Set Stalled=False
↓
Ensure PackageVariants
↓
Success? ──No──> Set Stalled=True, return
│
Yes
↓
Set Ready=True
↓
Update Status
↓
Return
Process characteristics:
- Deferred status update: Status updated at end regardless of success/failure
- Early validation: Validation errors prevent further processing
- Upstream dependency: Requires upstream PackageRevision to exist
- Target unrolling: Converts target selectors to concrete downstream contexts
- Set reconciliation: Ensures desired PackageVariants match actual
Target Unrolling
The controller converts target selectors into concrete downstream contexts:
Unrolling Process
Unroll Downstream Targets
↓
For Each Target in Spec.Targets:
↓
Repositories specified? ──Yes──> Explicit Unroll
│ ↓
No For each Repository:
↓ ↓
RepositorySelector? ──Yes──> Query Repos For each PackageName:
│ ↓ ↓
No For each match: Create pvContext
↓ ↓
ObjectSelector? ──Yes──> Query Objects
│ ↓
No For each match:
↓ ↓
Error Create pvContext
↓
Return all pvContexts
pvContext structure:
- template: PackageVariantTemplate from target
- repoDefault: Default repository name
- packageDefault: Default package name
- object: Matched object (for ObjectSelector only)
Repository List Unrolling
When used:
- Explicit list of repositories and package names
- Most direct targeting mechanism
Unrolling logic:
Target.Repositories = [
{Name: "repo-1", PackageNames: ["pkg-a", "pkg-b"]},
{Name: "repo-2", PackageNames: ["pkg-c"]}
]
↓
Unrolls to 3 pvContexts:
• repo-1/pkg-a
• repo-1/pkg-b
• repo-2/pkg-c
Characteristics:
- Each repository can specify multiple package names
- If PackageNames empty, defaults to upstream package name
- Creates one pvContext per repository-package combination
- Most predictable and explicit
Repository Selector Unrolling
When used:
- Label-based selection of Repository CRs
- Dynamic discovery of target repositories
Unrolling logic:
Target.RepositorySelector = {
MatchLabels: {env: "prod"}
}
↓
Query Repositories in namespace
↓
Filter by label selector
↓
For each matching Repository:
↓
Create pvContext:
• repoDefault = Repository.Name
• packageDefault = upstream.Package
• object = nil
Characteristics:
- Automatically discovers repositories with matching labels
- Package name defaults to upstream package name
- Dynamic - adjusts as repositories added/removed
- Internally converted to ObjectSelector with Repository GVK
Object Selector Unrolling
When used:
- Label-based selection of arbitrary Kubernetes objects
- Most flexible targeting mechanism
Unrolling logic:
Target.ObjectSelector = {
APIVersion: "v1",
Kind: "ConfigMap",
LabelSelector: {MatchLabels: {cluster: "edge"}}
}
↓
Query objects of specified GVK
↓
Filter by label selector
↓
For each matching object:
↓
Create pvContext:
• repoDefault = object.Name
• packageDefault = upstream.Package
• object = object (full unstructured)
Characteristics:
- Can select any Kubernetes object type
- Object metadata available in CEL expressions
- Repository name defaults to object name
- Package name defaults to upstream package name
- Most flexible but requires careful configuration
Empty results handling:
- Warning logged if no objects match selector
- No error returned (may be temporary condition)
- Reconciliation continues with empty target list
Template Evaluation
The controller evaluates CEL expressions to generate PackageVariant specs:
Evaluation Process
Render PackageVariant Spec
↓
Build Base Inputs
↓
• repoDefault, packageDefault
• upstream (PackageRevision)
• target (matched object)
↓
Evaluate Downstream.RepoExpr
↓
Load Repository object
↓
Add repository to inputs
↓
Evaluate Downstream.PackageExpr
↓
Evaluate LabelExprs
↓
Evaluate AnnotationExprs
↓
Evaluate PackageContext.DataExprs
↓
Evaluate PackageContext.RemoveKeyExprs
↓
Evaluate Injector.NameExpr
↓
Evaluate Pipeline.ConfigMapExprs
↓
Return PackageVariant Spec
Evaluation order:
- Base inputs: repoDefault, packageDefault, upstream, target
- Downstream repo: Evaluated first (needed to load Repository object)
- Repository object: Loaded and added to CEL environment
- All other expressions: Evaluated with full context
CEL Environment
Available variables:
- repoDefault (string): Default repository name from target unrolling
- packageDefault (string): Default package name from target unrolling
- upstream (dynamic): Upstream PackageRevision object
- repository (dynamic): Downstream Repository object
- target (dynamic): Matched target object (ObjectSelector only)
Object structure (security-limited):
- name: Object name
- namespace: Object namespace
- labels: Object labels map
- annotations: Object annotations map
Security rationale:
- Only metadata fields accessible
- Prevents leaking sensitive data from cluster
- Namespace isolation enforced
- No access to spec or status fields
Expression Types
Static vs Dynamic:
- Static fields: Direct values (repo, package, labels, annotations)
- Expression fields: CEL expressions (repoExpr, packageExpr, labelExprs, annotationExprs)
- Precedence: Expression results override static values
Expression evaluation examples:
Downstream repository:
- Static:
downstream.repo: "prod-repo" - Dynamic:
downstream.repoExpr: "target.labels.environment + '-repo'"
Package naming:
- Static:
downstream.package: "my-package" - Dynamic:
downstream.packageExpr: "upstream.name + '-' + target.name"
Label generation:
- Static:
labels: {env: prod} - Dynamic:
labelExprs: [{keyExpr: "'cluster'", valueExpr: "target.name"}]
Package context injection:
- Static:
packageContext.data: {key: value} - Dynamic:
packageContext.dataExprs: [{keyExpr: "'region'", valueExpr: "repository.labels.region"}]
Function configuration:
- Static:
pipeline.mutators[0].configMap: {namespace: prod} - Dynamic:
pipeline.mutators[0].configMapExprs: [{keyExpr: "'namespace'", valueExpr: "target.labels.ns"}]
Map Expression Overlay
Overlay pattern:
Static Map + Expression Map = Result Map
↓
Copy static entries
↓
Evaluate each MapExpr
↓
• Evaluate keyExpr or use key
• Evaluate valueExpr or use value
↓
Overlay onto result map
↓
Return merged map
Characteristics:
- Static entries copied first
- Expression entries evaluated and overlaid
- Expression entries can override static entries
- Empty result returns nil (not empty map)
Set-Based Reconciliation
The controller reconciles desired PackageVariants with existing PackageVariants:
Reconciliation Strategy
Ensure PackageVariants
↓
List Existing PackageVariants
↓
• Filter by owner label
• Build existingMap by identifier
↓
For Each Downstream Target:
↓
Render PackageVariant Spec
↓
Generate Identifier
↓
Create PackageVariant Object
↓
Add to desiredMap
↓
Compare existingMap vs desiredMap
↓
┌────┴────┬────────┬─────────┐
↓ ↓ ↓ ↓
Only Both Only
Existing Desired
↓ ↓ ↓
Delete Update Create
Set operations:
- Existing only: PackageVariant no longer needed → Delete
- Both: PackageVariant exists and needed → Update spec
- Desired only: PackageVariant needed but missing → Create
Identifier Generation
Identifier format:
{pvsName}-{downstreamRepo}-{downstreamPackage}
Example:
- PVS name:
my-pvs - Downstream repo:
prod-repo - Downstream package:
my-package - Identifier:
my-pvs-prod-repo-my-package
Characteristics:
- Stable across reconciliations
- Unique per downstream repo/package combination
- Used to match existing with desired PackageVariants
Name Generation
Name generation logic:
Generate PackageVariant Name
↓
Identifier length ≤ 63? ──Yes──> Use identifier as-is
│
No
↓
Truncate and hash:
↓
• Take first 54 characters
• Compute SHA1 hash of full identifier
• Take first 8 hex characters of hash
• Format: {identifier[:54]}-{hash[:8]}
Rationale:
- Kubernetes names limited to 63 characters
- Long identifiers truncated with hash suffix
- Hash ensures uniqueness even after truncation
- Stable names across reconciliations
Example:
- Short identifier:
my-pvs-prod-repo-my-package(28 chars) → Used as-is - Long identifier:
very-long-packagevariantset-name-very-long-repo-name-very-long-package-name(76 chars) →very-long-packagevariantset-name-very-long-repo-name-v-a1b2c3d4
PackageVariant Creation
Generated PackageVariant structure:
PackageVariant:
ObjectMeta:
Name: Generated from identifier
Namespace: Same as PackageVariantSet
Labels:
config.porch.kpt.dev/packagevariantset: {PVS UID}
OwnerReferences:
- PackageVariantSet (controller=true)
Finalizers:
- config.porch.kpt.dev/packagevariants
Spec:
Upstream: From PackageVariantSet
Downstream: From template evaluation
AdoptionPolicy: From template
DeletionPolicy: From template
Labels: From template evaluation
Annotations: From template evaluation
PackageContext: From template evaluation
Pipeline: From template evaluation
Injectors: From template evaluation
Owner reference characteristics:
- PackageVariantSet is controller owner
- Enables garbage collection
- Cascading deletion when PackageVariantSet deleted
- Label for efficient querying
Update vs Create Logic
Update existing PackageVariant:
PackageVariant exists in both sets
↓
Copy existing ObjectMeta
↓
Replace Spec with desired Spec
↓
Update via Kubernetes API
↓
Return
Update characteristics:
- Only spec is updated
- Metadata (labels, annotations) preserved
- Owner references unchanged
- Finalizers unchanged
Create new PackageVariant:
PackageVariant only in desired set
↓
Create full PackageVariant object
↓
• Set owner reference
• Set label for tracking
• Set finalizer
↓
Create via Kubernetes API
↓
Return
Delete obsolete PackageVariant:
PackageVariant only in existing set
↓
Delete via Kubernetes API
↓
PackageVariant controller handles cleanup
↓
Downstream packages handled per PV policy
Deletion characteristics:
- No longer in desired set
- Deleted via Kubernetes API
- PackageVariant controller handles cleanup
- Downstream packages handled per PackageVariant deletion policy
Validation
The controller validates PackageVariantSet specs before processing:
Validation Flow
Validate PackageVariantSet
↓
Check Upstream
↓
• Package specified?
• Repo specified?
• Revision or WorkspaceName specified?
↓
Check Targets
↓
• At least one target?
• For each target:
- Exactly one selector type?
- Repository list not empty?
- Repository names not empty?
- Package names not empty?
- ObjectSelector has APIVersion/Kind?
↓
Check Template (if present)
↓
• AdoptionPolicy valid?
• DeletionPolicy valid?
• Not both Repo and RepoExpr?
• Not both Package and PackageExpr?
• MapExpr not both Key and KeyExpr?
• MapExpr not both Value and ValueExpr?
• Injector has Name or NameExpr?
• Function image not empty?
• Function name no dots?
↓
Return all errors
Validation categories:
Upstream validation:
- Upstream field must be present
- Package must be specified
- Repo must be specified
- Either Revision or WorkspaceName must be specified
Targets validation:
- At least one target must be specified
- Each target must specify exactly one of: Repositories, RepositorySelector, or ObjectSelector
- Repository list must not be empty if specified
- Repository names cannot be empty
- Package names cannot be empty
- ObjectSelector must have APIVersion and Kind
Template validation:
- AdoptionPolicy must be “adoptNone” or “adoptExisting”
- DeletionPolicy must be “delete” or “orphan”
- Cannot specify both Repo and RepoExpr in Downstream
- Cannot specify both Package and PackageExpr in Downstream
- Cannot specify both Key and KeyExpr in MapExpr
- Cannot specify both Value and ValueExpr in MapExpr
- Cannot specify both Name and NameExpr in Injectors
- Must specify either Name or NameExpr in Injectors
- Function image must not be empty
- Function name must not contain dots
Validation failures:
- Set Condition: Stalled=True, Ready=False
- Do NOT requeue (requires PackageVariantSet change)
- Error message includes all validation errors combined
Error Handling
The controller handles errors at multiple stages:
Validation Errors
Validate Spec
↓
Errors Found? ──Yes──> Set Conditions
│ ↓
No Stalled=True
↓ Ready=False
Continue ↓
Do NOT Requeue
Validation failures:
- Set Stalled condition to True with error message
- Set Ready condition to False
- Do NOT requeue (requires PackageVariantSet spec change)
- Error message includes all validation errors combined
Upstream Not Found Errors
Find Upstream PR
↓
Not Found? ──Yes──> Set Conditions
│ ↓
No Stalled=True
↓ Ready=False
Continue ↓
Do NOT Requeue
Upstream not found:
- Set Stalled condition to True with reason “UpstreamNotFound”
- Set Ready condition to False
- Do NOT requeue (watch will trigger when upstream appears)
- Watch on PackageRevisions triggers reconciliation when upstream appears
Target Unroll Errors
Unroll Downstream Targets
↓
Error? ──Yes──> Check Error Type
│ ↓
No NoMatchError? ──Yes──> Stalled=True, "NoMatchingTargets"
↓ │
Continue No
↓
Stalled=True, "UnexpectedError"
Target unroll failures:
- NoMatchError: CRD not found for ObjectSelector
- Set Stalled=True with reason “NoMatchingTargets”
- Do NOT requeue (requires CRD installation)
- Other errors: Unexpected errors
- Set Stalled=True with reason “UnexpectedError”
- Do NOT requeue (may require investigation)
Reconciliation Errors
Ensure PackageVariants
↓
Error? ──Yes──> Set Conditions
│ ↓
No Stalled=True
↓ Ready=False
Success ↓
↓ Do NOT Requeue
Ready=True
Stalled=False
Reconciliation failures:
- Set Stalled condition to True with reason “UnexpectedError”
- Set Ready condition to False
- Do NOT requeue (may be transient, watch will trigger)
- Examples: API errors, network issues, Porch unavailable
CEL Evaluation Errors
Evaluate CEL Expression
↓
Error? ──Yes──> Return Error
│ ↓
No Include field context
↓ ↓
Continue "template.downstream.repoExpr: {error}"
CEL evaluation failures:
- Returned during template rendering
- Include expression field name in error
- Include original CEL error details
- Set Stalled=True condition
- Requires expression fix
Condition Management
Condition types:
Stalled condition:
- True: Validation error, upstream not found, or target unroll error (no progress possible)
- False: All validation passed, upstream found, targets unrolled (can make progress)
- Reasons: “ValidationError”, “UpstreamNotFound”, “NoMatchingTargets”, “UnexpectedError”, “Valid”
Ready condition:
- True: Successfully ensured all PackageVariants
- False: Error during reconciliation
- Reasons: “Reconciled”, “UnexpectedError”
Status update:
- Deferred to end of reconciliation
- Updated even if reconciliation fails
- Provides visibility into controller state
- Used by users and automation to monitor progress
Watch Configuration
The controller watches multiple resource types:
Watch Setup
Controller Manager
↓
Set up Watches
↓
┌─────┴─────┬─────────────┐
↓ ↓ ↓
Primary Secondary Secondary
Watch Watch Watch
↓ ↓ ↓
PackageVariantSet PackageVariant PackageRevision
Watch configuration:
- Primary watch: PackageVariantSet CRs (main resource being reconciled)
- Secondary watch: PackageVariant CRs (triggers reconciliation when child changes)
- Secondary watch: PackageRevision CRs (triggers reconciliation when upstream changes)
Secondary Watch Mapping
Mapping function:
PackageVariant or PackageRevision Change
↓
Map Function
↓
List All PackageVariantSets
↓
In Same Namespace
↓
Enqueue All
↓
Each PVS Reconciled
Mapping rationale:
- Simple implementation (no complex filtering)
- Ensures all PackageVariantSets see child changes
- Ensures all PackageVariantSets see upstream changes
- Acceptable performance for typical scale (hundreds of resources)
- Trade-off: More reconciliations, simpler logic
Implications:
- Broad watches ensure consistency
- May trigger unnecessary reconciliations
- Guarantees correctness over efficiency
- Suitable for typical deployment scales
Design Patterns
Fan-Out Pattern
Single Upstream Package
↓
PackageVariantSet
↓
Target Unrolling
↓
┌─────┴─────┬─────────┬─────────┐
↓ ↓ ↓ ↓
Target1 Target2 Target3 TargetN
↓ ↓ ↓ ↓
└─────┬─────┴─────────┴─────────┘
↓
Template Evaluation
↓
┌─────┴─────┬─────────┬─────────┐
↓ ↓ ↓ ↓
PV1 PV2 PV3 PVN
↓ ↓ ↓ ↓
└─────┬─────┴─────────┴─────────┘
↓
Multiple Downstream Packages
Pattern characteristics:
- One-to-many relationship
- Declarative targeting
- Template-based customization
- Automatic synchronization
Template Pattern
Static + Dynamic configuration:
- Static values provide defaults
- Dynamic expressions override defaults
- Expressions evaluated per-target
- Context-aware customization
Expression precedence:
- Static fields evaluated first
- Expression fields evaluated second
- Expression results override static values
- Enables flexible configuration
Set-Based Reconciliation
Desired state computation:
- Compute complete desired set
- Compare with existing set
- Apply minimal changes
- Idempotent operations
Benefits:
- Declarative approach
- Handles additions, updates, deletions
- Consistent with Kubernetes patterns
- Easy to reason about
Integration with PackageVariant Controller
The PackageVariantSet controller creates PackageVariant CRs, which are then reconciled by the PackageVariant controller:
Two-Level Hierarchy
PackageVariantSet (Parent)
↓
Creates/Manages
↓
PackageVariant (Child)
↓
Creates/Manages
↓
PackageRevision (Grandchild)
Ownership chain:
- PackageVariantSet owns PackageVariant (via OwnerReference)
- PackageVariant owns PackageRevision (via OwnerReference)
- Kubernetes garbage collection handles cascading deletion
- Child watches trigger parent reconciliation
Separation of concerns:
- PackageVariantSet: Bulk creation, target selection, template evaluation
- PackageVariant: Individual package management, upstream tracking, mutation application
- Clear boundaries: Each controller has distinct responsibilities
Reconciliation flow:
PackageVariantSet Reconcile
↓
Create/Update/Delete PackageVariant CRs
↓
PackageVariant Controller Watches
↓
Reconcile Each PackageVariant
↓
Create/Update PackageRevisions