From d029853a0f1758c32e39d4b0f55061b4b0540aa4 Mon Sep 17 00:00:00 2001 From: Kimmo Lehto Date: Sun, 10 Mar 2024 22:33:49 +0200 Subject: [PATCH] Doc touch up and a couple of examples Signed-off-by: Kimmo Lehto --- aliases.go | 2 ++ client.go | 64 ++++++++++++++++++++++++++++++++++++++++------ compositeconfig.go | 62 ++++++++++++++++++++++++++++++++++++++++---- homedir/expand.go | 1 - homedir/homedir.go | 2 +- kv/decoder.go | 45 +++++++++++++++++++++++++++----- kv/decoder_test.go | 36 ++++++++++++++++++++++++++ kv/split.go | 3 ++- kv/split_test.go | 10 ++++++++ passphrase.go | 19 ++++++++++++++ 10 files changed, 222 insertions(+), 22 deletions(-) create mode 100644 passphrase.go diff --git a/aliases.go b/aliases.go index 7bd4c50e..a9526825 100644 --- a/aliases.go +++ b/aliases.go @@ -17,9 +17,11 @@ import ( var ( // ErrAbort is returned when retrying an action will not yield a different outcome. + // An alias of protocol.ErrAbort for easier access without importing subpackages. ErrAbort = protocol.ErrAbort // ErrValidationFailed is returned when a validation check fails. + // An alias of protocol.ErrValidationFailed for easier access without importing subpackages. ErrValidationFailed = protocol.ErrValidationFailed ) diff --git a/client.go b/client.go index a6ec111a..3a6392b5 100644 --- a/client.go +++ b/client.go @@ -1,5 +1,20 @@ // Package rig provides an easy way to add multi-protocol connectivity and -// multi-os operation support to your application's Host objects +// multi-os operation support to your application's Host objects by +// embedding or directly using the Client or Connection objects. +// +// Rig's core functionality revolves around providing a unified interface +// for interacting with remote systems. This includes managing services, +// file systems, package managers, and getting OS release information, +// abstracting away the intricacies of different operating systems and +// communication protocols. +// +// The protocol implementations aim to provide out-of-the-box default +// behavior similar to what you would expect when using the official +// clients like openssh "ssh" command instead of having to deal with +// implementing ssh config parsing, key managemnt, agent forwarding +// and so on yourself. +// +// To get started, see [Client] package rig import ( @@ -29,6 +44,11 @@ import ( // interface to the host's operating system's basic functions in a // similar manner as the stdlib's os package does for the local system, // for example chmod, stat, and so on. +// +// The easiest way to set up a client instance is through a protocol +// config struct, like [protocol/ssh.Config] +// or the unified [CompositeConfig] and then use the [NewClient] +// function to create a new client. type Client struct { options *ClientOptions @@ -52,7 +72,8 @@ type Client struct { sudoClone *Client } -// ClientWithConfig is a Client that is suitable for embedding into something that is unmarshalled from YAML. +// ClientWithConfig is a [Client] that is suitable for embedding into +// a host object that is unmarshalled from YAML configuration. // // When embedded into a "host" object like this: // @@ -61,7 +82,7 @@ type Client struct { // // ... // } // -// And a configuration YAML like this: +// And having a configuration YAML like this: // // hosts: // - ssh: @@ -75,7 +96,7 @@ type Client struct { // } // out, err := host.ExecOutput("ls") // -// The available protocols are defined in the CompositeConfig struct. +// The available protocols are defined in the [CompositeConfig] struct. type ClientWithConfig struct { mu sync.Mutex ConnectionConfig CompositeConfig `yaml:",inline"` @@ -98,7 +119,11 @@ func (c *ClientWithConfig) Setup(opts ...ClientOption) error { return nil } -// Connect to the host. +// Connect to the host. Unlike in [Client.Connect], the Connect method here +// accepts a variadic list of options similar to [NewClient]. This is to +// allow configuring the connection before connecting, since you won't +// be calling [NewClient] to create the [ClientWithConfig] instance when +// unmarshalling from a configuration file. func (c *ClientWithConfig) Connect(ctx context.Context, opts ...ClientOption) error { if err := c.Setup(opts...); err != nil { //nolint:contextcheck // it's the trace logger return err @@ -106,7 +131,8 @@ func (c *ClientWithConfig) Connect(ctx context.Context, opts ...ClientOption) er return c.Client.Connect(ctx) } -// UnmarshalYAML unmarshals and setups a connection from a YAML configuration. +// UnmarshalYAML implements the yaml.Unmarshaler interface, it unmarshals and +// sets up a connection from a YAML configuration. func (c *ClientWithConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { type configuredConnection ClientWithConfig conn := (*configuredConnection)(c) @@ -121,9 +147,28 @@ func (c *ClientWithConfig) UnmarshalYAML(unmarshal func(interface{}) error) erro // You must use either WithConnection or WithConnectionConfigurer to provide a connection or // a way to configure a connection for the client. // -// An example SSH connection: +// An example SSH connection via ssh.Config:: // // client, err := rig.NewClient(WithConnectionConfigurer(&ssh.Config{Address: "10.0.0.1"})) +// +// Using the [CompositeConfig] struct: +// +// client, err := rig.NewClient(WithConnectionConfigurer(&rig.CompositeConfig{SSH: &ssh.Config{...}})) +// +// If you want to use a pre-configured connection, you can use WithConnection: +// +// conn, err := ssh.NewConnection(ssh.Config{...}) +// client, err := rig.NewClient(WithConnection(conn)) +// +// Once you have a client, you can use it to interact with the remote host. +// +// err := client.Connect(context.Background()) +// if err != nil { +// log.Fatal(err) +// } +// out, err := client.ExecOutput("ls") +// +// To see all of the available ways to run commands, see [cmd.Executor]. func NewClient(opts ...ClientOption) (*Client, error) { options := NewClientOptions(opts...) if err := options.Validate(); err != nil { @@ -294,7 +339,10 @@ func (c *Client) ExecInteractive(cmd string, stdin io.Reader, stdout, stderr io. return errInteractiveNotSupported } -// FS returns a fs.FS compatible filesystem interface for accessing files on the host. +// The service Getters would be available and working via the embedding alrady, but the +// accessors are provided here directly on the Client mainly for discoverability in docs. + +// FS returns an fs.FS compatible filesystem interface for accessing files on the host. // // If the filesystem can't be accessed, a filesystem that returns an error for all operations is returned // instead. If you need to handle the error, you can use c.RemoteFSService.GetFS() directly. diff --git a/compositeconfig.go b/compositeconfig.go index 9b43df01..d4f50227 100644 --- a/compositeconfig.go +++ b/compositeconfig.go @@ -21,28 +21,80 @@ type CompositeConfig struct { Localhost bool `yaml:"localhost,omitempty"` } +type oldLocalhost struct { + Enabled bool `yaml:"enabled"` +} + +// intermediary structure for handling both the old v0.x and the new format +// for localhost. +type compositeConfigIntermediary struct { + SSH *ssh.Config `yaml:"ssh,omitempty"` + WinRM *winrm.Config `yaml:"winRM,omitempty"` + OpenSSH *openssh.Config `yaml:"openSSH,omitempty"` + Localhost any `yaml:"localhost,omitempty"` +} + +// UnmarshalYAML implements the yaml.Unmarshaler interface. +func (c *CompositeConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { + var intermediary compositeConfigIntermediary + if err := unmarshal(&intermediary); err != nil { + return err + } + + c.SSH = intermediary.SSH + c.WinRM = intermediary.WinRM + c.OpenSSH = intermediary.OpenSSH + + if intermediary.Localhost != nil { + switch v := intermediary.Localhost.(type) { + case bool: + c.Localhost = v + case oldLocalhost: + c.Localhost = v.Enabled + default: + return fmt.Errorf("unmarshal localhost - invalid type %T: %w", v, protocol.ErrValidationFailed) + } + } + + return nil +} + func (c *CompositeConfig) configuredConfig() (ConnectionConfigurer, error) { + var configurer ConnectionConfigurer + count := 0 + if c.WinRM != nil { - return c.WinRM, nil + configurer = c.WinRM + count++ } if c.SSH != nil { - return c.SSH, nil + configurer = c.SSH + count++ } if c.OpenSSH != nil { - return c.OpenSSH, nil + configurer = c.OpenSSH + count++ } if c.Localhost { + count++ conn, err := localhost.NewConnection() if err != nil { return nil, fmt.Errorf("create localhost connection: %w", err) } - return conn, nil + configurer = conn } - return nil, fmt.Errorf("%w: no protocol configuration", protocol.ErrValidationFailed) + switch count { + case 0: + return nil, fmt.Errorf("%w: no protocol configuration", protocol.ErrValidationFailed) + case 1: + return configurer, nil + default: + return nil, fmt.Errorf("%w: multiple protocols configured for a single client", protocol.ErrValidationFailed) + } } type validatable interface { diff --git a/homedir/expand.go b/homedir/expand.go index 08e1e2cb..3388231a 100644 --- a/homedir/expand.go +++ b/homedir/expand.go @@ -1,6 +1,5 @@ //go:build !windows -// Package homedir provides functions for getting the user's home directory in Go. package homedir import ( diff --git a/homedir/homedir.go b/homedir/homedir.go index 7a6f0651..6b33fac5 100644 --- a/homedir/homedir.go +++ b/homedir/homedir.go @@ -1,4 +1,4 @@ -// Package homedir provides functions for getting the user's home directory in Go. +// Package homedir provides functions for expanding paths like ~/.ssh. package homedir import ( diff --git a/kv/decoder.go b/kv/decoder.go index 6aae9e2e..f8666491 100644 --- a/kv/decoder.go +++ b/kv/decoder.go @@ -179,6 +179,7 @@ func (ra *reflectAssigner) setupCatchAll(field reflect.Value) error { return nil } +// TODO There could be a "true" option in the tag that would set which value means true. func parseBool(str string) bool { switch str { case "1", "true", "TRUE", "True", "on", "ON", "On", "yes", "YES", "Yes", "y", "Y": @@ -334,15 +335,47 @@ func (ra *reflectAssigner) assign(key, value string) error { // Decoder is a decoder for key-value pairs, similar to the encoding/json package. // You can use struct tags to customize the decoding process. // -// The tag format is `kv:"key,option1,option2"`. +// The tag format is: // -// The key is the key in the key-value pair. If the key is empty, the field name is used. -// If the key is "-", the field is ignored. -// If the key is "*", the field is a map of string to string and will catch all keys that are not explicitly defined. +// Field type `kv:"key_name,option1,option2"`. +// +// The key_name is the key in the key-value pair. +// +// - If the key is empty, the field name is used. +// - If the key is '-', the field is ignored. +// - If the key is '*' and the field is a map[string]string, it will catch all the keys that are not matched by the other tags. // // The options are: -// - ignorecase: the key is matched case-insensitively -// - delim: when the field is a slice, the value is split by the specified delimiter. +// +// - ignorecase: the key is matched case-insensitively +// - delim: when the field is a slice, the value is split by the specified delimiter. Default is ','. +// +// Given a struct like this: +// +// type Config struct { +// Host string `kv:"host"` +// Port int `kv:"port"` +// Enabled bool `kv:"enabled"` +// Options []string `kv:"options,delim=|"` +// Extra map[string]string `kv:"*"` +// } +// +// And having data like this in config.txt: +// +// host=10.0.0.1 +// port=8080 +// enabled=true +// options=opt1|opt2|opt3 +// hostname=example.com +// +// Once decoded, you will get the Config fields set like this: +// +// - Host: "10.0.0.1" (string) +// - Port: 8080 (int) +// - Enabled: true (bool). The values parsed as "true" for booleans are: +// "1", "true", "TRUE", "True", "on", "ON", "On", "yes", "YES", "Yes", "y" and "Y" +// - Options: []string{"opt1", "opt2", "opt3"} +// - Extra: map[string]string{"hostname": "example.com"} type Decoder struct { r io.Reader rdelim byte diff --git a/kv/decoder_test.go b/kv/decoder_test.go index 672c596a..2423d07a 100644 --- a/kv/decoder_test.go +++ b/kv/decoder_test.go @@ -1,6 +1,7 @@ package kv_test import ( + "fmt" "strings" "testing" @@ -123,3 +124,38 @@ func TestDecoderWithStruct(t *testing.T) { }) }) } + +func ExampleDecoder() { + type testStruct struct { + Key1 string `kv:"key1"` + Key2 int `kv:"key2"` + Key3 []string `kv:"key3,delim:/"` + CatchAll map[string]string `kv:"*"` + } + target := testStruct{} + data := "key1=value1\nkey2=42\nkey3=foo/bar\nexample=example-catch-all" + decoder := kv.NewDecoder(strings.NewReader(data)) + err := decoder.Decode(&target) + if err != nil { + fmt.Println(err) + } + fmt.Println("* Input data:") + fmt.Println(data) + fmt.Println() + fmt.Println("String value:", target.Key1) + fmt.Println("Int value:", target.Key2) + fmt.Println("Slice of strings:", target.Key3) + fmt.Println("Catch all:", target.CatchAll["example"]) + + // Output: + // * Input data: + // key1=value1 + // key2=42 + // key3=foo/bar + // example=example-catch-all + // + // String value: value1 + // Int value: 42 + // Slice of strings: [foo bar] + // Catch all: example-catch-all +} diff --git a/kv/split.go b/kv/split.go index 77bef3c3..2863e779 100644 --- a/kv/split.go +++ b/kv/split.go @@ -1,4 +1,5 @@ -// Package kv is for working with key-value pairs often found in configuration files. +// Package kv is for working with key-value pair files and strings often found in configuration files, +// environment variables, and other sources. package kv import ( diff --git a/kv/split_test.go b/kv/split_test.go index 76a8d417..e1279980 100644 --- a/kv/split_test.go +++ b/kv/split_test.go @@ -1,6 +1,7 @@ package kv_test import ( + "fmt" "testing" "github.com/k0sproject/rig/v2/kv" @@ -85,3 +86,12 @@ func TestSplitS(t *testing.T) { }) } } + +func ExampleSplitRune() { + key, value, _ := kv.SplitRune(`key="value in quotes"`, '=') + fmt.Println("key:", key) + fmt.Println("value:", value) + // Output: + // key: key + // value: value in quotes +} diff --git a/passphrase.go b/passphrase.go new file mode 100644 index 00000000..9032a6d6 --- /dev/null +++ b/passphrase.go @@ -0,0 +1,19 @@ +package rig + +import ( + "fmt" + "os" + + "golang.org/x/term" +) + +// DefaultPasswordCallback is a default implementation for PasswordCallback +func DefaultPasswordCallback() (string, error) { + fmt.Print("Enter passphrase: ") + pass, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Println() + if err != nil { + return "", fmt.Errorf("failed to read password: %w", err) + } + return string(pass), nil +}