Skip to content

Commit

Permalink
Doc touch up and a couple of examples
Browse files Browse the repository at this point in the history
Signed-off-by: Kimmo Lehto <[email protected]>
  • Loading branch information
kke committed Mar 10, 2024
1 parent 6e991d7 commit d029853
Show file tree
Hide file tree
Showing 10 changed files with 222 additions and 22 deletions.
2 changes: 2 additions & 0 deletions aliases.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand Down
64 changes: 56 additions & 8 deletions client.go
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down Expand Up @@ -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

Expand All @@ -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:
//
Expand All @@ -61,7 +82,7 @@ type Client struct {
// // ...
// }
//
// And a configuration YAML like this:
// And having a configuration YAML like this:
//
// hosts:
// - ssh:
Expand All @@ -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"`
Expand All @@ -98,15 +119,20 @@ 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
}
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)
Expand All @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
62 changes: 57 additions & 5 deletions compositeconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 0 additions & 1 deletion homedir/expand.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
//go:build !windows

// Package homedir provides functions for getting the user's home directory in Go.
package homedir

import (
Expand Down
2 changes: 1 addition & 1 deletion homedir/homedir.go
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down
45 changes: 39 additions & 6 deletions kv/decoder.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions kv/decoder_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package kv_test

import (
"fmt"
"strings"
"testing"

Expand Down Expand Up @@ -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
}
3 changes: 2 additions & 1 deletion kv/split.go
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down
10 changes: 10 additions & 0 deletions kv/split_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package kv_test

import (
"fmt"
"testing"

"github.com/k0sproject/rig/v2/kv"
Expand Down Expand Up @@ -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
}
19 changes: 19 additions & 0 deletions passphrase.go
Original file line number Diff line number Diff line change
@@ -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
}

0 comments on commit d029853

Please sign in to comment.