Skip to content
generated from SAP/repository-template

Crossplane Provider Export CLI framework for resource data extraction

License

Notifications You must be signed in to change notification settings

SAP/xp-clifford

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

19 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

REUSE status

xp-clifford

About this project

xp-clifford (Crossplane CLI Framework for Resource Data Extraction) is a Go module that facilitates the development of CLI tools for exporting definitions of external resources in the format of specific Crossplane provider managed resource definitions.

The resource definitions can then be imported into Crossplane using the standard import procedure. It is recommended to check the generated definitions for comments, before doing the import. See also Exporting commented out resources.

Requirements

xp-clifford is a Go module and requires only a working Go development environment.

Setup

To install the xp-clifford Go module, run the following command:

go get github.com/SAP/xp-clifford

Support, Feedback, Contributing

This project is open to feature requests/suggestions, bug reports etc. via GitHub issues. Contribution and feedback are encouraged and always welcome. For more information about how to contribute, the project structure, as well as additional contribution information, see our Contribution Guidelines.

Security / Disclosure

If you find any bug that may be a security problem, please follow our instructions at in our security policy on how to report it. Please do not create GitHub issues for security-related doubts or problems.

Code of Conduct

We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone. By participating in this project, you agree to abide by its Code of Conduct at all times.

Licensing

Copyright 2026 SAP SE or an SAP affiliate company and xp-clifford contributors. Please see our LICENSE for copyright and license information. Detailed information including third-party components and their licensing/copyright information is available via the REUSE tool.

Examples

These examples demonstrate the basic features of xp-clifford and build progressively on one another.

The simplest CLI tool

The simplest CLI tool you can create using xp-clifford looks like this:

package main

import (
	"github.com/SAP/xp-clifford/cli"
	_ "github.com/SAP/xp-clifford/cli/export"
)

func main() {
	cli.Configuration.ShortName = "test"
	cli.Configuration.ObservedSystem = "test system"
	cli.Execute()
}

Let's examine the import section.

import (
	"github.com/SAP/xp-clifford/cli"
	_ "github.com/SAP/xp-clifford/cli/export"
)

Two packages must be imported:

  • github.com/SAP/xp-clifford/cli
  • github.com/SAP/xp-clifford/cli/export

The cli/export package is imported for side effects only.

The main function looks like this:

func main() {
	cli.Configuration.ShortName = "test"
	cli.Configuration.ObservedSystem = "test system"
	cli.Execute()
}

The Configuration variable from the cli package is used to set specific parameters for the built CLI tool. Here we set the ShortName and ObservedSystem fields.

These fields have the following meanings:

  • ShortName: The abbreviated name of the observed system without spaces, such as "cf" for the CloudFoundry provider
  • ObservedSystem: The full name of the external system, which may contain spaces, such as "Cloud Foundry"

At the end of the main function, we invoke the Execute function from the cli package to start the CLI.

When we run this basic example, it generates the following output:

go run ./examples/basic/main.go
test system exporting tool is a CLI tool for exporting existing resources as Crossplane managed resources

Usage:
  test-exporter [command]

Available Commands:
  completion  Generate the autocompletion script for the specified shell
  export      Export test system resources
  help        Help about any command

Flags:
  -c, --config string   Configuration file
  -h, --help            help for test-exporter
  -v, --verbose         Verbose output

Use "test-exporter [command] --help" for more information about a command.

If you try running the CLI tool with the export subcommand, you get an error message.

go run ./examples/basic/main.go export
ERRO export subcommand is not set

Exporting

Basic export subcommand

The export subcommand is mandatory, but you are responsible for implementing the code that executes when it is invoked.

The code must be defined as a function with the following signature:

func(ctx context.Context, events export.EventHandler) error

The ctx parameter can be used to handle interruptions, such as when the user presses Ctrl-C. In such cases, the Done() channel of the context is closed.

The events parameter from the export package provides three methods for communicating progress to the CLI framework:

  • Warn: Indicates a recoverable error that does not terminate the export operation.
  • Resource: Indicates a processed managed resource to be printed or stored by the export operation.
  • Stop: Indicates that exporting has finished. No more Warn or Resource calls should be made after Stop.

A fatal error can be indicated by returning a non-nil error value.

A simple implementation of an export logic function looks like this:

func exportLogic(_ context.Context, events export.EventHandler) error {
	slog.Info("export command invoked")
	events.Stop()
	return nil
}

This implementation prints a log message, stops the event handler, and returns a nil error value.

You can configure the business logic function using the SetCommand function from the export package:

export.SetCommand(exportLogic)

A complete example is:

package main

import (
	"context"
	"log/slog"

	"github.com/SAP/xp-clifford/cli"
	"github.com/SAP/xp-clifford/cli/export"
)

func exportLogic(_ context.Context, events export.EventHandler) error {
	slog.Info("export command invoked")
	events.Stop()
	return nil
}

func main() {
	cli.Configuration.ShortName = "test"
	cli.Configuration.ObservedSystem = "test system"
	export.SetCommand(exportLogic)
	cli.Execute()
}

To invoke the export subcommand:

go run ./examples/export/main.go export
INFO export command invoked

Exporting a resource

In the previous example, we created a proper export subcommand, but didn't actually export any resources.

To export a resource, use the Resource method of the EventHandler type:

Resource(res resource.Object) // Object interface defined in
                              // github.com/crossplane/crossplane-runtime/pkg/resource

This method accepts a resource.Object, an interface implemented by all Crossplane resources.

Let's update our exportLogic function to export a single resource. For simplicity, we'll use the Unstructured type from k8s.io/apimachinery/pkg/apis/meta/v1/unstructured, which implements the resource.Object interface:

