Skip to content
Open
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
131 changes: 131 additions & 0 deletions apis/dev/v1alpha1/project_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,39 @@ const (
DependencyTypeXpkg = "xpkg"
)

// Function source constants.
const (
// FunctionSourceDirectory indicates a function whose source code lives in
// a directory under the project's functions path. The CLI builds the
// runtime image from this source.
FunctionSourceDirectory = "Directory"
// FunctionSourceTarball indicates a function whose runtime image is
// supplied as a pre-built OCI image tarball. The CLI skips building and
// uses the tarball as the runtime image.
FunctionSourceTarball = "Tarball"
)

// Schema language constants. These are the values accepted in
// ProjectSchemas.Languages. Each corresponds to a schema generator in
// internal/schemas/generator.
const (
SchemaLanguageGo = "go"
SchemaLanguageJSON = "json"
SchemaLanguageKCL = "kcl"
SchemaLanguagePython = "python"
)

// SupportedSchemaLanguages returns the set of language identifiers accepted
// in ProjectSchemas.Languages.
func SupportedSchemaLanguages() []string {
return []string{
SchemaLanguageGo,
SchemaLanguageJSON,
SchemaLanguageKCL,
SchemaLanguagePython,
}
}

// Project defines a Crossplane Project, which can be built into a Crossplane
// Configuration package.
//
Expand Down Expand Up @@ -64,11 +97,20 @@ type ProjectSpec struct {
Crossplane *pkgmetav1.CrossplaneConstraints `json:"crossplane,omitempty"`
// Dependencies are built-time and runtime dependencies of the project.
Dependencies []Dependency `json:"dependencies,omitempty"`
// Functions explicitly declares the embedded functions in this project.
// If specified, automatic discovery of functions under the functions path
// is disabled and only the functions listed here will be built and
// packaged. If omitted, all subdirectories of the functions path are
// treated as Directory-source functions and built automatically.
Functions []Function `json:"functions,omitempty"`
// Paths defines the relative paths to various parts of the project.
Paths *ProjectPaths `json:"paths,omitempty"`
// Architectures indicates for which architectures embedded functions should
// be built. If not specified, it defaults to [amd64, arm64].
Architectures []string `json:"architectures,omitempty"`
// Schemas configures language-specific schema generation for the
// project's XRDs and declared dependencies.
Schemas *ProjectSchemas `json:"schemas,omitempty"`
// ImageConfigs configure how images are fetched during
// development. Currently, only rewriting is supported; other options will
// be silently ignored. Note that these configs are for development only;
Expand All @@ -87,6 +129,24 @@ type ProjectPackageMetadata struct {
Readme string `json:"readme,omitempty"`
}

// ProjectSchemas configures language-specific schema generation. Schemas are
// produced both for the project's own XRDs and for its declared dependencies.
type ProjectSchemas struct {
// Languages restricts schema generation to the listed languages.
// Supported values are "go", "json", "kcl", and "python". If not
// specified, schemas are generated for all supported languages.
Languages []string `json:"languages,omitempty"`
}

// GetLanguages returns the configured schema languages, or nil if no Schemas
// config is set. It is safe to call on a nil receiver.
func (s *ProjectSchemas) GetLanguages() []string {
if s == nil {
return nil
}
return s.Languages
}

// ProjectPaths configures the locations of various parts of the project, for
// use at build time. All paths must be relative to the project root.
type ProjectPaths struct {
Expand Down Expand Up @@ -186,3 +246,74 @@ type K8sDependency struct {
// Version is the Kubernetes API version (e.g., "v1.33.0").
Version string `json:"version"`
}

// Function explicitly declares an embedded function in a Crossplane project.
// The Source field is the discriminator that determines which sub-field is
// relevant.
type Function struct {
// Source defines how the function's runtime image is supplied.
// +kubebuilder:validation:Enum=Directory;Tarball
Source string `json:"source"`

// Directory describes a function whose source code lives in a directory
// under the project's functions path. The CLI builds the runtime image
// from this source. Only used when Source is "Directory".
// +optional
Directory *FunctionDirectory `json:"directory,omitempty"`

// Tarball describes a function whose runtime image is supplied as a
// pre-built OCI image tarball. Only used when Source is "Tarball".
// +optional
Tarball *FunctionTarball `json:"tarball,omitempty"`
}

// Name returns the name of the function, derived from the source-specific
// fields.
func (f *Function) Name() string {
switch f.Source {
case FunctionSourceDirectory:
if f.Directory == nil {
return ""
}
return f.Directory.Name
case FunctionSourceTarball:
if f.Tarball == nil {
return ""
}
return f.Tarball.Name
}
return ""
}
Comment on lines +272 to +286
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Guard Name() against a nil receiver for safer API usage

Nice addition overall—could we make this method nil-safe as well (like GetLanguages) so external callers don’t panic on (*Function)(nil).Name()?

Suggested patch
 func (f *Function) Name() string {
+	if f == nil {
+		return ""
+	}
 	switch f.Source {
 	case FunctionSourceDirectory:
 		if f.Directory == nil {
 			return ""
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func (f *Function) Name() string {
switch f.Source {
case FunctionSourceDirectory:
if f.Directory == nil {
return ""
}
return f.Directory.Name
case FunctionSourceTarball:
if f.Tarball == nil {
return ""
}
return f.Tarball.Name
}
return ""
}
func (f *Function) Name() string {
if f == nil {
return ""
}
switch f.Source {
case FunctionSourceDirectory:
if f.Directory == nil {
return ""
}
return f.Directory.Name
case FunctionSourceTarball:
if f.Tarball == nil {
return ""
}
return f.Tarball.Name
}
return ""
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apis/dev/v1alpha1/project_types.go` around lines 272 - 286, The Function.Name
method should be nil-safe like GetLanguages: add a nil-receiver guard at the
start of Function.Name (check if f == nil and return an empty string) so callers
can safely call (*Function)(nil).Name() without panicking; update the
Function.Name method to return "" immediately when f is nil and keep the
existing switch logic unchanged.


// FunctionDirectory describes a function whose source code lives in a
// directory under the project's functions path.
type FunctionDirectory struct {
// Name is the name of the function. It must match the name of a
// subdirectory under the project's functions path.
Name string `json:"name"`
}

// FunctionTarball describes a function whose runtime images are supplied as
// pre-built single-platform OCI image tarballs (as produced by `docker save`,
// Nix's dockerTools.buildImage, Bazel's oci_tarball, ko --tarball, etc.).
//
// The CLI expects one tarball per target architecture, named according to
// the convention `<pathPrefix>-<arch>.tar` or `<pathPrefix>-<arch>.tar.gz`,
// resolved relative to the project root. The CLI prefers the plain `.tar`
// when both are present. For example, with PathPrefix "build/function-b" and
// project architectures [amd64, arm64], the CLI looks for:
//
// build/function-b-amd64.tar (or .tar.gz)
// build/function-b-arm64.tar (or .tar.gz)
type FunctionTarball struct {
// Name is the name of the function. It is used to derive the OCI
// repository for the function's package as `<project-repository>_<name>`.
Name string `json:"name"`

// PathPrefix is the prefix of the per-architecture runtime image
// tarballs, relative to the project root. For each target architecture
// the CLI loads either `<pathPrefix>-<arch>.tar` or
// `<pathPrefix>-<arch>.tar.gz`, preferring the former when both are
// present.
PathPrefix string `json:"pathPrefix"`
}
122 changes: 122 additions & 0 deletions apis/dev/v1alpha1/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ package v1alpha1
import (
"fmt"
"path/filepath"
"slices"

"k8s.io/apimachinery/pkg/util/validation"

"github.com/crossplane/crossplane-runtime/v2/pkg/errors"
)
Expand Down Expand Up @@ -68,16 +71,59 @@ func (s *ProjectSpec) Validate() error {
errs = append(errs, errors.New("architectures must not be empty"))
}

errs = append(errs, s.Schemas.Validate()...)

// Validate dependencies
for i, dep := range s.Dependencies {
if err := dep.Validate(); err != nil {
errs = append(errs, errors.Wrapf(err, "dependency %d", i))
}
}

// Validate functions. Names must be unique across the list, regardless of
// source, since the function name is used to derive both the package
// metadata name and the OCI repository.
seen := make(map[string]int, len(s.Functions))
for i, fn := range s.Functions {
if err := fn.Validate(); err != nil {
errs = append(errs, errors.Wrapf(err, "function %d", i))
continue
}
name := fn.Name()
if first, ok := seen[name]; ok {
errs = append(errs, errors.Errorf("function %d: name %q is already used by function %d", i, name, first))
continue
}
seen[name] = i
}

return errors.Join(errs...)
}

// Validate returns errors for an invalid ProjectSchemas. A nil receiver is
// valid (it means "generate schemas for all languages"); an explicitly empty
// Languages list is rejected because it would disable all schema generation,
// which is almost certainly a mistake.
func (s *ProjectSchemas) Validate() []error {
if s == nil {
return nil
}
if s.Languages == nil {
return nil
}
if len(s.Languages) == 0 {
return []error{errors.New("schemas.languages must not be empty when specified")}
}
supported := SupportedSchemaLanguages()
var errs []error
for i, lang := range s.Languages {
if !slices.Contains(supported, lang) {
errs = append(errs, errors.Errorf("schemas.languages[%d]: %q is not a supported schema language, must be one of %v", i, lang, supported))
}
Comment on lines +121 to +122
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Make new validation errors more user-actionable

Great coverage expansion. Could we rephrase these messages to be less technical and include a clear next step (e.g., what to change and retry), especially around unsupported languages and invalid function names?

Example wording direction
- schemas.languages[%d]: %q is not a supported schema language, must be one of %v
+ schemas.languages[%d]: %q is not supported. Choose one of %v and run the command again.

- name %q is not a valid DNS-1123 subdomain: %v
+ name %q is invalid for a function name. Use lowercase letters, numbers, '-' or '.', then try again.

As per coding guidelines: "CRITICAL: Ensure all error messages are meaningful to end users, not just developers - avoid technical jargon, include context about what the user was trying to do, and suggest next steps when possible."

Also applies to: 269-270, 287-293

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apis/dev/v1alpha1/validate.go` around lines 121 - 122, Update the user-facing
validation messages that are currently appended to errs for unsupported schema
languages and invalid function names: replace technical phrasing like "is not a
supported schema language" with clear, actionable text that states what the user
tried to provide, what valid options are, and exactly what to change and retry
(e.g., "The schema language 'X' is not supported. Please choose one of [A,B,C]
and update schemas.languages to one of these values, then retry."). Apply the
same style to the other related error appends (the ones that reference schema
languages, the variable supported, lang, and the checks for invalid function
names) so each error suggests the corrective action and shows valid examples or
accepted patterns.

}
return errs
}

// Validate validates a dependency.
func (d *Dependency) Validate() error {
var errs []error
Expand Down Expand Up @@ -172,3 +218,79 @@ func (k *K8sDependency) Validate() error {

return errors.Join(errs...)
}

// Validate validates a Function declaration.
func (f *Function) Validate() error {
var errs []error

// Count non-nil sources to enforce that exactly one matches the
// discriminator.
sourceCount := 0
if f.Directory != nil {
sourceCount++
}
if f.Tarball != nil {
sourceCount++
}
if sourceCount != 1 {
errs = append(errs, errors.New("exactly one source (directory or tarball) must be specified"))
}

switch f.Source {
case FunctionSourceDirectory:
if err := f.Directory.Validate(); err != nil {
errs = append(errs, fmt.Errorf("directory: %w", err))
}
case FunctionSourceTarball:
if err := f.Tarball.Validate(); err != nil {
errs = append(errs, fmt.Errorf("tarball: %w", err))
}
Comment on lines +241 to +247
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify wrapper style in validation files.
rg -nP --type=go 'fmt\.Errorf\(".*: %w"' apis/dev/v1alpha1

Repository: crossplane/cli

Length of output: 580


Switch validation error wrapping to crossplane-runtime/pkg/errors.Wrap

apis/dev/v1alpha1/validate.go currently wraps directory/tarball validation errors with fmt.Errorf("...: %w", err). For consistency with Crossplane’s wrapping patterns (this same fmt.Errorf(...: %w, err) approach is used for other sources in this file too), consider switching to errors.Wrap.

Suggested patch
 	case FunctionSourceDirectory:
 		if err := f.Directory.Validate(); err != nil {
-			errs = append(errs, fmt.Errorf("directory: %w", err))
+			errs = append(errs, errors.Wrap(err, "directory"))
 		}
 	case FunctionSourceTarball:
 		if err := f.Tarball.Validate(); err != nil {
-			errs = append(errs, fmt.Errorf("tarball: %w", err))
+			errs = append(errs, errors.Wrap(err, "tarball"))
 		}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apis/dev/v1alpha1/validate.go` around lines 241 - 247, The validation error
wrapping uses fmt.Errorf("...: %w", err) for the Directory and Tarball cases;
replace those with crossplane-runtime's errors.Wrap to match project
conventions: import "github.com/crossplane/crossplane-runtime/pkg/errors" (or
add to existing imports) and change the two append lines to errs = append(errs,
errors.Wrap(err, "directory")) for f.Directory.Validate() and errs =
append(errs, errors.Wrap(err, "tarball")) for f.Tarball.Validate(), leaving the
rest of the switch logic unchanged.

case "":
errs = append(errs, errors.New("source must not be empty"))
default:
errs = append(errs, errors.Errorf("source %q is not supported, must be one of %q or %q", f.Source, FunctionSourceDirectory, FunctionSourceTarball))
}

return errors.Join(errs...)
}

// Validate validates a FunctionDirectory. A nil receiver is invalid; this is
// the failure mode when a function is declared with source Directory but no
// directory field set.
func (d *FunctionDirectory) Validate() error {
if d == nil {
return errors.Errorf("source %q requires the directory field to be set", FunctionSourceDirectory)
}

var errs []error
if d.Name == "" {
errs = append(errs, errors.New("name must not be empty"))
} else if msgs := validation.IsDNS1123Subdomain(d.Name); len(msgs) > 0 {
errs = append(errs, errors.Errorf("name %q is not a valid DNS-1123 subdomain: %v", d.Name, msgs))
}

return errors.Join(errs...)
}

// Validate validates a FunctionTarball. A nil receiver is invalid; this is
// the failure mode when a function is declared with source Tarball but no
// tarball field set.
func (t *FunctionTarball) Validate() error {
if t == nil {
return errors.Errorf("source %q requires the tarball field to be set", FunctionSourceTarball)
}

var errs []error
if t.Name == "" {
errs = append(errs, errors.New("name must not be empty"))
} else if msgs := validation.IsDNS1123Subdomain(t.Name); len(msgs) > 0 {
errs = append(errs, errors.Errorf("name %q is not a valid DNS-1123 subdomain: %v", t.Name, msgs))
}
if t.PathPrefix == "" {
errs = append(errs, errors.New("pathPrefix must not be empty"))
} else if filepath.IsAbs(t.PathPrefix) {
errs = append(errs, errors.New("pathPrefix must be relative"))
}

return errors.Join(errs...)
}
Loading
Loading