Skip to content
Merged
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
4 changes: 3 additions & 1 deletion apis/dev/v1alpha1/project_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const (
SchemaLanguageJSON = "json"
SchemaLanguageKCL = "kcl"
SchemaLanguagePython = "python"
SchemaLanguageRust = "rust"
)

// SupportedSchemaLanguages returns the set of language identifiers accepted
Expand All @@ -63,6 +64,7 @@ func SupportedSchemaLanguages() []string {
SchemaLanguageJSON,
SchemaLanguageKCL,
SchemaLanguagePython,
SchemaLanguageRust,
Comment thread
adamwg marked this conversation as resolved.
}
}

Expand Down Expand Up @@ -133,7 +135,7 @@ type ProjectPackageMetadata struct {
// 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
// Supported values are "go", "json", "kcl", "python", and "rust". If not
// specified, schemas are generated for all supported languages.
Languages []string `json:"languages,omitempty"`
}
Expand Down
85 changes: 63 additions & 22 deletions cmd/crossplane/function/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ var (
kclTemplates embed.FS
//go:embed all:templates/python
pythonTemplates embed.FS
// The rust template contains a .gitignore, which embed skips without all.
//go:embed all:templates/rust
rustTemplates embed.FS
//go:embed templates/go-templating/*
goTemplatingTemplates embed.FS

Expand All @@ -70,7 +73,7 @@ var (
type generateCmd struct {
Name string `arg:"" help:"Name of the function to generate. Must be a valid DNS-1035 label."`
PipelinePath string `arg:"" help:"Path to a Composition YAML file to add a pipeline step to." optional:""`
Language string `default:"go-templating" enum:"go,go-templating,kcl,python" help:"Language to use for the function." short:"l"`
Language string `default:"go-templating" enum:"go,go-templating,kcl,python,rust" help:"Language to use for the function." short:"l"`
ProjectFile string `default:"${project_file}" help:"Path to project definition file." short:"f"`

projFS afero.Fs
Expand Down Expand Up @@ -180,6 +183,7 @@ func (c *generateCmd) Run(sp terminal.SpinnerPrinter, cfg *config.Config) error
"go-templating": c.generateGoTemplatingFiles,
"kcl": c.generateKCLFiles,
"python": c.generatePythonFiles,
"rust": c.generateRustFiles,
}

generator, ok := generators[c.Language]
Expand Down Expand Up @@ -297,48 +301,85 @@ func (c *generateCmd) generateKCLFiles(fs afero.Fs) error {
return renderTemplates(fs, tmpls, tmplData)
}

type pythonTemplateData struct {
HasSchemas bool
SchemasPath string
}

func (c *generateCmd) generatePythonFiles(targetFS afero.Fs) error {
hasSchemas, err := afero.DirExists(c.schemasFS, "python")
// schemasInfo reports whether the project has generated models for a language
// and the path at which a function of this project depends on them, relative
// to the function's directory. A schemas directory that exists but is empty
// counts as no models.
func (c *generateCmd) schemasInfo(lang string) (bool, string, error) {
hasSchemas, err := afero.DirExists(c.schemasFS, lang)
if err != nil {
return errors.Wrap(err, "cannot inspect python schemas directory")
return false, "", errors.Wrapf(err, "cannot inspect %s schemas directory", lang)
}
if hasSchemas {
entries, err := afero.ReadDir(c.schemasFS, "python")
entries, err := afero.ReadDir(c.schemasFS, lang)
if err != nil {
return errors.Wrap(err, "cannot read python schemas directory")
return false, "", errors.Wrapf(err, "cannot read %s schemas directory", lang)
}
hasSchemas = len(entries) > 0
}

// Compute the relative path from the function dir to schemas/python/.
fnDir := filepath.Join("/", c.proj.Spec.Paths.Functions, c.Name)
relRoot, err := filepath.Rel(fnDir, "/")
if err != nil {
return errors.Wrap(err, "cannot determine path to schemas directory")
return false, "", errors.Wrap(err, "cannot determine path to schemas directory")
}

return hasSchemas, filepath.ToSlash(filepath.Join(relRoot, c.proj.Spec.Paths.Schemas, lang)), nil
}

// renderTemplateTree renders the templates in root into targetFS, and the
// templates in root's sub directory into that directory of targetFS.
// template.ParseFS does not walk subdirectories, so a scaffold laid out on two
// levels is rendered in two passes.
func renderTemplateTree(targetFS afero.Fs, templates fs.FS, root, sub string, data any) error {
tmpls := template.Must(template.ParseFS(templates, path.Join(root, "*.*")))
if err := renderTemplates(targetFS, tmpls, data); err != nil {
return err
}

if err := targetFS.Mkdir(sub, 0o755); err != nil {
return errors.Wrapf(err, "cannot create %s directory", sub)
}
tmpls = template.Must(template.ParseFS(templates, path.Join(root, sub, "*.*")))
return renderTemplates(afero.NewBasePathFs(targetFS, sub), tmpls, data)
}

type pythonTemplateData struct {
HasSchemas bool
SchemasPath string
}

func (c *generateCmd) generatePythonFiles(targetFS afero.Fs) error {
hasSchemas, schemasPath, err := c.schemasInfo("python")
if err != nil {
return err
}
schemasPath := filepath.ToSlash(filepath.Join(relRoot, c.proj.Spec.Paths.Schemas, "python"))

// template.ParseFS doesn't handle subdirectories, so we need to template
// the top-level directory and the 'function' sub-directory separately.
data := pythonTemplateData{
HasSchemas: hasSchemas,
SchemasPath: schemasPath,
}
tmpls := template.Must(template.ParseFS(pythonTemplates, "templates/python/*.*"))
if err := renderTemplates(targetFS, tmpls, data); err != nil {
return renderTemplateTree(targetFS, pythonTemplates, "templates/python", "function", data)
}

type rustTemplateData struct {
Name string
HasSchemas bool
SchemasPath string
}

func (c *generateCmd) generateRustFiles(targetFS afero.Fs) error {
hasSchemas, schemasPath, err := c.schemasInfo("rust")
if err != nil {
return err
}

if err := targetFS.Mkdir("function", 0o755); err != nil {
return errors.Wrap(err, "cannot create function directory")
data := rustTemplateData{
Name: c.Name,
HasSchemas: hasSchemas,
SchemasPath: schemasPath,
}
tmpls = template.Must(template.ParseFS(pythonTemplates, "templates/python/function/*.*"))
return renderTemplates(afero.NewBasePathFs(targetFS, "function"), tmpls, data)
return renderTemplateTree(targetFS, rustTemplates, "templates/rust", "src", data)
}

type goTemplateData struct {
Expand Down
167 changes: 167 additions & 0 deletions cmd/crossplane/function/generate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,104 @@ func TestGenerateKCLFiles(t *testing.T) {
}
}

func TestSchemasInfo(t *testing.T) {
cases := map[string]struct {
reason string
args struct {
lang string
seedSchemas map[string][]byte
seedSchemaDirs []string
paths v1alpha1.ProjectPaths
}
want struct {
hasSchemas bool
schemasPath string
}
}{
"NoSchemasDirectory": {
reason: "A project without models for the language has no schemas, but the path is computed all the same.",
args: struct {
lang string
seedSchemas map[string][]byte
seedSchemaDirs []string
paths v1alpha1.ProjectPaths
}{lang: "rust", paths: v1alpha1.ProjectPaths{Functions: "functions", Schemas: "schemas"}},
want: struct {
hasSchemas bool
schemasPath string
}{hasSchemas: false, schemasPath: "../../schemas/rust"},
},
"EmptySchemasDirectory": {
reason: "A schemas directory with nothing in it counts as no models.",
args: struct {
lang string
seedSchemas map[string][]byte
seedSchemaDirs []string
paths v1alpha1.ProjectPaths
}{lang: "python", seedSchemaDirs: []string{"python"}, paths: v1alpha1.ProjectPaths{Functions: "functions", Schemas: "schemas"}},
want: struct {
hasSchemas bool
schemasPath string
}{hasSchemas: false, schemasPath: "../../schemas/python"},
},
"GeneratedModels": {
reason: "A populated schemas directory means the function can depend on the models.",
args: struct {
lang string
seedSchemas map[string][]byte
seedSchemaDirs []string
paths v1alpha1.ProjectPaths
}{lang: "rust", seedSchemas: map[string][]byte{"rust/Cargo.toml": nil}, paths: v1alpha1.ProjectPaths{Functions: "functions", Schemas: "schemas"}},
want: struct {
hasSchemas bool
schemasPath string
}{hasSchemas: true, schemasPath: "../../schemas/rust"},
},
"ProjectPaths": {
reason: "The path follows the project's own functions and schemas directories.",
args: struct {
lang string
seedSchemas map[string][]byte
seedSchemaDirs []string
paths v1alpha1.ProjectPaths
}{lang: "python", seedSchemas: map[string][]byte{"python/models/__init__.py": nil}, paths: v1alpha1.ProjectPaths{Functions: "src/functions", Schemas: "generated/models"}},
want: struct {
hasSchemas bool
schemasPath string
}{hasSchemas: true, schemasPath: "../../../generated/models/python"},
},
}

for name, tc := range cases {
t.Run(name, func(t *testing.T) {
schemasFS := seedFS(t, tc.args.seedSchemas)
for _, dir := range tc.args.seedSchemaDirs {
if err := schemasFS.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
}
paths := tc.args.paths
c := &generateCmd{
Name: "my-func",
schemasFS: schemasFS,
proj: &v1alpha1.Project{Spec: v1alpha1.ProjectSpec{Paths: &paths}},
}

hasSchemas, schemasPath, err := c.schemasInfo(tc.args.lang)
if err != nil {
t.Fatalf("\n%s\nschemasInfo(%q): %v", tc.reason, tc.args.lang, err)
}
got := struct {
hasSchemas bool
schemasPath string
}{hasSchemas: hasSchemas, schemasPath: schemasPath}
if diff := cmp.Diff(tc.want, got, cmp.AllowUnexported(got)); diff != "" {
t.Errorf("\n%s\nschemasInfo(%q): -want, +got:\n%s", tc.reason, tc.args.lang, diff)
}
})
}
}

func TestGeneratePythonFiles(t *testing.T) {
cases := map[string]struct {
seedSchemas map[string][]byte
Expand Down Expand Up @@ -233,6 +331,75 @@ func TestGeneratePythonFiles(t *testing.T) {
}
}

func TestGenerateRustFiles(t *testing.T) {
cases := map[string]struct {
seedSchemas map[string][]byte
seedSchemaDirs []string
wantFiles []string
wantContains map[string][]byte
wantNotContains map[string][]byte
}{
"NoSchemas": {
wantFiles: []string{
".gitignore",
"Cargo.toml",
"README.md",
"rust-toolchain.toml",
"src/main.rs",
"src/function.rs",
},
wantContains: map[string][]byte{
"Cargo.toml": []byte(`name = "my-func"`),
"README.md": []byte("# my-func"),
},
wantNotContains: map[string][]byte{
"Cargo.toml": []byte("crossplane-models"),
"README.md": []byte("crossplane-models"),
"src/function.rs": []byte("crossplane_models"),
},
},
"WithSchemas": {
seedSchemas: map[string][]byte{
"rust/Cargo.toml": nil,
},
wantContains: map[string][]byte{
"Cargo.toml": []byte(`crossplane-models = { path = "../../schemas/rust" }`),
"README.md": []byte("`../../schemas/rust`"),
"src/function.rs": []byte("use crossplane_models::"),
},
},
"EmptySchemasDirectory": {
seedSchemaDirs: []string{"rust"},
wantNotContains: map[string][]byte{
"Cargo.toml": []byte("crossplane-models"),
},
},
}

for name, tc := range cases {
t.Run(name, func(t *testing.T) {
schemasFS := seedFS(t, tc.seedSchemas)
for _, dir := range tc.seedSchemaDirs {
if err := schemasFS.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
}

c := &generateCmd{
Name: "my-func",
schemasFS: schemasFS,
proj: testProject(),
}
fs := afero.NewMemMapFs()
if err := c.generateRustFiles(fs); err != nil {
t.Fatal(err)
}
assertFiles(t, fs, tc.wantFiles)
assertContains(t, fs, tc.wantContains, tc.wantNotContains)
})
}
}

func TestGenerateGoFiles(t *testing.T) {
cases := map[string]struct {
seedSchemas map[string][]byte
Expand Down
7 changes: 7 additions & 0 deletions cmd/crossplane/function/help/generate.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ The following are valid arguments to the `--language` / `-l` flag:
- `go`
- `kcl`
- `python`
- `rust`

## Examples

Expand All @@ -27,6 +28,12 @@ Create a Python function in `functions/fn2`:
crossplane function generate fn2 --language python
```

Create a Rust function in `functions/fn3`:

```shell
crossplane function generate fn3 --language rust
```

Create a Go function in `functions/compose-cluster` and add it as a pipeline
step in the given Composition:

Expand Down
1 change: 1 addition & 0 deletions cmd/crossplane/function/templates/rust/.gitignore.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
target/
Loading
Loading