func exportLogic(_ context.Context, events export.EventHandler) error {
	slog.Info("export command invoked")

	res := &unstructured.Unstructured{
	  Object: map[string]interface{}{
	      "user": "test-user",
	      "password": "secret",
	  },
	}
	events.Resource(res)

	events.Stop()
	return nil
}

The complete example now looks like this:

package main

import (
	"context"
	"log/slog"

	"github.com/SAP/xp-clifford/cli"
	"github.com/SAP/xp-clifford/cli/export"

	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)

func exportLogic(_ context.Context, events export.EventHandler) error {
	slog.Info("export command invoked")

	res := &unstructured.Unstructured{
	  Object: map[string]interface{}{
	      "user": "test-user",
	      "password": "secret",
	  },
	}
	events.Resource(res)

	events.Stop()
	return nil
}

func main() {
	cli.Configuration.ShortName = "test"
	cli.Configuration.ObservedSystem = "test system"
	export.SetCommand(exportLogic)
	cli.Execute()
}

Running this example produces the following output:

go run ./examples/exportsingle/main.go export
INFO export command invoked


    ---
    password: secret
    user: test-user
    ...

The exported resource is printed to the console. You can redirect the output to a file using the -o flag:

go run ./examples/exportsingle/main.go export -o output.yaml
INFO export command invoked
INFO Writing output to file output=output.yaml

The output.yaml file contains the exported resource object:

cat output.yaml
---
password: secret
user: test-user
...

Displaying warnings

During the processing and conversion of external resources, the export logic may encounter unexpected situations such as unstable network connections, authentication issues, or unknown resource configurations.

These events should not halt the resource export process, but they must be reported to the user.

You can report warnings using the Warn method of the EventHandler type:

Warn(err error)

The Warn method supports erratt.Error types. The erratt.Error type is demonstrated in 6.3.

Let's add a warning message to our exportLogic function:

func exportLogic(_ context.Context, events export.EventHandler) error {
	slog.Info("export command invoked")

	events.Warn(errors.New("generating test resource"))

	res := &unstructured.Unstructured{
	  Object: map[string]interface{}{
	      "user": "test-user-with-warning",
	      "password": "secret",
	  },
	}
	events.Resource(res)

	events.Stop()
	return nil
}

The complete example now looks like this:

package main

import (
	"context"
	"errors"
	"log/slog"

	"github.com/SAP/xp-clifford/cli"
	"github.com/SAP/xp-clifford/cli/export"

	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)

func exportLogic(_ context.Context, events export.EventHandler) error {
	slog.Info("export command invoked")

	events.Warn(errors.New("generating test resource"))

	res := &unstructured.Unstructured{
	  Object: map[string]interface{}{
	      "user": "test-user-with-warning",
	      "password": "secret",
	  },
	}
	events.Resource(res)

	events.Stop()
	return nil
}

func main() {
	cli.Configuration.ShortName = "test"
	cli.Configuration.ObservedSystem = "test system"
	export.SetCommand(exportLogic)
	cli.Execute()
}

Running this example displays the warning message in the logs:

go run ./examples/exportwarn/main.go export
INFO export command invoked
WARN generating test resource


    ---
    password: secret
    user: test-user-with-warning
    ...

When redirecting the output to a file, the warning appears on screen but not in the file:

go run ./examples/exportwarn/main.go export -o output.yaml
INFO export command invoked
WARN generating test resource
INFO Writing output to file output=output.yaml
cat output.yaml
---
password: secret
user: test-user-with-warning
...

Exporting commented out resources

During the export process, problems may prevent generation of valid managed resource definitions, or the definitions produced may be unsafe to apply.

You have two options for handling problematic resources: omit them from the output entirely, or include them but commented out. Commenting out invalid or unsafe resource definitions ensures users won't encounter problems when applying the export tool output.

xp-clifford comments out resources that implement the yaml.CommentedYAML interface, which defines a single method:

type CommentedYAML interface {
	Comment() (string, bool)
}

The bool return value indicates whether the managed resource should be commented out. The string return value provides a message that will be printed as part of the comment.

Since Crossplane managed resources don't typically implement the CommentedYAML interface, you can wrap them to add this functionality.

The yaml.NewResourceWithComment function handles this wrapping for you:

func NewResourceWithComment(res resource.Object) *yaml.ResourceWithComment

The *yaml.ResourceWithComment type wraps res and implements the yaml.CommentedYAML interface. It also provides helper methods:

  • SetComment: sets the comment string
  • AddComment: appends to the comment string

The following example demonstrates the commenting feature:

func exportLogic(_ context.Context, events export.EventHandler) error {
	slog.Info("export command invoked")

	res := &unstructured.Unstructured{
	  Object: map[string]interface{}{
	      "user": "test-user-commented",
	      "password": "secret",
	  },
	}

	commentedResource := yaml.NewResourceWithComment(res)
	commentedResource.SetComment("don't deploy it, this is a test resource!")
	events.Resource(commentedResource)

	events.Stop()
	return nil
}

Here is the complete example:

package main

import (
	"context"
	"log/slog"

	"github.com/SAP/xp-clifford/cli"
	"github.com/SAP/xp-clifford/cli/export"
	"github.com/SAP/xp-clifford/yaml"

	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)

func exportLogic(_ context.Context, events export.EventHandler) error {
	slog.Info("export command invoked")

	res := &unstructured.Unstructured{
	  Object: map[string]interface{}{
	      "user": "test-user-commented",
	      "password": "secret",
	  },
	}

	commentedResource := yaml.NewResourceWithComment(res)
	commentedResource.SetComment("don't deploy it, this is a test resource!")
	events.Resource(commentedResource)

	events.Stop()
	return nil
}

func main() {
	cli.Configuration.ShortName = "test"
	cli.Configuration.ObservedSystem = "test system"
	export.SetCommand(exportLogic)
	cli.Execute()
}

Running this example displays the commented resource with its comment message:

