diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml new file mode 100644 index 0000000..84c1844 --- /dev/null +++ b/.github/workflows/golangci-lint.yml @@ -0,0 +1,41 @@ +name: golangci-lint +on: + push: + branches: + - '**' # Run on all branches + pull_request: + +permissions: + contents: read + # Optional: allow read access to pull request. Use with `only-new-issues` option. + # pull-requests: read + +jobs: + golangci: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + check-latest: true + go-version: '1.22' + - name: golangci-lint + uses: golangci/golangci-lint-action@v5 + with: + version: v1.57 + skip-cache: true + args: --timeout=5m + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + check-latest: true + go-version: '1.22' + - name: Test all + run: go test ./... \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e7e9976 --- /dev/null +++ b/.gitignore @@ -0,0 +1,111 @@ +# via https://github.com/github/gitignore +# License/attribution not required, CC0 + +### +# +# Go +# +### + +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# doc/ + +# Go workspace file +go.work + +### +# +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 +# +### + +# We'll ignore the entire idea folder +.idea/ +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..07a114a --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,330 @@ +# This code is licensed under the terms of the MIT license https://opensource.org/license/mit +# Copyright (c) 2021 Marat Reymers + +## Golden config for golangci-lint v1.57.2 +# +# This is the best config for golangci-lint based on my experience and opinion. +# It is very strict, but not extremely strict. +# Feel free to adapt and change it for your needs. + +run: + # Timeout for analysis, e.g. 30s, 5m. + # Default: 1m + timeout: 3m + + +# This file contains only configs which differ from defaults. +# All possible options can be found here https://github.com/golangci/golangci-lint/blob/master/.golangci.reference.yml +linters-settings: + cyclop: + # The maximal code complexity to report. + # Default: 10 + max-complexity: 40 + # The maximal average package complexity. + # If it's higher than 0.0 (float) the check is enabled + # Default: 0.0 + package-average: 10.0 + + errcheck: + # Report about not checking of errors in type assertions: `a := b.(MyStruct)`. + # Such cases aren't reported by default. + # Default: false + check-type-assertions: true + + exhaustive: + # Program elements to check for exhaustiveness. + # Default: [ switch ] + check: + - switch + - map + + exhaustruct: + # List of regular expressions to exclude struct packages and their names from checks. + # Regular expressions must match complete canonical struct package/name/structname. + # Default: [] + exclude: + # std libs + - "^net/http.Client$" + - "^net/http.Cookie$" + - "^net/http.Request$" + - "^net/http.Response$" + - "^net/http.Server$" + - "^net/http.Transport$" + - "^net/url.URL$" + - "^os/exec.Cmd$" + - "^reflect.StructField$" + # public libs + - "^github.com/Shopify/sarama.Config$" + - "^github.com/Shopify/sarama.ProducerMessage$" + - "^github.com/mitchellh/mapstructure.DecoderConfig$" + - "^github.com/prometheus/client_golang/.+Opts$" + - "^github.com/spf13/cobra.Command$" + - "^github.com/spf13/cobra.CompletionOptions$" + - "^github.com/stretchr/testify/mock.Mock$" + - "^github.com/testcontainers/testcontainers-go.+Request$" + - "^github.com/testcontainers/testcontainers-go.FromDockerfile$" + - "^golang.org/x/tools/go/analysis.Analyzer$" + - "^google.golang.org/protobuf/.+Options$" + - "^gopkg.in/yaml.v3.Node$" + + funlen: + # Checks the number of lines in a function. + # If lower than 0, disable the check. + # Default: 60 + lines: 100 + # Checks the number of statements in a function. + # If lower than 0, disable the check. + # Default: 40 + statements: 50 + # Ignore comments when counting lines. + # Default false + ignore-comments: true + + gocognit: + # Minimal code complexity to report. + # Default: 30 (but we recommend 10-20) + min-complexity: 40 + + gocritic: + # Settings passed to gocritic. + # The settings key is the name of a supported gocritic checker. + # The list of supported checkers can be find in https://go-critic.github.io/overview. + settings: + captLocal: + # Whether to restrict checker to params only. + # Default: true + paramsOnly: false + underef: + # Whether to skip (*x).method() calls where x is a pointer receiver. + # Default: true + skipRecvDeref: false + + gomnd: + # List of function patterns to exclude from analysis. + # Values always ignored: `time.Date`, + # `strconv.FormatInt`, `strconv.FormatUint`, `strconv.FormatFloat`, + # `strconv.ParseInt`, `strconv.ParseUint`, `strconv.ParseFloat`. + # Default: [] + ignored-functions: + - flag.Arg + - flag.Duration.* + - flag.Float.* + - flag.Int.* + - flag.Uint.* + - os.Chmod + - os.Mkdir.* + - os.OpenFile + - os.WriteFile + - prometheus.ExponentialBuckets.* + - prometheus.LinearBuckets + + gomodguard: + blocked: + # List of blocked modules. + # Default: [] + modules: + - github.com/golang/protobuf: + recommendations: + - google.golang.org/protobuf + reason: "see https://developers.google.com/protocol-buffers/docs/reference/go/faq#modules" + - github.com/satori/go.uuid: + recommendations: + - github.com/google/uuid + reason: "satori's package is not maintained" + - github.com/gofrs/uuid: + recommendations: + - github.com/gofrs/uuid/v5 + reason: "gofrs' package was not go module before v5" + + govet: + # Enable all analyzers. + # Default: false + enable-all: true + # Disable analyzers by name. + # Run `go tool vet help` to see all analyzers. + # Default: [] + disable: + - fieldalignment # too strict + # Settings per analyzer. + settings: + shadow: + # Whether to be strict about shadowing; can be noisy. + # Default: false + strict: true + + inamedparam: + # Skips check for interface methods with only a single parameter. + # Default: false + skip-single-param: true + + nakedret: + # Make an issue if func has more lines of code than this setting, and it has naked returns. + # Default: 30 + max-func-lines: 0 + + nolintlint: + # Exclude following linters from requiring an explanation. + # Default: [] + allow-no-explanation: [ funlen, gocognit, lll ] + # Enable to require an explanation of nonzero length after each nolint directive. + # Default: false + require-explanation: true + # Enable to require nolint directives to mention the specific linter being suppressed. + # Default: false + require-specific: true + + perfsprint: + # Optimizes into strings concatenation. + # Default: true + strconcat: false + + rowserrcheck: + # database/sql is always checked + # Default: [] + packages: + - github.com/jmoiron/sqlx + + tenv: + # The option `all` will run against whole test files (`_test.go`) regardless of method/function signatures. + # Otherwise, only methods that take `*testing.T`, `*testing.B`, and `testing.TB` as arguments are checked. + # Default: false + all: true + + +linters: + disable-all: true + enable: + ## enabled by default + - errcheck # checking for unchecked errors, these unchecked errors can be critical bugs in some cases + - gosimple # specializes in simplifying a code + - govet # reports suspicious constructs, such as Printf calls whose arguments do not align with the format string + - ineffassign # detects when assignments to existing variables are not used + - staticcheck # is a go vet on steroids, applying a ton of static analysis checks + - typecheck # like the front-end of a Go compiler, parses and type-checks Go code + - unused # checks for unused constants, variables, functions and types + ## disabled by default + - asasalint # checks for pass []any as any in variadic func(...any) + - asciicheck # checks that your code does not contain non-ASCII identifiers + - bidichk # checks for dangerous unicode character sequences + - bodyclose # checks whether HTTP response body is closed successfully + - copyloopvar # detects places where loop variables are copied + - cyclop # checks function and package cyclomatic complexity + - dupl # tool for code clone detection + - durationcheck # checks for two durations multiplied together + - errname # checks that sentinel errors are prefixed with the Err and error types are suffixed with the Error + - errorlint # finds code that will cause problems with the error wrapping scheme introduced in Go 1.13 + - execinquery # checks query string in Query function which reads your Go src files and warning it finds + - exhaustive # checks exhaustiveness of enum switch statements + - exportloopref # checks for pointers to enclosing loop variables + - forbidigo # forbids identifiers + - funlen # tool for detection of long functions + - gocheckcompilerdirectives # validates go compiler directive comments (//go:) + - gochecknoglobals # checks that no global variables exist + # - gochecknoinits # checks that no init functions are present in Go code + - gochecksumtype # checks exhaustiveness on Go "sum types" + - gocognit # computes and checks the cognitive complexity of functions + - goconst # finds repeated strings that could be replaced by a constant + - gocritic # provides diagnostics that check for bugs, performance and style issues + - gocyclo # computes and checks the cyclomatic complexity of functions + - godot # checks if comments end in a period + - goimports # in addition to fixing imports, goimports also formats your code in the same style as gofmt + - gomnd # detects magic numbers + - gomoddirectives # manages the use of 'replace', 'retract', and 'excludes' directives in go.mod + - gomodguard # allow and block lists linter for direct Go module dependencies. This is different from depguard where there are different block types for example version constraints and module recommendations + - goprintffuncname # checks that printf-like functions are named with f at the end + - gosec # inspects source code for security problems + - intrange # finds places where for loops could make use of an integer range + - lll # reports long lines + - loggercheck # checks key value pairs for common logger libraries (kitlog,klog,logr,zap) + - makezero # finds slice declarations with non-zero initial length + - mirror # reports wrong mirror patterns of bytes/strings usage + - musttag # enforces field tags in (un)marshaled structs + - nakedret # finds naked returns in functions greater than a specified function length + - nestif # reports deeply nested if statements + - nilerr # finds the code that returns nil even if it checks that the error is not nil + - nilnil # checks that there is no simultaneous return of nil error and an invalid value + - noctx # finds sending http request without context.Context + - nolintlint # reports ill-formed or insufficient nolint directives + - nonamedreturns # reports all named returns + - nosprintfhostport # checks for misuse of Sprintf to construct a host with port in a URL + - perfsprint # checks that fmt.Sprintf can be replaced with a faster alternative + - predeclared # finds code that shadows one of Go's predeclared identifiers + - promlinter # checks Prometheus metrics naming via promlint + - protogetter # reports direct reads from proto message fields when getters should be used + - reassign # checks that package variables are not reassigned + - revive # fast, configurable, extensible, flexible, and beautiful linter for Go, drop-in replacement of golint + - rowserrcheck # checks whether Err of rows is checked successfully + - sloglint # ensure consistent code style when using log/slog + - spancheck # checks for mistakes with OpenTelemetry/Census spans + - sqlclosecheck # checks that sql.Rows and sql.Stmt are closed + - stylecheck # is a replacement for golint + - tenv # detects using os.Setenv instead of t.Setenv since Go1.17 + - testableexamples # checks if examples are testable (have an expected output) + - testifylint # checks usage of github.com/stretchr/testify + # - testpackage # makes you use a separate _test package + - tparallel # detects inappropriate usage of t.Parallel() method in your Go test codes + - unconvert # removes unnecessary type conversions + - unparam # reports unused function parameters + - usestdlibvars # detects the possibility to use variables/constants from the Go standard library + - wastedassign # finds wasted assignment statements + - whitespace # detects leading and trailing whitespace + + ## you may want to enable + #- decorder # checks declaration order and count of types, constants, variables and functions + #- exhaustruct # [highly recommend to enable] checks if all structure fields are initialized + #- gci # controls golang package import order and makes it always deterministic + #- ginkgolinter # [if you use ginkgo/gomega] enforces standards of using ginkgo and gomega + #- godox # detects FIXME, TODO and other comment keywords + #- goheader # checks is file header matches to pattern + #- inamedparam # [great idea, but too strict, need to ignore a lot of cases by default] reports interfaces with unnamed method parameters + #- interfacebloat # checks the number of methods inside an interface + #- ireturn # accept interfaces, return concrete types + #- prealloc # [premature optimization, but can be used in some cases] finds slice declarations that could potentially be preallocated + #- tagalign # checks that struct tags are well aligned + #- varnamelen # [great idea, but too many false positives] checks that the length of a variable's name matches its scope + #- wrapcheck # checks that errors returned from external packages are wrapped + #- zerologlint # detects the wrong usage of zerolog that a user forgets to dispatch zerolog.Event + + ## disabled + #- containedctx # detects struct contained context.Context field + #- contextcheck # [too many false positives] checks the function whether use a non-inherited context + #- depguard # [replaced by gomodguard] checks if package imports are in a list of acceptable packages + #- dogsled # checks assignments with too many blank identifiers (e.g. x, _, _, _, := f()) + #- dupword # [useless without config] checks for duplicate words in the source code + #- errchkjson # [don't see profit + I'm against of omitting errors like in the first example https://github.com/breml/errchkjson] checks types passed to the json encoding functions. Reports unsupported types and optionally reports occasions, where the check for the returned error can be omitted + #- forcetypeassert # [replaced by errcheck] finds forced type assertions + #- goerr113 # [too strict] checks the errors handling expressions + #- gofmt # [replaced by goimports] checks whether code was gofmt-ed + #- gofumpt # [replaced by goimports, gofumports is not available yet] checks whether code was gofumpt-ed + #- gosmopolitan # reports certain i18n/l10n anti-patterns in your Go codebase + #- grouper # analyzes expression groups + #- importas # enforces consistent import aliases + #- maintidx # measures the maintainability index of each function + #- misspell # [useless] finds commonly misspelled English words in comments + #- nlreturn # [too strict and mostly code is not more readable] checks for a new line before return and branch statements to increase code clarity + #- paralleltest # [too many false positives] detects missing usage of t.Parallel() method in your Go test + #- tagliatelle # checks the struct tags + #- thelper # detects golang test helpers without t.Helper() call and checks the consistency of test helpers + #- wsl # [too strict and mostly code is not more readable] whitespace linter forces you to use empty lines + + +issues: + # Maximum count of issues with the same text. + # Set to 0 to disable. + # Default: 3 + max-same-issues: 50 + + exclude-rules: + - source: "(noinspection|TODO)" + linters: [ godot ] + - source: "//noinspection" + linters: [ gocritic ] + - path: "_test\\.go" + linters: + - bodyclose + - dupl + - funlen + - goconst + - gosec + - noctx + - wrapcheck diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..9b3d91a --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,16 @@ +repos: + - repo: https://github.com/golangci/golangci-lint + rev: v1.57.2 + hooks: + - id: golangci-lint-full + - repo: local + hooks: + - id: go-licenses-save + name: go-licenses-save + description: Discover and save 3rd party dependency licenses + entry: go-licenses save ./... --save_path="doc/3rd-party-deps" --ignore github.com/omc --force + types: [ go ] + language: golang + require_serial: true + pass_filenames: false + files: ^go\.(mod|sum)$ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e69de29 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..15eba9d --- /dev/null +++ b/LICENSE @@ -0,0 +1,375 @@ +Copyright (c) 2021 HashiCorp, Inc. + +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a998d81 --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +# bonsai-go: Bonsai Cloud Go API Client + +## Installation + +```shell +go get github.com/omc/bonsai-api-go/v1 +``` + +## Example + +```go +package main + +import ( + "context" + "fmt" + "log" + + "github.com/omc/bonsai-api-go/v1/bonsai" +) + +func main() { + token, err := bonsai.NewToken("TestToken") + if err != nil { + log.Fatal(fmt.Errorf("invalid token: %w", err)) + } + + client := bonsai.NewClient( + bonsai.WithToken(token), + ) + + clusters, _, err := client.Clusters.All(context.Background()) + if err != nil { + log.Fatalf("error listing clusters: %s\n", err) + } + log.Printf("Found %d clusters!\n", len(clusters)) +} +``` + +## Contributing + +### Pre-commit + +This project uses [pre-commit](https://pre-commit.com/) to lint and store 3rd-party dependency licenses. +Installation instructions are available on the [pre-commit](https://pre-commit.com/) website! + +To verify your installation, run this project's pre-commit hooks against all files: + +```shell +pre-commit run --all-files +``` + +#### Go-licenses pre-commit hook + +Windows users: Ensure that you have `C:\Program Files\Git\usr\bin` added +to your `PATH`! \ No newline at end of file diff --git a/bonsai/bonsai.go b/bonsai/bonsai.go new file mode 100644 index 0000000..55ce06c --- /dev/null +++ b/bonsai/bonsai.go @@ -0,0 +1,6 @@ +// Package bonsai wraps the Bonsai.io HTTP API to create a Go API Client. +package bonsai + +const ( + Float64Epsilon = 1e-9 +) diff --git a/bonsai/bonsai_test.go b/bonsai/bonsai_test.go new file mode 100644 index 0000000..c777f4c --- /dev/null +++ b/bonsai/bonsai_test.go @@ -0,0 +1,35 @@ +package bonsai_test + +import ( + "fmt" + "log" + "log/slog" + "os" +) + +func init() { + initLogger() +} + +func initLogger() { + // https://github.com/golang/go/issues/62403 + // https://cs.opensource.google/go/x/exp/+/master:slog/handler.go;l=442 + + logHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + AddSource: true, + ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { + if a.Key == slog.SourceKey { + src, ok := a.Value.Any().(*slog.Source) + if !ok { + log.Fatalf("sourceKey attr is not a Source: %v", a.Value) + } + // Ruby on Rails-ish formatting + a.Value = slog.StringValue(fmt.Sprintf("%s:%d:in '%s'", src.File, src.Line, src.Function)) + } + return a + }, + }) + + logger := slog.New(logHandler) + slog.SetDefault(logger) +} diff --git a/bonsai/client.go b/bonsai/client.go new file mode 100644 index 0000000..d7aa24c --- /dev/null +++ b/bonsai/client.go @@ -0,0 +1,519 @@ +package bonsai + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "reflect" + "strconv" + "strings" + "time" + + "golang.org/x/net/http/httpguts" + "golang.org/x/time/rate" +) + +// Client representation configuration. +const ( + // Version reflects this API Client's version. + Version = "1.0.0" + // BaseEndpoint is the target API URL base location. + BaseEndpoint = "https://api.bonsai.io" + // UserAgent is the internally used value for the User-Agent header + // in all outgoing HTTP requests. + UserAgent = "bonsai-api-go/" + Version +) + +// Client rate limiter configuration. +const ( + // DefaultClientBurstAllowance is the default Bonsai API request burst + // allowance. + DefaultClientBurstAllowance = 60 + // DefaultClientBurstDuration is the default interval for a token + // bucket of size DefaultClientBurstAllowance to be refilled. + DefaultClientBurstDuration = 1 * time.Minute + // ProvisionClientBurstAllowance is the default Bonsai API request burst allowance. + ProvisionClientBurstAllowance = 5 + // ProvisionClientBurstDuration is the default interval for a token bucket + // of size ProvisionClientBurstAllowance to be refilled. + ProvisionClientBurstDuration = 1 * time.Minute +) + +// Common API Response headers. +const ( + // HeaderRetryAfter holds the number of seconds to delay before making the next request. + // ref: https://bonsai.io/docs/api-error-429-too-many-requests + HeaderRetryAfter = "Retry-After" +) + +// HTTP Content Types and related Header. +const ( + HTTPHeaderContentType = "Content-Type" + HTTPContentTypeJSON string = "application/json" +) + +// Magic numbers used to limit allocations, etc. +const ( + defaultResponseCapacity = 8 + defaultListResultSize = 100 +) + +// HTTP Status Response Errors. +var ( + ErrHTTPStatusNotFound = errors.New("not found") + ErrHTTPStatusForbidden = errors.New("forbidden") + ErrHTTPStatusPaymentRequired = errors.New("payment required") + ErrHTTPStatusUnprocessableEntity = errors.New("unprocessable entity") + ErrHTTPStatusUnauthorized = errors.New("unauthorized") + ErrHTTPStatusTooManyRequests = errors.New("too many requests") +) + +// ResponseError captures API response errors +// returned as JSON in supported scenarios. +// +// ref: https://bonsai.io/docs/introduction-to-the-api +type ResponseError struct { + Errors []string `json:"errors"` + Status int `json:"status"` +} + +// Error represents ResponseError, which may have multiple Errors +// as a string. +// +// The community is as yet undecided on a great way to handle this +// ref: https://github.com/golang/go/issues/47811 +func (r ResponseError) Error() string { + return fmt.Sprintf("%v (%d)", r.Errors, r.Status) +} + +func (r ResponseError) Is(target error) bool { + switch r.Status { + case http.StatusUnauthorized: + return target == ErrHTTPStatusUnauthorized + case http.StatusNotFound: + return target == ErrHTTPStatusNotFound + case http.StatusForbidden: + return target == ErrHTTPStatusForbidden + case http.StatusPaymentRequired: + return target == ErrHTTPStatusPaymentRequired + case http.StatusUnprocessableEntity: + return target == ErrHTTPStatusUnprocessableEntity + case http.StatusTooManyRequests: + return target == ErrHTTPStatusTooManyRequests + } + + return false +} + +// listOpts specifies options for listing resources. +// ref: https://bonsai.io/docs/api-result-pagination +type listOpts struct { + Page int // Page number, starting at 1 + Size int // Size of each page, with a max of 100 +} + +// newListOpts creates a new listOpts with default values per the API docs. +// +//nolint:unused // will be used for clusters endpoint +func newDefaultListOpts() listOpts { + return listOpts{ + Page: 1, + Size: defaultListResultSize, + } +} + +// newEmptyListOpts returns an empty list opts, +// to make it easy for readers to immediately see that there are no options +// being passed, rather than seeing a struct be initialized in-line. +func newEmptyListOpts() listOpts { + return listOpts{} +} + +// values returns the listOpts as URL values. +func (l listOpts) values() url.Values { + vals := url.Values{} + if l.Page > 0 { + vals.Add("page", strconv.Itoa(l.Page)) + } + if l.Size > 0 { + vals.Add("size", strconv.Itoa(l.Size)) + } + return vals +} + +func (l listOpts) IsZero() bool { + return l.Page == 0 && l.Size == 0 +} + +func (l listOpts) Valid() bool { + return !l.IsZero() +} + +type Application struct { + Name string + Version string +} + +func (app Application) String() string { + switch { + case app.Name != "" && app.Version != "": + return app.Name + "/" + app.Version + case app.Name != "" && app.Version == "": + return app.Name + default: + return "" + } +} + +type Token struct { + string +} + +func (t Token) Empty() bool { + return t.string == "" +} + +func (t Token) NotEmpty() bool { + return !t.Empty() +} + +func NewToken(token string) (Token, error) { + t := Token{token} + if ok := t.validHTTPValue(); !ok { + return Token{}, errors.New("invalid token") + } + return t, nil +} + +func (t Token) validHTTPValue() bool { + return httpguts.ValidHeaderFieldValue(t.string) +} + +// ClientOption is a functional option, used to configure Client. +type ClientOption func(*Client) + +// WithEndpoint configures a Client to use the specified API endpoint. +func WithEndpoint(endpoint string) ClientOption { + return func(c *Client) { + c.endpoint = strings.TrimRight(endpoint, "/") + } +} + +// WithToken configures a Client to use the specified token for authentication. +func WithToken(token Token) ClientOption { + return func(c *Client) { + c.token = token + } +} + +// WithApplication configures the client to represent itself as +// a particular Application by modifying the User-Agent header +// sent in all requests. +func WithApplication(app Application) ClientOption { + return func(c *Client) { + c.userAgent = app.String() + if c.userAgent == "" { + c.userAgent = UserAgent + } else { + c.userAgent += " " + UserAgent + } + } +} + +// WithDefaultRateLimit configures the default rate limit for client requests. +func WithDefaultRateLimit(l *rate.Limiter) ClientOption { + return func(c *Client) { + c.rateLimiter.limiter = l + } +} + +// WithProvisionRateLimit configures the rate limit for client requests to the Provision API. +func WithProvisionRateLimit(l *rate.Limiter) ClientOption { + return func(c *Client) { + c.rateLimiter.provisionLimiter = l + } +} + +type PaginatedResponse struct { + PageNumber int `json:"page_number"` + PageSize int `json:"page_size"` + TotalRecords int `json:"total_records"` +} + +type httpResponse = *http.Response +type Response struct { + httpResponse `json:"-"` + BodyBuf bytes.Buffer `json:"-"` + PaginatedResponse `json:"pagination"` +} + +func (r *Response) isJSON() bool { + return r.Header.Get("Content-Type") == HTTPContentTypeJSON +} + +// WithHTTPResponse assigns an *http.Response to a *Response item +// and reads its response body into the *Response. +func (r *Response) WithHTTPResponse(httpResp *http.Response) error { + r.httpResponse = httpResp + + err := r.readHTTPResponseBody() + if err != nil { + return fmt.Errorf("reading response body for error extraction: %w", err) + } + + return err +} + +func (r *Response) MarkPaginationComplete() { + r.PaginatedResponse = PaginatedResponse{} +} + +func (r *Response) readHTTPResponseBody() error { + var ( + err error + ) + + _, err = r.BodyBuf.ReadFrom(r.Body) + if err != nil { + return fmt.Errorf("error reading response body: %w", err) + } + + return nil +} + +func extractRetryDelay(r *Response) (int64, error) { + var ( + retryAfter int64 + err error + ) + // We're already blocking on this routine, so sleep inline per the header request. + if retryAfterStr := r.Header.Get(HeaderRetryAfter); retryAfterStr != "" { + retryAfter, err = strconv.ParseInt(retryAfterStr, 10, 64) + if err != nil { + return retryAfter, fmt.Errorf("error parsing retry-after response: %w", err) + } + } + return retryAfter, nil +} + +// NewResponse reserves this function signature, and is +// the recommended way to instantiate a Response, as its behavior +// may change. +func NewResponse() (*Response, error) { + return &Response{}, nil +} + +type limiter = *rate.Limiter +type ClientLimiter struct { + // limiter is an embedded default rate limiter, but not exposed. + limiter + // provisionLimiter is the rate limiter to be used for Provision endpoints + provisionLimiter *rate.Limiter +} + +// Client is the exported client that users interact with. +type Client struct { + httpClient *http.Client + + rateLimiter *ClientLimiter + endpoint string + token Token + userAgent string + + // Clients + Space SpaceClient + Plan PlanClient + Release ReleaseClient + Cluster ClusterClient +} + +func NewClient(options ...ClientOption) *Client { + client := &Client{ + endpoint: BaseEndpoint, + httpClient: &http.Client{}, + rateLimiter: &ClientLimiter{ + limiter: rate.NewLimiter(rate.Every(DefaultClientBurstDuration), DefaultClientBurstAllowance), + provisionLimiter: rate.NewLimiter(rate.Every(ProvisionClientBurstDuration), ProvisionClientBurstAllowance), + }, + } + + for _, option := range options { + option(client) + } + + // Configure child clients + client.Space = SpaceClient{client} + client.Plan = PlanClient{client} + client.Release = ReleaseClient{client} + client.Cluster = ClusterClient{client} + + return client +} + +func (c *Client) UserAgent() string { + return c.userAgent +} + +// NewRequest creates an HTTP request against the API. The returned request +// is assigned with ctx and has all necessary headers set (auth, user agent, etc.). +func (c *Client) NewRequest(ctx context.Context, method, path string, body io.Reader) (*http.Request, error) { + reqURL := c.endpoint + path + req, err := http.NewRequest(method, reqURL, body) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", c.userAgent) + + if c.token.NotEmpty() { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.token)) + } + + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + req = req.WithContext(ctx) + + return req, nil +} + +// Do performs an HTTP request against the API. +func (c *Client) Do(ctx context.Context, req *http.Request) (*Response, error) { + reqBuf := new(bytes.Buffer) + + // Capture the original request body + if req.ContentLength > 0 { + _, err := reqBuf.ReadFrom(req.Body) + if err != nil { + return nil, fmt.Errorf("error reading request body: %w", err) + } + + err = IoClose(req.Body, err) + if err != nil { + return nil, err + } + } + + // We only retry in the scenario of http.StatusTooManyRequests (429). + for { + respErr := &ResponseError{} + resp, err := c.doRequest(ctx, req, reqBuf) + switch { + case errors.As(err, respErr): + if reflect.ValueOf(respErr).IsZero() { + return resp, fmt.Errorf("unknown error occurred with response status %d", resp.StatusCode) + } else if errors.Is(err, ErrHTTPStatusTooManyRequests) { + // Block in this routine, if needed. + var delay int64 + if delay, err = extractRetryDelay(resp); err != nil { + time.Sleep(time.Duration(delay) * time.Second) + } + continue + } + return resp, err + default: + return resp, err + } + } +} + +func (c *Client) doRequest(ctx context.Context, req *http.Request, reqBuf *bytes.Buffer) (*Response, error) { + // Wrap the buffer in a no-op Closer, such that + // it satisfies the ReadCloser interface + if req.ContentLength > 0 { + req.Body = io.NopCloser(reqBuf) + } + + // Context canceled, timed-out, burst issue, or other rate limit issue; + // let the callers handle it. + if err := c.rateLimiter.Wait(ctx); err != nil { + return nil, fmt.Errorf("failed while awaiting execution per rate-limit: %w", err) + } + + httpResp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("http request failed: %w", err) + } + defer func() { err = IoClose(httpResp.Body, err) }() + + if httpResp == nil { + return nil, errors.New("received nil http.Response") + } + + resp, err := NewResponse() + if err != nil { + return resp, errors.New("creating new Response") + } + + err = resp.WithHTTPResponse(httpResp) + if err != nil { + return resp, fmt.Errorf("setting http response: %w", err) + } + + // All error reposes should come with a JSON response per the Error handling + // section @ https://bonsai.io/docs/introduction-to-the-api. + // + // That said, in the scenario that an error *isn't* returned as a JSON body + // response, it would be jarring to receive a message about an internal + // unmarshaling attempt, rather than to receive the HTTP Status Error + if resp.StatusCode >= http.StatusBadRequest { + respErr := ResponseError{Status: resp.StatusCode} + + if ok := resp.isJSON(); ok { + // Suppress unmarshaling errors in the event that the response didn't + // contain a message. + _ = json.Unmarshal(resp.BodyBuf.Bytes(), &respErr) + } + + return resp, respErr + } + + // Extract the pagination details + if resp.isJSON() { + err = json.Unmarshal(resp.BodyBuf.Bytes(), &resp) + if err != nil { + return resp, fmt.Errorf("error unmarshaling response body for pagination: %w", err) + } + } + + return resp, err +} + +// all loops through the next page pagination results until empty +// it allows the caller to pass a func (typically a closure) to collect +// results. +func (c *Client) all(ctx context.Context, opt listOpts, f func(opts listOpts) (*Response, error)) error { + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + resp, err := f(opt) + if err != nil { + return err + } + + // The caller is responsible for determining whether we've exhausted + // retries. + if reflect.ValueOf(resp.PaginatedResponse).IsZero() || resp.PageNumber <= 0 { + return nil + } + + // If the response contains a page number, provide the next call with an + // incremented page number, and the response page size. + // + // Again, the caller must determine whether the total number of results have been delivered. + if resp.PageNumber > 0 { + opt = listOpts{ + Page: resp.PageNumber + 1, + Size: resp.PageSize, + } + } + } + } +} diff --git a/bonsai/client_impl_test.go b/bonsai/client_impl_test.go new file mode 100644 index 0000000..98f6c11 --- /dev/null +++ b/bonsai/client_impl_test.go @@ -0,0 +1,163 @@ +package bonsai + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + "golang.org/x/time/rate" +) + +// ClientImplTestSuite is responsible for testing internal facing items/behavior +// that isn't part of the exposed interface, but is hard to test via that interface. +// Things like the default HTTP Client's rate-limiter (unexposed), and other implementation +// details fall under this umbrella - these test cases should be few. +type ClientImplTestSuite struct { + // Assertions embedded here allows all tests to reach through the suite to access assertion methods + *require.Assertions + // Suite is the testify/suite used for all HTTP request tests + suite.Suite + + // serveMux is the request multiplexer used for tests + serveMux *chi.Mux + // server is the testing server on some local port + server *httptest.Server + // client allows each test to have a reachable *Client for testing + client *Client +} + +func (s *ClientImplTestSuite) SetupSuite() { + // Configure http client and other miscellany + s.serveMux = chi.NewRouter() + s.server = httptest.NewServer(s.serveMux) + token, err := NewToken("TestToken") + if err != nil { + log.Fatal(fmt.Errorf("invalid token received: %w", err)) + } + s.client = NewClient( + WithEndpoint(s.server.URL), + WithToken(token), + ) + + // configure testify + s.Assertions = require.New(s.T()) +} + +func (s *ClientImplTestSuite) TestClientDefaultRateLimit() { + c := NewClient() + s.Equal(DefaultClientBurstAllowance, c.rateLimiter.Burst()) + s.InEpsilon(float64(rate.Every(DefaultClientBurstDuration)), float64(c.rateLimiter.Limit()), Float64Epsilon) +} + +func (s *ClientImplTestSuite) TestListOptsValues() { + testCases := []struct { + name string + received listOpts + expect string + }{ + { + name: "with populated values", + received: listOpts{ + Page: 3, + Size: 100, + }, + expect: "page=3&size=100", + }, + { + name: "with empty values", + received: listOpts{}, + expect: "", + }, + } + + for _, tc := range testCases { + s.Run(tc.name, func() { + s.Equal(tc.received.values().Encode(), tc.expect) + }) + } +} + +func (s *ClientImplTestSuite) TestClientAll() { + const expectedPageCount = 4 + var ( + ctx = context.Background() + expectedPage = 1 + ) + + s.serveMux.Get("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set(HTTPHeaderContentType, HTTPContentTypeJSON) + + respBody, _ := NewResponse() + respBody.PaginatedResponse = PaginatedResponse{ + PageNumber: 3, + PageSize: 1, + TotalRecords: 3, + } + + switch page := r.URL.Query().Get("page"); page { + case "", "1": + respBody.PaginatedResponse.PageNumber = 1 + case "2": + respBody.PaginatedResponse.PageNumber = 2 + case "3": + respBody.PaginatedResponse.PageNumber = 3 + default: + s.FailNowf("invalid page parameter", "page parameter: %v", page) + } + + err := json.NewEncoder(w).Encode(respBody) + s.NoError(err, "encode response body") + }) + + // The caller must track results against expected count + // A reminder to the reader: this is the caller. + var resultCount = 0 + err := s.client.all(context.Background(), newEmptyListOpts(), func(opt listOpts) (*Response, error) { + reqPath := "/" + + if opt.Valid() { + s.Equalf(expectedPage, opt.Page, "expected page number (%d) matches actual (%d)", expectedPage, opt.Page) + reqPath = fmt.Sprintf("%s?page=%d&size=1", reqPath, opt.Page) + } + + req, err := s.client.NewRequest(ctx, "GET", reqPath, nil) + s.NoError(err, "new request for path") + + resp, err := s.client.Do(context.Background(), req) + s.NoError(err, "do request") + + expectedPage++ + // A reference of how these funcs should handle this; + // recall, the response may be shorter than max. + // + // Ideally, this count wouldn't be derived from PageSize, + // but rather, from the total count of discovered items + // unmarshaled. + resultCount += max(resp.PageSize, 0) + + if resultCount >= resp.TotalRecords { + resp.MarkPaginationComplete() + } + return resp, err + }) + s.NoError(err, "client.all call") + + s.Equalf( + expectedPage, + expectedPageCount, + "expected page visit count (%d) matches actual visit count (%d)", + expectedPageCount-1, + expectedPage-1, + ) +} + +func TestClientImplTestSuite(t *testing.T) { + suite.Run(t, new(ClientImplTestSuite)) +} diff --git a/bonsai/client_test.go b/bonsai/client_test.go new file mode 100644 index 0000000..148c083 --- /dev/null +++ b/bonsai/client_test.go @@ -0,0 +1,196 @@ +package bonsai_test + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + "github.com/go-chi/chi/v5" + "github.com/omc/bonsai-api-go/v1/bonsai" +) + +const ( + ResponseErrorHTTPStatusNotFound = ` + { + "errors": [ + "Cluster doesnotexist-1234 not found.", + "Please review the documentation available at https://docs.bonsai.io", + "Undefined request." + ], + "status": 404 + }` +) + +type ClientTestSuite struct { + // Assertions embedded here allows all tests to reach through the suite to access assertion methods + *require.Assertions + // Suite is the testify/suite used for all HTTP request tests + suite.Suite + + // serveMux is the request multiplexer used for tests + serveMux *chi.Mux + // server is the testing server on some local port + server *httptest.Server + // client allows each test to have a reachable *bonsai.Client for testing + client *bonsai.Client +} + +func (s *ClientTestSuite) SetupSuite() { + // Configure http client and other miscellany + s.serveMux = chi.NewRouter() + s.server = httptest.NewServer(s.serveMux) + token, err := bonsai.NewToken("TestToken") + if err != nil { + log.Fatal(fmt.Errorf("invalid token received: %w", err)) + } + s.client = bonsai.NewClient( + bonsai.WithEndpoint(s.server.URL), + bonsai.WithToken(token), + ) + + // configure testify + s.Assertions = require.New(s.T()) +} + +func (s *ClientTestSuite) TestResponseErrorUnmarshallJson() { + testCases := []struct { + name string + received string + expect bonsai.ResponseError + }{ + { + name: "error example from docs site", + received: ` + { + "errors": [ + "This request has failed authentication. ` + + `Please read the docs or email us at support@bonsai.io." + ], + "status": 401 + } + `, + expect: bonsai.ResponseError{ + Errors: []string{ + "This request has failed authentication. Please read the docs or email us at support@bonsai.io.", + }, + Status: 401, + }, + }, + } + + for _, tc := range testCases { + s.Run(tc.name, func() { + respErr := bonsai.ResponseError{} + err := json.Unmarshal([]byte(tc.received), &respErr) + s.NoError(err) + s.Equal(tc.expect, respErr) + }) + } +} + +func (s *ClientTestSuite) TestClientResponseError() { + const p = "/clusters/doesnotexist-1234" + + // Configure Servemux to serve the error response at this path + s.serveMux.Get(p, func(w http.ResponseWriter, _ *http.Request) { + var err error + + w.Header().Set("Content-Type", bonsai.HTTPContentTypeJSON) + w.WriteHeader(http.StatusNotFound) + + respErr := &bonsai.ResponseError{} + err = json.Unmarshal([]byte(ResponseErrorHTTPStatusNotFound), respErr) + s.NoError(err, "successfully unmarshals json into bonsaiResponseError") + + err = json.NewEncoder(w).Encode(respErr) + s.NoError(err, "encodes http response into ResponseError") + }) + + req, err := s.client.NewRequest(context.Background(), "GET", p, nil) + s.NoError(err, "request creation returns no error") + + resp, err := s.client.Do(context.Background(), req) + s.Error(err, "Client.Do returns an error") + + s.Equal(http.StatusNotFound, resp.StatusCode) + s.ErrorAs(err, &bonsai.ResponseError{}, "Client.Do error response type is of ResponseError") + s.ErrorIs(err, bonsai.ErrHTTPStatusNotFound, "ResponseError is comparable to bonsai.ErrorHttpResponseStatus") +} + +func (s *ClientTestSuite) TestClientResponseWithPagination() { + s.serveMux.Get("/clusters", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, err := fmt.Fprint(w, ` + { + "foo": "bar", + "pagination": { + "page_number": 1, + "page_size": 20, + "total_records": 255 + } + } + `) + s.NoError(err, "writes json response into response writer") + }) + + req, err := s.client.NewRequest(context.Background(), "GET", "/clusters", nil) + s.NoError(err, "request creation returns no error") + + resp, err := s.client.Do(context.Background(), req) + s.NoError(err, "Client.Do succeeds") + + s.Equal(1, resp.PaginatedResponse.PageNumber) + s.Equal(20, resp.PaginatedResponse.PageSize) + s.Equal(255, resp.PaginatedResponse.TotalRecords) +} + +func (s *ClientTestSuite) TestClient_WithApplication() { + testCases := []struct { + name string + received bonsai.Application + expect string + }{ + { + name: "both Application fields filled in", + received: bonsai.Application{ + Name: "withName", + Version: "withVersion", + }, + expect: fmt.Sprintf("%s/%s %s", "withName", "withVersion", bonsai.UserAgent), + }, + { + name: "application name non-empty; version empty", + received: bonsai.Application{ + Name: "withName", + Version: "", + }, + expect: fmt.Sprintf("%s %s", "withName", bonsai.UserAgent), + }, + { + name: "Application fields both empty", + received: bonsai.Application{}, + expect: bonsai.UserAgent, + }, + } + + for _, tc := range testCases { + s.Run(tc.name, func() { + c := bonsai.NewClient( + bonsai.WithApplication(tc.received), + ) + s.Equal(tc.expect, c.UserAgent()) + }) + } +} + +func TestClientTestSuite(t *testing.T) { + suite.Run(t, new(ClientTestSuite)) +} diff --git a/bonsai/cluster.go b/bonsai/cluster.go new file mode 100644 index 0000000..2dc4e1e --- /dev/null +++ b/bonsai/cluster.go @@ -0,0 +1,461 @@ +package bonsai + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "path" + "reflect" + + "github.com/google/go-querystring/query" +) + +const ( + ClusterAPIBasePath = "/clusters" +) + +// ClusterStats holds *some* statistics about the cluster. +// +// This attribute should not be used for real-time monitoring! +// Stats are updated every 10-15 minutes. To monitor real-time metrics, monitor +// your cluster directly, via the Index Stats API. +type ClusterStats struct { + // Number of documents in the index. + Docs int64 `json:"docs,omitempty"` + // Number of shards the cluster is using. + ShardsUsed int64 `json:"shards_used,omitempty"` + // Number of bytes the cluster is using on-disk. + DataBytesUsed int64 `json:"data_bytes_used,omitempty"` +} + +// ClusterAccess holds information about connecting to the cluster. +type ClusterAccess struct { + // Host name of the cluster + Host string `json:"host"` + // HTTP Port the cluster is running on. + Port int `json:"port"` + // HTTP Scheme needed to access the cluster. Default: "https". + Scheme string `json:"scheme"` + + // User holds the username to access the cluster with. + // Only shown once, during cluster creation. + Username string `json:"user,omitempty"` + // Pass holds the password to access the cluster with. + // Only shown once, during cluster creation. + Password string `json:"pass,omitempty"` + // URL is the Cluster endpoint for access. + // Only shown once, during cluster creation. + URL string `json:"url,omitempty"` +} + +// ClusterState represents the current state of the cluster, indicating what +// the cluster is doing at any given moment. +type ClusterState string + +const ( + ClusterStateDeprovisioned ClusterState = "DEPROVISIONED" + ClusterStateDeprovisioning ClusterState = "DEPROVISIONING" + ClusterStateDisabled ClusterState = "DISABLED" + ClusterStateMaintenance ClusterState = "MAINTENANCE" + ClusterStateProvisioned ClusterState = "PROVISIONED" + ClusterStateProvisioning ClusterState = "PROVISIONING" + ClusterStateReadOnly ClusterState = "READONLY" + ClusterStateUpdatingPlan ClusterState = "UPDATING PLAN" +) + +// Cluster represents a subscription cluster. +type Cluster struct { + // Slug represents a unique, machine-readable name for the cluster. + // A cluster slug is based its name at creation, to which a random integer + // is concatenated. + Slug string `json:"slug"` + // Name is the human-readable name of the cluster. + Name string `json:"name"` + // URI is a machine-readable name for the cluster. + URI string `json:"uri"` + + // Plan holds some information about the cluster's current subscription plan. + Plan Plan `json:"plan"` + // Release holds some information about the cluster's current release. + Release Release `json:"release"` + + // Space holds some information about where the cluster is running. + Space Space `json:"space"` + + // Stats holds a collection of statistics about the cluster. + Stats ClusterStats `json:"stats"` + + // ClusterAccess holds information about connecting to the cluster. + Access ClusterAccess `json:"access"` + + // State represents the current state of the cluster. This indicates what + // the cluster is doing at any given moment. + State ClusterState `json:"state"` +} + +// ClustersResultList is a wrapper around a slice of +// Clusters for json unmarshaling. +type ClustersResultList struct { + Clusters []Cluster `json:"clusters"` +} + +// ClustersResultCreate is the result response for Create (POST) requests to the +// clusters endpoint. +type ClustersResultCreate struct { + // Message contains details about the cluster creation request. + Message string `json:"message"` + // Monitor holds a URI to the Cluster overview page. + Monitor string `json:"monitor"` + Access ClusterAccess `json:"access"` +} + +// ClustersResultUpdate is the result response for Update (PUT) requests to the +// clusters endpoint. +type ClustersResultUpdate struct { + // Message contains details about the cluster update request. + Message string `json:"message"` + // Monitor holds a URI to the Cluster overview page. + Monitor string `json:"monitor"` +} + +// ClustersResultDestroy is the result response for Destroy (DELETE) requests to the +// clusters endpoint. +type ClustersResultDestroy struct { + // Message contains details about the cluster destroy request. + Message string `json:"message"` + // Monitor holds a URI to the Cluster overview page. + Monitor string `json:"monitor"` +} + +// ClusterClient is a client for the Clusters API. +type ClusterClient struct { + *Client +} + +type ClusterAllOpts struct { + // Optional. A query string for filtering matching clusters. + // This currently works on name. + Query string `url:"q,omitempty"` + // Optional. A string which will constrain results to parent or child + // cluster. Valid values are: "parent", "child" + Tenancy string `url:"tenancy,omitempty"` + // Optional. A string representing the account, region, space, or cluster + // path where the cluster is located. You can get a list of available spaces + // with the [bonsai.SpaceClient] API. Space path prefixes work here, so you + // can find all clusters in a given region for a given cloud. + Location string `url:"location,omitempty"` +} + +type ClusterCreateOpts struct { + // Required. A String representing the name for the new cluster. + Name string `json:"name"` + // The slug of the Plan that the new cluster will be configured for. + // Use the [PlanClient.All] method to view a list of all Plans available. + Plan string `json:"plan,omitempty"` + // The slug of the Space where the new cluster should be deployed to. + // Use the [SpaceClient.All] method to view a list of all Spaces. + Space string `json:"space,omitempty"` + // The Search Service Release that the new cluster will use. + // Use the [ReleaseClient.All] method to view a list of all Spaces. + Release string `json:"release,omitempty"` +} + +func (o ClusterCreateOpts) Valid() error { + if o.Name == "" { + return errors.New("name can't be empty") + } + return nil +} + +type ClusterUpdateOpts struct { + // Required. A String representing the name for the new cluster. + Name string `json:"name"` + // The slug of the Plan that the new cluster will be configured for. + // Use the [PlanClient.All] method to view a list of all Plans available. + Plan string `json:"plan,omitempty"` +} + +func (o ClusterUpdateOpts) Valid() error { + if o.Name == "" { + return errors.New("name can't be empty") + } + return nil +} + +type clusterListOpts struct { + listOpts + ClusterAllOpts +} + +func (o clusterListOpts) values() (url.Values, error) { + queryValues := o.listOpts.values() + + clusterValues, err := query.Values(o.ClusterAllOpts) + if err != nil { + return nil, fmt.Errorf("error parsing cluster list options: %w", err) + } + + for k, v := range clusterValues { + queryValues[k] = v + } + + return queryValues, nil +} + +// list returns a list of Clusters for the page specified, +// by performing a GET request against [spaceAPIBasePath]. +// +// Note: Pagination is not currently supported. +func (c *ClusterClient) list(ctx context.Context, opt clusterListOpts) ( + []Cluster, + *Response, + error, +) { + var ( + req *http.Request + reqURL *url.URL + resp *Response + err error + ) + // Let's make some initial capacity to reduce allocations + results := ClustersResultList{ + Clusters: make([]Cluster, 0, defaultResponseCapacity), + } + + reqURL, err = url.Parse(ClusterAPIBasePath) + if err != nil { + return results.Clusters, nil, fmt.Errorf("cannot parse relative url from basepath (%s): %w", ClusterAPIBasePath, err) + } + + // Conditionally set options if we received any + if !reflect.ValueOf(opt).IsZero() { + var optVals url.Values + + optVals, err = opt.values() + if err != nil { + return results.Clusters, nil, fmt.Errorf("failed to get values from opt (%+v): %w", opt, err) + } + + reqURL.RawQuery = optVals.Encode() + } + + req, err = c.NewRequest(ctx, "GET", reqURL.String(), nil) + if err != nil { + return results.Clusters, nil, fmt.Errorf("creating new http request for URL (%s): %w", reqURL.String(), err) + } + + resp, err = c.Do(ctx, req) + if err != nil { + return results.Clusters, resp, fmt.Errorf("client.do failed: %w", err) + } + + if err = json.Unmarshal(resp.BodyBuf.Bytes(), &results); err != nil { + return results.Clusters, resp, fmt.Errorf("json.Unmarshal failed: %w", err) + } + + return results.Clusters, resp, nil +} + +// All lists all Clusters from the Clusters API. +func (c *ClusterClient) All(ctx context.Context) ([]Cluster, error) { + var ( + err error + resp *Response + ) + + allResults := make([]Cluster, 0, defaultListResultSize) + // No pagination support as yet, but support it for future use + + err = c.all(ctx, newEmptyListOpts(), func(opt listOpts) (*Response, error) { + var listResults []Cluster + + listResults, resp, err = c.list(ctx, clusterListOpts{listOpts: opt}) + if err != nil { + return resp, fmt.Errorf("client.list failed: %w", err) + } + + allResults = append(allResults, listResults...) + if len(allResults) >= resp.PageSize { + resp.MarkPaginationComplete() + } + return resp, err + }) + + if err != nil { + return allResults, fmt.Errorf("client.all failed: %w", err) + } + + return allResults, err +} + +// GetBySlug gets a Cluster from the Clusters API by its slug. +// +//nolint:dupl // Allow duplicated code blocks in code paths that may change +func (c *ClusterClient) GetBySlug(ctx context.Context, slug string) (Cluster, error) { + var ( + req *http.Request + reqURL *url.URL + resp *Response + err error + result Cluster + ) + + reqURL, err = url.Parse(ClusterAPIBasePath) + if err != nil { + return result, fmt.Errorf("cannot parse relative url from basepath (%s): %w", ClusterAPIBasePath, err) + } + + reqURL.Path = path.Join(reqURL.Path, slug) + + req, err = c.NewRequest(ctx, "GET", reqURL.String(), nil) + if err != nil { + return result, fmt.Errorf("creating new http request for URL (%s): %w", reqURL.String(), err) + } + + resp, err = c.Do(ctx, req) + if err != nil { + return result, fmt.Errorf("client.do failed: %w", err) + } + + if err = json.Unmarshal(resp.BodyBuf.Bytes(), &result); err != nil { + return result, fmt.Errorf("json.Unmarshal failed: %w", err) + } + + return result, nil +} + +// Create requests a new Cluster to be created. +// +//nolint:dupl // Allow duplicated code blocks in code paths that may change +func (c *ClusterClient) Create(ctx context.Context, opt ClusterCreateOpts) ( + ClustersResultCreate, + error, +) { + var ( + req *http.Request + reqURL *url.URL + reqBody []byte + resp *Response + err error + ) + // Let's make some initial capacity to reduce allocations + result := ClustersResultCreate{} + + reqURL, err = url.Parse(ClusterAPIBasePath) + if err != nil { + return result, fmt.Errorf("cannot parse relative url from basepath (%s): %w", ClusterAPIBasePath, err) + } + + if err = opt.Valid(); err != nil { + return result, fmt.Errorf("invalid create options (%v): %w", opt, err) + } + + reqBody, err = json.Marshal(opt) + if err != nil { + return result, fmt.Errorf("failed to marshal options (%v): %w", opt, err) + } + + req, err = c.NewRequest(ctx, "POST", reqURL.String(), bytes.NewReader(reqBody)) + if err != nil { + return result, fmt.Errorf("creating new http request for URL (%s): %w", reqURL.String(), err) + } + + resp, err = c.Do(ctx, req) + if err != nil { + return result, fmt.Errorf("client.do failed: %w", err) + } + + if err = json.Unmarshal(resp.BodyBuf.Bytes(), &result); err != nil { + return result, fmt.Errorf("json.Unmarshal failed: %w", err) + } + + return result, nil +} + +// Update requests a new Cluster be updated. +// +//nolint:dupl // Allow duplicated code blocks in code paths that may change +func (c *ClusterClient) Update(ctx context.Context, opt ClusterUpdateOpts) ( + ClustersResultUpdate, + error, +) { + var ( + req *http.Request + reqURL *url.URL + reqBody []byte + resp *Response + err error + ) + // Let's make some initial capacity to reduce allocations + result := ClustersResultUpdate{} + + reqURL, err = url.Parse(ClusterAPIBasePath) + if err != nil { + return result, fmt.Errorf("cannot parse relative url from basepath (%s): %w", ClusterAPIBasePath, err) + } + + if err = opt.Valid(); err != nil { + return result, fmt.Errorf("invalid create options (%v): %w", opt, err) + } + + reqBody, err = json.Marshal(opt) + if err != nil { + return result, fmt.Errorf("failed to marshal options (%v): %w", opt, err) + } + + req, err = c.NewRequest(ctx, "PUT", reqURL.String(), bytes.NewReader(reqBody)) + if err != nil { + return result, fmt.Errorf("creating new http request for URL (%s): %w", reqURL.String(), err) + } + + resp, err = c.Do(ctx, req) + if err != nil { + return result, fmt.Errorf("client.do failed: %w", err) + } + + if err = json.Unmarshal(resp.BodyBuf.Bytes(), &result); err != nil { + return result, fmt.Errorf("json.Unmarshal failed: %w", err) + } + + return result, nil +} + +// Destroy triggers the deprovisioning of the cluster associated with the slug. +// +//nolint:dupl // Allow duplicated code blocks in code paths that may change +func (c *ClusterClient) Destroy(ctx context.Context, slug string) (ClustersResultDestroy, error) { + var ( + req *http.Request + reqURL *url.URL + resp *Response + err error + result ClustersResultDestroy + ) + + reqURL, err = url.Parse(ClusterAPIBasePath) + if err != nil { + return result, fmt.Errorf("cannot parse relative url from basepath (%s): %w", ClusterAPIBasePath, err) + } + + reqURL.Path = path.Join(reqURL.Path, slug) + + req, err = c.NewRequest(ctx, "DELETE", reqURL.String(), nil) + if err != nil { + return result, fmt.Errorf("creating new http request for URL (%s): %w", reqURL.String(), err) + } + + resp, err = c.Do(ctx, req) + if err != nil { + return result, fmt.Errorf("client.do failed: %w", err) + } + + if err = json.Unmarshal(resp.BodyBuf.Bytes(), &result); err != nil { + return result, fmt.Errorf("json.Unmarshal failed: %w", err) + } + + return result, nil +} diff --git a/bonsai/cluster_impl_test.go b/bonsai/cluster_impl_test.go new file mode 100644 index 0000000..96e10c0 --- /dev/null +++ b/bonsai/cluster_impl_test.go @@ -0,0 +1,59 @@ +package bonsai + +import "net/url" + +func (s *ClientImplTestSuite) TestClusterListOptsValues() { + testCases := []struct { + name string + received clusterListOpts + expect url.Values + }{ + { + name: "with populated values", + received: clusterListOpts{ + listOpts: listOpts{ + Page: 3, + Size: 100, + }, + ClusterAllOpts: ClusterAllOpts{ + Query: "a query string", + Tenancy: "parent", + Location: "omc/bonsai/us-east-1/common", + }, + }, + expect: url.Values{ + "page": []string{"3"}, + "size": []string{"100"}, + "q": []string{"a query string"}, + "tenancy": []string{"parent"}, + "location": []string{"omc/bonsai/us-east-1/common"}, + }, + }, + { + name: "with pagination, but empty ClusterAllOpts values", + received: clusterListOpts{ + listOpts: listOpts{ + Page: 3, + Size: 100, + }, + }, + expect: url.Values{ + "page": []string{"3"}, + "size": []string{"100"}, + }, + }, + { + name: "with empty values", + received: clusterListOpts{}, + expect: url.Values{}, + }, + } + + for _, tc := range testCases { + s.Run(tc.name, func() { + receivedVal, err := tc.received.values() + s.NoError(err, "received values values()") + s.Equal(receivedVal, tc.expect) + }) + } +} diff --git a/bonsai/cluster_test.go b/bonsai/cluster_test.go new file mode 100644 index 0000000..07f2202 --- /dev/null +++ b/bonsai/cluster_test.go @@ -0,0 +1,454 @@ +package bonsai_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + + "github.com/omc/bonsai-api-go/v1/bonsai" +) + +func (s *ClientTestSuite) TestClusterClient_All() { + s.serveMux.Get(bonsai.ClusterAPIBasePath, func(w http.ResponseWriter, _ *http.Request) { + respStr := ` + { + "pagination": { + "page_number": 1, + "page_size": 10, + "total_records": 3 + }, + "clusters": [ + { + "slug": "first-testing-cluste-1234567890", + "name": "first_testing_cluster", + "uri": "https://api.bonsai.io/clusters/first-testing-cluste-1234567890", + "plan": { + "slug": "sandbox-aws-us-east-1", + "uri": "https://api.bonsai.io/plans/sandbox-aws-us-east-1" + }, + "release": { + "version": "7.2.0", + "slug": "elasticsearch-7.2.0", + "package_name": "7.2.0", + "service_type": "elasticsearch", + "uri": "https://api.bonsai.io/releases/elasticsearch-7.2.0" + }, + "space": { + "path": "omc/bonsai/us-east-1/common", + "region": "aws-us-east-1", + "uri": "https://api.bonsai.io/spaces/omc/bonsai/us-east-1/common" + }, + "stats": { + "docs": 0, + "shards_used": 0, + "data_bytes_used": 0 + }, + "access": { + "host": "first-testing-cluste-1234567890.us-east-1.bonsaisearch.net", + "port": 443, + "scheme": "https" + }, + "state": "PROVISIONED" + }, + { + "slug": "second-testing-clust-1234567890", + "name": "second_testing_cluster", + "uri": "https://api.bonsai.io/clusters/second-testing-clust-1234567890", + "plan": { + "slug": "sandbox-aws-us-east-1", + "uri": "https://api.bonsai.io/plans/sandbox-aws-us-east-1" + }, + "release": { + "version": "7.2.0", + "slug": "elasticsearch-7.2.0", + "package_name": "7.2.0", + "service_type": "elasticsearch", + "uri": "https://api.bonsai.io/releases/elasticsearch-7.2.0" + }, + "space": { + "path": "omc/bonsai/us-east-1/common", + "region": "aws-us-east-1", + "uri": "https://api.bonsai.io/spaces/omc/bonsai/us-east-1/common" + }, + "stats": { + "docs": 0, + "shards_used": 0, + "data_bytes_used": 0 + }, + "access": { + "host": "second-testing-clust-1234567890.us-east-1.bonsaisearch.net", + "port": 443, + "scheme": "https" + }, + "state": "PROVISIONED" + }, + { + "slug": "third-testing-clust-1234567890", + "name": "third_testing_cluster", + "uri": "https://api.bonsai.io/clusters/third-testing-clust-1234567890", + "plan": { + "slug": "sandbox-aws-us-east-1", + "uri": "https://api.bonsai.io/plans/sandbox-aws-us-east-1" + }, + "release": { + "version": "7.2.0", + "slug": "elasticsearch-7.2.0", + "package_name": "7.2.0", + "service_type": "elasticsearch", + "uri": "https://api.bonsai.io/releases/elasticsearch-7.2.0" + }, + "space": { + "path": "omc/bonsai/us-east-1/common", + "region": "aws-us-east-1", + "uri": "https://api.bonsai.io/spaces/omc/bonsai/us-east-1/common" + }, + "stats": { + "docs": 1500000, + "shards_used": 14, + "data_bytes_used": 93180912390 + }, + "access": { + "host": "third-testing-clust-1234567890.us-east-1.bonsaisearch.net", + "port": 443, + "scheme": "https" + }, + "state": "PROVISIONED" + } + ] + } + ` + + resp := &bonsai.ClustersResultList{Clusters: make([]bonsai.Cluster, 0, 2)} + err := json.Unmarshal([]byte(respStr), resp) + s.NoError(err, "unmarshal json into bonsai.ClustersResultList") + + err = json.NewEncoder(w).Encode(resp) + s.NoError(err, "encode bonsai.ClustersResultList into json") + }) + + expect := []bonsai.Cluster{ + { + Slug: "first-testing-cluste-1234567890", + Name: "first_testing_cluster", + URI: "https://api.bonsai.io/clusters/first-testing-cluste-1234567890", + Plan: bonsai.Plan{ + Slug: "sandbox-aws-us-east-1", + AvailableReleases: []bonsai.Release{}, + AvailableSpaces: []bonsai.Space{}, + URI: "https://api.bonsai.io/plans/sandbox-aws-us-east-1", + }, + Release: bonsai.Release{ + Version: "7.2.0", + Slug: "elasticsearch-7.2.0", + PackageName: "7.2.0", + ServiceType: "elasticsearch", + URI: "https://api.bonsai.io/releases/elasticsearch-7.2.0", + }, + Space: bonsai.Space{ + Path: "omc/bonsai/us-east-1/common", + Region: "aws-us-east-1", + URI: "https://api.bonsai.io/spaces/omc/bonsai/us-east-1/common", + }, + Stats: bonsai.ClusterStats{ + Docs: 0, + ShardsUsed: 0, + DataBytesUsed: 0, + }, + Access: bonsai.ClusterAccess{ + Host: "first-testing-cluste-1234567890.us-east-1.bonsaisearch.net", + Port: 443, + Scheme: "https", + }, + State: bonsai.ClusterStateProvisioned, + }, + { + Slug: "second-testing-clust-1234567890", + Name: "second_testing_cluster", + URI: "https://api.bonsai.io/clusters/second-testing-clust-1234567890", + Plan: bonsai.Plan{ + Slug: "sandbox-aws-us-east-1", + AvailableReleases: []bonsai.Release{}, + AvailableSpaces: []bonsai.Space{}, + URI: "https://api.bonsai.io/plans/sandbox-aws-us-east-1", + }, + Release: bonsai.Release{ + Version: "7.2.0", + Slug: "elasticsearch-7.2.0", + PackageName: "7.2.0", + ServiceType: "elasticsearch", + URI: "https://api.bonsai.io/releases/elasticsearch-7.2.0", + }, + Space: bonsai.Space{ + Path: "omc/bonsai/us-east-1/common", + Region: "aws-us-east-1", + URI: "https://api.bonsai.io/spaces/omc/bonsai/us-east-1/common", + }, + Stats: bonsai.ClusterStats{ + Docs: 0, + ShardsUsed: 0, + DataBytesUsed: 0, + }, + Access: bonsai.ClusterAccess{ + Host: "second-testing-clust-1234567890.us-east-1.bonsaisearch.net", + Port: 443, + Scheme: "https", + }, + State: bonsai.ClusterStateProvisioned, + }, + { + Slug: "third-testing-clust-1234567890", + Name: "third_testing_cluster", + URI: "https://api.bonsai.io/clusters/third-testing-clust-1234567890", + Plan: bonsai.Plan{ + Slug: "sandbox-aws-us-east-1", + AvailableReleases: []bonsai.Release{}, + AvailableSpaces: []bonsai.Space{}, + URI: "https://api.bonsai.io/plans/sandbox-aws-us-east-1", + }, + Release: bonsai.Release{ + Version: "7.2.0", + Slug: "elasticsearch-7.2.0", + PackageName: "7.2.0", + ServiceType: "elasticsearch", + URI: "https://api.bonsai.io/releases/elasticsearch-7.2.0", + }, + Space: bonsai.Space{ + Path: "omc/bonsai/us-east-1/common", + Region: "aws-us-east-1", + URI: "https://api.bonsai.io/spaces/omc/bonsai/us-east-1/common", + }, + Stats: bonsai.ClusterStats{ + Docs: 1500000, + ShardsUsed: 14, + DataBytesUsed: 93180912390, + }, + Access: bonsai.ClusterAccess{ + Host: "third-testing-clust-1234567890.us-east-1.bonsaisearch.net", + Port: 443, + Scheme: "https", + }, + State: bonsai.ClusterStateProvisioned, + }, + } + clusters, err := s.client.Cluster.All(context.Background()) + s.NoError(err, "successfully get all clusters") + s.Len(clusters, 3) + + s.ElementsMatch(expect, clusters, "elements in expect match elements in received clusters") + + // Comparisons on the individual struct level are much easier to debug + for i, cluster := range clusters { + s.Run(fmt.Sprintf("Cluster #%d", i), func() { + s.Equal(expect[i], cluster) + }) + } +} + +func (s *ClientTestSuite) TestClusterClient_GetBySlug() { + const targetClusterSlug = "second-testing-clust-1234567890" + + urlPath, err := url.JoinPath(bonsai.ClusterAPIBasePath, targetClusterSlug) + s.NoError(err, "successfully resolved path") + + s.serveMux.Get(urlPath, func(w http.ResponseWriter, _ *http.Request) { + respStr := fmt.Sprintf(` + { + "slug": "%s", + "name": "second_testing_cluster", + "uri": "https://api.bonsai.io/clusters/second-testing-clust-1234567890", + "plan": { + "slug": "sandbox-aws-us-east-1", + "uri": "https://api.bonsai.io/plans/sandbox-aws-us-east-1" + }, + "release": { + "version": "7.2.0", + "slug": "elasticsearch-7.2.0", + "package_name": "7.2.0", + "service_type": "elasticsearch", + "uri": "https://api.bonsai.io/releases/elasticsearch-7.2.0" + }, + "space": { + "path": "omc/bonsai/us-east-1/common", + "region": "aws-us-east-1", + "uri": "https://api.bonsai.io/spaces/omc/bonsai/us-east-1/common" + }, + "stats": { + "docs": 0, + "shards_used": 0, + "data_bytes_used": 0 + }, + "access": { + "host": "second-testing-clust-1234567890.us-east-1.bonsaisearch.net", + "port": 443, + "scheme": "https" + }, + "state": "PROVISIONED" + } + `, targetClusterSlug) + + resp := &bonsai.Cluster{} + err = json.Unmarshal([]byte(respStr), resp) + s.NoError(err, "unmarshals json into bonsai.Space") + + err = json.NewEncoder(w).Encode(resp) + s.NoError(err, "encodes bonsai.Space into json on the writer") + }) + + expect := bonsai.Cluster{ + Slug: "second-testing-clust-1234567890", + Name: "second_testing_cluster", + URI: "https://api.bonsai.io/clusters/second-testing-clust-1234567890", + Plan: bonsai.Plan{ + Slug: "sandbox-aws-us-east-1", + AvailableReleases: []bonsai.Release{}, + AvailableSpaces: []bonsai.Space{}, + URI: "https://api.bonsai.io/plans/sandbox-aws-us-east-1", + }, + Release: bonsai.Release{ + Version: "7.2.0", + Slug: "elasticsearch-7.2.0", + PackageName: "7.2.0", + ServiceType: "elasticsearch", + URI: "https://api.bonsai.io/releases/elasticsearch-7.2.0", + }, + Space: bonsai.Space{ + Path: "omc/bonsai/us-east-1/common", + Region: "aws-us-east-1", + URI: "https://api.bonsai.io/spaces/omc/bonsai/us-east-1/common", + }, + Stats: bonsai.ClusterStats{ + Docs: 0, + ShardsUsed: 0, + DataBytesUsed: 0, + }, + Access: bonsai.ClusterAccess{ + Host: "second-testing-clust-1234567890.us-east-1.bonsaisearch.net", + Port: 443, + Scheme: "https", + }, + State: bonsai.ClusterStateProvisioned, + } + + resultResp, err := s.client.Cluster.GetBySlug(context.Background(), targetClusterSlug) + s.NoError(err, "successfully get cluster by path") + + s.Equal(expect, resultResp, "elements in expect match elements in received cluster response") +} + +func (s *ClientTestSuite) TestClusterClient_Create() { + s.serveMux.Post(bonsai.ClusterAPIBasePath, func(w http.ResponseWriter, _ *http.Request) { + respStr := ` + { + "message": "Your cluster is being provisioned.", + "monitor": "https://api.bonsai.io/clusters/test-5-x-3968320296", + "access": { + "user": "utji08pwu6", + "pass": "18v1fbey2y", + "host": "test-5-x-3968320296", + "port": 443, + "scheme": "https", + "url": "https://utji08pwu6:18v1fbey2y@test-5-x-3968320296.us-east-1.bonsaisearch.net:443" + }, + "status": 202 + } + ` + + resp := &bonsai.ClustersResultCreate{} + err := json.Unmarshal([]byte(respStr), resp) + s.NoError(err, "unmarshals json into bonsai.ClustersResultCreate") + + err = json.NewEncoder(w).Encode(resp) + s.NoError(err, "encodes bonsai.ClustersResultCreate into json on the writer") + }) + + expect := bonsai.ClustersResultCreate{ + Message: "Your cluster is being provisioned.", + Monitor: "https://api.bonsai.io/clusters/test-5-x-3968320296", + Access: bonsai.ClusterAccess{ + Host: "test-5-x-3968320296", + Port: 443, + Scheme: "https", + Username: "utji08pwu6", + Password: "18v1fbey2y", + URL: "https://utji08pwu6:18v1fbey2y@test-5-x-3968320296.us-east-1.bonsaisearch.net:443", + }, + } + + resultResp, err := s.client.Cluster.Create(context.Background(), bonsai.ClusterCreateOpts{ + Name: "test-5-x-3968320296", + Plan: "sandbox-aws-us-east-1", + Space: "omc/bonsai/us-east-1/common", + Release: "elasticsearch-7.2.0", + }) + s.NoError(err, "successfully execute create cluster request") + + s.Equal(expect, resultResp, "elements in expect match elements in received cluster create response") +} + +func (s *ClientTestSuite) TestClusterClient_Update() { + s.serveMux.Put(bonsai.ClusterAPIBasePath, func(w http.ResponseWriter, _ *http.Request) { + respStr := ` + { + "message": "Your cluster is being updated.", + "monitor": "https://api.bonsai.io/clusters/test-5-x-3968320296", + "status": 202 + } + ` + + resp := &bonsai.ClustersResultUpdate{} + err := json.Unmarshal([]byte(respStr), resp) + s.NoError(err, "unmarshals json into bonsai.ClustersResultUpdate") + + err = json.NewEncoder(w).Encode(resp) + s.NoError(err, "encodes bonsai.ClustersResultUpdate into json on the writer") + }) + + expect := bonsai.ClustersResultUpdate{ + Message: "Your cluster is being updated.", + Monitor: "https://api.bonsai.io/clusters/test-5-x-3968320296", + } + + resultResp, err := s.client.Cluster.Update(context.Background(), bonsai.ClusterUpdateOpts{ + Name: "test-5-x-3968320296", + Plan: "sandbox-aws-us-east-2", + }) + s.NoError(err, "successfully execute create cluster request") + + s.Equal(expect, resultResp, "items in expect match items in received cluster update response") +} + +func (s *ClientTestSuite) TestClusterClient_Delete() { + const targetClusterSlug = "second-testing-clust-1234567890" + + reqPath, err := url.JoinPath(bonsai.ClusterAPIBasePath, targetClusterSlug) + s.NoError(err, "successfully resolved path") + + s.serveMux.Delete(reqPath, func(w http.ResponseWriter, _ *http.Request) { + respStr := fmt.Sprintf(` + { + "message": "Your cluster is being deprovisioned.", + "monitor": "%s", + "status": 202 + } + `, targetClusterSlug) + + resp := &bonsai.ClustersResultDestroy{} + err = json.Unmarshal([]byte(respStr), resp) + s.NoError(err, "unmarshals json into bonsai.ClustersResultDestroy") + + err = json.NewEncoder(w).Encode(resp) + s.NoError(err, "encodes bonsai.ClustersResultDestroy into json on the writer") + }) + + expect := bonsai.ClustersResultDestroy{ + Message: "Your cluster is being deprovisioned.", + Monitor: targetClusterSlug, + } + + resultResp, err := s.client.Cluster.Destroy(context.Background(), targetClusterSlug) + s.NoError(err, "successfully execute create cluster request") + + s.Equal(expect, resultResp, "items in expect match items in received cluster update response") +} diff --git a/bonsai/error.go b/bonsai/error.go new file mode 100644 index 0000000..48de1b7 --- /dev/null +++ b/bonsai/error.go @@ -0,0 +1 @@ +package bonsai diff --git a/bonsai/internal/dep/dep.go b/bonsai/internal/dep/dep.go new file mode 100644 index 0000000..9635495 --- /dev/null +++ b/bonsai/internal/dep/dep.go @@ -0,0 +1,9 @@ +// Package dep imports unused, but licensed, dependencies +// for capture by tooling like "go mod vendor" and +// github.com/google/go-licenses +package dep + +import ( + // bonsai.Client is based on hcloud's implementation. + _ "github.com/hetznercloud/hcloud-go/v2/hcloud" +) diff --git a/bonsai/io.go b/bonsai/io.go new file mode 100644 index 0000000..196ae00 --- /dev/null +++ b/bonsai/io.go @@ -0,0 +1,26 @@ +package bonsai + +import ( + "errors" + "fmt" + "io" +) + +// IoClose will catch io.Closer errors and wrap them around the +// previous errors, if any. +// +// Note: due to the Go spec, in order to actually modify the parent's +// error value, the calling function *must* use named result parameters. +// +// ref: https://go.dev/ref/spec#Defer_statements +func IoClose(c io.Closer, err error) error { + cerr := c.Close() + + if cerr != nil { + return errors.Join( + fmt.Errorf("failed to close io.Closer: %w", cerr), + err, + ) + } + return err +} diff --git a/bonsai/io_test.go b/bonsai/io_test.go new file mode 100644 index 0000000..e6d0436 --- /dev/null +++ b/bonsai/io_test.go @@ -0,0 +1 @@ +package bonsai_test diff --git a/bonsai/plan.go b/bonsai/plan.go new file mode 100644 index 0000000..2f2cba0 --- /dev/null +++ b/bonsai/plan.go @@ -0,0 +1,281 @@ +package bonsai + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "path" + "reflect" +) + +const ( + PlanAPIBasePath = "/plans" +) + +// planAllResponse represents the JSON response object returned from the +// GET /plans endpoint. +// +// It differs from Plan namely in that the AvailableReleases returned is +// a list of string, not Release. +// +// Indeed, it exists to resolve differences between index list response and +// other response structures. +type planAllResponse struct { + // Represents a machine-readable name for the plan. + Slug string `json:"slug,omitempty"` + // Represents the human-readable name of the plan. + Name string `json:"name,omitempty"` + // Represents the plan price in cents. + PriceInCents int64 `json:"price_in_cents,omitempty"` + // Represents the plan billing interval in months. + BillingIntervalInMonths int `json:"billing_interval_in_months,omitempty"` + // Indicates whether the plan is single-tenant or not. A value of false + // indicates the Cluster will share hardware with other Clusters. Single + // tenant environments can be reached via the public Internet. + SingleTenant bool `json:"single_tenant,omitempty"` + // Indicates whether the plan is on a publicly addressable network. + // Private plans provide environments that cannot be reached by the public + // Internet. A VPC connection will be needed to communicate with a private + // cluster. + PrivateNetwork bool `json:"private_network,omitempty"` + // A collection of search release slugs available for the plan. Additional + // information about a release can be retrieved from the Releases API. + AvailableReleases []string `json:"available_releases"` + AvailableSpaces []string `json:"available_spaces"` + + // A URI to retrieve more information about this Plan. + URI string `json:"uri,omitempty"` +} + +type planAllResponseList struct { + Plans []planAllResponse `json:"plans"` +} + +type planAllResponseConverter struct{} + +// Convert copies a single planAllResponse into a Plan, +// transforming types as needed. +func (c *planAllResponseConverter) Convert(source planAllResponse) Plan { + plan := Plan{ + AvailableReleases: make([]Release, len(source.AvailableReleases)), + AvailableSpaces: make([]Space, len(source.AvailableSpaces)), + } + plan.Slug = source.Slug + plan.Name = source.Name + plan.PriceInCents = source.PriceInCents + plan.BillingIntervalInMonths = source.BillingIntervalInMonths + plan.SingleTenant = source.SingleTenant + plan.PrivateNetwork = source.PrivateNetwork + for i, release := range source.AvailableReleases { + plan.AvailableReleases[i] = Release{Slug: release} + } + for i, space := range source.AvailableSpaces { + plan.AvailableSpaces[i] = Space{Path: space} + } + plan.URI = source.URI + + return plan +} + +// ConvertItems converts a slice of planAllResponse into a slice of Plan +// by way of the planAllResponseConverter.ConvertItems method. +func (c *planAllResponseConverter) ConvertItems(source []planAllResponse) []Plan { + var planList []Plan + if source != nil { + planList = make([]Plan, len(source)) + for i := range source { + planList[i] = c.Convert(source[i]) + } + } + return planList +} + +// Plan represents a subscription plan. +type Plan struct { + // Represents a machine-readable name for the plan. + Slug string `json:"slug"` + // Represents the human-readable name of the plan. + Name string `json:"name,omitempty"` + // Represents the plan price in cents. + PriceInCents int64 `json:"price_in_cents,omitempty"` + // Represents the plan billing interval in months. + BillingIntervalInMonths int `json:"billing_interval_months,omitempty"` + // Indicates whether the plan is single-tenant or not. A value of false + // indicates the Cluster will share hardware with other Clusters. Single + // tenant environments can be reached via the public Internet. + SingleTenant bool `json:"single_tenant,omitempty"` + // Indicates whether the plan is on a publicly addressable network. + // Private plans provide environments that cannot be reached by the public + // Internet. A VPC connection will be needed to communicate with a private + // cluster. + PrivateNetwork bool `json:"private_network,omitempty"` + // A collection of search release slugs available for the plan. Additional + // information about a release can be retrieved from the Releases API. + AvailableReleases []Release `json:"available_releases"` + AvailableSpaces []Space `json:"available_spaces"` + + // A URI to retrieve more information about this Plan. + URI string `json:"uri,omitempty"` +} + +func (p *Plan) UnmarshalJSON(data []byte) error { + intermediary := planAllResponse{} + if err := json.Unmarshal(data, &intermediary); err != nil { + return fmt.Errorf("unmarshaling into intermediary type: %w", err) + } + + converter := planAllResponseConverter{} + converted := converter.Convert(intermediary) + *p = converted + + return nil +} + +// PlansResultList is a wrapper around a slice of +// Plans for json unmarshaling. +type PlansResultList struct { + Plans []Plan `json:"plans"` +} + +func (p *PlansResultList) UnmarshalJSON(data []byte) error { + planAllResponseList := make([]planAllResponse, 0) + + if err := json.Unmarshal(data, &planAllResponseList); err != nil { + return fmt.Errorf("unmarshaling into planAllResponseList type: %w", err) + } + + converter := planAllResponseConverter{} + p.Plans = converter.ConvertItems(planAllResponseList) + return nil +} + +// PlanClient is a client for the Plans API. +type PlanClient struct { + *Client +} + +type planListOptions struct { + listOpts +} + +func (o planListOptions) values() url.Values { + return o.listOpts.values() +} + +// list returns a list of Plans for the page specified, +// by performing a GET request against [spaceAPIBasePath]. +// +// Note: Pagination is not currently supported. +func (c *PlanClient) list(ctx context.Context, opt planListOptions) ([]Plan, *Response, error) { + var ( + req *http.Request + reqURL *url.URL + resp *Response + err error + + results []Plan + ) + // Let's make some initial capacity to reduce allocations + intermediaryResults := planAllResponseList{ + Plans: make([]planAllResponse, 0, defaultResponseCapacity), + } + + reqURL, err = url.Parse(PlanAPIBasePath) + if err != nil { + return results, nil, fmt.Errorf("cannot parse relative url from basepath (%s): %w", PlanAPIBasePath, err) + } + + // Conditionally set options if we received any + if !reflect.ValueOf(opt).IsZero() { + reqURL.RawQuery = opt.values().Encode() + } + + req, err = c.NewRequest(ctx, "GET", reqURL.String(), nil) + if err != nil { + return results, nil, fmt.Errorf("creating new http request for URL (%s): %w", reqURL.String(), err) + } + + resp, err = c.Do(ctx, req) + if err != nil { + return results, resp, fmt.Errorf("client.do failed: %w", err) + } + + if err = json.Unmarshal(resp.BodyBuf.Bytes(), &intermediaryResults); err != nil { + return results, resp, fmt.Errorf("json.Unmarshal failed: %w", err) + } + + converter := planAllResponseConverter{} + results = converter.ConvertItems(intermediaryResults.Plans) + + return results, resp, nil +} + +// All lists all Plans from the Plans API. +func (c *PlanClient) All(ctx context.Context) ([]Plan, error) { + var ( + err error + resp *Response + ) + + allResults := make([]Plan, 0, defaultListResultSize) + // No pagination support as yet, but support it for future use + + err = c.all(ctx, newEmptyListOpts(), func(opt listOpts) (*Response, error) { + var listResults []Plan + + listResults, resp, err = c.list(ctx, planListOptions{listOpts: opt}) + if err != nil { + return resp, fmt.Errorf("client.list failed: %w", err) + } + + allResults = append(allResults, listResults...) + if len(allResults) >= resp.PageSize { + resp.MarkPaginationComplete() + } + return resp, err + }) + + if err != nil { + return allResults, fmt.Errorf("client.all failed: %w", err) + } + + return allResults, err +} + +// GetBySlug gets a Plan from the Plans API by its slug. +// +//nolint:dupl // Allow duplicated code blocks in code paths that may change +func (c *PlanClient) GetBySlug(ctx context.Context, slug string) (Plan, error) { + var ( + req *http.Request + reqURL *url.URL + resp *Response + err error + result Plan + ) + + reqURL, err = url.Parse(PlanAPIBasePath) + if err != nil { + return result, fmt.Errorf("cannot parse relative url from basepath (%s): %w", PlanAPIBasePath, err) + } + + reqURL.Path = path.Join(reqURL.Path, slug) + + req, err = c.NewRequest(ctx, "GET", reqURL.String(), nil) + if err != nil { + return result, fmt.Errorf("creating new http request for URL (%s): %w", reqURL.String(), err) + } + + resp, err = c.Do(ctx, req) + if err != nil { + return result, fmt.Errorf("client.do failed: %w", err) + } + + if err = json.Unmarshal(resp.BodyBuf.Bytes(), &result); err != nil { + return result, fmt.Errorf("json.Unmarshal failed: %w", err) + } + + return result, nil +} diff --git a/bonsai/plan_impl_test.go b/bonsai/plan_impl_test.go new file mode 100644 index 0000000..ddb950e --- /dev/null +++ b/bonsai/plan_impl_test.go @@ -0,0 +1,68 @@ +package bonsai + +import ( + "encoding/json" +) + +func (s *ClientImplTestSuite) TestPlanAllResponseJsonUnmarshal() { + testCases := []struct { + name string + received string + expect planAllResponse + }{ + { + name: "plan example response from docs site", + received: ` + { + "slug": "sandbox-aws-us-east-1", + "name": "Sandbox", + "price_in_cents": 0, + "billing_interval_in_months": 1, + "single_tenant": false, + "private_network": false, + "available_releases": [ + "elasticsearch-7.2.0" + ], + "available_spaces": [ + "omc/bonsai-gcp/us-east4/common", + "omc/bonsai/ap-northeast-1/common", + "omc/bonsai/ap-southeast-2/common", + "omc/bonsai/eu-central-1/common", + "omc/bonsai/eu-west-1/common", + "omc/bonsai/us-east-1/common", + "omc/bonsai/us-west-2/common" + ] + } + `, + expect: planAllResponse{ + Slug: "sandbox-aws-us-east-1", + Name: "Sandbox", + PriceInCents: 0, + BillingIntervalInMonths: 1, + SingleTenant: false, + PrivateNetwork: false, + AvailableReleases: []string{ + "elasticsearch-7.2.0", + }, + AvailableSpaces: []string{ + "omc/bonsai-gcp/us-east4/common", + "omc/bonsai/ap-northeast-1/common", + "omc/bonsai/ap-southeast-2/common", + "omc/bonsai/eu-central-1/common", + "omc/bonsai/eu-west-1/common", + "omc/bonsai/us-east-1/common", + "omc/bonsai/us-west-2/common", + }, + }, + }, + } + + for _, tc := range testCases { + s.Run(tc.name, func() { + result := planAllResponse{} + err := json.Unmarshal([]byte(tc.received), &result) + s.NoError(err) + s.Equal(tc.expect, result, "expected struct matches unmarshaled result") + }) + } +} diff --git a/bonsai/plan_test.go b/bonsai/plan_test.go new file mode 100644 index 0000000..e2d9feb --- /dev/null +++ b/bonsai/plan_test.go @@ -0,0 +1,178 @@ +package bonsai_test + +import ( + "context" + "fmt" + "net/http" + "net/url" + + "github.com/omc/bonsai-api-go/v1/bonsai" +) + +func (s *ClientTestSuite) TestPlanClient_All() { + s.serveMux.Get(bonsai.PlanAPIBasePath, func(w http.ResponseWriter, _ *http.Request) { + respStr := ` + { + "plans": [ + { + "slug": "sandbox-aws-us-east-1", + "name": "Sandbox", + "price_in_cents": 0, + "billing_interval_in_months": 1, + "single_tenant": false, + "private_network": false, + "available_releases": [ + "7.2.0" + ], + "available_spaces": [ + "omc/bonsai-gcp/us-east4/common", + "omc/bonsai/ap-northeast-1/common", + "omc/bonsai/ap-southeast-2/common", + "omc/bonsai/eu-central-1/common", + "omc/bonsai/eu-west-1/common", + "omc/bonsai/us-east-1/common", + "omc/bonsai/us-west-2/common" + ] + }, + { + "slug": "standard-sm", + "name": "Standard Small", + "price_in_cents": 5000, + "billing_interval_in_months": 1, + "single_tenant": false, + "private_network": false, + "available_releases": [ + "elasticsearch-5.6.16", + "elasticsearch-6.8.3", + "elasticsearch-7.2.0" + ], + "available_spaces": [ + "omc/bonsai/ap-northeast-1/common", + "omc/bonsai/ap-southeast-2/common", + "omc/bonsai/eu-central-1/common", + "omc/bonsai/eu-west-1/common", + "omc/bonsai/us-east-1/common", + "omc/bonsai/us-west-2/common" + ] + } + ] + } + ` + _, err := w.Write([]byte(respStr)) + s.NoError(err, "write respStr to http.ResponseWriter") + }) + + expect := []bonsai.Plan{ + { + Slug: "sandbox-aws-us-east-1", + Name: "Sandbox", + PriceInCents: 0, + BillingIntervalInMonths: 1, + SingleTenant: false, + PrivateNetwork: false, + AvailableReleases: []bonsai.Release{ + // TODO: we'll see whether the response is actually a + // shortened version like this or a slug + // the documentation is conflicting at + // https://bonsai.io/docs/plans-api-introduction + {Slug: "7.2.0"}, + }, + AvailableSpaces: []bonsai.Space{ + {Path: "omc/bonsai-gcp/us-east4/common"}, + {Path: "omc/bonsai/ap-northeast-1/common"}, + {Path: "omc/bonsai/ap-southeast-2/common"}, + {Path: "omc/bonsai/eu-central-1/common"}, + {Path: "omc/bonsai/eu-west-1/common"}, + {Path: "omc/bonsai/us-east-1/common"}, + {Path: "omc/bonsai/us-west-2/common"}, + }, + }, + { + Slug: "standard-sm", + Name: "Standard Small", + PriceInCents: 5000, + BillingIntervalInMonths: 1, + SingleTenant: false, + PrivateNetwork: false, + AvailableReleases: []bonsai.Release{ + {Slug: "elasticsearch-5.6.16"}, + {Slug: "elasticsearch-6.8.3"}, + {Slug: "elasticsearch-7.2.0"}, + }, + AvailableSpaces: []bonsai.Space{ + {Path: "omc/bonsai/ap-northeast-1/common"}, + {Path: "omc/bonsai/ap-southeast-2/common"}, + {Path: "omc/bonsai/eu-central-1/common"}, + {Path: "omc/bonsai/eu-west-1/common"}, + {Path: "omc/bonsai/us-east-1/common"}, + {Path: "omc/bonsai/us-west-2/common"}, + }, + }, + } + spaces, err := s.client.Plan.All(context.Background()) + s.NoError(err, "successfully get all spaces") + s.Len(spaces, 2) + + s.ElementsMatch(expect, spaces, "elements expected match elements in received spaces") +} + +func (s *ClientTestSuite) TestPlanClient_GetByPath() { + const targetPlanPath = "sandbox-aws-us-east-1" + + urlPath, err := url.JoinPath(bonsai.PlanAPIBasePath, "sandbox-aws-us-east-1") + s.NoError(err, "successfully resolved path") + + respStr := fmt.Sprintf(` + { + "slug": "%s", + "name": "Sandbox", + "price_in_cents": 0, + "billing_interval_in_months": 1, + "single_tenant": false, + "private_network": false, + "available_releases": [ + "elasticsearch-7.2.0" + ], + "available_spaces": [ + "omc/bonsai-gcp/us-east4/common", + "omc/bonsai/ap-northeast-1/common", + "omc/bonsai/ap-southeast-2/common", + "omc/bonsai/eu-central-1/common", + "omc/bonsai/eu-west-1/common", + "omc/bonsai/us-east-1/common", + "omc/bonsai/us-west-2/common" + ] + } + `, targetPlanPath) + + s.serveMux.Get(urlPath, func(w http.ResponseWriter, _ *http.Request) { + _, err = w.Write([]byte(respStr)) + s.NoError(err, "wrote response string to writer") + }) + + expect := bonsai.Plan{ + Slug: "sandbox-aws-us-east-1", + Name: "Sandbox", + PriceInCents: 0, + BillingIntervalInMonths: 1, + SingleTenant: false, + PrivateNetwork: false, + AvailableReleases: []bonsai.Release{ + {Slug: "elasticsearch-7.2.0"}, + }, + AvailableSpaces: []bonsai.Space{ + {Path: "omc/bonsai-gcp/us-east4/common"}, + {Path: "omc/bonsai/ap-northeast-1/common"}, + {Path: "omc/bonsai/ap-southeast-2/common"}, + {Path: "omc/bonsai/eu-central-1/common"}, + {Path: "omc/bonsai/eu-west-1/common"}, + {Path: "omc/bonsai/us-east-1/common"}, + {Path: "omc/bonsai/us-west-2/common"}, + }, + } + + resultResp, err := s.client.Plan.GetBySlug(context.Background(), "sandbox-aws-us-east-1") + s.NoError(err, "successfully get space by path") + + s.Equal(expect, resultResp, "expected struct matches unmarshaled result") +} diff --git a/bonsai/release.go b/bonsai/release.go new file mode 100644 index 0000000..7089087 --- /dev/null +++ b/bonsai/release.go @@ -0,0 +1,157 @@ +package bonsai + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "path" + "reflect" +) + +const ReleaseAPIBasePath = "/releases" + +// Release is a placeholder for now. +type Release struct { + Name string `json:"name,omitempty"` + Slug string `json:"slug,omitempty"` + ServiceType string `json:"service_type,omitempty"` + Version string `json:"version,omitempty"` + MultiTenant bool `json:"multitenant,omitempty"` + + // A URI to retrieve more information about this Release. + URI string `json:"uri,omitempty"` + // PackageName is the package name of the release. + PackageName string `json:"package_name,omitempty"` +} + +// ReleasesResultList is a wrapper around a slice of +// Releases for json unmarshaling. +type ReleasesResultList struct { + Releases []Release `json:"releases,omitempty"` +} + +// ReleaseClient is a client for the Releases API. +type ReleaseClient struct { + *Client +} + +type releaseListOptions struct { + listOpts +} + +func (o releaseListOptions) values() url.Values { + return o.listOpts.values() +} + +// list returns a list of Releases for the page specified, +// by performing a GET request against [spaceAPIBasePath]. +// +// Note: Pagination is not currently supported. +func (c *ReleaseClient) list(ctx context.Context, opt releaseListOptions) ([]Release, *Response, error) { + var ( + req *http.Request + reqURL *url.URL + resp *Response + err error + ) + // Let's make some initial capacity to reduce allocations + results := ReleasesResultList{ + Releases: make([]Release, 0, defaultResponseCapacity), + } + + reqURL, err = url.Parse(ReleaseAPIBasePath) + if err != nil { + return results.Releases, nil, fmt.Errorf("cannot parse relative url from basepath (%s): %w", ReleaseAPIBasePath, err) + } + + // Conditionally set options if we received any + if !reflect.ValueOf(opt).IsZero() { + reqURL.RawQuery = opt.values().Encode() + } + + req, err = c.NewRequest(ctx, "GET", reqURL.String(), nil) + if err != nil { + return results.Releases, nil, fmt.Errorf("creating new http request for URL (%s): %w", reqURL.String(), err) + } + + resp, err = c.Do(ctx, req) + if err != nil { + return results.Releases, resp, fmt.Errorf("client.do failed: %w", err) + } + + if err = json.Unmarshal(resp.BodyBuf.Bytes(), &results); err != nil { + return results.Releases, resp, fmt.Errorf("json.Unmarshal failed: %w", err) + } + + return results.Releases, resp, nil +} + +// All lists all Releases from the Releases API. +func (c *ReleaseClient) All(ctx context.Context) ([]Release, error) { + var ( + err error + resp *Response + ) + + allResults := make([]Release, 0, defaultListResultSize) + // No pagination support as yet, but support it for future use + + err = c.all(ctx, newEmptyListOpts(), func(opt listOpts) (*Response, error) { + var listResults []Release + + listResults, resp, err = c.list(ctx, releaseListOptions{listOpts: opt}) + if err != nil { + return resp, fmt.Errorf("client.list failed: %w", err) + } + + allResults = append(allResults, listResults...) + if len(allResults) >= resp.PageSize { + resp.MarkPaginationComplete() + } + return resp, err + }) + + if err != nil { + return allResults, fmt.Errorf("client.all failed: %w", err) + } + + return allResults, err +} + +// GetBySlug gets a Release from the Releases API by its slug. +// +//nolint:dupl // Allow duplicated code blocks in code paths that may change +func (c *ReleaseClient) GetBySlug(ctx context.Context, slug string) (Release, error) { + var ( + req *http.Request + reqURL *url.URL + resp *Response + err error + result Release + ) + + reqURL, err = url.Parse(ReleaseAPIBasePath) + if err != nil { + return result, fmt.Errorf("cannot parse relative url from basepath (%s): %w", ReleaseAPIBasePath, err) + } + + reqURL.Path = path.Join(reqURL.Path, slug) + + req, err = c.NewRequest(ctx, "GET", reqURL.String(), nil) + if err != nil { + return result, fmt.Errorf("creating new http request for URL (%s): %w", reqURL.String(), err) + } + + resp, err = c.Do(ctx, req) + if err != nil { + return result, fmt.Errorf("client.do failed: %w", err) + } + + if err = json.Unmarshal(resp.BodyBuf.Bytes(), &result); err != nil { + return result, fmt.Errorf("json.Unmarshal failed: %w", err) + } + + return result, nil +} diff --git a/bonsai/release_test.go b/bonsai/release_test.go new file mode 100644 index 0000000..da471e9 --- /dev/null +++ b/bonsai/release_test.go @@ -0,0 +1,118 @@ +package bonsai_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + + "github.com/omc/bonsai-api-go/v1/bonsai" +) + +func (s *ClientTestSuite) TestReleaseClient_All() { + s.serveMux.Get(bonsai.ReleaseAPIBasePath, func(w http.ResponseWriter, _ *http.Request) { + respStr := ` + { + "releases": [ + { + "name": "Elasticsearch 5.6.16", + "slug": "elasticsearch-5.6.16", + "service_type": "elasticsearch", + "version": "5.6.16", + "multitenant": true + }, + { + "name": "Elasticsearch 6.5.4", + "slug": "elasticsearch-6.5.4", + "service_type": "elasticsearch", + "version": "6.5.4", + "multitenant": true + }, + { + "name": "Elasticsearch 7.2.0", + "slug": "elasticsearch-7.2.0", + "service_type": "elasticsearch", + "version": "7.2.0", + "multitenant": true + } + ] + } + ` + + resp := &bonsai.ReleasesResultList{Releases: make([]bonsai.Release, 0, 3)} + err := json.Unmarshal([]byte(respStr), resp) + s.NoError(err, "unmarshal json into bonsai.ReleasesResultList") + + err = json.NewEncoder(w).Encode(resp) + s.NoError(err, "encode bonsai.ReleasesResultList into json") + }) + + expect := []bonsai.Release{ + { + Name: "Elasticsearch 5.6.16", + Slug: "elasticsearch-5.6.16", + ServiceType: "elasticsearch", + Version: "5.6.16", + MultiTenant: true, + }, + { + Name: "Elasticsearch 6.5.4", + Slug: "elasticsearch-6.5.4", + ServiceType: "elasticsearch", + Version: "6.5.4", + MultiTenant: true, + }, + { + Name: "Elasticsearch 7.2.0", + Slug: "elasticsearch-7.2.0", + ServiceType: "elasticsearch", + Version: "7.2.0", + MultiTenant: true, + }, + } + releases, err := s.client.Release.All(context.Background()) + s.NoError(err, "successfully get all releases") + s.Len(releases, 3) + + s.ElementsMatch(expect, releases, "elements in expect match elements in received releases") +} + +func (s *ClientTestSuite) TestReleaseClient_GetBySlug() { + const targetReleaseSlug = "elasticsearch-7.2.0" + + urlPath, err := url.JoinPath(bonsai.ReleaseAPIBasePath, targetReleaseSlug) + s.NoError(err, "successfully resolved path") + + s.serveMux.Get(urlPath, func(w http.ResponseWriter, _ *http.Request) { + respStr := fmt.Sprintf(` + { + "name": "Elasticsearch 7.2.0", + "slug": "%s", + "service_type": "elasticsearch", + "version": "7.2.0", + "multitenant": true + } + `, targetReleaseSlug) + + resp := &bonsai.Release{} + err = json.Unmarshal([]byte(respStr), resp) + s.NoError(err, "unmarshals json into bonsai.Space") + + err = json.NewEncoder(w).Encode(resp) + s.NoError(err, "encodes bonsai.Space into json on the writer") + }) + + expect := bonsai.Release{ + Slug: "elasticsearch-7.2.0", + Name: "Elasticsearch 7.2.0", + ServiceType: "elasticsearch", + Version: "7.2.0", + MultiTenant: true, + } + + resultResp, err := s.client.Release.GetBySlug(context.Background(), targetReleaseSlug) + s.NoError(err, "successfully get release by path") + + s.Equal(expect, resultResp, "elements in expect match elements in received release response") +} diff --git a/bonsai/space.go b/bonsai/space.go new file mode 100644 index 0000000..1f0e82f --- /dev/null +++ b/bonsai/space.go @@ -0,0 +1,163 @@ +package bonsai + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "path" + "reflect" +) + +const ( + SpaceAPIBasePath = "/spaces" +) + +// CloudProvider contains details about the cloud provider and region +// attributes. +type CloudProvider struct { + Provider string `json:"provider"` + Region string `json:"region"` +} + +// Space represents the server groups and geographic regions available to their +// account, where clusters may be provisioned. +type Space struct { + Path string `json:"path"` + PrivateNetwork bool `json:"private_network"` + Cloud CloudProvider `json:"cloud,omitempty"` + + // The geographic region in which the cluster is running. + Region string `json:"region,omitempty"` + // A URI to retrieve more information about this Release. + URI string `json:"uri,omitempty"` +} + +// SpacesResultList is a wrapper around a slice of +// Spaces for json unmarshaling. +type SpacesResultList struct { + Spaces []Space `json:"spaces,omitempty"` +} + +// SpaceClient is a client for the Spaces API. +type SpaceClient struct { + *Client +} + +type SpaceListOptions struct { + listOpts +} + +func (o SpaceListOptions) values() url.Values { + return o.listOpts.values() +} + +// list returns a list of Spaces for the page specified, +// by performing a GET request against [spaceAPIBasePath]. +// +// Note: Pagination is not currently supported. +func (c *SpaceClient) list(ctx context.Context, opt SpaceListOptions) ([]Space, *Response, error) { + var ( + req *http.Request + reqURL *url.URL + resp *Response + err error + ) + // Let's make some initial capacity to reduce allocations + results := SpacesResultList{ + Spaces: make([]Space, 0, defaultResponseCapacity), + } + + reqURL, err = url.Parse(SpaceAPIBasePath) + if err != nil { + return results.Spaces, nil, fmt.Errorf("cannot parse relative url from basepath (%s): %w", SpaceAPIBasePath, err) + } + + // Conditionally set options if we received any + if !reflect.ValueOf(opt).IsZero() { + reqURL.RawQuery = opt.values().Encode() + } + + req, err = c.NewRequest(ctx, "GET", reqURL.String(), nil) + if err != nil { + return results.Spaces, nil, fmt.Errorf("creating new http request for URL (%s): %w", reqURL.String(), err) + } + + resp, err = c.Do(ctx, req) + if err != nil { + return results.Spaces, resp, fmt.Errorf("client.do failed: %w", err) + } + + if err = json.Unmarshal(resp.BodyBuf.Bytes(), &results); err != nil { + return results.Spaces, resp, fmt.Errorf("json.Unmarshal failed: %w", err) + } + + return results.Spaces, resp, nil +} + +// All lists all Spaces from the Spaces API. +func (c *SpaceClient) All(ctx context.Context) ([]Space, error) { + var ( + err error + resp *Response + ) + + allResults := make([]Space, 0, defaultListResultSize) + // No pagination support as yet, but support it for future use + + err = c.all(ctx, newEmptyListOpts(), func(opt listOpts) (*Response, error) { + var listResults []Space + + listResults, resp, err = c.list(ctx, SpaceListOptions{listOpts: opt}) + if err != nil { + return resp, fmt.Errorf("client.list failed: %w", err) + } + + allResults = append(allResults, listResults...) + if len(allResults) >= resp.PageSize { + resp.MarkPaginationComplete() + } + return resp, err + }) + + if err != nil { + return allResults, fmt.Errorf("client.all failed: %w", err) + } + + return allResults, err +} + +//nolint:dupl // Allow duplicated code blocks in code paths that may change +func (c *SpaceClient) GetByPath(ctx context.Context, spacePath string) (Space, error) { + var ( + req *http.Request + reqURL *url.URL + resp *Response + err error + result Space + ) + + reqURL, err = url.Parse(SpaceAPIBasePath) + if err != nil { + return result, fmt.Errorf("cannot parse relative url from basepath (%s): %w", SpaceAPIBasePath, err) + } + + reqURL.Path = path.Join(reqURL.Path, spacePath) + + req, err = c.NewRequest(ctx, "GET", reqURL.String(), nil) + if err != nil { + return result, fmt.Errorf("creating new http request for URL (%s): %w", reqURL.String(), err) + } + + resp, err = c.Do(ctx, req) + if err != nil { + return result, fmt.Errorf("client.do failed: %w", err) + } + + if err = json.Unmarshal(resp.BodyBuf.Bytes(), &result); err != nil { + return result, fmt.Errorf("json.Unmarshal failed: %w", err) + } + + return result, nil +} diff --git a/bonsai/space_test.go b/bonsai/space_test.go new file mode 100644 index 0000000..d3f59c1 --- /dev/null +++ b/bonsai/space_test.go @@ -0,0 +1,96 @@ +package bonsai_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + + "github.com/omc/bonsai-api-go/v1/bonsai" +) + +func (s *ClientTestSuite) TestSpaceClient_All() { + s.serveMux.Get(bonsai.SpaceAPIBasePath, func(w http.ResponseWriter, _ *http.Request) { + respStr := ` + { + "spaces": [ + { + "path": "omc/bonsai/us-east-1/common", + "private_network": false, + "cloud": { + "provider": "aws", + "region": "aws-us-east-1" + } + }, + { + "path": "omc/bonsai/eu-west-1/common", + "private_network": false, + "cloud": { + "provider": "aws", + "region": "aws-eu-west-1" + } + }, + { + "path": "omc/bonsai/ap-southeast-2/common", + "private_network": false, + "cloud": { + "provider": "aws", + "region": "aws-ap-southeast-2" + } + } + ] + } + ` + + resp := &bonsai.SpacesResultList{Spaces: make([]bonsai.Space, 0, 1)} + err := json.Unmarshal([]byte(respStr), resp) + s.NoError(err, "successfully unmarshals json into bonsai.SpacesResultList") + + err = json.NewEncoder(w).Encode(resp) + s.NoError(err, "successfully encodes bonsai.SpacesResultList into json") + }) + + ctx := context.Background() + + spaces, err := s.client.Space.All(ctx) + s.NoError(err, "successfully get all spaces") + s.Len(spaces, 3) +} + +func (s *ClientTestSuite) TestSpaceClient_GetByPath() { + const targetSpacePath = "omc/bonsai/us-east-1/common" + + urlPath, err := url.JoinPath(bonsai.SpaceAPIBasePath, targetSpacePath) + s.NoError(err, "successfully create url path") + + s.serveMux.Get(urlPath, func(w http.ResponseWriter, _ *http.Request) { + respStr := fmt.Sprintf(` + { + "path": "%s", + "private_network": false, + "cloud": { + "provider": "aws", + "region": "aws-us-east-1" + } + } + `, targetSpacePath) + + resp := &bonsai.Space{} + err = json.Unmarshal([]byte(respStr), resp) + s.NoError(err, "successfully unmarshals json into bonsai.Space") + + err = json.NewEncoder(w).Encode(resp) + s.NoError(err, "successfully encodes bonsai.Space into json") + }) + + ctx := context.Background() + + space, err := s.client.Space.GetByPath(ctx, "omc/bonsai/us-east-1/common") + s.NoError(err, "successfully get space by path") + + s.Equal(space.Path, targetSpacePath) + s.Equal(space.PrivateNetwork, false) + s.Equal(space.Cloud.Provider, "aws") + s.Equal(space.Cloud.Region, "aws-us-east-1") +} diff --git a/doc/3rd-party-deps/github.com/beorn7/perks/quantile/LICENSE b/doc/3rd-party-deps/github.com/beorn7/perks/quantile/LICENSE new file mode 100644 index 0000000..339177b --- /dev/null +++ b/doc/3rd-party-deps/github.com/beorn7/perks/quantile/LICENSE @@ -0,0 +1,20 @@ +Copyright (C) 2013 Blake Mizerany + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/doc/3rd-party-deps/github.com/cespare/xxhash/v2/LICENSE.txt b/doc/3rd-party-deps/github.com/cespare/xxhash/v2/LICENSE.txt new file mode 100644 index 0000000..24b5306 --- /dev/null +++ b/doc/3rd-party-deps/github.com/cespare/xxhash/v2/LICENSE.txt @@ -0,0 +1,22 @@ +Copyright (c) 2016 Caleb Spare + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/doc/3rd-party-deps/github.com/hetznercloud/hcloud-go/v2/hcloud/LICENSE b/doc/3rd-party-deps/github.com/hetznercloud/hcloud-go/v2/hcloud/LICENSE new file mode 100644 index 0000000..394ce10 --- /dev/null +++ b/doc/3rd-party-deps/github.com/hetznercloud/hcloud-go/v2/hcloud/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018-2020 Hetzner Cloud GmbH + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/doc/3rd-party-deps/github.com/prometheus/client_golang/prometheus/LICENSE b/doc/3rd-party-deps/github.com/prometheus/client_golang/prometheus/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/doc/3rd-party-deps/github.com/prometheus/client_golang/prometheus/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/doc/3rd-party-deps/github.com/prometheus/client_golang/prometheus/NOTICE b/doc/3rd-party-deps/github.com/prometheus/client_golang/prometheus/NOTICE new file mode 100644 index 0000000..dd878a3 --- /dev/null +++ b/doc/3rd-party-deps/github.com/prometheus/client_golang/prometheus/NOTICE @@ -0,0 +1,23 @@ +Prometheus instrumentation library for Go applications +Copyright 2012-2015 The Prometheus Authors + +This product includes software developed at +SoundCloud Ltd. (http://soundcloud.com/). + + +The following components are included in this product: + +perks - a fork of https://github.com/bmizerany/perks +https://github.com/beorn7/perks +Copyright 2013-2015 Blake Mizerany, Björn Rabenstein +See https://github.com/beorn7/perks/blob/master/README.md for license details. + +Go support for Protocol Buffers - Google's data interchange format +http://github.com/golang/protobuf/ +Copyright 2010 The Go Authors +See source code for license details. + +Support for streaming Protocol Buffer messages for the Go language (golang). +https://github.com/matttproud/golang_protobuf_extensions +Copyright 2013 Matt T. Proud +Licensed under the Apache License, Version 2.0 diff --git a/doc/3rd-party-deps/github.com/prometheus/client_model/go/LICENSE b/doc/3rd-party-deps/github.com/prometheus/client_model/go/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/doc/3rd-party-deps/github.com/prometheus/client_model/go/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/doc/3rd-party-deps/github.com/prometheus/client_model/go/NOTICE b/doc/3rd-party-deps/github.com/prometheus/client_model/go/NOTICE new file mode 100644 index 0000000..20110e4 --- /dev/null +++ b/doc/3rd-party-deps/github.com/prometheus/client_model/go/NOTICE @@ -0,0 +1,5 @@ +Data model artifacts for Prometheus. +Copyright 2012-2015 The Prometheus Authors + +This product includes software developed at +SoundCloud Ltd. (http://soundcloud.com/). diff --git a/doc/3rd-party-deps/github.com/prometheus/common/LICENSE b/doc/3rd-party-deps/github.com/prometheus/common/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/doc/3rd-party-deps/github.com/prometheus/common/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/doc/3rd-party-deps/github.com/prometheus/common/NOTICE b/doc/3rd-party-deps/github.com/prometheus/common/NOTICE new file mode 100644 index 0000000..636a2c1 --- /dev/null +++ b/doc/3rd-party-deps/github.com/prometheus/common/NOTICE @@ -0,0 +1,5 @@ +Common libraries shared by Prometheus Go components. +Copyright 2015 The Prometheus Authors + +This product includes software developed at +SoundCloud Ltd. (http://soundcloud.com/). diff --git a/doc/3rd-party-deps/github.com/prometheus/common/internal/bitbucket.org/ww/goautoneg/README.txt b/doc/3rd-party-deps/github.com/prometheus/common/internal/bitbucket.org/ww/goautoneg/README.txt new file mode 100644 index 0000000..7723656 --- /dev/null +++ b/doc/3rd-party-deps/github.com/prometheus/common/internal/bitbucket.org/ww/goautoneg/README.txt @@ -0,0 +1,67 @@ +PACKAGE + +package goautoneg +import "bitbucket.org/ww/goautoneg" + +HTTP Content-Type Autonegotiation. + +The functions in this package implement the behaviour specified in +http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html + +Copyright (c) 2011, Open Knowledge Foundation Ltd. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + + Neither the name of the Open Knowledge Foundation Ltd. nor the + names of its contributors may be used to endorse or promote + products derived from this software without specific prior written + permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +FUNCTIONS + +func Negotiate(header string, alternatives []string) (content_type string) +Negotiate the most appropriate content_type given the accept header +and a list of alternatives. + +func ParseAccept(header string) (accept []Accept) +Parse an Accept Header string returning a sorted list +of clauses + + +TYPES + +type Accept struct { + Type, SubType string + Q float32 + Params map[string]string +} +Structure to represent a clause in an HTTP Accept Header + + +SUBDIRECTORIES + + .hg diff --git a/doc/3rd-party-deps/golang.org/x/net/LICENSE b/doc/3rd-party-deps/golang.org/x/net/LICENSE new file mode 100644 index 0000000..6a66aea --- /dev/null +++ b/doc/3rd-party-deps/golang.org/x/net/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2009 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/doc/3rd-party-deps/golang.org/x/sys/windows/LICENSE b/doc/3rd-party-deps/golang.org/x/sys/windows/LICENSE new file mode 100644 index 0000000..6a66aea --- /dev/null +++ b/doc/3rd-party-deps/golang.org/x/sys/windows/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2009 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/doc/3rd-party-deps/golang.org/x/text/LICENSE b/doc/3rd-party-deps/golang.org/x/text/LICENSE new file mode 100644 index 0000000..6a66aea --- /dev/null +++ b/doc/3rd-party-deps/golang.org/x/text/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2009 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/doc/3rd-party-deps/golang.org/x/time/rate/LICENSE b/doc/3rd-party-deps/golang.org/x/time/rate/LICENSE new file mode 100644 index 0000000..6a66aea --- /dev/null +++ b/doc/3rd-party-deps/golang.org/x/time/rate/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2009 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/doc/3rd-party-deps/google.golang.org/protobuf/LICENSE b/doc/3rd-party-deps/google.golang.org/protobuf/LICENSE new file mode 100644 index 0000000..49ea0f9 --- /dev/null +++ b/doc/3rd-party-deps/google.golang.org/protobuf/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2018 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..901e977 --- /dev/null +++ b/go.mod @@ -0,0 +1,27 @@ +module github.com/omc/bonsai-api-go/v1 + +go 1.22 + +require ( + github.com/go-chi/chi/v5 v5.0.12 + github.com/google/go-querystring v1.1.0 + github.com/hetznercloud/hcloud-go/v2 v2.7.2 + github.com/stretchr/testify v1.9.0 + golang.org/x/net v0.24.0 + golang.org/x/time v0.5.0 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/prometheus/client_golang v1.19.0 // indirect + github.com/prometheus/client_model v0.5.0 // indirect + github.com/prometheus/common v0.48.0 // indirect + github.com/prometheus/procfs v0.12.0 // indirect + golang.org/x/sys v0.19.0 // indirect + golang.org/x/text v0.14.0 // indirect + google.golang.org/protobuf v1.32.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..56ffe21 --- /dev/null +++ b/go.sum @@ -0,0 +1,49 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s= +github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/hetznercloud/hcloud-go/v2 v2.7.2 h1:UlE7n1GQZacCfyjv9tDVUN7HZfOXErPIfM/M039u9A0= +github.com/hetznercloud/hcloud-go/v2 v2.7.2/go.mod h1:49tIV+pXRJTUC7fbFZ03s45LKqSQdOPP5y91eOnJo/k= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU= +github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k= +github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= +github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= +github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE= +github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc= +github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= +github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= +github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= +golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= +google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=