From 6ba75cb320d188f9a1656543fd72868ee2df68dd Mon Sep 17 00:00:00 2001 From: Mateusz Karasiewicz <100848248+mkaras-nobl9@users.noreply.github.com> Date: Fri, 27 Sep 2024 09:45:13 +0200 Subject: [PATCH 1/5] feat: extend SLO status replay with source and user [PC-14276] (#558) ## Motivation Extend ReplayStatus with source and the user who triggered the Replay. ## Summary - added fields to `v1alpha.ReplayStatus`: - `Source` - `user` | `composite_slo` | `error_budget_adjustment` - `TriggeredBy` - user okta ID ## Release Notes Extend `v1alpha.ReplayStatus` with source and the user who triggered the Replay. --- manifest/v1alpha/slo/slo.go | 10 ++++++---- sdk/models/replay.go | 10 ++++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/manifest/v1alpha/slo/slo.go b/manifest/v1alpha/slo/slo.go index ce3384e7..bd465223 100644 --- a/manifest/v1alpha/slo/slo.go +++ b/manifest/v1alpha/slo/slo.go @@ -166,8 +166,10 @@ type Status struct { } type ReplayStatus struct { - Status string `json:"status"` - Unit string `json:"unit"` - Value int `json:"value"` - StartTime string `json:"startTime,omitempty"` + Source string `json:"source"` + Status string `json:"status"` + TriggeredBy string `json:"triggeredBy"` + Unit string `json:"unit"` + Value int `json:"value"` + StartTime string `json:"startTime"` } diff --git a/sdk/models/replay.go b/sdk/models/replay.go index 72478bbe..adfff45f 100644 --- a/sdk/models/replay.go +++ b/sdk/models/replay.go @@ -40,10 +40,12 @@ type ReplayWithStatus struct { } type ReplayStatus struct { - Status string `json:"status"` - Unit string `json:"unit"` - Value int `json:"value"` - StartTime string `json:"startTime,omitempty"` + Source string `json:"source"` + Status string `json:"status"` + TriggeredBy string `json:"triggeredBy"` + Unit string `json:"unit"` + Value int `json:"value"` + StartTime string `json:"startTime"` } // Variants of ReplayStatus.Status. From b0cf38e90596dbdbc53a4098392de499f023c09c Mon Sep 17 00:00:00 2001 From: natalialanga Date: Mon, 30 Sep 2024 10:26:09 +0200 Subject: [PATCH 2/5] fix: PC-14338 System Health Review report name should not be longer than 63 chars (#557) ## Summary Add validation for System Health Review column display name for consistency with other names in the system. ## Release Notes Adjust System Health Review report validation. --- .../report/validation_system_health_review.go | 3 +- manifest/v1alpha/report/validation_test.go | 28 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/manifest/v1alpha/report/validation_system_health_review.go b/manifest/v1alpha/report/validation_system_health_review.go index a932e98b..1cda762b 100644 --- a/manifest/v1alpha/report/validation_system_health_review.go +++ b/manifest/v1alpha/report/validation_system_health_review.go @@ -33,7 +33,8 @@ var systemHealthReviewValidation = govy.New[SystemHealthReviewConfig]( var columnValidation = govy.New[ColumnSpec]( govy.For(func(s ColumnSpec) string { return s.DisplayName }). WithName("displayName"). - Required(), + Required(). + Rules(rules.StringMaxLength(63)), govy.ForMap(func(c ColumnSpec) map[LabelKey][]LabelValue { return c.Labels }). WithName("labels"). Rules(rules.MapMinLength[map[LabelKey][]LabelValue](1)), diff --git a/manifest/v1alpha/report/validation_test.go b/manifest/v1alpha/report/validation_test.go index dfa98926..d3e71b12 100644 --- a/manifest/v1alpha/report/validation_test.go +++ b/manifest/v1alpha/report/validation_test.go @@ -647,6 +647,34 @@ func TestValidate_Spec_SystemHealthReview(t *testing.T) { }, }, }, + "fails with too long displayName": { + ExpectedErrorsCount: 1, + ExpectedErrors: []testutils.ExpectedError{ + { + Prop: "spec.systemHealthReview.columns[0].displayName", + Code: rules.ErrorCodeStringMaxLength, + }, + }, + Config: SystemHealthReviewConfig{ + TimeFrame: SystemHealthReviewTimeFrame{ + Snapshot: SnapshotTimeFrame{ + Point: SnapshotPointLatest, + }, + TimeZone: "Europe/Warsaw", + }, + RowGroupBy: RowGroupByProject, + Columns: []ColumnSpec{ + { + DisplayName: "it is a very long display name, longer than sixty three characters", + Labels: properLabel, + }, + }, + Thresholds: Thresholds{ + RedLessThanOrEqual: ptr(0.0), + GreenGreaterThan: ptr(0.2), + }, + }, + }, "fails with empty thresholds": { ExpectedErrorsCount: 1, ExpectedErrors: []testutils.ExpectedError{ From af5904128aff7eb73b9d49cdb75856e179349acb Mon Sep 17 00:00:00 2001 From: Mateusz Hawrus <48822818+nieomylnieja@users.noreply.github.com> Date: Mon, 30 Sep 2024 15:06:03 +0200 Subject: [PATCH 3/5] chore: Add retry to end-to-end workflow run (#562) --- .github/workflows/e2e-tests.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index ea04b4c7..a2eed552 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -43,6 +43,11 @@ jobs: go-version-file: go.mod cache: false - name: Run tests + uses: nick-fields/retry@v3 + with: + timeout_minutes: 15 + max_attempts: 3 + command: make test/e2e env: NOBL9_SDK_CLIENT_ID: ${{ inputs.clientId }} NOBL9_SDK_CLIENT_SECRET: ${{ secrets.clientSecret }} @@ -50,4 +55,3 @@ jobs: NOBL9_SDK_OKTA_AUTH_SERVER: ${{ inputs.oktaAuthServer }} NOBL9_SDK_NO_CONFIG_FILE: true NOBL9_SDK_TEST_RUN_SEQUENTIAL_APPLY_AND_DELETE: ${{ inputs.sequentialApplyAndDelete }} - run: make test/e2e From 621dce5596ae0a294933df9c10892a21bfe66c2f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 1 Oct 2024 01:22:05 +0000 Subject: [PATCH 4/5] chore: Update minor and patch Golang dependencies (#563) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | Age | Adoption | Passing | Confidence | |---|---|---|---|---|---| | [github.com/nobl9/govy](https://redirect.github.com/nobl9/govy) | `v0.2.0` -> `v0.3.0` | [![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fnobl9%2fgovy/v0.3.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/go/github.com%2fnobl9%2fgovy/v0.3.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/go/github.com%2fnobl9%2fgovy/v0.2.0/v0.3.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fnobl9%2fgovy/v0.2.0/v0.3.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | | [github.com/nobl9/nobl9-go](https://redirect.github.com/nobl9/nobl9-go) | `v0.85.1` -> `v0.86.0` | [![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fnobl9%2fnobl9-go/v0.86.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/go/github.com%2fnobl9%2fnobl9-go/v0.86.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/go/github.com%2fnobl9%2fnobl9-go/v0.85.1/v0.86.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fnobl9%2fnobl9-go/v0.85.1/v0.86.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | --- ### Release Notes
nobl9/govy (github.com/nobl9/govy) ### [`v0.3.0`](https://redirect.github.com/nobl9/govy/releases/tag/v0.3.0) [Compare Source](https://redirect.github.com/nobl9/govy/compare/v0.2.0...v0.3.0) ##### What's Changed ##### 🚀 Features - feat: Add test utilities ([#​25](https://redirect.github.com/nobl9/govy/issues/25)) [@​nieomylnieja](https://redirect.github.com/nieomylnieja) > Added `govytest` package which exposes utilities which help test govy validation rules. > It comes with two functions `AssertNoError`, which ensures no error was produced, and `AssertError` which checks that the expected errors are equal to the actual `govy.ValidatorError`. > Added `OneOfProperties` rule which checks if at least one of the properties is set. ##### 🧰 Maintenance - chore: Update dependency markdownlint-cli to v0.42.0 ([#​24](https://redirect.github.com/nobl9/govy/issues/24)) [@​renovate](https://redirect.github.com/renovate) - chore: Create coverage-pr-report.yml ([#​23](https://redirect.github.com/nobl9/govy/issues/23)) [@​nieomylnieja](https://redirect.github.com/nieomylnieja)
nobl9/nobl9-go (github.com/nobl9/nobl9-go) ### [`v0.86.0`](https://redirect.github.com/nobl9/nobl9-go/releases/tag/v0.86.0) [Compare Source](https://redirect.github.com/nobl9/nobl9-go/compare/v0.85.1...v0.86.0) ### What's Changed #### 🚀 Features - feat: extend SLO status replay with source and user \[PC-14276] ([#​558](https://redirect.github.com/nobl9/nobl9-go/issues/558)) [@​mkaras-nobl9](https://redirect.github.com/mkaras-nobl9) > Extend `v1alpha.ReplayStatus` with source and the user who triggered the > Replay. #### 🐞 Bug Fixes - fix: PC-14338 System Health Review report name should not be longer than 63 chars ([#​557](https://redirect.github.com/nobl9/nobl9-go/issues/557)) [@​natalialanga](https://redirect.github.com/natalialanga) > Adjust System Health Review report validation. #### 🧰 Maintenance - chore: Update module github.com/nobl9/nobl9-go to v0.85.1 ([#​556](https://redirect.github.com/nobl9/nobl9-go/issues/556)) [@​renovate](https://redirect.github.com/renovate)
--- ### Configuration 📅 **Schedule**: Branch creation - "after 10pm every weekday,before 5am every weekday,every weekend" (UTC), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Enabled. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 👻 **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://redirect.github.com/renovatebot/renovate/discussions) if that's undesired. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/nobl9/nobl9-go). [PC-14276]: https://nobl9.atlassian.net/browse/PC-14276?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docs/mock_example/go.mod | 2 +- docs/mock_example/go.sum | 4 ++-- go.mod | 2 +- go.sum | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/mock_example/go.mod b/docs/mock_example/go.mod index 86bcdeb5..0975c9d9 100644 --- a/docs/mock_example/go.mod +++ b/docs/mock_example/go.mod @@ -3,7 +3,7 @@ module mock_example go 1.22 require ( - github.com/nobl9/nobl9-go v0.85.1 + github.com/nobl9/nobl9-go v0.86.0 github.com/stretchr/testify v1.9.0 go.uber.org/mock v0.4.0 ) diff --git a/docs/mock_example/go.sum b/docs/mock_example/go.sum index ae76d952..1643e226 100644 --- a/docs/mock_example/go.sum +++ b/docs/mock_example/go.sum @@ -44,8 +44,8 @@ github.com/nobl9/go-yaml v1.0.1 h1:Aj1kSaYdRQTKlvS6ihvXzQJhCpoHhtf9nfA95zqWH4Q= github.com/nobl9/go-yaml v1.0.1/go.mod h1:t7vCO8ctYdBweZxU5lUgxzAw31+ZcqJYeqRtrv+5RHI= github.com/nobl9/govy v0.2.0 h1:KXZRzHte3uJSpB2i0wBD+3fUoON5ptvlfMrkvtRO8Sc= github.com/nobl9/govy v0.2.0/go.mod h1:O+xSiKwZ6gs/orRvH5qLkfkgyT7CkuXprRIq3C5uNXQ= -github.com/nobl9/nobl9-go v0.85.1 h1:kpybdGbBfwm1Zv1Uqa7ywJOIS28G7F/2f8E2YPuBfek= -github.com/nobl9/nobl9-go v0.85.1/go.mod h1:DPGLjkUkf2BHDj72BbKFuJzD/gxO/qTLtgFU9wKtqzE= +github.com/nobl9/nobl9-go v0.86.0 h1:TpBjVgcluHwgBHtTUuyJF9UCcTiGtHQN5VrFKSjfL00= +github.com/nobl9/nobl9-go v0.86.0/go.mod h1:DPGLjkUkf2BHDj72BbKFuJzD/gxO/qTLtgFU9wKtqzE= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/go.mod b/go.mod index 8e4d0944..609b87a7 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.2.1 github.com/hashicorp/go-retryablehttp v0.7.7 github.com/nobl9/go-yaml v1.0.1 - github.com/nobl9/govy v0.2.0 + github.com/nobl9/govy v0.3.0 github.com/pkg/errors v0.9.1 github.com/stretchr/testify v1.9.0 github.com/teambition/rrule-go v1.8.2 diff --git a/go.sum b/go.sum index 2961c073..f57e1a0b 100644 --- a/go.sum +++ b/go.sum @@ -44,8 +44,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/nobl9/go-yaml v1.0.1 h1:Aj1kSaYdRQTKlvS6ihvXzQJhCpoHhtf9nfA95zqWH4Q= github.com/nobl9/go-yaml v1.0.1/go.mod h1:t7vCO8ctYdBweZxU5lUgxzAw31+ZcqJYeqRtrv+5RHI= -github.com/nobl9/govy v0.2.0 h1:KXZRzHte3uJSpB2i0wBD+3fUoON5ptvlfMrkvtRO8Sc= -github.com/nobl9/govy v0.2.0/go.mod h1:O+xSiKwZ6gs/orRvH5qLkfkgyT7CkuXprRIq3C5uNXQ= +github.com/nobl9/govy v0.3.0 h1:OokgZ9PHfFNt2TkX8h/9rF0Y3doj/nnr0BlPo1BsasY= +github.com/nobl9/govy v0.3.0/go.mod h1:O+xSiKwZ6gs/orRvH5qLkfkgyT7CkuXprRIq3C5uNXQ= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= From 45a5c1c30a847899d8751f7e8ffa2fb57210a895 Mon Sep 17 00:00:00 2001 From: Mateusz Hawrus <48822818+nieomylnieja@users.noreply.github.com> Date: Tue, 1 Oct 2024 13:53:46 +0200 Subject: [PATCH 5/5] feat: Expose structured API response errors (#531) ## Motivation `sdk.Client` methods which interact with Nobl9 API currently return only generic text based errors. This obstructs the relevant details from the user. Furthermore, the current default text format for these errors is fairly poor and doesn't contain details like endpoint path. ## Summary - Added `sdk.APIError` which is a structured error that contains all the relevant API error details as well as produces an improved (over the previous state) text representation. - Improved docs. - Removed PlantUML in favour of Mermaid, since it's much easier to use as it doesn't require Java and renders natively in Markdown. ## Testing Covered by both unit and end-to-end tests. ## Release Notes `sdk.Client` API calls now can return `sdk.APIError`, which is a structured error providing details of the error returned by Nobl9's API. --- Makefile | 22 +- README.md | 241 +++++++++++------- cspell.yaml | 1 + docs/CONTRIBUTING.md | 31 +++ docs/DEVELOPMENT.md | 5 +- internal/sdk/response_errors.go | 40 --- internal/sdk/response_errors_test.go | 68 ----- sdk/api_error.tmpl | 1 + sdk/client.go | 4 + sdk/client_errors.go | 80 ++++++ sdk/client_errors_test.go | 127 +++++++++ sdk/client_test.go | 2 +- sdk/config.go | 4 +- sdk/config_activity.png | Bin 30429 -> 0 bytes sdk/config_activity.puml | 34 --- sdk/endpoints/authdata/v1/endpoints.go | 6 - sdk/endpoints/objects/v1/endpoints.go | 11 +- sdk/endpoints/objects/v1/endpoints_v1alpha.go | 3 - 18 files changed, 407 insertions(+), 273 deletions(-) create mode 100644 docs/CONTRIBUTING.md delete mode 100644 internal/sdk/response_errors.go delete mode 100644 internal/sdk/response_errors_test.go create mode 100644 sdk/api_error.tmpl create mode 100644 sdk/client_errors.go create mode 100644 sdk/client_errors_test.go delete mode 100644 sdk/config_activity.png delete mode 100644 sdk/config_activity.puml diff --git a/Makefile b/Makefile index 8dadddda..c647602f 100644 --- a/Makefile +++ b/Makefile @@ -110,9 +110,9 @@ check/format: $(call _print_check_step,Checking if files are formatted) ./scripts/check-formatting.sh -.PHONY: generate generate/code generate/examples generate/plantuml +.PHONY: generate generate/code generate/examples ## Auto generate files. -generate: generate/code generate/examples generate/plantuml +generate: generate/code generate/examples ## Generate Golang code. generate/code: @@ -127,22 +127,6 @@ generate/examples: echo "Generating examples..." go run internal/cmd/examplegen/main.go -PLANTUML_JAR_URL := https://sourceforge.net/projects/plantuml/files/plantuml.jar/download -PLANTUML_JAR := $(BIN_DIR)/plantuml.jar -DIAGRAMS_PATH ?= . - -## Generate PNG diagrams from PlantUML files. -generate/plantuml: $(PLANTUML_JAR) - for path in $$(find $(DIAGRAMS_PATH) -name "*.puml" -type f); do \ - echo "Generating PNG file(s) for $$path"; \ - java -jar $(PLANTUML_JAR) -tpng $$path; \ - done - -# If the plantuml.jar file isn't already present, download it. -$(PLANTUML_JAR): - echo "Downloading PlantUML JAR..." - curl -sSfL $(PLANTUML_JAR_URL) -o $(PLANTUML_JAR) - .PHONY: format format/go format/cspell ## Format files. format: format/go format/cspell @@ -151,7 +135,7 @@ format: format/go format/cspell format/go: echo "Formatting Go files..." $(call _ensure_installed,binary,goimports) - go fmt ./... + gofmt -w -l -s . $(BIN_DIR)/goimports -local=github.com/nobl9/nobl9-go -w . ## Format cspell config file. diff --git a/README.md b/README.md index 967b2a1c..12806d2d 100644 --- a/README.md +++ b/README.md @@ -38,8 +38,9 @@ Legend: 1. [Installation](#installation) 2. [Examples](#examples) -3. [Repository structure](#repository-structure) -4. [Contributing](#contributing) +3. [Usage](#usage) +4. [Repository structure](#repository-structure) +5. [Contributing](#contributing) # Installation @@ -54,105 +55,155 @@ go get github.com/nobl9/nobl9-go ## Basic usage + ```go package main import ( - "context" - "encoding/json" - "fmt" - "log" - - "github.com/nobl9/nobl9-go/manifest" - "github.com/nobl9/nobl9-go/manifest/v1alpha" - "github.com/nobl9/nobl9-go/manifest/v1alpha/project" - "github.com/nobl9/nobl9-go/manifest/v1alpha/service" - "github.com/nobl9/nobl9-go/sdk" - objectsV1 "github.com/nobl9/nobl9-go/sdk/endpoints/objects/v1" + "context" + "encoding/json" + "fmt" + "log" + + "github.com/nobl9/nobl9-go/manifest" + "github.com/nobl9/nobl9-go/manifest/v1alpha" + "github.com/nobl9/nobl9-go/manifest/v1alpha/project" + "github.com/nobl9/nobl9-go/manifest/v1alpha/service" + "github.com/nobl9/nobl9-go/sdk" + objectsV1 "github.com/nobl9/nobl9-go/sdk/endpoints/objects/v1" ) func main() { - ctx := context.Background() - - // Create client. - client, err := sdk.DefaultClient() - if err != nil { - log.Fatalf("failed to create sdk client, err: %v", err) - } - - // Read from file, url or glob pattern. - objects, err := sdk.ReadObjects(ctx, "./project.yaml") - if err != nil { - log.Fatalf("failed to read project.yaml file, err: %v", err) - } - // Use manifest.FilterByKind to extract specific objects from the manifest.Object slice. - myProject := manifest.FilterByKind[project.Project](objects)[0] - // Define objects in code. - myService := service.New( - service.Metadata{ - Name: "my-service", - DisplayName: "My Service", - Project: myProject.GetName(), - Labels: v1alpha.Labels{ - "team": []string{"green", "orange"}, - "region": []string{"eu-central-1"}, - }, - }, - service.Spec{ - Description: "Example service", - }, - ) - objects = append(objects, myService) - - // Verify the objects. - if errs := manifest.Validate(objects); len(errs) > 0 { - log.Fatalf("service validation failed, errors: %v", errs) - } - - // Apply the objects. - if err = client.Objects().V1().Apply(ctx, objects); err != nil { - log.Fatalf("failed to apply objects, err: %v", err) - } - - // Get the applied resources. - services, err := client.Objects().V1().GetV1alphaServices(ctx, objectsV1.GetServicesRequest{ - Project: myProject.GetName(), - Names: []string{myService.GetName()}, - }) - if err != nil { - log.Fatalf("failed to get services, err: %v", err) - } - projects, err := client.Objects().V1().GetV1alphaProjects(ctx, objectsV1.GetProjectsRequest{ - Names: []string{myProject.GetName()}, - }) - if err != nil { - log.Fatalf("failed to get projects, err: %v", err) - } - - // Aggregate objects back into manifest.Objects slice. - appliedObjects := make([]manifest.Object, 0, len(services)+len(projects)) - for _, service := range services { - appliedObjects = append(appliedObjects, service) - } - for _, project := range projects { - appliedObjects = append(appliedObjects, project) - } - - // Print JSON representation of these objects. - data, err := json.MarshalIndent(appliedObjects, "", " ") - if err != nil { - log.Fatalf("failed to marshal objects, err: %v", err) - } - fmt.Println(string(data)) - - // Delete resources. - if err = client.Objects().V1().Delete(ctx, objects); err != nil { - log.Fatalf("failed to delete objects, err: %v", err) - } + ctx := context.Background() + + // Create client. + client, err := sdk.DefaultClient() + if err != nil { + log.Fatalf("failed to create sdk client, err: %v", err) + } + + // Read from file, url or glob pattern. + objects, err := sdk.ReadObjects(ctx, "./project.yaml") + if err != nil { + log.Fatalf("failed to read project.yaml file, err: %v", err) + } + // Use manifest.FilterByKind to extract specific objects from the manifest.Object slice. + myProject := manifest.FilterByKind[project.Project](objects)[0] + // Define objects in code. + myService := service.New( + service.Metadata{ + Name: "my-service", + DisplayName: "My Service", + Project: myProject.GetName(), + Labels: v1alpha.Labels{ + "team": []string{"green", "orange"}, + "region": []string{"eu-central-1"}, + }, + }, + service.Spec{ + Description: "Example service", + }, + ) + objects = append(objects, myService) + + // Verify the objects. + if errs := manifest.Validate(objects); len(errs) > 0 { + log.Fatalf("service validation failed, errors: %v", errs) + } + + // Apply the objects. + if err = client.Objects().V1().Apply(ctx, objects); err != nil { + log.Fatalf("failed to apply objects, err: %v", err) + } + + // Get the applied resources. + services, err := client.Objects().V1().GetV1alphaServices(ctx, objectsV1.GetServicesRequest{ + Project: myProject.GetName(), + Names: []string{myService.GetName()}, + }) + if err != nil { + log.Fatalf("failed to get services, err: %v", err) + } + projects, err := client.Objects().V1().GetV1alphaProjects(ctx, objectsV1.GetProjectsRequest{ + Names: []string{myProject.GetName()}, + }) + if err != nil { + log.Fatalf("failed to get projects, err: %v", err) + } + + // Aggregate objects back into manifest.Objects slice. + appliedObjects := make([]manifest.Object, 0, len(services)+len(projects)) + for _, service := range services { + appliedObjects = append(appliedObjects, service) + } + for _, project := range projects { + appliedObjects = append(appliedObjects, project) + } + + // Print JSON representation of these objects. + data, err := json.MarshalIndent(appliedObjects, "", " ") + if err != nil { + log.Fatalf("failed to marshal objects, err: %v", err) + } + fmt.Println(string(data)) + + // Delete resources. + if err = client.Objects().V1().Delete(ctx, objects); err != nil { + log.Fatalf("failed to delete objects, err: %v", err) + } } ``` + +# Usage + +## Reading configuration + +In order for `sdk.Client` to work, it needs to be configured. +The configuration can be read from a file, environment variables, +code options or a combination of these. + +The precedence of the configuration sources is as follows +(starting from the highest): + +- Code options +- Environment variables +- Configuration file +- Default values + +The following flowchart illustrates the process of reading the configuration: + +```mermaid +flowchart TD + subgraph s1[Read config file] + direction LR + As1{{Config file exists}} -- true --> Bs1(Read config file) + As1 -- false --> Cs1(Create default config file) + Cs1 --> Bs1 + end + subgraph s2[Build config struct] + direction LR + As2{{Has ConfigOption}} -- not set --> Bs2{{Has env variable}} + As2 -- set --> Fs2(Use value) + Bs2 -- not set --> Cs2{{Has config file option}} + Bs2 -- set --> Fs2 + Cs2 -- not set --> Ds2{{Has default value}} + Cs2 -- set --> Fs2 + Ds2 -- not set --> Es2(No value) + Ds2 -- set --> Fs2 + end + A(Read config) --> B(Read config options defined in code) + B --> C(Read env variables) + C --> s1 + s1 --> s2 --> I(Return Config) +``` + +## Testing code relying on nobl9-go + +Checkout [these instructions](./docs/mock_example/README.md) +along with a working example for recommendations on mocking `sdk.Client`. + # Repository structure ## Public packages @@ -184,6 +235,14 @@ func main() { - Object-specific packages, like [slo](./manifest/v1alpha/slo), provide object definition for specific object versions. +## Internal packages + +1. [tests](./tests) contains the end-to-end tests code. + These tests are run directly against a Nobl9 platform. +2. [internal](./internal) holds internal packages that are not meant to be + exposed as part of the library's API. + # Contributing -TBA +Checkout both [contributing guidelines](./docs/CONTRIBUTING.md) and +[development instructions](./docs/DEVELOPMENT.md). diff --git a/cspell.yaml b/cspell.yaml index 63f20bfc..fbd0db71 100644 --- a/cspell.yaml +++ b/cspell.yaml @@ -81,6 +81,7 @@ words: - generify - gobin - gofile + - gofmt - goimports - golangci - gomnd diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md new file mode 100644 index 00000000..0b465c1b --- /dev/null +++ b/docs/CONTRIBUTING.md @@ -0,0 +1,31 @@ +# Contributing to nobl9-go + +If you're here, chances are you want to contribute ;) +Thanks a lot for that! + +Your pull request will be reviewed by one of the maintainers. +We encourage and welcome any and all feedback. + +## Before you contribute + +The goal of this project is to provide a feature-rich and easy-to-use +Golang SDK for Nobl9 platform. + +Make sure you're familiarized with +[development instructions](./DEVELOPMENT.md). + +## Making a pull request + +Please make a fork of this repo and submit a PR from there. +More information can be found +[here](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request). + +## Merge Request title + +Try to be as descriptive as you can in your PR title. +Note that the title must adhere to the rules defined in +[this workflow](./.github/workflows/pr-title.yml). + +## License + +Nobl9-go is licensed under Mozilla Public License Version 2.0, see [LICENSE](../LICENSE). diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 44ba3a7f..5a5e3702 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -124,8 +124,9 @@ We use the following tools to do that: ## Validation -We're using our own validation library to write validation for all objects. -Refer to this [README.md](../internal/validation/README.md) for more information. +We're using [govy](https://github.com/nobl9/govy) library for validation. +If you encounter any bugs or shortcomings feel free to open an issue or PR +at govy's GitHub page. ## Dependencies diff --git a/internal/sdk/response_errors.go b/internal/sdk/response_errors.go deleted file mode 100644 index 205d02a6..00000000 --- a/internal/sdk/response_errors.go +++ /dev/null @@ -1,40 +0,0 @@ -package sdk - -import ( - "bytes" - "fmt" - "io" - "net/http" - - "github.com/pkg/errors" -) - -func ProcessResponseErrors(resp *http.Response) error { - switch { - case resp.StatusCode >= 300 && resp.StatusCode < 400: - rawErr, _ := io.ReadAll(resp.Body) - return fmt.Errorf("unexpected status code response: %d, body: %s", resp.StatusCode, string(rawErr)) - case resp.StatusCode >= 400 && resp.StatusCode < 500: - body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("%s", bytes.TrimSpace(body)) - case resp.StatusCode >= 500: - return getResponseServerError(resp) - } - return nil -} - -var ErrConcurrencyIssue = errors.New("operation failed due to concurrency issue but can be retried") - -func getResponseServerError(resp *http.Response) error { - rawBody, _ := io.ReadAll(resp.Body) - body := string(bytes.TrimSpace(rawBody)) - if body == ErrConcurrencyIssue.Error() { - return ErrConcurrencyIssue - } - msg := fmt.Sprintf("%s error message: %s", http.StatusText(resp.StatusCode), rawBody) - traceID := resp.Header.Get(HeaderTraceID) - if traceID != "" { - msg = fmt.Sprintf("%s error id: %s", msg, traceID) - } - return errors.New(msg) -} diff --git a/internal/sdk/response_errors_test.go b/internal/sdk/response_errors_test.go deleted file mode 100644 index 74ebbec5..00000000 --- a/internal/sdk/response_errors_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package sdk - -import ( - "bytes" - "fmt" - "io" - "net/http" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestProcessResponseErrors(t *testing.T) { - t.Parallel() - - t.Run("status code smaller than 300, no error", func(t *testing.T) { - t.Parallel() - for code := 200; code < 300; code++ { - require.NoError(t, ProcessResponseErrors(&http.Response{StatusCode: code})) - } - }) - - t.Run("status code between 300 and 399", func(t *testing.T) { - t.Parallel() - for code := 300; code < 400; code++ { - err := ProcessResponseErrors(&http.Response{ - StatusCode: code, - Body: io.NopCloser(bytes.NewBufferString("error!"))}) - require.Error(t, err) - require.EqualError(t, err, fmt.Sprintf("unexpected status code response: %d, body: error!", code)) - } - }) - - t.Run("user errors", func(t *testing.T) { - t.Parallel() - for code := 400; code < 500; code++ { - err := ProcessResponseErrors(&http.Response{ - StatusCode: code, - Body: io.NopCloser(bytes.NewBufferString("error!"))}) - require.Error(t, err) - require.EqualError(t, err, "error!") - } - }) - - t.Run("server errors", func(t *testing.T) { - t.Parallel() - for code := 500; code < 600; code++ { - err := ProcessResponseErrors(&http.Response{ - StatusCode: code, - Header: http.Header{HeaderTraceID: []string{"123"}}, - Body: io.NopCloser(bytes.NewBufferString("error!"))}) - require.Error(t, err) - require.EqualError(t, - err, - fmt.Sprintf("%s error message: error! error id: 123", http.StatusText(code))) - } - }) - - t.Run("concurrency issue", func(t *testing.T) { - t.Parallel() - err := ProcessResponseErrors(&http.Response{ - StatusCode: 500, - Body: io.NopCloser(bytes.NewBufferString( - "operation failed due to concurrency issue but can be retried"))}) - require.Error(t, err) - require.Equal(t, ErrConcurrencyIssue, err) - }) -} diff --git a/sdk/api_error.tmpl b/sdk/api_error.tmpl new file mode 100644 index 00000000..187b041e --- /dev/null +++ b/sdk/api_error.tmpl @@ -0,0 +1 @@ +{{- if .CodeText }}{{ .CodeText }}: {{ end -}}{{ .Message }} (code: {{ .Code }}{{- if .URL }}, endpoint: {{ .Method }} {{ .URL }}{{- end }}{{- if .TraceID }}, traceId: {{ .TraceID }}{{- end }}) \ No newline at end of file diff --git a/sdk/client.go b/sdk/client.go index 5e25e928..8e8c96e3 100644 --- a/sdk/client.go +++ b/sdk/client.go @@ -132,6 +132,10 @@ func (c *Client) Do(req *http.Request) (*http.Response, error) { if err != nil { return nil, errors.Wrap(err, "failed to execute request") } + if err = processHTTPResponse(resp); err != nil { + _ = resp.Body.Close() + return nil, err + } return resp, nil } diff --git a/sdk/client_errors.go b/sdk/client_errors.go new file mode 100644 index 00000000..e849e61d --- /dev/null +++ b/sdk/client_errors.go @@ -0,0 +1,80 @@ +package sdk + +import ( + "bytes" + _ "embed" + "fmt" + "io" + "net/http" + "os" + "text/template" +) + +// APIError represents an HTTP error response from the API. +type APIError struct { + Message string `json:"message"` + StatusCode int `json:"statusCode"` + Method string `json:"method"` + URL string `json:"url"` + TraceID string `json:"traceId,omitempty"` +} + +// IsRetryable returns true if the underlying API error can be retried. +func (r APIError) IsRetryable() bool { + return r.StatusCode >= 500 +} + +// Error returns a string representation of the error. +func (r APIError) Error() string { + buf := bytes.Buffer{} + buf.Grow(len(apiErrorTemplateData)) + if err := apiErrorTemplate.Execute(&buf, apiErrorTemplateFields{ + Message: r.Message, + Method: r.Method, + URL: r.URL, + TraceID: r.TraceID, + CodeText: http.StatusText(r.StatusCode), + Code: r.StatusCode, + }); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "failed to execute APIError template: %v\n", err) + } + return buf.String() +} + +// processHTTPResponse processes an HTTP response and returns an error if the response is erroneous. +func processHTTPResponse(resp *http.Response) error { + if resp.StatusCode < 300 { + return nil + } + var body string + if resp.Body != nil { + rawBody, _ := io.ReadAll(resp.Body) + body = string(bytes.TrimSpace(rawBody)) + } + respErr := APIError{ + StatusCode: resp.StatusCode, + TraceID: resp.Header.Get(HeaderTraceID), + Message: body, + } + if resp.Request != nil { + if resp.Request.URL != nil { + respErr.URL = resp.Request.URL.String() + } + respErr.Method = resp.Request.Method + } + return &respErr +} + +//go:embed api_error.tmpl +var apiErrorTemplateData string + +var apiErrorTemplate = template.Must(template.New("api_error").Parse(apiErrorTemplateData)) + +type apiErrorTemplateFields struct { + Message string + Method string + URL string + TraceID string + CodeText string + Code int +} diff --git a/sdk/client_errors_test.go b/sdk/client_errors_test.go new file mode 100644 index 00000000..baad6898 --- /dev/null +++ b/sdk/client_errors_test.go @@ -0,0 +1,127 @@ +package sdk + +import ( + "bytes" + "fmt" + "io" + "net/http" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAPIError(t *testing.T) { + t.Parallel() + t.Run("status code smaller than 300, no error", func(t *testing.T) { + t.Parallel() + for code := 200; code < 300; code++ { + require.NoError(t, processHTTPResponse(&http.Response{StatusCode: code})) + } + }) + t.Run("errors", func(t *testing.T) { + t.Parallel() + for code := 300; code < 600; code++ { + err := processHTTPResponse(&http.Response{ + StatusCode: code, + Body: io.NopCloser(bytes.NewBufferString("error!")), + Header: http.Header{HeaderTraceID: []string{"123"}}, + Request: &http.Request{ + Method: http.MethodGet, + URL: &url.URL{ + Scheme: "https", + Host: "app.nobl9.com", + Path: "/api/slos", + }, + }, + }) + require.Error(t, err) + expectedMessage := fmt.Sprintf("error! (code: %d, endpoint: GET https://app.nobl9.com/api/slos, traceId: 123)", code) + if textCode := http.StatusText(code); textCode != "" { + expectedMessage = textCode + ": " + expectedMessage + } + require.EqualError(t, err, expectedMessage) + } + }) + t.Run("missing trace id", func(t *testing.T) { + t.Parallel() + err := processHTTPResponse(&http.Response{ + StatusCode: http.StatusBadRequest, + Body: io.NopCloser(bytes.NewBufferString("error!")), + Request: &http.Request{ + Method: http.MethodGet, + URL: &url.URL{ + Scheme: "https", + Host: "app.nobl9.com", + Path: "/api/slos", + }, + }, + }) + require.Error(t, err) + expectedMessage := "Bad Request: error! (code: 400, endpoint: GET https://app.nobl9.com/api/slos)" + require.EqualError(t, err, expectedMessage) + }) + t.Run("missing status text", func(t *testing.T) { + t.Parallel() + err := processHTTPResponse(&http.Response{ + StatusCode: 555, + Body: io.NopCloser(bytes.NewBufferString("error!")), + Request: &http.Request{ + Method: http.MethodGet, + URL: &url.URL{ + Scheme: "https", + Host: "app.nobl9.com", + Path: "/api/slos", + }, + }, + }) + require.Error(t, err) + expectedMessage := "error! (code: 555, endpoint: GET https://app.nobl9.com/api/slos)" + require.EqualError(t, err, expectedMessage) + }) + t.Run("missing url", func(t *testing.T) { + t.Parallel() + err := processHTTPResponse(&http.Response{ + StatusCode: 555, + Body: io.NopCloser(bytes.NewBufferString("error!")), + }) + require.Error(t, err) + expectedMessage := "error! (code: 555)" + require.EqualError(t, err, expectedMessage) + }) +} + +func TestAPIError_IsRetryable(t *testing.T) { + t.Parallel() + tests := []*http.Response{ + { + StatusCode: http.StatusInternalServerError, + Body: io.NopCloser(bytes.NewBufferString("operation failed due to concurrency issue but can be retried")), + Request: &http.Request{ + Method: http.MethodPut, + URL: &url.URL{ + Scheme: "https", + Host: "app.nobl9.com", + Path: "/api/apply", + }, + }, + }, + { + StatusCode: http.StatusInternalServerError, + Request: &http.Request{ + Method: http.MethodPut, + URL: &url.URL{ + Scheme: "https", + Host: "app.nobl9.com", + Path: "/api/apply", + }, + }, + }, + } + for _, test := range tests { + err := processHTTPResponse(test) + require.Error(t, err) + assert.True(t, err.(*APIError).IsRetryable()) + } +} diff --git a/sdk/client_test.go b/sdk/client_test.go index f97e4fc8..e2ace0e8 100644 --- a/sdk/client_test.go +++ b/sdk/client_test.go @@ -145,8 +145,8 @@ func prepareTestClient(t *testing.T, endpoint endpointConfig) (client *Client, s KID: kid, }, }) - jwks := jwkset.JWKSMarshal{Keys: []jwkset.JWKMarshal{jwk.Marshal()}} require.NoError(t, err) + jwks := jwkset.JWKSMarshal{Keys: []jwkset.JWKMarshal{jwk.Marshal()}} // Prepare the token. claims := jwt.MapClaims{ diff --git a/sdk/config.go b/sdk/config.go index 33540446..4de09ce5 100644 --- a/sdk/config.go +++ b/sdk/config.go @@ -44,7 +44,7 @@ func GetDefaultConfigPath() (string, error) { // - config file // - default values where applicable // -// Detailed flow can be found in config_activity.png (generated from config_activity.puml). +// Detailed flow can be found in config_activity.mmd. func ReadConfig(options ...ConfigOption) (*Config, error) { conf, err := newConfig(options) if err != nil { @@ -243,7 +243,7 @@ func newConfig(options []ConfigOption) (*Config, error) { for _, applyOption := range options { applyOption(conf) } - if err := conf.processEnvVariables(&conf.options, false); err != nil { + if err = conf.processEnvVariables(&conf.options, false); err != nil { return nil, err } return conf, nil diff --git a/sdk/config_activity.png b/sdk/config_activity.png deleted file mode 100644 index b64ec382526a0017e7450f1173d10ded9d02282e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 30429 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