go run ./examples/exportcomment/main.go export
INFO export command invoked


    #
    # don't deploy it, this is a test resource!
    #
    # ---
    # password: secret
    # user: test-user-commented
    # ...

This works equally well when redirecting output to a file using the -o flag.

Errors with attributes

The erratt package implements a new error type designed for efficient use with the Warn method of EventHandler.

The erratt.Error type implements the standard Go error interface. Additionally, it can be extended with slog package compatible key-value pairs used for structured logging. The erratt.Error type also supports wrapping Go error values. When an erratt.Error is wrapped, its attributes are preserved.

You can create a simple erratt.Error using the erratt.New function:

err := erratt.New("something went wrong")
errWithAttrs1 := erratt.New("error opening file", "filename", filename)
errWithAttrs2 := erratt.New("authentication failed", "username", user, "password", pass)

In this example, errWithAttrs1 and errWithAttrs2 include additional attributes.

You can wrap an existing error value using the erratt.Errorf function:

err := callFunction()
errWrapped := erratt.Errorf("unexpected error occurred: %w", err)

You can extend an erratt.Error value with attributes using the With method:

err := connectToServer(url, username, password)
errWrapped := erratt.Errorf("cannot connect to server: %w", err).
	With("url", url, "username", username, "password", password)

For a complete example, consider two functions that return erratt.Error values and demonstrate wrapping:

func auth() erratt.Error {
	return erratt.New("authentication failure",
		"username", "test-user",
		"password", "test-password",
	)
}

func connect() erratt.Error {
	err := auth()
	if err != nil {
		return erratt.Errorf("connect failed: %w", err).
			With("url", "https://example.com")
	}
	return nil
}

The auth function returns an erratt.Error value with username and password attributes.

The exportLogic function calls connect and handles the error:

func exportLogic(_ context.Context, events export.EventHandler) error {
	slog.Info("export command invoked")

	err := connect()

	events.Stop()
	return err
}

Here is the complete example:

package main

import (
	"context"
	"log/slog"

	"github.com/SAP/xp-clifford/cli"
	"github.com/SAP/xp-clifford/cli/export"
	"github.com/SAP/xp-clifford/erratt"
)

func auth() erratt.Error {
	return erratt.New("authentication failure",
		"username", "test-user",
		"password", "test-password",
	)
}

func connect() erratt.Error {
	err := auth()
	if err != nil {
		return erratt.Errorf("connect failed: %w", err).
			With("url", "https://example.com")
	}
	return nil
}

func exportLogic(_ context.Context, events export.EventHandler) error {
	slog.Info("export command invoked")

	err := connect()

	events.Stop()
	return err
}

func main() {
	cli.Configuration.ShortName = "test"
	cli.Configuration.ObservedSystem = "test system"
	export.SetCommand(exportLogic)
	cli.Execute()
}

Running this code produces the following output:

go run ./examples/erratt/main.go export
INFO export command invoked
ERRO connect failed: authentication failure url=https://example.com username=test-user password=test-password

The error message appears on the console with all attributes displayed.

The EventHandler.Warn method handles erratt.Error values in the same manner.

Widgets

xp-clifford provides several CLI widgets to facilitate the interaction with the user.

Note that for the widgets to run, the CLI tool must be executed in an interactive terminal. This is not always the case by default, when running or debugging an application within an IDE (like GoLand) using a Run Configuration. In such cases, make sure to configure the Run Configuration appropriately. Specifically for GoLand it can be done by selecting Emulate terminal in output console.

TextInput widget

The TextInput widget prompts the user for a single line of text. Create a TextInput widget using the TextInput function from the widget package.

func TextInput(ctx context.Context, title, placeholder string, sensitive bool) (string, error)

Parameters:

  • ctx: Go context for handling Ctrl-C interrupts or timeouts
  • title: The prompt question displayed to the user
  • placeholder: Placeholder text shown when the input is empty
  • sensitive: When true, masks typed characters (useful for passwords)

The following example demonstrates an exportLogic function that prompts for a username and password:

func exportLogic(ctx context.Context, events export.EventHandler) error {
	slog.Info("export command invoked")

	username, err := widget.TextInput(ctx, "Username", "anonymous", false)
	if err != nil {
		return err
	}

	password, err := widget.TextInput(ctx, "Password", "", true)
	if err != nil {
		return err
	}

	slog.Info("data acquired",
		"username", username,
		"password", password,
	)

	events.Stop()
	return err
}

Complete example:

package main

import (
	"context"
	"log/slog"

	"github.com/SAP/xp-clifford/cli"
	"github.com/SAP/xp-clifford/cli/export"
	"github.com/SAP/xp-clifford/cli/widget"
)

func exportLogic(ctx context.Context, events export.EventHandler) error {
	slog.Info("export command invoked")

	username, err := widget.TextInput(ctx, "Username", "anonymous", false)
	if err != nil {
		return err
	}

	password, err := widget.TextInput(ctx, "Password", "", true)
	if err != nil {
		return err
	}

	slog.Info("data acquired",
		"username", username,
		"password", password,
	)

	events.Stop()
	return err
}

func main() {
	cli.Configuration.ShortName = "test"
	cli.Configuration.ObservedSystem = "test system"
	export.SetCommand(exportLogic)
	cli.Execute()
}

See the example in action:

img

MultiInput widget

The MultiInput widget creates a multi-selection interface that allows users to select multiple items from a predefined list of options:

func MultiInput(ctx context.Context, title string, options []string) ([]string, error)

Parameters:

  • ctx: Go context for handling Ctrl-C interrupts or timeouts
  • title: The selection prompt displayed to the user
  • options: The list of selectable items

The following example demonstrates an exportLogic function that uses the MultiInput widget:

