diff --git a/README.md b/README.md index 6a2f5a9..abc558d 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,17 @@ The `pkg/collections` package contains multiple interfaces for collections, mode The package also contains further packages that contain some auxiliary functions for working with slices and maps in golang, e.g. for filtering. +## clusters + +The `pkg/clusters` package helps with loading kubeconfigs and creating clients for multiple clusters. +```go +foo := clusters.New("foo") // initializes a new cluster with id 'foo' +foo.RegisterConfigPathFlag(cmd.Flags()) // adds a '--foo-cluster' flag to the flag set for passing in a kubeconfig path +foo.InitializeRESTConfig() // loads the kubeconfig using the 'LoadKubeconfig' function from the 'controller' package +foo.InitializeClient(myScheme) // initializes the 'Client' and 'Cluster' interfaces from the controller-runtime +``` +You can then use the different getter methods for working with the cluster. + ### conditions The `pkg/conditions` package helps with managing condition lists. @@ -92,6 +103,9 @@ The `pkg/controller` package contains useful functions for setting up and runnin #### Noteworthy Functions - `LoadKubeconfig` creates a REST config for accessing a k8s cluster. It can be used with a path to a kubeconfig file, or a directory containing files for a trust relationship. When called with an empty path, it returns the in-cluster configuration. +- There are some functions useful for working with annotations and labels, e.g. `HasAnnotationWithValue` or `EnsureLabel`. +- There are multiple predefined predicates to help with filtering reconciliation triggers in controllers, e.g. `HasAnnotationPredicate` or `DeletionTimestampChangedPredicate`. +- The `K8sNameHash` function can be used to create a hash that can be used as a name for k8s resources. ### logging diff --git a/pkg/clusters/cluster.go b/pkg/clusters/cluster.go new file mode 100644 index 0000000..d0c571a --- /dev/null +++ b/pkg/clusters/cluster.go @@ -0,0 +1,175 @@ +package clusters + +import ( + "fmt" + + flag "github.com/spf13/pflag" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/cluster" + + "github.com/openmcp-project/controller-utils/pkg/controller" +) + +type Cluster struct { + // identifier (for logging purposes only) + id string + // path to kubeconfig + cfgPath string + // cluster config + restCfg *rest.Config + // client + client client.Client + // cluster + cluster cluster.Cluster +} + +// Initializes a new cluster. +// Panics if id is empty. +func New(id string) *Cluster { + c := &Cluster{} + c.InitializeID(id) + return c +} + +// WithConfigPath sets the config path for the cluster. +// Returns the cluster for chaining. +func (c *Cluster) WithConfigPath(cfgPath string) *Cluster { + c.cfgPath = cfgPath + return c +} + +// RegisterConfigPathFlag adds a flag '---cluster' for the cluster's config path to the given flag set. +// Panics if the cluster's id is not set. +func (c *Cluster) RegisterConfigPathFlag(flags *flag.FlagSet) { + if !c.HasID() { + panic("cluster id must be set before registering the config path flag") + } + flags.StringVar(&c.cfgPath, fmt.Sprintf("%s-cluster", c.id), "", fmt.Sprintf("Path to the %s cluster kubeconfig file or directory containing either a kubeconfig or host, token, and ca file. Leave empty to use in-cluster config.", c.id)) +} + +/////////////////// +// STATUS CHECKS // +/////////////////// + +// HasID returns true if the cluster has an id. +// If this returns false, initialize a new cluster via New() or InitializeID(). +func (c *Cluster) HasID() bool { + return c != nil && c.id != "" +} + +// HasRESTConfig returns true if the cluster has a REST config. +// If this returns false, load the config via InitializeRESTConfig(). +func (c *Cluster) HasRESTConfig() bool { + return c != nil && c.restCfg != nil +} + +// HasClient returns true if the cluster has a client. +// If this returns false, create a client via InitializeClient(). +func (c *Cluster) HasClient() bool { + return c != nil && c.client != nil +} + +////////////////// +// INITIALIZERS // +////////////////// + +// InitializeID sets the cluster's id. +// Panics if id is empty. +func (c *Cluster) InitializeID(id string) { + if id == "" { + panic("id must not be empty") + } + c.id = id +} + +// InitializeRESTConfig loads the cluster's REST config. +// If the config has already been loaded, this is a no-op. +// Panics if the cluster's id is not set (InitializeID must be called first). +func (c *Cluster) InitializeRESTConfig() error { + if !c.HasID() { + panic("cluster id must be set before loading the config") + } + if c.HasRESTConfig() { + return nil + } + cfg, err := controller.LoadKubeconfig(c.cfgPath) + if err != nil { + return fmt.Errorf("failed to load '%s' cluster kubeconfig: %w", c.ID(), err) + } + c.restCfg = cfg + return nil +} + +// InitializeClient creates a new client for the cluster. +// This also initializes the cluster's controller-runtime 'Cluster' representation. +// If the client has already been initialized, this is a no-op. +// Panics if the cluster's REST config has not been loaded (InitializeRESTConfig must be called first). +func (c *Cluster) InitializeClient(scheme *runtime.Scheme) error { + if !c.HasRESTConfig() { + panic("cluster REST config must be set before creating the client") + } + if c.HasClient() { + return nil + } + cli, err := client.New(c.restCfg, client.Options{Scheme: scheme}) + if err != nil { + return fmt.Errorf("failed to create '%s' cluster client: %w", c.ID(), err) + } + clu, err := cluster.New(c.restCfg, func(o *cluster.Options) { o.Scheme = scheme }) + if err != nil { + return fmt.Errorf("failed to create '%s' cluster Cluster representation: %w", c.ID(), err) + } + c.client = cli + c.cluster = clu + return nil +} + +///////////// +// GETTERS // +///////////// + +// ID returns the cluster's id. +func (c *Cluster) ID() string { + return c.id +} + +// ConfigPath returns the cluster's config path. +func (c *Cluster) ConfigPath() string { + return c.cfgPath +} + +// RESTConfig returns the cluster's REST config. +// This returns a pointer, but modification can lead to inconsistent behavior and is not recommended. +func (c *Cluster) RESTConfig() *rest.Config { + return c.restCfg +} + +// Client returns the cluster's client. +func (c *Cluster) Client() client.Client { + return c.client +} + +// Cluster returns the cluster's controller-runtime 'Cluster' representation. +func (c *Cluster) Cluster() cluster.Cluster { + return c.cluster +} + +// Scheme returns the cluster's scheme. +// Returns nil if the client has not been initialized. +func (c *Cluster) Scheme() *runtime.Scheme { + if c.cluster == nil { + return nil + } + return c.cluster.GetScheme() +} + +// APIServerEndpoint returns the cluster's API server endpoint. +// Returns an empty string if the REST config has not been initialized. +func (c *Cluster) APIServerEndpoint() string { + if c.restCfg == nil { + return "" + } + return c.restCfg.Host +} diff --git a/pkg/controller/utils.go b/pkg/controller/utils.go new file mode 100644 index 0000000..7bfb774 --- /dev/null +++ b/pkg/controller/utils.go @@ -0,0 +1,37 @@ +package controller + +import ( + "crypto/sha1" + "encoding/base32" + "reflect" + "strings" +) + +const ( + maxLength int = 63 + Base32EncodeStdLowerCase = "abcdefghijklmnopqrstuvwxyz234567" +) + +// K8sNameHash takes any number of string arguments and computes a hash out of it, which is then base32-encoded to be a valid k8s resource name. +// The arguments are joined with '/' before being hashed. +func K8sNameHash(ids ...string) string { + name := strings.Join(ids, "/") + h := sha1.New() + _, _ = h.Write([]byte(name)) + // we need base32 encoding as some base64 (even url safe base64) characters are not supported by k8s + // see https://kubernetes.io/docs/concepts/overview/working-with-objects/names/ + return base32.NewEncoding(Base32EncodeStdLowerCase).WithPadding(base32.NoPadding).EncodeToString(h.Sum(nil)) +} + +// IsNil checks if a given pointer is nil. +// Opposed to 'i == nil', this works for typed and untyped nil values. +func IsNil(i any) bool { + if i == nil { + return true + } + switch reflect.TypeOf(i).Kind() { + case reflect.Ptr, reflect.Map, reflect.Array, reflect.Chan, reflect.Slice: + return reflect.ValueOf(i).IsNil() + } + return false +} diff --git a/pkg/logging/config.go b/pkg/logging/config.go index 3f69927..fb06a69 100644 --- a/pkg/logging/config.go +++ b/pkg/logging/config.go @@ -23,15 +23,15 @@ func InitFlags(flagset *flag.FlagSet) { } fs := flag.NewFlagSet("log", flag.ExitOnError) - fs.BoolVar(&configFromFlags.Development, "dev", false, "enable development logging") - fs.BoolVar(&configFromFlags.Cli, "cli", false, "use CLI formatting for logs (color, no timestamps)") - f := fs.VarPF(&configFromFlags.Format, "format", "f", "logging format [text, json]") + fs.BoolVar(&configFromFlags.Development, "dev", false, "Enable development logging.") + fs.BoolVar(&configFromFlags.Cli, "cli", false, "Use CLI formatting for logs (color, no timestamps).") + f := fs.VarPF(&configFromFlags.Format, "format", "f", "Logging format [text, json].") f.DefValue = "text if either dev or cli flag is set, json otherwise" - f = fs.VarPF(&configFromFlags.Level, "verbosity", "v", "logging verbosity [error, info, debug]") + f = fs.VarPF(&configFromFlags.Level, "verbosity", "v", "Logging verbosity [error, info, debug].") f.DefValue = "info, or debug if dev flag is set" - fs.BoolVar(&configFromFlags.DisableStacktrace, "disable-stacktrace", true, "disable the stacktrace of error logs") - fs.BoolVar(&configFromFlags.DisableCaller, "disable-caller", true, "disable the caller of logs") - fs.BoolVar(&configFromFlags.DisableTimestamp, "disable-timestamp", false, "disable timestamp output") + fs.BoolVar(&configFromFlags.DisableStacktrace, "disable-stacktrace", true, "Disable the stacktrace of error logs.") + fs.BoolVar(&configFromFlags.DisableCaller, "disable-caller", true, "Disable the caller of logs.") + fs.BoolVar(&configFromFlags.DisableTimestamp, "disable-timestamp", false, "Disable timestamp output.") configFromFlags.flagset = fs flagset.AddFlagSet(configFromFlags.flagset)