Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ Kro is Kubernetes native and integrates seamlessly with existing tools to preser
| [Examples][kro-examples] | Example resources |
| [Contributions](./CONTRIBUTING.md) | How to get involved |

[kro-instance-scope]: https://kro.run/docs/concepts/resource-group-definitions

### Instance scope (new)

ResourceGraphDefinitions can now choose the scope of the generated instance CRD via `spec.schema.scope`:
- `Namespaced` (default) — preserves current behavior.
- `Cluster` — generates a cluster-scoped instance CRD when your graph needs to be cluster-level.

The field is immutable after creation, matching Kubernetes CRD scope rules. See the concepts docs for details.

[kro-overview]: https://kro.run/docs/overview
[kro-installation]: https://kro.run/docs/getting-started/Installation
[kro-getting-started]: https://kro.run/docs/getting-started/deploy-a-resource-graph-definition
Expand Down
16 changes: 16 additions & 0 deletions api/v1alpha1/resourcegraphdefinition_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,14 @@ type Schema struct {
// +kubebuilder:default="kro.run"
// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="group is immutable"
Group string `json:"group,omitempty"`
// Scope determines whether the generated instance CRD is Namespaced or Cluster scoped.
// Default is Namespaced to preserve current behavior. This field is immutable.
//
// +kubebuilder:validation:Optional
// +kubebuilder:default="Namespaced"
// +kubebuilder:validation:Enum=Namespaced;Cluster
// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="scope is immutable"
Scope ResourceScope `json:"scope,omitempty"`
// Spec defines the schema for the instance's spec section using SimpleSchema syntax.
// This becomes the OpenAPI schema for instances of the generated CRD.
// Use SimpleSchema's concise syntax to define fields, types, defaults, and validations.
Expand Down Expand Up @@ -252,6 +260,14 @@ type Dependency struct {
ID string `json:"id,omitempty"`
}

// ResourceScope defines the scope of the generated instance CRD.
type ResourceScope string

const (
ScopeNamespaced ResourceScope = "Namespaced"
ScopeCluster ResourceScope = "Cluster"
)

// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:printcolumn:name="APIVERSION",type=string,priority=0,JSONPath=`.spec.schema.apiVersion`
Expand Down
31 changes: 23 additions & 8 deletions pkg/controller/instance/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,18 +141,33 @@ func (c *Controller) updateStatus(rcx *ReconcileContext) error {
inst.Object["status"] = status

return retry.RetryOnConflict(retry.DefaultRetry, func() error {
cur, err := c.client.Dynamic().
Resource(c.gvr).
Namespace(inst.GetNamespace()).
Get(rcx.Ctx, inst.GetName(), metav1.GetOptions{})
var cur *unstructured.Unstructured
var err error
// Handle cluster-scoped instances (namespace will be empty)
if inst.GetNamespace() != "" {
cur, err = c.client.Dynamic().
Resource(c.gvr).
Namespace(inst.GetNamespace()).
Get(rcx.Ctx, inst.GetName(), metav1.GetOptions{})
} else {
cur, err = c.client.Dynamic().
Resource(c.gvr).
Get(rcx.Ctx, inst.GetName(), metav1.GetOptions{})
}
if err != nil {
return err
}
cur.Object["status"] = status
_, err = c.client.Dynamic().
Resource(c.gvr).
Namespace(inst.GetNamespace()).
UpdateStatus(rcx.Ctx, cur, metav1.UpdateOptions{})
if inst.GetNamespace() != "" {
_, err = c.client.Dynamic().
Resource(c.gvr).
Namespace(inst.GetNamespace()).
UpdateStatus(rcx.Ctx, cur, metav1.UpdateOptions{})
} else {
_, err = c.client.Dynamic().
Resource(c.gvr).
UpdateStatus(rcx.Ctx, cur, metav1.UpdateOptions{})
}
return err
})
}
Expand Down
8 changes: 7 additions & 1 deletion pkg/graph/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -593,6 +593,12 @@ func (b *Builder) buildInstanceNode(
nodes map[string]*Node,
nodeSchemas map[string]*spec.Schema,
) (*Node, *extv1.CustomResourceDefinition, error) {
// Determine the scope for the instance CRD based on the schema definition.
scope := extv1.NamespaceScoped
if rgDefinition.Scope == v1alpha1.ScopeCluster {
scope = extv1.ClusterScoped
}

// The instance resource is the resource users will create in their cluster,
// to request the creation of the resources defined in the resource graph definition.
//
Expand All @@ -617,7 +623,7 @@ func (b *Builder) buildInstanceNode(

// Synthesize the CRD for the instance resource.
overrideStatusFields := true
instanceCRD := crd.SynthesizeCRD(group, apiVersion, kind, *instanceSpecSchema, *instanceStatusSchema, overrideStatusFields, rgDefinition)
instanceCRD := crd.SynthesizeCRD(group, apiVersion, kind, scope, *instanceSpecSchema, *instanceStatusSchema, overrideStatusFields, rgDefinition)

nodeNames := maps.Keys(nodes)
env, err := krocel.DefaultEnvironment(krocel.WithResourceIDs(nodeNames))
Expand Down
8 changes: 4 additions & 4 deletions pkg/graph/crd/crd.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,11 @@ import (

// SynthesizeCRD generates a CustomResourceDefinition for a given API version and kind
// with the provided spec and status schemas.
func SynthesizeCRD(group, apiVersion, kind string, spec, status extv1.JSONSchemaProps, statusFieldsOverride bool, rgSchema *v1alpha1.Schema) *extv1.CustomResourceDefinition {
return newCRD(group, apiVersion, kind, newCRDSchema(spec, status, statusFieldsOverride), rgSchema.AdditionalPrinterColumns, rgSchema.Metadata)
func SynthesizeCRD(group, apiVersion, kind string, scope extv1.ResourceScope, spec, status extv1.JSONSchemaProps, statusFieldsOverride bool, rgSchema *v1alpha1.Schema) *extv1.CustomResourceDefinition {
return newCRD(group, apiVersion, kind, scope, newCRDSchema(spec, status, statusFieldsOverride), rgSchema.AdditionalPrinterColumns, rgSchema.Metadata)
}

func newCRD(group, apiVersion, kind string, schema *extv1.JSONSchemaProps, additionalPrinterColumns []extv1.CustomResourceColumnDefinition, metadata *v1alpha1.CRDMetadata) *extv1.CustomResourceDefinition {
func newCRD(group, apiVersion, kind string, scope extv1.ResourceScope, schema *extv1.JSONSchemaProps, additionalPrinterColumns []extv1.CustomResourceColumnDefinition, metadata *v1alpha1.CRDMetadata) *extv1.CustomResourceDefinition {
pluralKind := flect.Pluralize(strings.ToLower(kind))

objectMeta := metav1.ObjectMeta{
Expand All @@ -56,7 +56,7 @@ func newCRD(group, apiVersion, kind string, schema *extv1.JSONSchemaProps, addit
Plural: pluralKind,
Singular: strings.ToLower(kind),
},
Scope: extv1.NamespaceScoped,
Scope: scope,
Versions: []extv1.CustomResourceDefinitionVersion{
{
Name: apiVersion,
Expand Down
28 changes: 23 additions & 5 deletions pkg/graph/crd/crd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ func TestSynthesizeCRD(t *testing.T) {
expectedGroup string
expectedLabels map[string]string
expectedAnnotations map[string]string
scope extv1.ResourceScope
}{
{
name: "standard group and kind",
Expand All @@ -49,6 +50,9 @@ func TestSynthesizeCRD(t *testing.T) {
schema: &v1alpha1.Schema{},
expectedName: "widgets.kro.com",
expectedGroup: "kro.com",
scope: extv1.NamespaceScoped,
expectedLabels: nil,
expectedAnnotations: nil,
},
{
name: "mixes case kind",
Expand All @@ -61,6 +65,9 @@ func TestSynthesizeCRD(t *testing.T) {
schema: &v1alpha1.Schema{},
expectedName: "databases.kro.com",
expectedGroup: "kro.com",
scope: extv1.ClusterScoped,
expectedLabels: nil,
expectedAnnotations: nil,
},
{
name: "with labels and annotations",
Expand All @@ -82,6 +89,7 @@ func TestSynthesizeCRD(t *testing.T) {
},
expectedName: "widgets.kro.com",
expectedGroup: "kro.com",
scope: extv1.NamespaceScoped,
expectedLabels: map[string]string{
"environment": "test",
},
Expand All @@ -103,19 +111,23 @@ func TestSynthesizeCRD(t *testing.T) {
Annotations: map[string]string{},
},
},
expectedName: "widgets.kro.com",
expectedGroup: "kro.com",
expectedName: "widgets.kro.com",
expectedGroup: "kro.com",
scope: extv1.NamespaceScoped,
expectedLabels: nil,
expectedAnnotations: nil,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
crd := SynthesizeCRD(tt.group, tt.apiVersion, tt.kind, tt.spec, tt.status, tt.statusFieldsOverride, tt.schema)
crd := SynthesizeCRD(tt.group, tt.apiVersion, tt.kind, tt.scope, tt.spec, tt.status, tt.statusFieldsOverride, tt.schema)

assert.Equal(t, tt.expectedName, crd.Name)
assert.Equal(t, tt.expectedGroup, crd.Spec.Group)
assert.Equal(t, tt.kind, crd.Spec.Names.Kind)
assert.Equal(t, tt.kind+"List", crd.Spec.Names.ListKind)
assert.Equal(t, tt.scope, crd.Spec.Scope)

require.Len(t, crd.Spec.Versions, 1)
version := crd.Spec.Versions[0]
Expand Down Expand Up @@ -160,6 +172,7 @@ func TestNewCRD(t *testing.T) {
expectedPlural string
expectedSingular string
expectedPrinterColumns []extv1.CustomResourceColumnDefinition
scope extv1.ResourceScope
}{
{
name: "basic example",
Expand All @@ -171,6 +184,7 @@ func TestNewCRD(t *testing.T) {
expectedPlural: "tests",
expectedSingular: "test",
expectedPrinterColumns: defaultAdditionalPrinterColumns,
scope: extv1.NamespaceScoped,
},
{
name: "uppercase kind",
Expand All @@ -182,6 +196,7 @@ func TestNewCRD(t *testing.T) {
expectedPlural: "configs",
expectedSingular: "config",
expectedPrinterColumns: defaultAdditionalPrinterColumns,
scope: extv1.NamespaceScoped,
},
{
name: "mixed case kind",
Expand All @@ -193,6 +208,7 @@ func TestNewCRD(t *testing.T) {
expectedPlural: "webhooks",
expectedSingular: "webhook",
expectedPrinterColumns: defaultAdditionalPrinterColumns,
scope: extv1.NamespaceScoped,
},
{
name: "non nil empty printer columns",
Expand All @@ -205,6 +221,7 @@ func TestNewCRD(t *testing.T) {
expectedPlural: "webhooks",
expectedSingular: "webhook",
expectedPrinterColumns: defaultAdditionalPrinterColumns,
scope: extv1.NamespaceScoped,
},
{
name: "custom printer columns",
Expand Down Expand Up @@ -239,13 +256,14 @@ func TestNewCRD(t *testing.T) {
JSONPath: ".spec.image",
},
},
scope: extv1.ClusterScoped,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
schema := &extv1.JSONSchemaProps{Type: "object"}
crd := newCRD(tt.group, tt.apiVersion, tt.kind, schema, tt.printerColumns, nil)
crd := newCRD(tt.group, tt.apiVersion, tt.kind, tt.scope, schema, tt.printerColumns, nil)

assert.Equal(t, tt.expectedName, crd.Name)
assert.Equal(t, tt.group, crd.Spec.Group)
Expand All @@ -254,7 +272,7 @@ func TestNewCRD(t *testing.T) {
assert.Equal(t, tt.expectedPlural, crd.Spec.Names.Plural)
assert.Equal(t, tt.expectedSingular, crd.Spec.Names.Singular)

assert.Equal(t, extv1.NamespaceScoped, crd.Spec.Scope)
assert.Equal(t, tt.scope, crd.Spec.Scope)

require.Len(t, crd.Spec.Versions, 1)
assert.Equal(t, tt.apiVersion, crd.Spec.Versions[0].Name)
Expand Down