func exportLogic(ctx context.Context, events export.EventHandler) error {
	slog.Info("export command invoked")

	protocols, err := widget.MultiInput(ctx,
		"Select the supported protocols",
		[]string{
			"FTP",
			"HTTP",
			"HTTPS",
			"SFTP",
			"SSH",
		},
	)

	slog.Info("data acquired",
		"protocols", protocols,
	)

	events.Stop()
	return err
}

The complete source code is assembled as follows:

package main

import (
	"context"
	"log/slog"

	"github.com/SAP/xp-clifford/cli"
	"github.com/SAP/xp-clifford/cli/export"
	"github.com/SAP/xp-clifford/cli/widget"
)

func exportLogic(ctx context.Context, events export.EventHandler) error {
	slog.Info("export command invoked")

	protocols, err := widget.MultiInput(ctx,
		"Select the supported protocols",
		[]string{
			"FTP",
			"HTTP",
			"HTTPS",
			"SFTP",
			"SSH",
		},
	)

	slog.Info("data acquired",
		"protocols", protocols,
	)

	events.Stop()
	return err
}

func main() {
	cli.Configuration.ShortName = "test"
	cli.Configuration.ObservedSystem = "test system"
	export.SetCommand(exportLogic)
	cli.Execute()
}

Running this example produces the following output:

img

Configuration parameters

CLI tools built using xp-clifford can be configured through several methods:

  • Command-line flags
  • Environment variables
  • Configuration files

xp-clifford provides types and functions to facilitate configuration and management of these parameters. Configuration parameter handling is also integrated with the widget capabilities of xp-clifford.

Currently, the following configuration parameter types are supported:

  • bool
  • string
  • []string

All configuration parameters managed by xp-clifford implement the configparam.ConfigParam interface.

Global configuration parameters

Any CLI tool built using xp-clifford includes the following global flags:

  • -c or --config: Configuration file for setting additional parameters (string)
  • -v or --verbose: Enable verbose logging (bool)
  • -h or --help: Print help message (bool)

The verbose logging is explained in Verbose logging. The configuration file handling is elaborated in the Configuration file.

Verbose logging

Enable verbose logging with the -v or --verbose flag. When enabled, structured log messages at the Debug level are also printed to the console.

An example exportLogic function:

func exportLogic(_ context.Context, events export.EventHandler) error {
	slog.Debug("export command invoked")
	events.Stop()
	return nil
}

The complete example:

package main

import (
	"context"
	"log/slog"

	"github.com/SAP/xp-clifford/cli"
	"github.com/SAP/xp-clifford/cli/export"
)

func exportLogic(_ context.Context, events export.EventHandler) error {
	slog.Debug("export command invoked")
	events.Stop()
	return nil
}

func main() {
	cli.Configuration.ShortName = "test"
	cli.Configuration.ObservedSystem = "test system"
	export.SetCommand(exportLogic)
	cli.Execute()
}

Executing the export subcommand without the -v flag produces no output:

go run ./examples/verbose/main.go export

With the -v flag, the debug-level message appears:

go run ./examples/verbose/main.go export -v
DEBU export command invoked

Configuration parameters of the export subcommand

The export subcommand includes the following default configuration parameters:

  • -k or --kind: Resource kinds to export ([]string)
  • -o or --output: Redirect output to a file (string)

You can extend the export subcommand with additional configuration parameters using the export.AddConfigParams function:

func AddConfigParams(param ...configparam.ConfigParam)

Bool configuration parameter

Create a new bool configuration parameter using the configparam.Bool function:

func Bool(name, description string) *BoolParam

The two mandatory arguments are name and description. Fine-tune the parameter with these methods:

  • WithShortName: Single-character short command-line flag
  • WithFlagName: Long format of the command-line flag (defaults to name)
  • WithEnvVarName: Environment variable name for the parameter
  • WithDefaultValue: Default value of the parameter

Use the Value() method to retrieve the parameter value. The IsSet() method returns true if the user has explicitly set the value.

Here is a bool configuration parameter definition:

var testParam = configparam.Bool("test", "test bool parameter").
        WithShortName("t").
        WithEnvVarName("CLIFFORD_TEST")

Add the parameter to the export subcommand:

export.AddConfigParams(testParam)

A complete working example:

package main

import (
	"context"
	"log/slog"

	"github.com/SAP/xp-clifford/cli"
	"github.com/SAP/xp-clifford/cli/configparam"
	"github.com/SAP/xp-clifford/cli/export"
)

func exportLogic(_ context.Context, events export.EventHandler) error {
	slog.Info("export command invoked", "test-value", testParam.Value())
	events.Stop()
	return nil
}

var testParam = configparam.Bool("test", "test bool parameter").
        WithShortName("t").
        WithEnvVarName("CLIFFORD_TEST")

func main() {
	cli.Configuration.ShortName = "test"
	cli.Configuration.ObservedSystem = "test system"
	export.AddConfigParams(testParam)
	export.SetCommand(exportLogic)
	cli.Execute()
}

The new parameter appears in the help output:

go run ./examples/boolparam/main.go export --help
Export test system resources and transform them into managed resources that the Crossplane provider can consume

Usage:
  test-exporter export [flags]

Flags:
  -h, --help            help for export
  -k, --kind strings    Resource kinds to export
  -o, --output string   redirect the YAML output to a file
  -t, --test            test bool parameter

Global Flags:
  -c, --config string   Configuration file
  -v, --verbose         Verbose output

By default, test is false:

go run ./examples/boolparam/main.go export
INFO export command invoked test-value=false

Enable it using the --test flag:

go run ./examples/boolparam/main.go export --test
INFO export command invoked test-value=true

Or using the shorthand -t flag:

go run ./examples/boolparam/main.go export -t
INFO export command invoked test-value=true

Or using the CLIFFORD_TEST environment variable:

CLIFFORD_TEST=1 go run ./examples/boolparam/main.go export
INFO export command invoked test-value=true

