diff --git a/client/client.go b/client/client.go index 2904e02..f1d28de 100644 --- a/client/client.go +++ b/client/client.go @@ -1,7 +1,9 @@ package client import ( + "encoding/json" "fmt" + "github.com/conduktor/ctl/printutils" "github.com/conduktor/ctl/resource" "github.com/go-resty/resty/v2" "os" @@ -13,15 +15,15 @@ type Client struct { client *resty.Client } -func Make(token string, baseUrl string) Client { +func Make(token string, baseUrl string, debug bool) Client { return Client{ token: token, baseUrl: baseUrl, - client: resty.New(), + client: resty.New().SetDebug(debug).SetHeader("Authorization", "Bearer "+token), } } -func MakeFromEnv() Client { +func MakeFromEnv(debug bool) Client { token := os.Getenv("CDK_TOKEN") if token == "" { fmt.Fprintln(os.Stderr, "Please set CDK_TOKEN") @@ -33,14 +35,58 @@ func MakeFromEnv() Client { os.Exit(2) } - return Make(token, baseUrl) + return Make(token, baseUrl, debug) } func (client *Client) Apply(resource *resource.Resource) error { url := client.baseUrl + "/" + resource.Kind - resp, err := client.client.R().SetHeader("Authentication", "Bearer "+client.token).SetBody(resource.Json).Put(url) + resp, err := client.client.R().SetBody(resource.Json).Put(url) if resp.IsError() { return fmt.Errorf("Error applying resource %s/%s, got status code: %d:\n %s", resource.Kind, resource.Name, resp.StatusCode(), string(resp.Body())) } return err } + +func printResponseAsYaml(bytes []byte) error { + var data interface{} + err := json.Unmarshal(bytes, &data) + if err != nil { + return err + } + return printutils.PrintResourceLikeYamlFile(os.Stdout, data) +} + +func (client *Client) Get(kind string) error { + url := client.baseUrl + "/" + kind + resp, err := client.client.R().Get(url) + if resp.IsError() { + return fmt.Errorf("Error listing resources of kind %s, got status code: %d:\n %s", kind, resp.StatusCode(), string(resp.Body())) + } + if err != nil { + return err + } + return printResponseAsYaml(resp.Body()) +} +func (client *Client) Describe(kind, name string) error { + url := client.baseUrl + "/" + kind + "/" + name + resp, err := client.client.R().Get(url) + if resp.IsError() { + return fmt.Errorf("Error describing resources %s/%s, got status code: %d:\n %s", kind, name, resp.StatusCode(), string(resp.Body())) + } + if err != nil { + return err + } + return printResponseAsYaml(resp.Body()) +} + +func (client *Client) Delete(kind, name string) error { + url := client.baseUrl + "/" + kind + "/" + name + resp, err := client.client.R().Delete(url) + if resp.IsError() { + return fmt.Errorf("Error deleting resources %s/%s, got status code: %d:\n %s", kind, name, resp.StatusCode(), string(resp.Body())) + } else { + fmt.Printf("%s/%s deleted\n", kind, name) + } + + return err +} diff --git a/client/client_test.go b/client/client_test.go index 240083a..ab74310 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -7,9 +7,10 @@ import ( ) func TestApplyShouldWork(t *testing.T) { + defer httpmock.Reset() baseUrl := "http://baseUrl/api" token := "aToken" - client := Make(token, baseUrl) + client := Make(token, baseUrl, false) httpmock.ActivateNonDefault( client.client.GetClient(), ) @@ -19,17 +20,17 @@ func TestApplyShouldWork(t *testing.T) { } topic := resource.Resource{ - Json: []byte(`{"yolo": "data"}`), - Kind: "topic", - Name: "toto", - ApiVersion: "v1", + Json: []byte(`{"yolo": "data"}`), + Kind: "topic", + Name: "toto", + Version: "v1", } httpmock.RegisterMatcherResponderWithQuery( "PUT", "http://baseUrl/api/topic", nil, - httpmock.HeaderIs("Authentication", "Bearer "+token). + httpmock.HeaderIs("Authorization", "Bearer "+token). And(httpmock.BodyContainsBytes(topic.Json)), responder, ) @@ -41,9 +42,10 @@ func TestApplyShouldWork(t *testing.T) { } func TestApplyShouldFailIfNo2xx(t *testing.T) { + defer httpmock.Reset() baseUrl := "http://baseUrl/api" token := "aToken" - client := Make(token, baseUrl) + client := Make(token, baseUrl, false) httpmock.ActivateNonDefault( client.client.GetClient(), ) @@ -53,17 +55,17 @@ func TestApplyShouldFailIfNo2xx(t *testing.T) { } topic := resource.Resource{ - Json: []byte(`{"yolo": "data"}`), - Kind: "topic", - Name: "toto", - ApiVersion: "v1", + Json: []byte(`{"yolo": "data"}`), + Kind: "topic", + Name: "toto", + Version: "v1", } httpmock.RegisterMatcherResponderWithQuery( "PUT", "http://baseUrl/api/topic", nil, - httpmock.HeaderIs("Authentication", "Bearer "+token). + httpmock.HeaderIs("Authorization", "Bearer "+token). And(httpmock.BodyContainsBytes(topic.Json)), responder, ) @@ -73,3 +75,164 @@ func TestApplyShouldFailIfNo2xx(t *testing.T) { t.Failed() } } + +func TestGetShouldWork(t *testing.T) { + defer httpmock.Reset() + baseUrl := "http://baseUrl/api" + token := "aToken" + client := Make(token, baseUrl, false) + httpmock.ActivateNonDefault( + client.client.GetClient(), + ) + responder, err := httpmock.NewJsonResponder(200, "[]") + if err != nil { + panic(err) + } + + httpmock.RegisterMatcherResponderWithQuery( + "GET", + "http://baseUrl/api/application", + nil, + httpmock.HeaderIs("Authorization", "Bearer "+token), + responder, + ) + + err = client.Get("application") + if err != nil { + t.Error(err) + } +} + +func TestGetShouldFailIfN2xx(t *testing.T) { + defer httpmock.Reset() + baseUrl := "http://baseUrl/api" + token := "aToken" + client := Make(token, baseUrl, false) + httpmock.ActivateNonDefault( + client.client.GetClient(), + ) + responder, err := httpmock.NewJsonResponder(404, "") + if err != nil { + panic(err) + } + + httpmock.RegisterMatcherResponderWithQuery( + "GET", + "http://baseUrl/api/application", + nil, + httpmock.HeaderIs("Authorization", "Bearer "+token), + responder, + ) + + err = client.Get("application") + if err == nil { + t.Failed() + } +} + +func TestDescribeShouldWork(t *testing.T) { + defer httpmock.Reset() + baseUrl := "http://baseUrl/api" + token := "aToken" + client := Make(token, baseUrl, false) + httpmock.ActivateNonDefault( + client.client.GetClient(), + ) + responder, err := httpmock.NewJsonResponder(200, "[]") + if err != nil { + panic(err) + } + + httpmock.RegisterMatcherResponderWithQuery( + "GET", + "http://baseUrl/api/application/yo", + nil, + httpmock.HeaderIs("Authorization", "Bearer "+token), + responder, + ) + + err = client.Describe("application", "yo") + if err != nil { + t.Error(err) + } +} + +func TestDescribeShouldFailIfNo2xx(t *testing.T) { + defer httpmock.Reset() + baseUrl := "http://baseUrl/api" + token := "aToken" + client := Make(token, baseUrl, false) + httpmock.ActivateNonDefault( + client.client.GetClient(), + ) + responder, err := httpmock.NewJsonResponder(500, "[]") + if err != nil { + panic(err) + } + + httpmock.RegisterMatcherResponderWithQuery( + "GET", + "http://baseUrl/api/application/yo", + nil, + httpmock.HeaderIs("Authorization", "Bearer "+token), + responder, + ) + + err = client.Describe("application", "yo") + if err == nil { + t.Failed() + } +} + +func TestDeleteShouldWork(t *testing.T) { + defer httpmock.Reset() + baseUrl := "http://baseUrl/api" + token := "aToken" + client := Make(token, baseUrl, false) + httpmock.ActivateNonDefault( + client.client.GetClient(), + ) + responder, err := httpmock.NewJsonResponder(200, "[]") + if err != nil { + panic(err) + } + + httpmock.RegisterMatcherResponderWithQuery( + "DELETE", + "http://baseUrl/api/application/yo", + nil, + httpmock.HeaderIs("Authorization", "Bearer "+token), + responder, + ) + + err = client.Delete("application", "yo") + if err != nil { + t.Error(err) + } +} +func TestDeleteShouldFailOnNot2XX(t *testing.T) { + defer httpmock.Reset() + baseUrl := "http://baseUrl/api" + token := "aToken" + client := Make(token, baseUrl, false) + httpmock.ActivateNonDefault( + client.client.GetClient(), + ) + responder, err := httpmock.NewJsonResponder(404, "[]") + if err != nil { + panic(err) + } + + httpmock.RegisterMatcherResponderWithQuery( + "DELETE", + "http://baseUrl/api/application/yo", + nil, + httpmock.HeaderIs("Authorization", "Bearer "+token), + responder, + ) + + err = client.Delete("application", "yo") + if err == nil { + t.Fail() + } +} diff --git a/cmd/apply.go b/cmd/apply.go index 6f20254..044ee42 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -21,7 +21,7 @@ var applyCmd = &cobra.Command{ fmt.Fprintf(os.Stderr, "%s\n", error) os.Exit(1) } - client := client.MakeFromEnv() + client := client.MakeFromEnv(*debug) for _, resource := range resources { err := client.Apply(&resource) if err != nil { diff --git a/cmd/delete.go b/cmd/delete.go new file mode 100644 index 0000000..c14562b --- /dev/null +++ b/cmd/delete.go @@ -0,0 +1,28 @@ +package cmd + +import ( + "fmt" + "github.com/conduktor/ctl/client" + "github.com/spf13/cobra" + "os" +) + +// applyCmd represents the apply command +var deleteCmd = &cobra.Command{ + Use: "delete", + Short: "delete resource of a given kind and name", + Long: ``, + Args: cobra.ExactArgs(2), + Run: func(cmd *cobra.Command, args []string) { + client := client.MakeFromEnv(*debug) + err := client.Delete(args[0], args[1]) + if err != nil { + fmt.Fprintf(os.Stderr, "%s\n", err) + os.Exit(1) + } + }, +} + +func init() { + rootCmd.AddCommand(deleteCmd) +} diff --git a/cmd/get.go b/cmd/get.go new file mode 100644 index 0000000..477feef --- /dev/null +++ b/cmd/get.go @@ -0,0 +1,33 @@ +package cmd + +import ( + "fmt" + "github.com/conduktor/ctl/client" + "github.com/spf13/cobra" + "os" +) + +// applyCmd represents the apply command +var getCmd = &cobra.Command{ + Use: "get kind [name]", + Short: "get resource of a given kind", + Long: ``, + Args: cobra.MatchAll(cobra.MinimumNArgs(1), cobra.MaximumNArgs(2)), + Run: func(cmd *cobra.Command, args []string) { + client := client.MakeFromEnv(*debug) + var err error + if len(args) == 1 { + err = client.Get(args[0]) + } else if len(args) == 2 { + err = client.Describe(args[0], args[1]) + } + if err != nil { + fmt.Fprintf(os.Stderr, "%s\n", err) + os.Exit(1) + } + }, +} + +func init() { + rootCmd.AddCommand(getCmd) +} diff --git a/cmd/root.go b/cmd/root.go index 8d0560b..b1e3483 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -8,6 +8,8 @@ import ( "os" ) +var debug *bool + // rootCmd represents the base command when called without any subcommands var rootCmd = &cobra.Command{ Use: "conduktor", @@ -25,7 +27,9 @@ func Execute() { if err != nil { os.Exit(1) } + } func init() { + debug = rootCmd.PersistentFlags().BoolP("debug", "d", false, "Show rest query") } diff --git a/docker/initializer.json b/docker/initializer.json index ece0274..8b20b6b 100644 --- a/docker/initializer.json +++ b/docker/initializer.json @@ -4,7 +4,55 @@ "method": "Put", "path": "/api/Topic", "headers": { - "Authentication": "Bearer yo" + "Authorization": "Bearer yo" + } + }, + "httpResponse": { + "statusCode": 200, + "body": "{}", + "headers": { + "Content-Type": ["application/json"] + } + } + }, + { + "httpRequest": { + "method": "Get", + "path": "/api/Topic", + "headers": { + "Authorization": "Bearer yo" + } + }, + "httpResponse": { + "statusCode": 200, + "body": "[{}]", + "headers": { + "Content-Type": ["application/json"] + } + } + }, + { + "httpRequest": { + "method": "Get", + "path": "/api/Topic/yolo", + "headers": { + "Authorization": "Bearer yo" + } + }, + "httpResponse": { + "statusCode": 200, + "body": "{}", + "headers": { + "Content-Type": ["application/json"] + } + } + }, + { + "httpRequest": { + "method": "Delete", + "path": "/api/Topic/yolo", + "headers": { + "Authorization": "Bearer yo" } }, "httpResponse": { diff --git a/printutils/printYaml.go b/printutils/printYaml.go new file mode 100644 index 0000000..d9720e6 --- /dev/null +++ b/printutils/printYaml.go @@ -0,0 +1,66 @@ +package printutils + +import ( + "fmt" + yaml "gopkg.in/yaml.v3" + "io" + "slices" +) + +func printKeyYaml(w io.Writer, key string, data interface{}) error { + if data != nil { + yamlBytes, err := yaml.Marshal(map[string]interface{}{ + key: data, + }) + if err != nil { + return err + } + fmt.Fprint(w, string(yamlBytes)) + } + return nil +} + +// this print a interface that is expected to a be a resource +// with the following field "version", "kind", "spec", "metadata" +// wit the field in a defined order. +// But in case the given interface is not a map or is a map with more or less field +// than expected we still properly write it +func printResource(w io.Writer, data interface{}) error { + yamlBytes, err := yaml.Marshal(data) + if err != nil { + return err + } + asMap, ok := data.(map[string]interface{}) + if !ok { + fmt.Fprint(w, string(yamlBytes)) + } else { + wantedKeys := []string{"version", "kind", "metadata", "spec"} + for _, wantedKey := range wantedKeys { + printKeyYaml(w, wantedKey, asMap[wantedKey]) + } + for otherKey, data := range asMap { + if !slices.Contains(wantedKeys, otherKey) { + printKeyYaml(w, otherKey, data) + } + } + } + return err +} + +// take a interface that can be a resource or multiple resource +// and print it as the content of a file we could use for an apply +func PrintResourceLikeYamlFile(w io.Writer, data interface{}) error { + switch data.(type) { + case []interface{}: + for _, d := range data.([]interface{}) { + fmt.Fprintln(w, "---") + err := printResource(w, d) + if err != nil { + return err + } + } + default: + return printResource(w, data) + } + return nil +} diff --git a/printutils/printYaml_test.go b/printutils/printYaml_test.go new file mode 100644 index 0000000..d516f08 --- /dev/null +++ b/printutils/printYaml_test.go @@ -0,0 +1,96 @@ +package printutils + +import ( + "bytes" + "encoding/json" + "strings" + "testing" +) + +func TestPrintResourceLikeYamlOnSingleResource(t *testing.T) { + resourceFromBe := `{"spec": "someSpec", "version": "v4", "kind": "gelato", "metadata": "arancia"}` + var data interface{} + err := json.Unmarshal([]byte(resourceFromBe), &data) + if err != nil { + t.Fatal(err) + } + var output bytes.Buffer + PrintResourceLikeYamlFile(&output, data) + expected := strings.TrimSpace(` +version: v4 +kind: gelato +metadata: arancia +spec: someSpec`) + got := strings.TrimSpace(output.String()) + if got != expected { + t.Errorf("got:\n%s \nexpected:\n%s", got, expected) + } +} + +func TestPrintResourceLikeYamlInCaseOfScalarValue(t *testing.T) { + resourceFromBe := `[[1], 3, true, "cat"]` + var data interface{} + err := json.Unmarshal([]byte(resourceFromBe), &data) + if err != nil { + t.Fatal(err) + } + var output bytes.Buffer + PrintResourceLikeYamlFile(&output, data) + expected := strings.TrimSpace(` +--- +- 1 +--- +3 +--- +true +--- +cat`) + got := strings.TrimSpace(output.String()) + if got != expected { + t.Errorf("got:\n%s \nexpected:\n%s", got, expected) + } +} + +func TestPrintResourceLikeYamlOnMultileResources(t *testing.T) { + resourceFromBe := `{"spec": "someSpec", "version": "v4", "newKind": "gelato", "metadata": "arancia"}` + var data interface{} + err := json.Unmarshal([]byte(resourceFromBe), &data) + if err != nil { + t.Fatal(err) + } + var output bytes.Buffer + PrintResourceLikeYamlFile(&output, data) + expected := strings.TrimSpace(` +version: v4 +metadata: arancia +spec: someSpec +newKind: gelato +`) + got := strings.TrimSpace(output.String()) + if got != expected { + t.Errorf("got:\n%s \nexpected:\n%s", got, expected) + } +} +func TestPrintResourceWithMissingFieldAndUnexpectedField(t *testing.T) { + resourceFromBe := `[[1], 3, true, "cat"]` + var data interface{} + err := json.Unmarshal([]byte(resourceFromBe), &data) + if err != nil { + t.Fatal(err) + } + var output bytes.Buffer + PrintResourceLikeYamlFile(&output, data) + expected := strings.TrimSpace(` +--- +- 1 +--- +3 +--- +true +--- +cat`) + got := strings.TrimSpace(output.String()) + if got != expected { + t.Errorf("got:\n%s \nexpected:\n%s", got, expected) + } +} diff --git a/resource/resource.go b/resource/resource.go index dfcb75f..cfc562c 100644 --- a/resource/resource.go +++ b/resource/resource.go @@ -11,20 +11,20 @@ import ( ) type Resource struct { - Json []byte - Kind string - Name string - ApiVersion string + Json []byte + Kind string + Name string + Version string } func (r Resource) String() string { - return fmt.Sprintf(`version: %s, kind: %s, name: %s, json: '%s'`, r.ApiVersion, r.Kind, r.Name, string(r.Json)) + return fmt.Sprintf(`version: %s, kind: %s, name: %s, json: '%s'`, r.Version, r.Kind, r.Name, string(r.Json)) } type yamlRoot struct { - ApiVersion string - Kind string - Metadata metadata + Version string + Kind string + Metadata metadata } type metadata struct { @@ -77,5 +77,5 @@ func yamlByteToResource(data []byte) (Resource, error) { return Resource{}, nil } - return Resource{Json: jsonByte, Kind: yamlRoot.Kind, Name: yamlRoot.Metadata.Name, ApiVersion: yamlRoot.ApiVersion}, nil + return Resource{Json: jsonByte, Kind: yamlRoot.Kind, Name: yamlRoot.Metadata.Name, Version: yamlRoot.Version}, nil } diff --git a/resource/resource_test.go b/resource/resource_test.go index 3a2176a..ce76290 100644 --- a/resource/resource_test.go +++ b/resource/resource_test.go @@ -8,14 +8,14 @@ func TestFromByteForOneResourceWithValidResource(t *testing.T) { yamlByte := []byte(` # comment --- -apiVersion: v1 +version: v1 kind: Topic metadata: name: abc.myTopic spec: replicationFactor: 1 --- -apiVersion: v2 +version: v2 kind: ConsumerGroup metadata: name: cg1 @@ -31,10 +31,10 @@ metadata: result1 := results[0] expected1 := Resource{ - ApiVersion: "v1", - Kind: "Topic", - Name: "abc.myTopic", - Json: []byte(`{"apiVersion":"v1","kind":"Topic","metadata":{"name":"abc.myTopic"},"spec":{"replicationFactor":1}}`), + Version: "v1", + Kind: "Topic", + Name: "abc.myTopic", + Json: []byte(`{"kind":"Topic","metadata":{"name":"abc.myTopic"},"spec":{"replicationFactor":1},"version":"v1"}`), } if result1.Name != expected1.Name { @@ -45,22 +45,22 @@ metadata: t.Errorf("Expected name %s got %s", expected1.Kind, result1.Kind) } - if result1.ApiVersion != expected1.ApiVersion { - t.Errorf("Expected name %s got %s", expected1.ApiVersion, result1.ApiVersion) + if result1.Version != expected1.Version { + t.Errorf("Expected name %s got %s", expected1.Version, result1.Version) } expectedJsonString1 := string(expected1.Json) resultJsonString1 := string(result1.Json) if expectedJsonString1 != resultJsonString1 { - t.Errorf("Expected json %s got %s", expectedJsonString1, resultJsonString1) + t.Errorf("\nExpected json:\n%s got:\n%s", expectedJsonString1, resultJsonString1) } result2 := results[1] expected2 := Resource{ - ApiVersion: "v2", - Kind: "ConsumerGroup", - Name: "cg1", - Json: []byte(`{"apiVersion":"v2","kind":"ConsumerGroup","metadata":{"name":"cg1"}}`), + Version: "v2", + Kind: "ConsumerGroup", + Name: "cg1", + Json: []byte(`{"kind":"ConsumerGroup","metadata":{"name":"cg1"},"version":"v2"}`), } if result2.Name != expected2.Name { @@ -71,14 +71,14 @@ metadata: t.Errorf("Expected name %s got %s", expected2.Kind, result2.Kind) } - if result2.ApiVersion != expected2.ApiVersion { - t.Errorf("Expected name %s got %s", expected2.ApiVersion, result2.ApiVersion) + if result2.Version != expected2.Version { + t.Errorf("Expected name %s got %s", expected2.Version, result2.Version) } expectedJsonString2 := string(expected2.Json) resultJsonString2 := string(result2.Json) if expectedJsonString2 != resultJsonString2 { - t.Errorf("Expected json %s got %s", expectedJsonString2, resultJsonString2) + t.Errorf("\nExpected json:\n%s got:\n%s", expectedJsonString2, resultJsonString2) } } diff --git a/test.yml b/test.yml new file mode 100644 index 0000000..79a7e95 --- /dev/null +++ b/test.yml @@ -0,0 +1,20 @@ +# topics.yml +--- +version: v1 +kind: application +metadata: + name: yo +spec: + title: yo + description: yo + owner: me +--- +version: v1 +kind: application +metadata: + name: ya +spec: + title: ya + description: ya + owner: me + diff --git a/test_final_exec.sh b/test_final_exec.sh index 13d6570..8f1d230 100755 --- a/test_final_exec.sh +++ b/test_final_exec.sh @@ -12,9 +12,12 @@ function cleanup { trap cleanup EXIT main() { cd "$SCRIPTDIR" + docker compose -f docker/docker-compose.yml build docker compose -f docker/docker-compose.yml up -d mock sleep 1 docker compose -f docker/docker-compose.yml run conduktor apply -f /test_resource.yml + docker compose -f docker/docker-compose.yml run conduktor get Topic yolo + docker compose -f docker/docker-compose.yml run conduktor delete Topic yolo -d } main "$@" diff --git a/todo b/todo new file mode 100644 index 0000000..6332a7b --- /dev/null +++ b/todo @@ -0,0 +1,14 @@ +apiVersion -> version +get prend ou deux argument il remplace le describe +-d -> -v +-k (-R pour récursion dans les sous dossier) +get il faut du yaml equivalent à ce qui est envoyé + +apply/delete resultat effective: + application/yo: (CREATED) + application/yo: (UPDATED) + application/yo: (UNCHANGED) + +dry-run +audit log for CRUD +diff get/comparaison des yaml