From 0b9b96b8c9eea4a783530caaf4715bd191a5cf74 Mon Sep 17 00:00:00 2001 From: Nikolay Edigaryev Date: Tue, 7 Feb 2023 19:48:31 +0400 Subject: [PATCH] Introduce "orchard context" (#18) --- go.mod | 5 + go.sum | 13 ++ internal/command/context/context.go | 23 ++++ internal/command/context/create.go | 193 ++++++++++++++++++++++++++++ internal/command/context/default.go | 28 ++++ internal/command/context/delete.go | 28 ++++ internal/command/context/list.go | 42 ++++++ internal/command/controller/init.go | 2 +- internal/command/root.go | 2 + internal/config/base64.go | 23 ++++ internal/config/config.go | 52 ++++++++ internal/config/context.go | 31 +++++ internal/config/error.go | 9 ++ internal/config/handle.go | 165 ++++++++++++++++++++++++ internal/controller/api.go | 5 + internal/controller/controller.go | 7 +- internal/orchardhome/orchardhome.go | 10 +- pkg/client/client.go | 23 +++- 18 files changed, 656 insertions(+), 5 deletions(-) create mode 100644 internal/command/context/context.go create mode 100644 internal/command/context/create.go create mode 100644 internal/command/context/default.go create mode 100644 internal/command/context/delete.go create mode 100644 internal/command/context/list.go create mode 100644 internal/config/base64.go create mode 100644 internal/config/config.go create mode 100644 internal/config/context.go create mode 100644 internal/config/error.go create mode 100644 internal/config/handle.go diff --git a/go.mod b/go.mod index e05dc09..8ad4ee3 100644 --- a/go.mod +++ b/go.mod @@ -6,15 +6,20 @@ require ( github.com/dgraph-io/badger/v3 v3.2103.5 github.com/dustin/go-humanize v1.0.0 github.com/gin-gonic/gin v1.8.2 + github.com/gofrs/flock v0.8.1 github.com/google/uuid v1.3.0 github.com/gosuri/uitable v0.0.4 + github.com/manifoldco/promptui v0.9.0 github.com/spf13/cobra v1.6.0 go.uber.org/zap v1.24.0 + golang.org/x/exp v0.0.0-20230203172020-98cc5a0785f9 + gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/cespare/xxhash v1.1.0 // indirect github.com/cespare/xxhash/v2 v2.1.2 // indirect + github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect github.com/dgraph-io/ristretto v0.1.1 // indirect github.com/fatih/color v1.13.0 // indirect github.com/gin-contrib/sse v0.1.0 // indirect diff --git a/go.sum b/go.sum index 6a4affb..5aa862d 100644 --- a/go.sum +++ b/go.sum @@ -9,6 +9,12 @@ github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghf github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= @@ -44,6 +50,8 @@ github.com/go-playground/validator/v10 v10.11.1 h1:prmOlTVv+YjZjmRmNSF3VmspqJIxJ github.com/go-playground/validator/v10 v10.11.1/go.mod h1:i+3WkQ1FvaUjjxh1kSvIA4dMGDBiPU55YFDl0WbKdWU= github.com/goccy/go-json v0.9.11 h1:/pAaQDLHEoCq/5FFmSKBswWmK6H0e8g4159Kc/X/nqk= github.com/goccy/go-json v0.9.11/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= +github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58= @@ -92,6 +100,8 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= +github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= github.com/mattn/go-colorable v0.1.9 h1:sqDoxXbdeALODt0DAeJCVp38ps9ZogZEAXjus69YV3U= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= @@ -170,6 +180,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 h1:0es+/5331RGQPcXlMfP+WrnIIS6dNnNRe0WB02W0F4M= golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20230203172020-98cc5a0785f9 h1:frX3nT9RkKybPnjyI+yvZh6ZucTZatCCEm9D47sZ2zo= +golang.org/x/exp v0.0.0-20230203172020-98cc5a0785f9/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= @@ -194,6 +206,7 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/internal/command/context/context.go b/internal/command/context/context.go new file mode 100644 index 0000000..7d4e905 --- /dev/null +++ b/internal/command/context/context.go @@ -0,0 +1,23 @@ +package context + +import ( + "github.com/spf13/cobra" +) + +var contextName string + +func NewCommand() *cobra.Command { + command := &cobra.Command{ + Use: "context", + Short: "Manage client/worker → controller contexts", + } + + command.AddCommand( + newCreateCommand(), + newListCommand(), + newDefaultCommand(), + newDeleteCommand(), + ) + + return command +} diff --git a/internal/command/context/create.go b/internal/command/context/create.go new file mode 100644 index 0000000..2d086f0 --- /dev/null +++ b/internal/command/context/create.go @@ -0,0 +1,193 @@ +package context + +import ( + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "github.com/cirruslabs/orchard/internal/config" + "github.com/cirruslabs/orchard/internal/controller" + "github.com/cirruslabs/orchard/pkg/client" + "github.com/manifoldco/promptui" + "github.com/spf13/cobra" + "net/url" + "strconv" + "strings" +) + +var ErrCreateFailed = errors.New("failed to create context") + +var force bool + +func newCreateCommand() *cobra.Command { + command := &cobra.Command{ + Use: "create", + Short: "Create a new context", + Args: cobra.ExactArgs(1), + RunE: runCreate, + } + + command.PersistentFlags().StringVar(&contextName, "name", "default", + "context name to use") + command.PersistentFlags().BoolVar(&force, "force", false, + "create the context even if a context with the same name already exists") + + return command +} + +func runCreate(cmd *cobra.Command, args []string) error { + addr := args[0] + + if !strings.HasPrefix(addr, "https://") && !strings.HasPrefix(addr, "http://") { + addr = "https://" + addr + } + + controllerURL, err := url.Parse(addr) + if err != nil { + return err + } + + if controllerURL.Port() == "" { + controllerURL.Host += fmt.Sprintf(":%d", controller.DefaultPort) + } + + // Establish trust + trustedControllerCertificate, err := probeControllerCertificate(controllerURL) + if err != nil { + return err + } + + // Check that the API is accessible + privatePool := x509.NewCertPool() + privatePool.AddCert(trustedControllerCertificate) + + tlsConfig := &tls.Config{ + MinVersion: tls.VersionTLS13, + RootCAs: privatePool, + ServerName: controller.DefaultServerName, + } + + client, err := client.New( + client.WithAddress(controllerURL.String()), + client.WithTLSConfig(tlsConfig), + ) + if err != nil { + return err + } + if err := client.Check(cmd.Context()); err != nil { + return err + } + + // Create and save the context + configHandle, err := config.NewHandle() + if err != nil { + return err + } + + certificatePEMBytes := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: trustedControllerCertificate.Raw, + }) + + return configHandle.CreateContext(contextName, config.Context{ + URL: controllerURL.String(), + Certificate: certificatePEMBytes, + }, force) +} + +func probeControllerCertificate(controllerURL *url.URL) (*x509.Certificate, error) { + // Do not use PKI + emptyPool := x509.NewCertPool() + + //nolint:gosec // since we're not using PKI, InsecureSkipVerify is a must here + insecureTLSConfig := &tls.Config{ + MinVersion: tls.VersionTLS13, + RootCAs: emptyPool, + ServerName: controller.DefaultServerName, + InsecureSkipVerify: true, + } + + var controllerCert *x509.Certificate + + insecureTLSConfig.VerifyConnection = func(state tls.ConnectionState) error { + if controllerCert != nil { + return fmt.Errorf("%w: encountered more than one certificate while probing the controller", + ErrCreateFailed) + } + + if len(state.PeerCertificates) != 1 { + return fmt.Errorf("%w: controller presented %d certificate(s), expected only one", + ErrCreateFailed, len(state.PeerCertificates)) + } + + controllerCert = state.PeerCertificates[0] + controllerCertFingerprint := sha256.Sum256(controllerCert.Raw) + formattedControllerCertFingerprint := formatFingerprint(controllerCertFingerprint[:]) + + shortControllerName := controllerURL.Hostname() + if controllerURL.Port() != strconv.FormatUint(controller.DefaultPort, 10) { + shortControllerName += ":" + controllerURL.Port() + } + + fmt.Printf("The authencity of controller %s cannot be established.\n", shortControllerName) + fmt.Printf("Certificate SHA-256 fingerprint is %s.\n", formattedControllerCertFingerprint) + + promptTemplates := &promptui.PromptTemplates{ + Prompt: "{{ . }} ", + Valid: "{{ . }} ", + Invalid: "{{ . }} ", + Success: "{{ . }} ", + ValidationError: "{{ . }} ", + } + prompt := promptui.Prompt{ + Label: "Are you sure you want to establish trust to this certificate? (yes/no)", + Validate: func(s string) error { + if s != "yes" && s != "no" { + //nolint:goerr113,golint,stylecheck // this is not a standard error + return fmt.Errorf("Please specify \"yes\" or \"no\".") + } + + return nil + }, + Templates: promptTemplates, + } + + promptResult, err := prompt.Run() + if err != nil { + return err + } + + switch promptResult { + case "yes": + return nil + case "no": + return fmt.Errorf("%w: certificate verification failed: no trust decision received from the user", + ErrCreateFailed) + default: + return fmt.Errorf("%w: certificate verification failed: received unsupported answer from the user", + ErrCreateFailed) + } + } + + conn, err := tls.Dial("tcp", controllerURL.Host, insecureTLSConfig) + if err != nil { + return nil, err + } + defer func() { + _ = conn.Close() + }() + + return controllerCert, nil +} + +func formatFingerprint(fingerprint []byte) string { + var fingerprintPieces []string + + for _, piece := range fingerprint { + fingerprintPieces = append(fingerprintPieces, fmt.Sprintf("%02X", piece)) + } + + return strings.Join(fingerprintPieces, " ") +} diff --git a/internal/command/context/default.go b/internal/command/context/default.go new file mode 100644 index 0000000..9109c88 --- /dev/null +++ b/internal/command/context/default.go @@ -0,0 +1,28 @@ +package context + +import ( + "github.com/cirruslabs/orchard/internal/config" + "github.com/spf13/cobra" +) + +func newDefaultCommand() *cobra.Command { + command := &cobra.Command{ + Use: "default", + Short: "Set a default context to use for client/worker commands", + Args: cobra.ExactArgs(1), + RunE: runDefault, + } + + return command +} + +func runDefault(cmd *cobra.Command, args []string) error { + name := args[0] + + configHandle, err := config.NewHandle() + if err != nil { + return err + } + + return configHandle.SetDefaultContext(name) +} diff --git a/internal/command/context/delete.go b/internal/command/context/delete.go new file mode 100644 index 0000000..4c4c292 --- /dev/null +++ b/internal/command/context/delete.go @@ -0,0 +1,28 @@ +package context + +import ( + "github.com/cirruslabs/orchard/internal/config" + "github.com/spf13/cobra" +) + +func newDeleteCommand() *cobra.Command { + command := &cobra.Command{ + Use: "delete", + Short: "Delete a context", + Args: cobra.ExactArgs(1), + RunE: runDelete, + } + + return command +} + +func runDelete(cmd *cobra.Command, args []string) error { + name := args[0] + + configHandle, err := config.NewHandle() + if err != nil { + return err + } + + return configHandle.DeleteContext(name) +} diff --git a/internal/command/context/list.go b/internal/command/context/list.go new file mode 100644 index 0000000..76b769c --- /dev/null +++ b/internal/command/context/list.go @@ -0,0 +1,42 @@ +package context + +import ( + "fmt" + "github.com/cirruslabs/orchard/internal/config" + "github.com/gosuri/uitable" + "github.com/spf13/cobra" +) + +func newListCommand() *cobra.Command { + command := &cobra.Command{ + Use: "list", + Short: "List contexts", + RunE: runList, + } + + return command +} + +func runList(cmd *cobra.Command, args []string) error { + configHandle, err := config.NewHandle() + if err != nil { + return err + } + + config, err := configHandle.Config() + if err != nil { + return err + } + + table := uitable.New() + + table.AddRow("Name", "URL") + + for name, context := range config.Contexts { + table.AddRow(name, context.URL) + } + + fmt.Println(table) + + return nil +} diff --git a/internal/command/controller/init.go b/internal/command/controller/init.go index ac1403a..af55ff2 100644 --- a/internal/command/controller/init.go +++ b/internal/command/controller/init.go @@ -119,7 +119,7 @@ func generateSelfSignedControllerCertificate() (tls.Certificate, error) { BasicConstraintsValid: true, KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}, - DNSNames: []string{"orchard-controller"}, + DNSNames: []string{controller.DefaultServerName}, } certBytes, err := x509.CreateCertificate(cryptorand.Reader, cert, cert, privateKey.Public(), privateKey) diff --git a/internal/command/root.go b/internal/command/root.go index 7bf3e5d..e206d6f 100644 --- a/internal/command/root.go +++ b/internal/command/root.go @@ -1,6 +1,7 @@ package command import ( + "github.com/cirruslabs/orchard/internal/command/context" "github.com/cirruslabs/orchard/internal/command/controller" "github.com/cirruslabs/orchard/internal/command/create" deletepkg "github.com/cirruslabs/orchard/internal/command/deletecmd" @@ -24,6 +25,7 @@ func NewRootCmd() *cobra.Command { ) addGroupedCommands(command, "Administrative Tasks:", + context.NewCommand(), controller.NewCommand(), worker.NewCommand(), dev.NewCommand(), diff --git a/internal/config/base64.go b/internal/config/base64.go new file mode 100644 index 0000000..3d70cfb --- /dev/null +++ b/internal/config/base64.go @@ -0,0 +1,23 @@ +package config + +import ( + "encoding/base64" + "gopkg.in/yaml.v3" +) + +type Base64 []byte + +func (b64 Base64) MarshalYAML() (interface{}, error) { + return base64.StdEncoding.EncodeToString(b64), nil +} + +func (b64 *Base64) UnmarshalYAML(value *yaml.Node) error { + result, err := base64.StdEncoding.DecodeString(value.Value) + if err != nil { + return err + } + + *b64 = result + + return nil +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..e231f48 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,52 @@ +package config + +import ( + "fmt" +) + +type Config struct { + DefaultContext string `yaml:"default-context,omitempty"` + Contexts map[string]Context `yaml:"contexts,omitempty"` +} + +func (config *Config) SetContext(name string, context Context) { + config.Contexts[name] = context + + if config.DefaultContext == "" { + config.DefaultContext = name + } +} + +func (config *Config) RetrieveContext(name string) (Context, bool) { + context, ok := config.Contexts[name] + + return context, ok +} + +func (config *Config) RetrieveDefaultContext() (Context, bool) { + if config.DefaultContext == "" { + return Context{}, false + } + + return config.RetrieveContext(config.DefaultContext) +} + +func (config *Config) DeleteContext(name string) error { + _, exists := config.Contexts[name] + if !exists { + return fmt.Errorf("%w: no such context: %q", ErrConfigConflict, name) + } + + delete(config.Contexts, name) + + if config.DefaultContext == name { + config.DefaultContext = "" + + for name := range config.Contexts { + config.DefaultContext = name + break + } + } + + return nil +} diff --git a/internal/config/context.go b/internal/config/context.go new file mode 100644 index 0000000..ea0d1e5 --- /dev/null +++ b/internal/config/context.go @@ -0,0 +1,31 @@ +package config + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "github.com/cirruslabs/orchard/internal/controller" +) + +type Context struct { + URL string `yaml:"url,omitempty"` + Certificate Base64 `yaml:"certificate,omitempty"` +} + +func (context *Context) TLSConfig() (*tls.Config, error) { + if len(context.Certificate) == 0 { + return nil, nil + } + + privatePool := x509.NewCertPool() + + if ok := privatePool.AppendCertsFromPEM(context.Certificate); !ok { + return nil, fmt.Errorf("%w: failed to load context's certificate", ErrConfigReadFailed) + } + + return &tls.Config{ + MinVersion: tls.VersionTLS13, + ServerName: controller.DefaultServerName, + RootCAs: privatePool, + }, nil +} diff --git a/internal/config/error.go b/internal/config/error.go new file mode 100644 index 0000000..7388b81 --- /dev/null +++ b/internal/config/error.go @@ -0,0 +1,9 @@ +package config + +import "errors" + +var ( + ErrConfigReadFailed = errors.New("failed to read configuration file") + ErrConfigWriteFailed = errors.New("failed to write configuration file") + ErrConfigConflict = errors.New("conflict while operating on configuration file") +) diff --git a/internal/config/handle.go b/internal/config/handle.go new file mode 100644 index 0000000..c789ac3 --- /dev/null +++ b/internal/config/handle.go @@ -0,0 +1,165 @@ +package config + +import ( + "errors" + "fmt" + "github.com/cirruslabs/orchard/internal/controller" + "github.com/cirruslabs/orchard/internal/orchardhome" + "github.com/gofrs/flock" + "gopkg.in/yaml.v3" + "os" + "path/filepath" +) + +const configName = "orchard.yml" + +type Handle struct { + configPath string +} + +func NewHandle() (*Handle, error) { + orchardHomeDir, err := orchardhome.Path() + if err != nil { + return nil, err + } + + return &Handle{ + configPath: filepath.Join(orchardHomeDir, configName), + }, nil +} + +func (handle *Handle) Config() (*Config, error) { + config := Config{ + Contexts: map[string]Context{}, + } + + configBytes, err := os.ReadFile(handle.configPath) + if err != nil { + // Handle a case where the config file is not created yet + if errors.Is(err, os.ErrNotExist) { + return &Config{ + Contexts: map[string]Context{}, + }, nil + } + + return nil, fmt.Errorf("%w: %v", ErrConfigReadFailed, err) + } + + if err := yaml.Unmarshal(configBytes, &config); err != nil { + return nil, fmt.Errorf("%w: invalid YAML: %v", ErrConfigReadFailed, err) + } + + return &config, nil +} + +func (handle *Handle) SetConfig(config *Config) error { + configBytes, err := yaml.Marshal(config) + if err != nil { + return fmt.Errorf("%w: failed to marshal YAML: %v", ErrConfigWriteFailed, err) + } + + if err := os.WriteFile(handle.configPath, configBytes, 0600); err != nil { + return fmt.Errorf("%w: %v", ErrConfigWriteFailed, err) + } + + return nil +} + +func (handle *Handle) CreateContext(name string, context Context, force bool) error { + lock := flock.New(handle.configPath) + if err := lock.Lock(); err != nil { + return err + } + defer func() { + _ = lock.Unlock() + }() + + config, err := handle.Config() + if err != nil { + return err + } + + _, exists := config.RetrieveContext(name) + if exists && !force { + return fmt.Errorf("%w: context %q already exists", ErrConfigConflict, name) + } + + config.SetContext(name, context) + + return handle.SetConfig(config) +} + +func (handle *Handle) DefaultContext() (Context, error) { + lock := flock.New(handle.configPath) + if err := lock.Lock(); err != nil { + return Context{}, err + } + defer func() { + _ = lock.Unlock() + }() + + config, err := handle.Config() + if err != nil { + return Context{}, err + } + + defaultContext, ok := config.RetrieveDefaultContext() + if !ok { + defaultContext = Context{ + URL: fmt.Sprintf("http://127.0.0.1:%d", controller.DefaultPort), + } + + config.SetContext("default", defaultContext) + + if err := handle.SetConfig(config); err != nil { + return Context{}, err + } + } + + return defaultContext, nil +} + +func (handle *Handle) SetDefaultContext(name string) error { + lock := flock.New(handle.configPath) + if err := lock.Lock(); err != nil { + return err + } + defer func() { + _ = lock.Unlock() + }() + + config, err := handle.Config() + if err != nil { + return err + } + + _, ok := config.RetrieveContext(name) + if !ok { + return fmt.Errorf("%w: no such context: %q", ErrConfigConflict, name) + } + + config.DefaultContext = name + + return handle.SetConfig(config) +} + +func (handle *Handle) DeleteContext(name string) error { + lock := flock.New(handle.configPath) + if err := lock.Lock(); err != nil { + return err + } + defer func() { + _ = lock.Unlock() + }() + + config, err := handle.Config() + if err != nil { + return err + } + + if err := config.DeleteContext(name); err != nil { + return err + } + + return handle.SetConfig(config) +} diff --git a/internal/controller/api.go b/internal/controller/api.go index 7c6b58c..3df0805 100644 --- a/internal/controller/api.go +++ b/internal/controller/api.go @@ -17,6 +17,11 @@ func (controller *Controller) initAPI() *gin.Engine { // v1 API v1 := ginEngine.Group("/v1") + // A way to for the clients to check that the API is working + v1.GET("/", func(c *gin.Context) { + c.Status(http.StatusOK) + }) + // Workers v1.POST("/workers", func(c *gin.Context) { controller.createWorker(c).Respond(c) diff --git a/internal/controller/controller.go b/internal/controller/controller.go index d2acab6..dfe494e 100644 --- a/internal/controller/controller.go +++ b/internal/controller/controller.go @@ -12,6 +12,11 @@ import ( "time" ) +const ( + DefaultPort = 6120 + DefaultServerName = "orchard-controller" +) + var ErrInitFailed = errors.New("controller initialization failed") type Controller struct { @@ -38,7 +43,7 @@ func New(opts ...Option) (*Controller, error) { ErrInitFailed) } if controller.listenAddr == "" { - controller.listenAddr = ":6120" + controller.listenAddr = fmt.Sprintf(":%d", DefaultPort) } if controller.logger == nil { controller.logger = zap.NewNop().Sugar() diff --git a/internal/orchardhome/orchardhome.go b/internal/orchardhome/orchardhome.go index 48d68cf..50a840f 100644 --- a/internal/orchardhome/orchardhome.go +++ b/internal/orchardhome/orchardhome.go @@ -1,20 +1,26 @@ package orchardhome import ( + "errors" + "fmt" "os" "path/filepath" ) +var ErrFailed = errors.New("failed to retrieve Orchard's home directory path") + func Path() (string, error) { homeDir, err := os.UserHomeDir() if err != nil { - return "", err + return "", fmt.Errorf("%w: failed to retrieve current user's home directory %v", + ErrFailed, err) } orchardDir := filepath.Join(homeDir, ".orchard") if err := os.Mkdir(orchardDir, 0700); err != nil && !os.IsExist(err) { - return "", err + return "", fmt.Errorf("%w: cannot create directory %s: %v", + ErrFailed, orchardDir, err) } return orchardDir, nil diff --git a/pkg/client/client.go b/pkg/client/client.go index afdec73..79bbb46 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/cirruslabs/orchard/internal/config" "io" "net/http" "net/url" @@ -39,7 +40,23 @@ func New(opts ...Option) (*Client, error) { // Apply defaults if client.address == "" { - client.address = "http://127.0.0.1:6120" + configHandle, err := config.NewHandle() + if err != nil { + return nil, err + } + + defaultContext, err := configHandle.DefaultContext() + if err != nil { + return nil, err + } + + client.address = defaultContext.URL + + tlsConfig, err := defaultContext.TLSConfig() + if err != nil { + return nil, err + } + client.tlsConfig = tlsConfig } // Instantiate client @@ -130,6 +147,10 @@ func (client *Client) request( return nil } +func (client *Client) Check(ctx context.Context) error { + return client.request(ctx, http.MethodGet, "/", nil, nil, nil) +} + func (client *Client) Workers() *WorkersService { return &WorkersService{ client: client,