String configuration parameter

Create a new string configuration parameter using the configparam.String function:

func String(name, description string) *StringParam

The two mandatory arguments are name and description. Fine-tune the parameter with these methods:

  • WithShortName: Single-character short command-line flag
  • WithFlagName: Long format of the command-line flag (defaults to name)
  • WithEnvVarName: Environment variable name for the parameter
  • WithDefaultValue: Default value of the parameter

Use the Value() method to retrieve the parameter value. The IsSet() method returns true if the user has explicitly set the value.

The ValueOrAsk method returns the value if set. Otherwise, it prompts for the value interactively using the TextInput widget.

Consider the following string configuration parameter:

var testParam = configparam.String("username", "username used for authentication").
	WithShortName("u").
	WithEnvVarName("USERNAME").
	WithDefaultValue("testuser")

A complete example:

package main

import (
	"context"
	"log/slog"

	"github.com/SAP/xp-clifford/cli"
	"github.com/SAP/xp-clifford/cli/configparam"
	"github.com/SAP/xp-clifford/cli/export"
)

func exportLogic(ctx context.Context, events export.EventHandler) error {
	slog.Info("export command invoked",
		"username", testParam.Value(),
		"is-set", testParam.IsSet(),
	)

	// If not set, ask the value
	username, err := testParam.ValueOrAsk(ctx)
	if err != nil {
		return err
	}

	slog.Info("value set by user", "value", username)

	events.Stop()
	return nil
}

var testParam = configparam.String("username", "username used for authentication").
	WithShortName("u").
	WithEnvVarName("USERNAME").
	WithDefaultValue("testuser")

func main() {
	cli.Configuration.ShortName = "test"
	cli.Configuration.ObservedSystem = "test system"
	export.AddConfigParams(testParam)
	export.SetCommand(exportLogic)
        cli.Execute()
}

The new parameter appears in the help output:

go run ./examples/stringparam/main.go export --help
Export test system resources and transform them into managed resources that the Crossplane provider can consume

Usage:
  test-exporter export [flags]

Flags:
  -h, --help              help for export
  -k, --kind strings      Resource kinds to export
  -o, --output string     redirect the YAML output to a file
  -u, --username string   username used for authentication

Global Flags:
  -c, --config string   Configuration file
  -v, --verbose         Verbose output

Set the value using the --username flag:

go run ./examples/stringparam/main.go export --username anonymous
INFO export command invoked username=anonymous is-set=true
INFO value set by user value=anonymous

Or using the shorthand -u flag:

go run ./examples/stringparam/main.go export -u anonymous
INFO export command invoked username=anonymous is-set=true
INFO value set by user value=anonymous

Or using the USERNAME environment variable:

USERNAME=anonymous go run ./examples/stringparam/main.go export
INFO export command invoked username=anonymous is-set=true
INFO value set by user value=anonymous

When no value is provided, the TextInput widget prompts for it interactively:

img

String slice configuration parameter

A string slice configuration parameter configures values of type []string.

Create a new string slice configuration parameter using the configparam.StringSlice function:

func StringSlice(name, description string) *StringSliceParam

The two mandatory arguments are name and description. Fine-tune the parameter with these methods:

  • WithShortName: Single-character short command-line flag
  • WithFlagName: Long format of the command-line flag (defaults to name)
  • WithEnvVarName: Environment variable name for the parameter
  • WithDefaultValue: Default value of the parameter
  • WithPossibleValues: Limit the selection options offered during ValueOrAsk
  • WithPossibleValuesFn: Function that provides the selection options offered during ValueOrAsk

Use the Value() method to retrieve the parameter value. The IsSet() method returns true if the user has explicitly set the value.

The ValueOrAsk method returns the value if set. Otherwise, it prompts for the value interactively using the MultiInput widget. Interactive prompting requires setting possible values with WithPossibleValues or WithPossibleValuesFn.

Without possible values

The following example configures a StringSlice parameter:

var testParam = configparam.StringSlice("protocol", "list of supported protocols").
	WithShortName("p").
	WithEnvVarName("PROTOCOLS")

Complete example:

package main

import (
	"context"
	"log/slog"

	"github.com/SAP/xp-clifford/cli"
	"github.com/SAP/xp-clifford/cli/configparam"
	"github.com/SAP/xp-clifford/cli/export"
)

func exportLogic(ctx context.Context, events export.EventHandler) error {
	slog.Info("export command invoked",
		"protocols", testParam.Value(),
		"num-of-protos", len(testParam.Value()),
		"is-set", testParam.IsSet(),
	)

	events.Stop()
	return nil
}

var testParam = configparam.StringSlice("protocol", "list of supported protocols").
	WithShortName("p").
	WithEnvVarName("PROTOCOLS")

func main() {
	cli.Configuration.ShortName = "test"
	cli.Configuration.ObservedSystem = "test system"
	export.AddConfigParams(testParam)
	export.SetCommand(exportLogic)
        cli.Execute()
}

The new parameter appears in the help output:

go run ./examples/stringslice/main.go export --help
Export test system resources and transform them into managed resources that the Crossplane provider can consume

Usage:
  test-exporter export [flags]

Flags:
  -h, --help               help for export
  -k, --kind strings       Resource kinds to export
  -o, --output string      redirect the YAML output to a file
  -p, --protocol strings   list of supported protocols

Global Flags:
  -c, --config string   Configuration file
  -v, --verbose         Verbose output

Without setting the value:

go run ./examples/stringslice/main.go export
INFO export command invoked protocols=[] num-of-protos=0 is-set=false

Set the value using the --protocol flag:

go run ./examples/stringslice/main.go export --protocol HTTP --protocol HTTPS --protocol SSH
INFO export command invoked protocols="[HTTP HTTPS SSH]" num-of-protos=3 is-set=true

Set the value using the -p flag:

