Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

spike: kratix test container #60

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
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
33 changes: 21 additions & 12 deletions cmd/add_container.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ func AddContainer(cmd *cobra.Command, args []string) error {
}

pipelineInput := args[0]
containerArgs, err := ParseContainerCmdArgs(pipelineInput)
containerArgs, err := ParseContainerCmdArgs(pipelineInput, 3)
if err != nil {
return err
}
Expand All @@ -68,16 +68,10 @@ func AddContainer(cmd *cobra.Command, args []string) error {
}

func generateWorkflow(c *ContainerCmdArgs, containerName, image string, overwrite bool) error {
if c.Lifecycle != "promise" && c.Lifecycle != "resource" {
return fmt.Errorf("invalid lifecycle: %s, expected one of: promise, resource", c.Lifecycle)
}

if c.Action != "configure" && c.Action != "delete" {
return fmt.Errorf("invalid action: %s, expected one of: configure, delete", c.Action)
}

if c.Pipeline == "" {
return fmt.Errorf("pipeline name cannot be empty")
var err error
err = validateContainerArgs(c)
if err != nil {
return err
}

container := v1alpha1.Container{
Expand All @@ -100,7 +94,6 @@ func generateWorkflow(c *ContainerCmdArgs, containerName, image string, overwrit
var pipelines []v1alpha1.Pipeline
var pipelineIdx = -1
var fileBytes []byte
var err error
if splitFiles && workflowFileFound(filePath) {
fileBytes, err = os.ReadFile(filePath)
if err != nil {
Expand Down Expand Up @@ -198,6 +191,22 @@ func generateWorkflow(c *ContainerCmdArgs, containerName, image string, overwrit
return nil
}

func validateContainerArgs(c *ContainerCmdArgs) error {
if c.Lifecycle != "promise" && c.Lifecycle != "resource" {
return fmt.Errorf("invalid lifecycle: %s, expected one of: promise, resource", c.Lifecycle)
}

if c.Action != "configure" && c.Action != "delete" {
return fmt.Errorf("invalid action: %s, expected one of: configure, delete", c.Action)
}

if c.Pipeline == "" {
return fmt.Errorf("pipeline name cannot be empty")
}

return nil
}

func generateContainerName(image string) string {
nameAndVersion := strings.ReplaceAll(image, "/", "-")
return strings.Split(nameAndVersion, ":")[0]
Expand Down
3 changes: 2 additions & 1 deletion cmd/build_container.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ type ContainerCmdArgs struct {
Lifecycle string
Action string
Pipeline string
Container string
}

type BuildContainerOptions struct {
Expand Down Expand Up @@ -115,7 +116,7 @@ func BuildContainer(cmd *cobra.Command, args []string) error {
}

for _, container := range containersToBuild {
containerArgs, err := ParseContainerCmdArgs(container)
containerArgs, err := ParseContainerCmdArgs(container, 3)
if err != nil {
return err
}
Expand Down
21 changes: 16 additions & 5 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,15 +64,26 @@ func templateFiles(templates embed.FS, outputDir string, filesToTemplate map[str
return nil
}

func ParseContainerCmdArgs(containerPath string) (*ContainerCmdArgs, error) {
func ParseContainerCmdArgs(containerPath string, expectedArgCount int) (*ContainerCmdArgs, error) {
parts := strings.Split(containerPath, "/")
if len(parts) != 3 {
return nil, fmt.Errorf("invalid pipeline format: %s, expected format: LIFECYCLE/ACTION/PIPELINE-NAME", containerPath)

if len(parts) != expectedArgCount {
expectedFormat := "LIFECYCLE/ACTION/PIPELINE-NAME"
if expectedArgCount == 4 {
expectedFormat += "/CONTAINER-NAME"
}
return nil, fmt.Errorf("invalid pipeline format: %s, expected format: %s", containerPath, expectedFormat)
}

return &ContainerCmdArgs{
cmdArgs := &ContainerCmdArgs{
Lifecycle: parts[0],
Action: parts[1],
Pipeline: parts[2],
}, nil
}

if expectedArgCount == 4 {
cmdArgs.Container = parts[3]
}

return cmdArgs, nil
}
79 changes: 79 additions & 0 deletions cmd/templates/test/README.md.tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Test directory for container image `{{ .RawImageName }}`

This directory contains testcases for the Kratix container image `{{ .RawImageName }}`.

## Directory structure

Initially, the directory structure is as follows:

```
{{ .Directory }}/
test_{{ .FormattedImageName }}/
```

As testcases are added, the directory structure is updated as follows:

```
{{ .Directory }}/
test_{{ .FormattedImageName }}/
resource/
configure/
test_one/
before/
input/
output/
metadata/
after/
input/
output/
metadata/
...
promise/
configure/
test_two/
before/
input/
output/
metadata/
after/
input/
output/
metadata/
...
...
```

The `resource/` directory contains testcases for resource workflows, while the `promise/`
directory contains testcases for the promise workflows.

Each testcase directory contains the following subdirectories:
- `before/` - contains the **prior** state input, output and metadata directories **before** the container is run
- `after/` - contains the **expected** state of the input, output and metadata directories **after** the container is run

## Adding testcases

To add a new testcase, use the following command:

```
kratix test container add --image {{ .RawImageName }} <testcase-name>
```

To add a new testcase with an input object, use the following command:

```
kratix test container add --image {{ .RawImageName }} <testcase-name> --input-object <path-to-input-object>
```

## Running testcases

To run the testcases, use the following command:

```
kratix test container run --image {{ .RawImageName }}
```

To run specific testcases, use the following command:

```
kratix test container run --image {{ .RawImageName }} --testcases <testcase1>,<testcase2>,<testcase3>
```
17 changes: 17 additions & 0 deletions cmd/test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package cmd

import (
"github.com/spf13/cobra"
)

var testCmd = &cobra.Command{
Use: "test",
Short: "Command to test Kratix resources",
Long: "Command to test Kratix resources",
}

func init() {
rootCmd.AddCommand(testCmd)

testCmd.PersistentFlags().StringVarP(&outputDir, "dir", "d", ".", "The working directory containing the bootstrapped Promise")
}
20 changes: 20 additions & 0 deletions cmd/test_container.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package cmd

import (
"github.com/spf13/cobra"
)

var testContainerCmd = &cobra.Command{
Use: "container",
Short: "Command to test Kratix container images",
Long: "Command to test Kratix container images",
}

var (
testImage string
testcaseDir string
)

func init() {
testCmd.AddCommand(testContainerCmd)
}
154 changes: 154 additions & 0 deletions cmd/test_container_add.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package cmd

import (
"fmt"
"io"
"os"
"path"
"regexp"

"github.com/spf13/cobra"
)

var testContainerAddCmd = &cobra.Command{
Use: "add LIFECYCLE/ACTION/PIPELINE-NAME/CONTAINER-NAME --testcase TESTCASE-NAME",
Short: "Adds a new testcase for the Kratix container image",
Example: ` # add a container testcase directory for a given image
> kratix test container add resource/configure/instance/syntasso-postgres-resource \
--testcase handles_empty_metadata`,
RunE: TestContainerAdd,
Args: cobra.ExactArgs(1),
}

var (
inputObject, testcaseName string
)

func init() {
testContainerCmd.AddCommand(testContainerAddCmd)

testContainerAddCmd.Flags().StringVarP(&inputObject, "input-object", "o", "", "The path to the input object to use for this testcase")
testContainerAddCmd.Flags().StringVarP(&testcaseName, "testcase", "t", "", "The name of the testcase to add")

testContainerAddCmd.MarkFlagRequired("testcase")
}

func TestContainerAdd(cmd *cobra.Command, args []string) error {
var err error

pipelineInput := args[0]
containerArgs, err := ParseContainerCmdArgs(pipelineInput, 4)
if err != nil {
return err
}

imageTestDir, err := getImageTestDir(containerArgs)
if err != nil {
return err
}

var testcaseDir string
if testcaseDir, err = validateTestcaseName(testcaseName, imageTestDir); err != nil {
return err
}

beforeDir := path.Join(testcaseDir, "before")
afterDir := path.Join(testcaseDir, "after")

dirsToCreate := []string{
path.Join(beforeDir, "metadata"),
path.Join(beforeDir, "input"),
path.Join(beforeDir, "output"),
path.Join(afterDir, "metadata"),
path.Join(afterDir, "input"),
path.Join(afterDir, "output"),
}

for _, dir := range dirsToCreate {
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
}

if inputObject != "" {
err := copyFile(inputObject, path.Join(beforeDir, "input", "object.yaml"))
if err != nil {
return err
}
} else {
var objectFile string
if containerArgs.Lifecycle == "resource" {
objectFile = path.Join(dir, "example-resource.yaml")
} else {
objectFile = path.Join(dir, "promise.yaml")
}
fmt.Printf("No input object provided, copying %s\n", objectFile)
err = copyFile(objectFile, path.Join(beforeDir, "input", "object.yaml"))
if err != nil {
return err
}
}

fmt.Printf("Testcase %s added successfully! ✅\n\n", testcaseName)
fmt.Printf("Customise your testcase by editing the files in %s\n", testcaseDir)

return nil
}

func copyFile(src, dst string) error {
sourceFile, err := os.Open(src)
if err != nil {
return err
}
defer sourceFile.Close()

destinationFile, err := os.Create(dst)
if err != nil {
return err
}
defer destinationFile.Close()

_, err = io.Copy(destinationFile, sourceFile)
if err != nil {
return err
}

err = destinationFile.Sync()
if err != nil {
return err
}

return nil
}

func validateTestcaseName(testcaseName, imageDir string) (string, error) {
testcaseDir := path.Join(imageDir, testcaseName)

if testcaseName == "" {
return "", fmt.Errorf("testcase name cannot be empty")
}

if _, err := os.Stat(testcaseDir); err == nil {
return "", fmt.Errorf("testcase directory already exists: %s", testcaseDir)
}

if !regexp.MustCompile(`^[a-zA-Z0-9_-]+$`).MatchString(testcaseName) {
return "", fmt.Errorf("invalid testcase name: %s, only alphanumeric characters, hyphens, and underscores are allowed", testcaseName)
}

return testcaseDir, nil
}

func getImageTestDir(containerArgs *ContainerCmdArgs) (string, error) {
containerDir := getContainerDir(containerArgs)

if _, err := os.Stat(containerDir); os.IsNotExist(err) {
return "", fmt.Errorf("container directory does not exist: %s", containerDir)
}

return path.Join(containerDir, "test"), nil
}

func getContainerDir(containerArgs *ContainerCmdArgs) string {
return path.Join(outputDir, "workflows", containerArgs.Lifecycle, containerArgs.Action, containerArgs.Pipeline, containerArgs.Container)
}
Loading