go run ./examples/stringslice/main.go export -p HTTP -p SFTP -p FTP
INFO export command invoked protocols="[HTTP SFTP FTP]" num-of-protos=3 is-set=true

Set the value using the PROTOCOLS environment variable:

PROTOCOLS="HTTP HTTPS FTP" go run ./examples/stringslice/main.go export
INFO export command invoked protocols="[HTTP HTTPS FTP]" num-of-protos=3 is-set=true
With static possible values

To enable interactive prompting with StringSlice configuration parameters, add static selection options using the WithPossibleValues method.

Define the configuration parameter:

var testParam = configparam.StringSlice("protocol", "list of supported protocols").
	WithShortName("p").
	WithEnvVarName("PROTOCOLS").
	WithPossibleValues([]string{"HTTP", "HTTPS", "FTP", "SSH", "SFTP"})

Complete example:

package main

import (
	"context"
	"log/slog"

	"github.com/SAP/xp-clifford/cli"
	"github.com/SAP/xp-clifford/cli/configparam"
	"github.com/SAP/xp-clifford/cli/export"
)

func exportLogic(ctx context.Context, events export.EventHandler) error {
	slog.Info("export command invoked",
		"protocols", testParam.Value(),
		"num-of-protos", len(testParam.Value()),
		"is-set", testParam.IsSet(),
	)

	protocols, err := testParam.ValueOrAsk(ctx)
	if err != nil {
		return err
	}

	slog.Info("data acquired", "protocols", protocols)

	events.Stop()
	return nil
}

var testParam = configparam.StringSlice("protocol", "list of supported protocols").
	WithShortName("p").
	WithEnvVarName("PROTOCOLS").
	WithPossibleValues([]string{"HTTP", "HTTPS", "FTP", "SSH", "SFTP"})

func main() {
	cli.Configuration.ShortName = "test"
	cli.Configuration.ObservedSystem = "test system"
	export.AddConfigParams(testParam)
	export.SetCommand(exportLogic)
        cli.Execute()
}

You can set values with flags or environment variables as before:

go run ./examples/stringslicestatic/main.go export --protocol HTTP --protocol HTTPS --protocol SSH
INFO export command invoked protocols="[HTTP HTTPS SSH]" num-of-protos=3 is-set=true
INFO data acquired protocols="[HTTP HTTPS SSH]"
go run ./examples/stringslicestatic/main.go export -p HTTP -p SFTP -p FTP
INFO export command invoked protocols="[HTTP SFTP FTP]" num-of-protos=3 is-set=true
INFO data acquired protocols="[HTTP SFTP FTP]"
PROTOCOLS="HTTP HTTPS FTP" go run ./examples/stringslicestatic/main.go export
INFO export command invoked protocols="[HTTP HTTPS FTP]" num-of-protos=3 is-set=true
INFO data acquired protocols="[HTTP HTTPS FTP]"

When you omit the parameter values, the CLI tool prompts for them interactively:

img

With dynamic possible values

Sometimes the set of possible StringSlice parameter values cannot be defined at build time. The value set may depend on a previous interactive selection or the result of an API request.

In such cases, set the possible values dynamically using the WithPossibleValuesFn method.

Consider a simple Bool configuration parameter:

var secureParam = configparam.Bool("secure", "secure protocol").
        WithShortName("s").
        WithEnvVarName("SECURE")

Based on the value of secureParam, the possibleProtocols function suggests different protocol names:

func possibleProtocols() ([]string, error) {
	if secureParam.Value() {
		return []string{"HTTPS", "SFTP", "SSH"}, nil
	}
	return []string{"FTP", "HTTP"}, nil
}

The protocolsParam configuration parameter uses possibleProtocols when prompting the user with the ValueOrAsk method:

var protocolsParam = configparam.StringSlice("protocol", "list of supported protocols").
	WithShortName("p").
	WithEnvVarName("PROTOCOLS").
	WithPossibleValuesFn(possibleProtocols)

Complete example:

package main

import (
	"context"
	"log/slog"

	"github.com/SAP/xp-clifford/cli"
	"github.com/SAP/xp-clifford/cli/configparam"
	"github.com/SAP/xp-clifford/cli/export"
)

func exportLogic(ctx context.Context, events export.EventHandler) error {
	slog.Info("export command invoked",
	        "secure", secureParam.Value(),
		"secure-is-set", secureParam.IsSet(),
		"protocols", protocolsParam.Value(),
		"num-of-protos", len(protocolsParam.Value()),
		"protocols-is-set", protocolsParam.IsSet(),
	)

	protocols, err := protocolsParam.ValueOrAsk(ctx)
	if err != nil {
		return err
	}

	slog.Info("data acquired", "protocols", protocols)

	events.Stop()
	return nil
}

func possibleProtocols() ([]string, error) {
	if secureParam.Value() {
		return []string{"HTTPS", "SFTP", "SSH"}, nil
	}
	return []string{"FTP", "HTTP"}, nil
}

var secureParam = configparam.Bool("secure", "secure protocol").
        WithShortName("s").
        WithEnvVarName("SECURE")

var protocolsParam = configparam.StringSlice("protocol", "list of supported protocols").
	WithShortName("p").
	WithEnvVarName("PROTOCOLS").
	WithPossibleValuesFn(possibleProtocols)

func main() {
	cli.Configuration.ShortName = "test"
	cli.Configuration.ObservedSystem = "test system"
	export.AddConfigParams(secureParam, protocolsParam)
	export.SetCommand(exportLogic)
        cli.Execute()
}

Both parameters appear in the help output:

go run ./examples/stringslicedynamic/main.go export --help
Export test system resources and transform them into managed resources that the Crossplane provider can consume

Usage:
  test-exporter export [flags]

Flags:
  -h, --help               help for export
  -k, --kind strings       Resource kinds to export
  -o, --output string      redirect the YAML output to a file
  -p, --protocol strings   list of supported protocols
  -s, --secure             secure protocol

Global Flags:
  -c, --config string   Configuration file
  -v, --verbose         Verbose output

Set the values using flags as usual:

go run ./examples/stringslicedynamic/main.go export -s --protocol HTTPS --protocol SFTP
INFO export command invoked secure=true secure-is-set=true protocols="[HTTPS SFTP]" num-of-protos=2 protocols-is-set=true
INFO data acquired protocols="[HTTPS SFTP]"

When the protocol configuration parameter is not set, the CLI prompts for its value interactively. The available options depend on the value of secure.

If secure is not set:

img

If secure is set:

img

Subcommands

CLI tools created with xp-clifford include the mandatory export subcommand. You can also define additional subcommands by creating a value that implements the cli.SubCommand interface.

You can implement your own type, or use the cli.BasicSubCommand type, which already implements the cli.SubCommand interface.

The business logic executed when the subcommand is invoked must have the following function signature:

func(context.Context) error

Let's consider the following logic function for an imaginary login subcommand:

func login(_ context.Context) error {
	slog.Info("login invoked")
	return nil
}

A BasicSubcommand value can be created for the login subcommand:

var loginSubCommand = &cli.BasicSubCommand{
	Name:         "login",
	Short:        "Login demo subcommand",
	Long:         "A subcommand demonstrating xp-clifford capabilities",
	ConfigParams: []configparam.ConfigParam{},
	Run:          login,
}

A subcommand can be registered using the cli.RegisterCommand function:

cli.RegisterSubCommand(loginSubCommand)

Complete example:

package main

import (
	"context"
	"log/slog"

	"github.com/SAP/xp-clifford/cli"
	"github.com/SAP/xp-clifford/cli/configparam"
	_ "github.com/SAP/xp-clifford/cli/export"
)

func login(_ context.Context) error {
	slog.Info("login invoked")
	return nil
}

func main() {
	cli.Configuration.ShortName = "test"
	cli.Configuration.ObservedSystem = "test system"

	var loginSubCommand = &cli.BasicSubCommand{
		Name:         "login",
		Short:        "Login demo subcommand",
		Long:         "A subcommand demonstrating xp-clifford capabilities",
		ConfigParams: []configparam.ConfigParam{},
		Run:          login,
	}

	cli.RegisterSubCommand(loginSubCommand)

	cli.Execute()
}

The login subcommand appears when we run the CLI application with the --help flag:

go run ./examples/loginsubcommand/main.go --help
test system exporting tool is a CLI tool for exporting existing resources as Crossplane managed resources

Usage:
  test-exporter [command]

Available Commands:
  completion  Generate the autocompletion script for the specified shell
  export      Export test system resources
  help        Help about any command
  login       Login demo subcommand

Flags:
  -c, --config string   Configuration file
  -h, --help            help for test-exporter
  -v, --verbose         Verbose output

Use "test-exporter [command] --help" for more information about a command.

The --help flag also works for the new login subcommand:

go run ./examples/loginsubcommand/main.go login --help
A subcommand demonstrating xp-clifford capabilities

Usage:
  test-exporter login [flags]

Flags:
  -h, --help   help for login

Global Flags:
  -c, --config string   Configuration file
  -v, --verbose         Verbose output

We can also run the login subcommand:

go run ./examples/loginsubcommand/main.go login
INFO login invoked
Subcommand with configuration parameters

Custom subcommands can be extended with configuration parameters using the GetConfigParams() method of the cli.SubCommand interface, or by setting the ConfigParams field of a BasicSubCommand value.

Let's update the loginSubCommand value:

var loginSubCommand = &cli.BasicSubCommand{
	Name:         "login",
	Short:        "Login demo subcommand",
	Long:         "A subcommand demonstrating xp-clifford capabilities",
	ConfigParams: []configparam.ConfigParam{
		testParam,
	},
	Run:          login,
}

Here, testParam is defined as follows:

var testParam = configparam.Bool("test", "test bool parameter").
        WithShortName("t").
        WithEnvVarName("CLIFFORD_TEST")

Let's extend the login function to print the value of testParam:

func login(_ context.Context) error {
	slog.Info("login invoked", "test", testParam.Value())
	return nil
}

Complete example:

package main

import (
	"context"
	"log/slog"

	"github.com/SAP/xp-clifford/cli"
	"github.com/SAP/xp-clifford/cli/configparam"
	_ "github.com/SAP/xp-clifford/cli/export"
)

func login(_ context.Context) error {
	slog.Info("login invoked", "test", testParam.Value())
	return nil
}

var testParam = configparam.Bool("test", "test bool parameter").
        WithShortName("t").
        WithEnvVarName("CLIFFORD_TEST")

func main() {
	cli.Configuration.ShortName = "test"
	cli.Configuration.ObservedSystem = "test system"

	var loginSubCommand = &cli.BasicSubCommand{
		Name:         "login",
		Short:        "Login demo subcommand",
		Long:         "A subcommand demonstrating xp-clifford capabilities",
		ConfigParams: []configparam.ConfigParam{
			testParam,
		},
		Run:          login,
	}

	cli.RegisterSubCommand(loginSubCommand)

	cli.Execute()
}

The --help flag for the login subcommand now shows the -t / --test parameter:

go run ./examples/loginsubcommandparam/main.go login --help
A subcommand demonstrating xp-clifford capabilities

Usage:
  test-exporter login [flags]

Flags:
  -h, --help   help for login
  -t, --test   test bool parameter

Global Flags:
  -c, --config string   Configuration file
  -v, --verbose         Verbose output

Let's invoke the login command:

go run ./examples/loginsubcommandparam/main.go login
INFO login invoked test=false

Let's see the configuration parameter in action:

go run ./examples/loginsubcommandparam/main.go login -t
INFO login invoked test=true

Configuration file

In addition to CLI flags and environment variables, a CLI tool built with xp-clifford can read configuration from a YAML file.

You can specify the configuration file path using the --config / -c global flag.

If you don't specify a configuration file, the CLI looks for one in these locations, in order:

  1. $XDG_CONFIG_HOME/<config_file_name>
  2. $HOME/<config_file_name>

The config_file_name is export-cli-config-<shortname>, where shortname is the value of cli.Configuration.ShortName.

The YAML file contains key-value pairs, where keys are configuration parameter names in lowercase.

Here is a simple example CLI with three configuration parameters:

package main

import (
	"context"
	"log/slog"

	"github.com/SAP/xp-clifford/cli"
	"github.com/SAP/xp-clifford/cli/configparam"
	"github.com/SAP/xp-clifford/cli/export"
)

func exportLogic(ctx context.Context, events export.EventHandler) error {
	slog.Info("export command invoked",
		"protocols", protocolParam.Value(),
		"username", usernameParam.Value(),
		"boolparam", boolParam.Value(),
	)

	events.Stop()
	return nil
}

var protocolParam = configparam.StringSlice("protocol", "list of supported protocols").
	WithShortName("p").
	WithEnvVarName("PROTOCOLS")

var usernameParam = configparam.String("username", "username used for authentication").
	WithShortName("u").
	WithEnvVarName("USERNAME")

var boolParam = configparam.Bool("bool", "test bool parameter").
	WithShortName("b").
	WithEnvVarName("CLIFFORD_BOOL")

func main() {
	cli.Configuration.ShortName = "test"
	cli.Configuration.ObservedSystem = "test system"
	export.AddConfigParams(protocolParam, usernameParam, boolParam)
	export.SetCommand(exportLogic)
	cli.Execute()
}

Flag-based configuration works as expected:

go run ./examples/configfile/main.go export -b --protocol HTTPS --protocol SFTP --username anonymous
INFO export command invoked protocols="[HTTPS SFTP]" username=anonymous boolparam=true

Without CLI flags:

go run ./examples/configfile/main.go export
INFO export command invoked protocols=[] username="" boolparam=false

Now let's create a configuration file:

protocol:
  - HTTP
  - FTP
username: config-user
bool: true

The CLI reads configuration parameter values from this file:

go run ./examples/configfile/main.go export --config ./examples/configfile/config
INFO export command invoked protocols="[HTTP FTP]" username=config-user boolparam=true

Environment variables override values from the configuration file:

PROTOCOLS="FTP" go run ./examples/configfile/main.go export --config ./examples/configfile/config
INFO export command invoked protocols=[FTP] username=config-user boolparam=true

CLI flags take the highest precedence and override everything else:

PROTOCOLS="FTP" go run ./examples/configfile/main.go export --config ./examples/configfile/config --protocol SSH -b=false
INFO export command invoked protocols=[SSH] username=config-user boolparam=false

Parsing and sanitizing

When creating Crossplane managed resource definitions, we frequently transform objects describing external resources into a different schema. Usually the values are preserved, but the data structure differs.

Sometimes we cannot preserve values exactly because they must conform to certain rules.

An example is the metadata.name field of Kubernetes resources1. The Kubernetes documentation references various RFCs and extends those requirements with additional rules.

The parsan package in xp-clifford provides functions that transform strings into formats satisfying different Kubernetes object name requirements. This process is called sanitization. The ParseAndSanitize function performs this action:

func ParseAndSanitize(input string, rule Rule) []string

The ParseAndSanitize function takes an input string and a rule, then transforms the input to conform to the rule. Since multiple valid sanitized solutions may exist, the function returns all of them.

Sanitizer rules

The following rules are available for sanitization.

RFC1035Subdomain

The RFC1035Subdomain rule conforms to:

<subdomain> ::= <label> | <subdomain> "." <label>

A subdomain is either a single label or multiple labels separated by dots (e.g., label.label.label).

A label is a string that:

  • starts with a letter (lowercase or uppercase),
  • ends with a letter (lowercase or uppercase) or a digit,
  • contains only letters, digits, and - characters.

A label cannot exceed 63 characters. A subdomain cannot exceed 253 characters.

During sanitization, invalid characters are replaced with - or x. The @ symbol is replaced with -at-. Labels and subdomains that are too long are trimmed.

Examples:

input sanitized
www.example.com www.example.com
Can you sanitize me? Can-you-sanitize-mex
99Luftballons x99Luftballons
admin@example.com admin-at-example.com
RFC1035LowerSubdomain

The RFC1035LowerSubdomain rule is a variation of RFC1035Subdomain that requires lowercase letters only. Uppercase letters are converted to lowercase:

input sanitized
www.example.com www.example.com
Can you sanitize me? can-you-sanitize-mex
99Luftballons x99luftballons
admin@example.com admin-at-example.com
RFC1035SubdomainRelaxed

The RFC1035SubdomainRelaxed rule is a variation of RFC1035Subdomain that allows labels to start with digits:

input sanitized
www.example.com www.example.com
Can you sanitize me? Can-you-sanitize-mex
99Luftballons 99Luftballons
admin@example.com admin-at-example.com
RFC1035LowerSubdomainRelaxed

The RFC1035LowerSubdomainRelaxed rule combines RFC1035LowerSubdomain and RFC1035SubdomainRelaxed. Uppercase characters are converted to lowercase, and labels may start with digits:

input sanitized
www.example.com www.example.com
Can you sanitize me? can-you-sanitize-mex
99Luftballons 99luftballons
admin@example.com admin-at-example.com

Footnotes

1 Object Names and IDs - kubernetes.io

About

Crossplane Provider Export CLI framework for resource data extraction

Topics

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Contributors 2

  •  
  •  

Languages