From eb5c699570e7e0cdb7b9d7f83f55b4eeba9e3b7f Mon Sep 17 00:00:00 2001 From: Forest Date: Fri, 19 Apr 2024 10:56:05 +0800 Subject: [PATCH] feat: init kcp core api (#1033) * feat: init kcp core api * fix: address unit test issues * fix: lint issues * fix: address issues after rebase * chore: add unit tests for persistence layer * refactor: offload heavy logic from handler to manager --- .gitignore | 8 + go.mod | 40 ++- go.sum | 70 ++-- pkg/apis/internal.kusion.io/v1/marshal.go | 22 +- .../internal.kusion.io/v1/marshal_test.go | 52 +-- pkg/apis/internal.kusion.io/v1/types.go | 8 + pkg/apis/internal.kusion.io/v1/unmarshal.go | 22 +- .../internal.kusion.io/v1/unmarshal_test.go | 52 +-- pkg/cmd/apply/apply.go | 2 +- pkg/cmd/cmd.go | 7 + pkg/cmd/destroy/destroy.go | 2 +- pkg/cmd/generate/generate.go | 4 +- pkg/cmd/preview/preview.go | 2 +- pkg/cmd/server/database.go | 93 +++++ pkg/cmd/server/database_access.go | 102 ++++++ pkg/cmd/server/options.go | 36 ++ pkg/cmd/server/server.go | 52 +++ pkg/cmd/server/types.go | 22 ++ pkg/cmd/server/util/util.go | 21 ++ pkg/domain/constant/organization.go | 26 ++ pkg/domain/constant/project.go | 14 + pkg/domain/constant/source.go | 45 +++ pkg/domain/constant/stack.go | 50 +++ pkg/domain/entity/backend.go | 44 +++ pkg/domain/entity/organization.go | 45 +++ pkg/domain/entity/project.go | 70 ++++ pkg/domain/entity/source.go | 52 +++ pkg/domain/entity/source_provider.go | 37 ++ pkg/domain/entity/source_providers/git.go | 116 ++++++ pkg/domain/entity/source_providers/types.go | 11 + pkg/domain/entity/source_providers/util.go | 60 ++++ pkg/domain/entity/stack.go | 94 +++++ pkg/domain/entity/workspace.go | 51 +++ pkg/domain/repository/repository.go | 109 ++++++ pkg/domain/repository/types.go | 49 +++ pkg/domain/request/backend_request.go | 27 ++ pkg/domain/request/organization_request.go | 29 ++ pkg/domain/request/project_request.go | 41 +++ pkg/domain/request/source_request.go | 33 ++ pkg/domain/request/stack_request.go | 116 ++++++ pkg/domain/request/util.go | 74 ++++ pkg/domain/request/workspace_request.go | 33 ++ pkg/domain/service/stack.go | 140 ++++++++ pkg/engine/api/apply.go | 222 ++++++++++++ pkg/engine/api/apply_test.go | 125 +++++++ .../api}/builders/appconfig_builder.go | 0 .../api}/builders/appconfig_builder_test.go | 0 .../api}/builders/testdata/kcl.mod | 0 pkg/engine/api/destroy.go | 162 +++++++++ pkg/engine/api/destroy_test.go | 166 +++++++++ pkg/engine/api/generate.go | 145 ++++++++ .../api}/generate/generator/fake/fake.go | 0 .../api}/generate/generator/generator.go | 4 +- .../api}/generate/run/fake/fake.go | 2 +- pkg/{cmd => engine/api}/generate/run/run.go | 0 .../api}/generate/run/run_test.go | 0 .../api}/generate/run/testdata/base/base.k | 0 .../api}/generate/run/testdata/prod/kcl.mod | 0 .../api}/generate/run/testdata/prod/main.k | 0 .../generate/run/testdata/prod/stack.yaml | 0 .../api}/generate/run/testdata/project.yaml | 0 pkg/engine/api/preview.go | 98 +++++ pkg/engine/api/preview_test.go | 104 ++++++ pkg/engine/api/source/source.go | 44 +++ pkg/engine/operation/models/change.go | 85 ++++- pkg/engine/operation/models/change_test.go | 6 +- pkg/infra/persistence/backend.go | 123 +++++++ pkg/infra/persistence/backend_model.go | 74 ++++ pkg/infra/persistence/backend_test.go | 152 ++++++++ pkg/infra/persistence/organization.go | 123 +++++++ pkg/infra/persistence/organization_model.go | 72 ++++ pkg/infra/persistence/organization_test.go | 149 ++++++++ pkg/infra/persistence/project.go | 126 +++++++ pkg/infra/persistence/project_model.go | 102 ++++++ pkg/infra/persistence/project_test.go | 208 +++++++++++ pkg/infra/persistence/source.go | 149 ++++++++ pkg/infra/persistence/source_model.go | 87 +++++ pkg/infra/persistence/source_test.go | 182 ++++++++++ pkg/infra/persistence/stack.go | 122 +++++++ pkg/infra/persistence/stack_model.go | 116 ++++++ pkg/infra/persistence/stack_test.go | 217 ++++++++++++ pkg/infra/persistence/types.go | 22 ++ pkg/infra/persistence/util.go | 88 +++++ pkg/infra/persistence/workspace.go | 129 +++++++ pkg/infra/persistence/workspace_model.go | 67 ++++ pkg/infra/persistence/workspace_test.go | 147 ++++++++ pkg/server/config.go | 13 + pkg/server/handler/backend/handler.go | 170 +++++++++ pkg/server/handler/backend/handler_test.go | 299 ++++++++++++++++ pkg/server/handler/backend/types.go | 21 ++ pkg/server/handler/endpoint/endpoint.go | 52 +++ pkg/server/handler/organization/handler.go | 169 +++++++++ .../handler/organization/handler_test.go | 302 ++++++++++++++++ pkg/server/handler/organization/types.go | 21 ++ pkg/server/handler/project/handler.go | 170 +++++++++ pkg/server/handler/project/handler_test.go | 333 +++++++++++++++++ pkg/server/handler/project/types.go | 21 ++ pkg/server/handler/render.go | 53 +++ pkg/server/handler/source/handler.go | 172 +++++++++ pkg/server/handler/source/handler_test.go | 304 ++++++++++++++++ pkg/server/handler/source/types.go | 21 ++ pkg/server/handler/stack/execute.go | 194 ++++++++++ pkg/server/handler/stack/handler.go | 150 ++++++++ pkg/server/handler/stack/handler_test.go | 330 +++++++++++++++++ pkg/server/handler/stack/types.go | 25 ++ pkg/server/handler/types.go | 45 +++ pkg/server/handler/util.go | 64 ++++ pkg/server/handler/workspace/handler.go | 172 +++++++++ pkg/server/handler/workspace/handler_test.go | 307 ++++++++++++++++ pkg/server/handler/workspace/types.go | 21 ++ pkg/server/manager/backend/backend_manager.go | 86 +++++ pkg/server/manager/backend/types.go | 23 ++ .../organization/organization_manager.go | 91 +++++ pkg/server/manager/organization/types.go | 23 ++ pkg/server/manager/project/project_manager.go | 120 +++++++ pkg/server/manager/project/types.go | 29 ++ pkg/server/manager/source/source_manager.go | 101 ++++++ pkg/server/manager/source/types.go | 23 ++ pkg/server/manager/stack/stack_manager.go | 334 ++++++++++++++++++ pkg/server/manager/stack/types.go | 30 ++ pkg/server/manager/stack/util.go | 229 ++++++++++++ pkg/server/manager/workspace/types.go | 26 ++ .../manager/workspace/workspace_manager.go | 95 +++++ pkg/server/middleware/logger.go | 35 ++ pkg/server/middleware/readonly.go | 18 + pkg/server/middleware/timing.go | 42 +++ pkg/server/middleware/types.go | 7 + pkg/server/route/route.go | 194 ++++++++++ pkg/server/route/route_test.go | 66 ++++ pkg/server/util/ctxutil.go | 22 ++ 130 files changed, 10486 insertions(+), 138 deletions(-) create mode 100644 pkg/cmd/server/database.go create mode 100644 pkg/cmd/server/database_access.go create mode 100644 pkg/cmd/server/options.go create mode 100644 pkg/cmd/server/server.go create mode 100644 pkg/cmd/server/types.go create mode 100644 pkg/cmd/server/util/util.go create mode 100644 pkg/domain/constant/organization.go create mode 100644 pkg/domain/constant/project.go create mode 100644 pkg/domain/constant/source.go create mode 100644 pkg/domain/constant/stack.go create mode 100644 pkg/domain/entity/backend.go create mode 100644 pkg/domain/entity/organization.go create mode 100644 pkg/domain/entity/project.go create mode 100644 pkg/domain/entity/source.go create mode 100644 pkg/domain/entity/source_provider.go create mode 100644 pkg/domain/entity/source_providers/git.go create mode 100644 pkg/domain/entity/source_providers/types.go create mode 100644 pkg/domain/entity/source_providers/util.go create mode 100644 pkg/domain/entity/stack.go create mode 100644 pkg/domain/entity/workspace.go create mode 100644 pkg/domain/repository/repository.go create mode 100644 pkg/domain/repository/types.go create mode 100644 pkg/domain/request/backend_request.go create mode 100644 pkg/domain/request/organization_request.go create mode 100644 pkg/domain/request/project_request.go create mode 100644 pkg/domain/request/source_request.go create mode 100644 pkg/domain/request/stack_request.go create mode 100644 pkg/domain/request/util.go create mode 100644 pkg/domain/request/workspace_request.go create mode 100644 pkg/domain/service/stack.go create mode 100644 pkg/engine/api/apply.go create mode 100644 pkg/engine/api/apply_test.go rename pkg/{cmd/generate => engine/api}/builders/appconfig_builder.go (100%) rename pkg/{cmd/generate => engine/api}/builders/appconfig_builder_test.go (100%) rename pkg/{cmd/generate => engine/api}/builders/testdata/kcl.mod (100%) create mode 100644 pkg/engine/api/destroy.go create mode 100644 pkg/engine/api/destroy_test.go create mode 100644 pkg/engine/api/generate.go rename pkg/{cmd => engine/api}/generate/generator/fake/fake.go (100%) rename pkg/{cmd => engine/api}/generate/generator/generator.go (97%) rename pkg/{cmd => engine/api}/generate/run/fake/fake.go (81%) rename pkg/{cmd => engine/api}/generate/run/run.go (100%) rename pkg/{cmd => engine/api}/generate/run/run_test.go (100%) rename pkg/{cmd => engine/api}/generate/run/testdata/base/base.k (100%) rename pkg/{cmd => engine/api}/generate/run/testdata/prod/kcl.mod (100%) rename pkg/{cmd => engine/api}/generate/run/testdata/prod/main.k (100%) rename pkg/{cmd => engine/api}/generate/run/testdata/prod/stack.yaml (100%) rename pkg/{cmd => engine/api}/generate/run/testdata/project.yaml (100%) create mode 100644 pkg/engine/api/preview.go create mode 100644 pkg/engine/api/preview_test.go create mode 100644 pkg/engine/api/source/source.go create mode 100644 pkg/infra/persistence/backend.go create mode 100644 pkg/infra/persistence/backend_model.go create mode 100644 pkg/infra/persistence/backend_test.go create mode 100644 pkg/infra/persistence/organization.go create mode 100644 pkg/infra/persistence/organization_model.go create mode 100644 pkg/infra/persistence/organization_test.go create mode 100644 pkg/infra/persistence/project.go create mode 100644 pkg/infra/persistence/project_model.go create mode 100644 pkg/infra/persistence/project_test.go create mode 100644 pkg/infra/persistence/source.go create mode 100644 pkg/infra/persistence/source_model.go create mode 100644 pkg/infra/persistence/source_test.go create mode 100644 pkg/infra/persistence/stack.go create mode 100644 pkg/infra/persistence/stack_model.go create mode 100644 pkg/infra/persistence/stack_test.go create mode 100644 pkg/infra/persistence/types.go create mode 100644 pkg/infra/persistence/util.go create mode 100644 pkg/infra/persistence/workspace.go create mode 100644 pkg/infra/persistence/workspace_model.go create mode 100644 pkg/infra/persistence/workspace_test.go create mode 100644 pkg/server/config.go create mode 100644 pkg/server/handler/backend/handler.go create mode 100644 pkg/server/handler/backend/handler_test.go create mode 100644 pkg/server/handler/backend/types.go create mode 100644 pkg/server/handler/endpoint/endpoint.go create mode 100644 pkg/server/handler/organization/handler.go create mode 100644 pkg/server/handler/organization/handler_test.go create mode 100644 pkg/server/handler/organization/types.go create mode 100644 pkg/server/handler/project/handler.go create mode 100644 pkg/server/handler/project/handler_test.go create mode 100644 pkg/server/handler/project/types.go create mode 100644 pkg/server/handler/render.go create mode 100644 pkg/server/handler/source/handler.go create mode 100644 pkg/server/handler/source/handler_test.go create mode 100644 pkg/server/handler/source/types.go create mode 100644 pkg/server/handler/stack/execute.go create mode 100644 pkg/server/handler/stack/handler.go create mode 100644 pkg/server/handler/stack/handler_test.go create mode 100644 pkg/server/handler/stack/types.go create mode 100644 pkg/server/handler/types.go create mode 100644 pkg/server/handler/util.go create mode 100644 pkg/server/handler/workspace/handler.go create mode 100644 pkg/server/handler/workspace/handler_test.go create mode 100644 pkg/server/handler/workspace/types.go create mode 100644 pkg/server/manager/backend/backend_manager.go create mode 100644 pkg/server/manager/backend/types.go create mode 100644 pkg/server/manager/organization/organization_manager.go create mode 100644 pkg/server/manager/organization/types.go create mode 100644 pkg/server/manager/project/project_manager.go create mode 100644 pkg/server/manager/project/types.go create mode 100644 pkg/server/manager/source/source_manager.go create mode 100644 pkg/server/manager/source/types.go create mode 100644 pkg/server/manager/stack/stack_manager.go create mode 100644 pkg/server/manager/stack/types.go create mode 100644 pkg/server/manager/stack/util.go create mode 100644 pkg/server/manager/workspace/types.go create mode 100644 pkg/server/manager/workspace/workspace_manager.go create mode 100644 pkg/server/middleware/logger.go create mode 100644 pkg/server/middleware/readonly.go create mode 100644 pkg/server/middleware/timing.go create mode 100644 pkg/server/middleware/types.go create mode 100644 pkg/server/route/route.go create mode 100644 pkg/server/route/route_test.go create mode 100644 pkg/server/util/ctxutil.go diff --git a/.gitignore b/.gitignore index efb313bf..7766a095 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,7 @@ _python37_home_/ /kclopenapi .kclvm/ kusion_state*.json +kusion_state*.yaml # Vscode __debug_bin @@ -52,3 +53,10 @@ zz_* # version *z_update_version.go + +# startup script +start.sh +air.toml + +# frontend +frontend/ diff --git a/go.mod b/go.mod index 6e9266b3..2d34955a 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/Azure/azure-sdk-for-go v68.0.0+incompatible github.com/Azure/go-autorest/autorest v0.11.29 github.com/Azure/go-autorest/autorest/azure/auth v0.5.12 + github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/Masterminds/semver/v3 v3.1.1 github.com/aliyun/aliyun-oss-go-sdk v2.1.8+incompatible github.com/aws/aws-sdk-go v1.48.6 @@ -21,9 +22,12 @@ require ( github.com/evanphx/json-patch v4.12.0+incompatible github.com/fluxcd/pkg/sourceignore v0.5.0 github.com/fluxcd/pkg/tar v0.4.0 + github.com/go-chi/chi/v5 v5.0.12 + github.com/go-chi/cors v1.2.1 + github.com/go-chi/render v1.0.3 github.com/go-git/go-git/v5 v5.11.0 github.com/go-sql-driver/mysql v1.7.0 - github.com/go-test/deep v1.0.8 + github.com/go-test/deep v1.0.3 github.com/goccy/go-yaml v1.11.3 github.com/gonvenience/bunt v1.1.1 github.com/gonvenience/neat v1.3.0 @@ -41,7 +45,6 @@ require ( github.com/hashicorp/go-version v1.6.0 github.com/hashicorp/hcl/v2 v2.16.1 github.com/hashicorp/vault/api v1.10.0 - github.com/hashicorp/waypoint-plugin-sdk v0.0.0-20230412210808-dcdb2a03f714 github.com/howieyuen/uilive v0.0.6 github.com/jinzhu/copier v0.3.2 github.com/lucasb-eyer/go-colorful v1.0.3 @@ -55,6 +58,8 @@ require ( github.com/spf13/afero v1.6.0 github.com/spf13/cobra v1.7.0 github.com/stretchr/testify v1.9.0 + github.com/swaggo/http-swagger v1.3.4 + github.com/swaggo/swag v1.16.3 github.com/texttheater/golang-levenshtein v1.0.1 github.com/tidwall/gjson v1.17.0 github.com/zclconf/go-cty v1.12.1 @@ -85,8 +90,10 @@ require ( cloud.google.com/go/compute/metadata v0.2.3 // indirect cloud.google.com/go/iam v1.1.5 // indirect cloud.google.com/go/storage v1.36.0 // indirect + github.com/KyleBanks/depth v1.2.1 // indirect github.com/Microsoft/hcsshim v0.11.0 // indirect github.com/VividCortex/ewma v1.1.1 // indirect + github.com/ajg/form v1.5.1 // indirect github.com/alibabacloud-go/darabonba-array v0.1.0 // indirect github.com/alibabacloud-go/darabonba-encode-util v0.0.2 // indirect github.com/alibabacloud-go/darabonba-map v0.0.2 // indirect @@ -109,6 +116,7 @@ require ( github.com/dominikbraun/graph v0.23.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-errors/errors v1.4.2 // indirect + github.com/go-openapi/spec v0.21.0 // indirect github.com/google/btree v1.0.1 // indirect github.com/google/gnostic-models v0.6.8 // indirect github.com/google/s2a-go v0.1.7 // indirect @@ -119,6 +127,7 @@ require ( github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7 // indirect github.com/hashicorp/go-getter v1.7.3 // indirect github.com/hashicorp/go-safetemp v1.0.0 // indirect + github.com/hashicorp/waypoint-plugin-sdk v0.0.0-20230412210808-dcdb2a03f714 // indirect github.com/hashicorp/yamux v0.1.1 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect @@ -134,6 +143,7 @@ require ( github.com/olekukonko/tablewriter v0.0.4 // indirect github.com/orcaman/concurrent-map v0.0.0-20210501183033-44dafcb38ecc // indirect github.com/peterbourgon/diskv v2.0.1+incompatible // indirect + github.com/swaggo/files v1.0.1 // indirect github.com/tj/go-spin v1.1.0 // indirect github.com/tjfoc/gmsm v1.4.1 // indirect github.com/ulikunitz/xz v0.5.10 // indirect @@ -211,11 +221,11 @@ require ( github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.5.0 // indirect github.com/go-jose/go-jose/v3 v3.0.0 // indirect - github.com/go-logr/logr v1.3.0 // indirect + github.com/go-logr/logr v1.3.0 github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-openapi/jsonpointer v0.20.2 // indirect - github.com/go-openapi/jsonreference v0.20.2 // indirect - github.com/go-openapi/swag v0.22.8 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/go-openapi/swag v0.23.0 // indirect github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect github.com/gofrs/flock v0.8.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect @@ -283,11 +293,11 @@ require ( github.com/prometheus/common v0.44.0 // indirect github.com/prometheus/procfs v0.11.1 // indirect github.com/rivo/uniseg v0.4.4 // indirect - github.com/rogpeppe/go-internal v1.12.0 // indirect + github.com/rogpeppe/go-internal v1.11.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/ryanuber/go-glob v1.0.0 // indirect github.com/satori/go.uuid v1.2.0 // indirect - github.com/sirupsen/logrus v1.9.3 // indirect + github.com/sirupsen/logrus v1.9.3 github.com/skeema/knownhosts v1.2.1 // indirect github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d // indirect github.com/smartystreets/goconvey v1.6.4 // indirect @@ -304,16 +314,16 @@ require ( go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.6.0 // indirect golang.org/x/arch v0.1.0 // indirect - golang.org/x/crypto v0.18.0 - golang.org/x/mod v0.14.0 // indirect - golang.org/x/net v0.20.0 // indirect + golang.org/x/crypto v0.21.0 + golang.org/x/mod v0.16.0 // indirect + golang.org/x/net v0.22.0 // indirect golang.org/x/oauth2 v0.16.0 // indirect golang.org/x/sync v0.6.0 // indirect - golang.org/x/sys v0.16.0 // indirect - golang.org/x/term v0.16.0 // indirect + golang.org/x/sys v0.18.0 // indirect + golang.org/x/term v0.18.0 // indirect golang.org/x/text v0.14.0 // indirect golang.org/x/time v0.5.0 // indirect - golang.org/x/tools v0.16.1 // indirect + golang.org/x/tools v0.19.0 // indirect golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect google.golang.org/appengine v1.6.8 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240123012728-ef4313101c80 // indirect @@ -321,7 +331,7 @@ require ( google.golang.org/protobuf v1.33.0 gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect - k8s.io/klog/v2 v2.110.1 // indirect + k8s.io/klog/v2 v2.110.1 k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00 // indirect oras.land/oras-go/v2 v2.3.0 // indirect sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect diff --git a/go.sum b/go.sum index 4044e9a5..8d4117fc 100644 --- a/go.sum +++ b/go.sum @@ -234,8 +234,12 @@ github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8 github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/DATA-DOG/go-sqlmock v1.3.3/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/KusionStack/kpm v0.8.4 h1:6CfgJ4jIeLkbHyELct/dBu/tmlhuvhv8QcP8mJ4/bE8= github.com/KusionStack/kpm v0.8.4/go.mod h1:3atE1tEbsSPaAuKslkADH1HTDi7SMWlDWllmuk2XsBA= +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/MarvinJWendt/testza v0.1.0/go.mod h1:7AxNvlfeHP7Z/hDQ5JtE3OKYT3XFUeLCDE2DQninSqs= @@ -265,6 +269,8 @@ github.com/VividCortex/ewma v1.1.1 h1:MnEK4VOv6n0RSY4vtRe3h11qjxL3+t0B8yOL8iMXdc github.com/VividCortex/ewma v1.1.1/go.mod h1:2Tkkvm3sRDVXaiyucHiACn4cqf7DpdyLvmxzcbUokwA= github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8= github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= +github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= +github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alibabacloud-go/darabonba-array v0.1.0 h1:vR8s7b1fWAQIjEjWnuF0JiKsCvclSRTfDzZHTYqfufY= @@ -382,6 +388,7 @@ github.com/chai2010/jsonv v1.1.3/go.mod h1:mEoT1dQ9qVF4oP9peVTl0UymTmJwXoTDOh+sN github.com/chai2010/protorpc v1.1.4 h1:CTtFUhzXRoeuR7FtgQ2b2vdT/KgWVpCM+sIus8zJjHs= github.com/chai2010/protorpc v1.1.4/go.mod h1:/wO0kiyVdu7ug8dCMrA2yDr2vLfyhsLEuzLa9J2HJ+I= github.com/cheggaaa/pb v1.0.27/go.mod h1:pQciLPpbU0oxA0h+VJYYLxO+XeDQb5pZijXscXHm81s= +github.com/cheggaaa/pb v1.0.29 h1:FckUN5ngEk2LpvuG0fw1GEFx6LtyY2pWI/Z2QgCnEYo= github.com/cheggaaa/pb/v3 v3.0.5 h1:lmZOti7CraK9RSjzExsY53+WWfub9Qv13B5m4ptEoPE= github.com/cheggaaa/pb/v3 v3.0.5/go.mod h1:X1L61/+36nz9bjIsrDU52qHKOQukUQe2Ge+YvGuquCw= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= @@ -492,6 +499,12 @@ github.com/gdamore/tcell v1.3.0/go.mod h1:Hjvr+Ofd+gLglo7RYKxxnzCBmev3BzsS67MebK github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY= github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4= +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/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= +github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4= +github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= @@ -517,14 +530,14 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/zapr v1.2.4 h1:QHVo+6stLbfJmYGkQ7uGHUCu5hnAFAj6mDe6Ea0SeOo= github.com/go-logr/zapr v1.2.4/go.mod h1:FyHWQIzQORZ0QVE1BtVHv3cKtNLuXsbNLtpuhNapBOA= -github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= -github.com/go-openapi/jsonpointer v0.20.2 h1:mQc3nmndL8ZBzStEo3JYF8wzmeWffDH4VbXz58sAx6Q= -github.com/go-openapi/jsonpointer v0.20.2/go.mod h1:bHen+N0u1KEO3YlmqOjTT9Adn1RfD91Ar825/PuiRVs= -github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= -github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= -github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= -github.com/go-openapi/swag v0.22.8 h1:/9RjDSQ0vbFR+NyjGMkFTsA1IA0fmhKSThmfGZjicbw= -github.com/go-openapi/swag v0.22.8/go.mod h1:6QT22icPLEqAM/z/TChgb4WAveCHF92+2gF0CNjHpPI= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= +github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= +github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY= +github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q= github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no= @@ -536,8 +549,8 @@ github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9 github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= -github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= -github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= +github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/goccy/go-yaml v1.11.3 h1:B3W9IdWbvrUu2OYQGwvU1nZtvMQJPBKgBUuweJjLj6I= github.com/goccy/go-yaml v1.11.3/go.mod h1:wKnAMd44+9JAAnGQpWVEgBzGt3YuTaQ4uXoHvE4m7WU= github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= @@ -802,6 +815,7 @@ github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4 github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= github.com/klauspost/compress v1.15.11/go.mod h1:QPwzmACJjUTFsnSHH934V6woptycfrDDJnH7hvFVbGM= github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I= github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= @@ -814,7 +828,6 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxv github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= @@ -991,8 +1004,8 @@ github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= -github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +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/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= @@ -1039,6 +1052,12 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 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= +github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= +github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= +github.com/swaggo/http-swagger v1.3.4 h1:q7t/XLx0n15H1Q9/tk3Y9L4n210XzJF5WtnDX64a5ww= +github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4UbucIg1MFkQ= +github.com/swaggo/swag v1.16.3 h1:PnCYjPCah8FK4I26l2F/KQ4yz3sILcVUN3cTlBFA9Pg= +github.com/swaggo/swag v1.16.3/go.mod h1:DImHIuOFXKpMFAQjcC7FG4m3Dg4+QuUgUzJmKjI/gRk= github.com/texttheater/golang-levenshtein v1.0.1 h1:+cRNoVrfiwufQPhoMzB6N0Yf/Mqajr6t1lOv8GyGE2U= github.com/texttheater/golang-levenshtein v1.0.1/go.mod h1:PYAKrbF5sAiq9wd+H82hs7gNaen0CplQ9uvm6+enD/8= github.com/thoas/go-funk v0.9.3 h1:7+nAEx3kn5ZJcnDm2Bh23N2yOtweO14bi//dvRtgLpw= @@ -1061,7 +1080,6 @@ github.com/ulikunitz/xz v0.5.10/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0o github.com/urfave/cli v1.22.12/go.mod h1:sSBEIC79qR6OvcmsD4U3KABeOTxDqQtdDnaFuUN30b8= github.com/vbatts/tar-split v0.11.3 h1:hLFqsOLQ1SsppQNTMpkpPXClLDfC2A3Zgy9OUU+RVck= github.com/vbatts/tar-split v0.11.3/go.mod h1:9QlHN18E+fEH7RdG+QAJJcuya3rqT7eXSTY7wGrAokY= -github.com/vektra/neko v0.0.0-20170502000624-99acbdf12420 h1:OMelMt+D75Fax25tMcBfUoOyNp8OziZK/Ca8dB8BX38= github.com/vektra/neko v0.0.0-20170502000624-99acbdf12420/go.mod h1:7tfPLehrsToaevw9Vi9iL6FOslcBJ/uqYQc8y3YIbdI= github.com/virtuald/go-ordered-json v0.0.0-20170621173500-b18e6e673d74 h1:JwtAtbp7r/7QSyGz8mKUbYJBg2+6Cd7OjM8o/GNOcVo= github.com/virtuald/go-ordered-json v0.0.0-20170621173500-b18e6e673d74/go.mod h1:RmMWU37GKR2s6pgrIEB4ixgpVCt/cf7dnJv3fuH1J1c= @@ -1146,8 +1164,8 @@ golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2Uz golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I= -golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= -golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -1187,8 +1205,8 @@ golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= -golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= +golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -1248,8 +1266,8 @@ golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.11.0/go.mod h1:2L/ixqYpgIVXmeoSA/4Lu7BzTG4KIyPIryS4IsOd1oQ= -golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= -golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= +golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -1387,8 +1405,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= -golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY= @@ -1401,8 +1419,8 @@ golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.9.0/go.mod h1:M6DEAAIenWoTxdKrOltXcmDY3rSplQUkrvaDU5FcQyo= -golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE= -golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= +golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8= +golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -1482,8 +1500,8 @@ golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.16.1 h1:TLyB3WofjdOEepBHAU20JdNC1Zbg87elYofWYAY5oZA= -golang.org/x/tools v0.16.1/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0= +golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= +golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/pkg/apis/internal.kusion.io/v1/marshal.go b/pkg/apis/internal.kusion.io/v1/marshal.go index a91f3161..d7d86d60 100644 --- a/pkg/apis/internal.kusion.io/v1/marshal.go +++ b/pkg/apis/internal.kusion.io/v1/marshal.go @@ -9,7 +9,7 @@ import ( // MarshalJSON implements the json.Marshaler interface for ProbeHandler. func (p *ProbeHandler) MarshalJSON() ([]byte, error) { switch p.Type { - case "Http": + case TypeHTTP: return json.Marshal(struct { TypeWrapper `json:",inline"` *HTTPGetAction `json:",inline"` @@ -17,7 +17,7 @@ func (p *ProbeHandler) MarshalJSON() ([]byte, error) { TypeWrapper: TypeWrapper{p.Type}, HTTPGetAction: p.HTTPGetAction, }) - case "Exec": + case TypeExec: return json.Marshal(struct { TypeWrapper `json:",inline"` *ExecAction `json:",inline"` @@ -25,7 +25,7 @@ func (p *ProbeHandler) MarshalJSON() ([]byte, error) { TypeWrapper: TypeWrapper{p.Type}, ExecAction: p.ExecAction, }) - case "Tcp": + case TypeTCP: return json.Marshal(struct { TypeWrapper `json:",inline"` *TCPSocketAction `json:",inline"` @@ -41,7 +41,7 @@ func (p *ProbeHandler) MarshalJSON() ([]byte, error) { // MarshalYAML implements the yaml.Marshaler interface for ProbeHandler. func (p *ProbeHandler) MarshalYAML() (interface{}, error) { switch p.Type { - case "Http": + case TypeHTTP: return struct { TypeWrapper `yaml:",inline" json:",inline"` HTTPGetAction `yaml:",inline" json:",inline"` @@ -49,7 +49,7 @@ func (p *ProbeHandler) MarshalYAML() (interface{}, error) { TypeWrapper: TypeWrapper{Type: p.Type}, HTTPGetAction: *p.HTTPGetAction, }, nil - case "Exec": + case TypeExec: return struct { TypeWrapper `yaml:",inline" json:",inline"` ExecAction `yaml:",inline" json:",inline"` @@ -57,7 +57,7 @@ func (p *ProbeHandler) MarshalYAML() (interface{}, error) { TypeWrapper: TypeWrapper{Type: p.Type}, ExecAction: *p.ExecAction, }, nil - case "Tcp": + case TypeTCP: return struct { TypeWrapper `yaml:",inline" json:",inline"` TCPSocketAction `yaml:",inline" json:",inline"` @@ -73,7 +73,7 @@ func (p *ProbeHandler) MarshalYAML() (interface{}, error) { // MarshalJSON implements the json.Marshaler interface for LifecycleHandler. func (l *LifecycleHandler) MarshalJSON() ([]byte, error) { switch l.Type { - case "Http": + case TypeHTTP: return json.Marshal(struct { TypeWrapper `json:",inline"` *HTTPGetAction `json:",inline"` @@ -81,7 +81,7 @@ func (l *LifecycleHandler) MarshalJSON() ([]byte, error) { TypeWrapper: TypeWrapper{l.Type}, HTTPGetAction: l.HTTPGetAction, }) - case "Exec": + case TypeExec: return json.Marshal(struct { TypeWrapper `json:",inline"` *ExecAction `json:",inline"` @@ -97,7 +97,7 @@ func (l *LifecycleHandler) MarshalJSON() ([]byte, error) { // MarshalYAML implements the yaml.Marshaler interface for LifecycleHandler. func (l *LifecycleHandler) MarshalYAML() (interface{}, error) { switch l.Type { - case "Http": + case TypeHTTP: return struct { TypeWrapper `yaml:",inline" json:",inline"` HTTPGetAction `yaml:",inline" json:",inline"` @@ -105,7 +105,7 @@ func (l *LifecycleHandler) MarshalYAML() (interface{}, error) { TypeWrapper: TypeWrapper{Type: l.Type}, HTTPGetAction: *l.HTTPGetAction, }, nil - case "Exec": + case TypeExec: return struct { TypeWrapper `yaml:",inline" json:",inline"` ExecAction `yaml:",inline" json:",inline"` @@ -138,7 +138,7 @@ func (w *Workload) MarshalJSON() ([]byte, error) { Job: w.Job, }) default: - return nil, errors.New("unknown workload type") + return nil, errors.New("unknown workload type marshal") } } diff --git a/pkg/apis/internal.kusion.io/v1/marshal_test.go b/pkg/apis/internal.kusion.io/v1/marshal_test.go index 5cc57574..575612dc 100644 --- a/pkg/apis/internal.kusion.io/v1/marshal_test.go +++ b/pkg/apis/internal.kusion.io/v1/marshal_test.go @@ -35,7 +35,7 @@ func TestContainerMarshalJSON(t *testing.T) { Image: "nginx:v1", ReadinessProbe: &Probe{ ProbeHandler: &ProbeHandler{ - TypeWrapper: TypeWrapper{"Http"}, + TypeWrapper: TypeWrapper{"kam.v1.workload.container.probe.Http"}, HTTPGetAction: &HTTPGetAction{ URL: "http://localhost:80", }, @@ -43,14 +43,14 @@ func TestContainerMarshalJSON(t *testing.T) { InitialDelaySeconds: 10, }, }, - result: `{"image":"nginx:v1","readinessProbe":{"probeHandler":{"_type":"Http","url":"http://localhost:80"},"initialDelaySeconds":10}}`, + result: `{"image":"nginx:v1","readinessProbe":{"probeHandler":{"_type":"kam.v1.workload.container.probe.Http","url":"http://localhost:80"},"initialDelaySeconds":10}}`, }, { input: Container{ Image: "nginx:v1", ReadinessProbe: &Probe{ ProbeHandler: &ProbeHandler{ - TypeWrapper: TypeWrapper{"Exec"}, + TypeWrapper: TypeWrapper{"kam.v1.workload.container.probe.Exec"}, ExecAction: &ExecAction{ Command: []string{"cat", "/tmp/healthy"}, }, @@ -58,14 +58,14 @@ func TestContainerMarshalJSON(t *testing.T) { InitialDelaySeconds: 10, }, }, - result: `{"image":"nginx:v1","readinessProbe":{"probeHandler":{"_type":"Exec","command":["cat","/tmp/healthy"]},"initialDelaySeconds":10}}`, + result: `{"image":"nginx:v1","readinessProbe":{"probeHandler":{"_type":"kam.v1.workload.container.probe.Exec","command":["cat","/tmp/healthy"]},"initialDelaySeconds":10}}`, }, { input: Container{ Image: "nginx:v1", ReadinessProbe: &Probe{ ProbeHandler: &ProbeHandler{ - TypeWrapper: TypeWrapper{Type: "Tcp"}, + TypeWrapper: TypeWrapper{Type: "kam.v1.workload.container.probe.Tcp"}, TCPSocketAction: &TCPSocketAction{ URL: "127.0.0.1:8080", }, @@ -73,47 +73,47 @@ func TestContainerMarshalJSON(t *testing.T) { InitialDelaySeconds: 10, }, }, - result: `{"image":"nginx:v1","readinessProbe":{"probeHandler":{"_type":"Tcp","url":"127.0.0.1:8080"},"initialDelaySeconds":10}}`, + result: `{"image":"nginx:v1","readinessProbe":{"probeHandler":{"_type":"kam.v1.workload.container.probe.Tcp","url":"127.0.0.1:8080"},"initialDelaySeconds":10}}`, }, { input: Container{ Image: "nginx:v1", Lifecycle: &Lifecycle{ PostStart: &LifecycleHandler{ - TypeWrapper: TypeWrapper{"Exec"}, + TypeWrapper: TypeWrapper{"kam.v1.workload.container.probe.Exec"}, ExecAction: &ExecAction{ Command: []string{"/bin/sh", "-c", "nginx -s quit; while killall -0 nginx; do sleep 1; done"}, }, }, PreStop: &LifecycleHandler{ - TypeWrapper: TypeWrapper{"Exec"}, + TypeWrapper: TypeWrapper{"kam.v1.workload.container.probe.Exec"}, ExecAction: &ExecAction{ Command: []string{"/bin/sh", "-c", "echo Hello from the postStart handler > /usr/share/message"}, }, }, }, }, - result: `{"image":"nginx:v1","lifecycle":{"preStop":{"_type":"Exec","command":["/bin/sh","-c","echo Hello from the postStart handler \u003e /usr/share/message"]},"postStart":{"_type":"Exec","command":["/bin/sh","-c","nginx -s quit; while killall -0 nginx; do sleep 1; done"]}}}`, + result: `{"image":"nginx:v1","lifecycle":{"preStop":{"_type":"kam.v1.workload.container.probe.Exec","command":["/bin/sh","-c","echo Hello from the postStart handler \u003e /usr/share/message"]},"postStart":{"_type":"kam.v1.workload.container.probe.Exec","command":["/bin/sh","-c","nginx -s quit; while killall -0 nginx; do sleep 1; done"]}}}`, }, { input: Container{ Image: "nginx:v1", Lifecycle: &Lifecycle{ PostStart: &LifecycleHandler{ - TypeWrapper: TypeWrapper{"Http"}, + TypeWrapper: TypeWrapper{"kam.v1.workload.container.probe.Http"}, HTTPGetAction: &HTTPGetAction{ URL: "http://localhost:80", }, }, PreStop: &LifecycleHandler{ - TypeWrapper: TypeWrapper{"Http"}, + TypeWrapper: TypeWrapper{"kam.v1.workload.container.probe.Http"}, HTTPGetAction: &HTTPGetAction{ URL: "http://localhost:80", }, }, }, }, - result: `{"image":"nginx:v1","lifecycle":{"preStop":{"_type":"Http","url":"http://localhost:80"},"postStart":{"_type":"Http","url":"http://localhost:80"}}}`, + result: `{"image":"nginx:v1","lifecycle":{"preStop":{"_type":"kam.v1.workload.container.probe.Http","url":"http://localhost:80"},"postStart":{"_type":"kam.v1.workload.container.probe.Http","url":"http://localhost:80"}}}`, }, } @@ -174,7 +174,7 @@ workingDir: /tmp WorkingDir: "/tmp", ReadinessProbe: &Probe{ ProbeHandler: &ProbeHandler{ - TypeWrapper: TypeWrapper{Type: "Http"}, + TypeWrapper: TypeWrapper{Type: "kam.v1.workload.container.probe.Http"}, HTTPGetAction: &HTTPGetAction{ URL: "http://localhost:80", }, @@ -196,7 +196,7 @@ env: workingDir: /tmp readinessProbe: probeHandler: - _type: Http + _type: kam.v1.workload.container.probe.Http url: http://localhost:80 initialDelaySeconds: 10 `, @@ -215,7 +215,7 @@ readinessProbe: WorkingDir: "/tmp", ReadinessProbe: &Probe{ ProbeHandler: &ProbeHandler{ - TypeWrapper: TypeWrapper{Type: "Exec"}, + TypeWrapper: TypeWrapper{Type: "kam.v1.workload.container.probe.Exec"}, ExecAction: &ExecAction{ Command: []string{"cat", "/tmp/healthy"}, }, @@ -237,7 +237,7 @@ env: workingDir: /tmp readinessProbe: probeHandler: - _type: Exec + _type: kam.v1.workload.container.probe.Exec command: - cat - /tmp/healthy @@ -258,7 +258,7 @@ readinessProbe: WorkingDir: "/tmp", ReadinessProbe: &Probe{ ProbeHandler: &ProbeHandler{ - TypeWrapper: TypeWrapper{Type: "Tcp"}, + TypeWrapper: TypeWrapper{Type: "kam.v1.workload.container.probe.Tcp"}, TCPSocketAction: &TCPSocketAction{ URL: "127.0.0.1:8080", }, @@ -280,7 +280,7 @@ env: workingDir: /tmp readinessProbe: probeHandler: - _type: Tcp + _type: kam.v1.workload.container.probe.Tcp url: 127.0.0.1:8080 initialDelaySeconds: 10 `, @@ -299,13 +299,13 @@ readinessProbe: WorkingDir: "/tmp", Lifecycle: &Lifecycle{ PostStart: &LifecycleHandler{ - TypeWrapper: TypeWrapper{"Exec"}, + TypeWrapper: TypeWrapper{"kam.v1.workload.container.probe.Exec"}, ExecAction: &ExecAction{ Command: []string{"/bin/sh", "-c", "nginx -s quit; while killall -0 nginx; do sleep 1; done"}, }, }, PreStop: &LifecycleHandler{ - TypeWrapper: TypeWrapper{"Exec"}, + TypeWrapper: TypeWrapper{"kam.v1.workload.container.probe.Exec"}, ExecAction: &ExecAction{ Command: []string{"/bin/sh", "-c", "echo Hello from the postStart handler > /usr/share/message"}, }, @@ -326,13 +326,13 @@ env: workingDir: /tmp lifecycle: preStop: - _type: Exec + _type: kam.v1.workload.container.probe.Exec command: - /bin/sh - -c - echo Hello from the postStart handler > /usr/share/message postStart: - _type: Exec + _type: kam.v1.workload.container.probe.Exec command: - /bin/sh - -c @@ -353,13 +353,13 @@ lifecycle: WorkingDir: "/tmp", Lifecycle: &Lifecycle{ PostStart: &LifecycleHandler{ - TypeWrapper: TypeWrapper{"Http"}, + TypeWrapper: TypeWrapper{"kam.v1.workload.container.probe.Http"}, HTTPGetAction: &HTTPGetAction{ URL: "http://localhost:80", }, }, PreStop: &LifecycleHandler{ - TypeWrapper: TypeWrapper{"Http"}, + TypeWrapper: TypeWrapper{"kam.v1.workload.container.probe.Http"}, HTTPGetAction: &HTTPGetAction{ URL: "http://localhost:80", }, @@ -380,10 +380,10 @@ env: workingDir: /tmp lifecycle: preStop: - _type: Http + _type: kam.v1.workload.container.probe.Http url: http://localhost:80 postStart: - _type: Http + _type: kam.v1.workload.container.probe.Http url: http://localhost:80 `, }, diff --git a/pkg/apis/internal.kusion.io/v1/types.go b/pkg/apis/internal.kusion.io/v1/types.go index 90fc553b..44d442bb 100644 --- a/pkg/apis/internal.kusion.io/v1/types.go +++ b/pkg/apis/internal.kusion.io/v1/types.go @@ -5,6 +5,14 @@ import ( v1 "k8s.io/api/core/v1" ) +const ( + BuiltinModulePrefix = "kam." + ProbePrefix = "v1.workload.container.probe." + TypeHTTP = BuiltinModulePrefix + ProbePrefix + "Http" + TypeExec = BuiltinModulePrefix + ProbePrefix + "Exec" + TypeTCP = BuiltinModulePrefix + ProbePrefix + "Tcp" +) + // Container describes how the App's tasks are expected to be run. type Container struct { // Image to run for this container diff --git a/pkg/apis/internal.kusion.io/v1/unmarshal.go b/pkg/apis/internal.kusion.io/v1/unmarshal.go index ddf532e8..1045593f 100644 --- a/pkg/apis/internal.kusion.io/v1/unmarshal.go +++ b/pkg/apis/internal.kusion.io/v1/unmarshal.go @@ -15,15 +15,15 @@ func (p *ProbeHandler) UnmarshalJSON(data []byte) error { p.Type = probeType.Type switch p.Type { - case "Http": + case TypeHTTP: handler := &HTTPGetAction{} err = json.Unmarshal(data, handler) p.HTTPGetAction = handler - case "Exec": + case TypeExec: handler := &ExecAction{} err = json.Unmarshal(data, handler) p.ExecAction = handler - case "Tcp": + case TypeTCP: handler := &TCPSocketAction{} err = json.Unmarshal(data, handler) p.TCPSocketAction = handler @@ -44,15 +44,15 @@ func (p *ProbeHandler) UnmarshalYAML(unmarshal func(interface{}) error) error { p.Type = probeType.Type switch p.Type { - case "Http": + case TypeHTTP: handler := &HTTPGetAction{} err = unmarshal(handler) p.HTTPGetAction = handler - case "Exec": + case TypeExec: handler := &ExecAction{} err = unmarshal(handler) p.ExecAction = handler - case "Tcp": + case TypeTCP: handler := &TCPSocketAction{} err = unmarshal(handler) p.TCPSocketAction = handler @@ -73,11 +73,11 @@ func (l *LifecycleHandler) UnmarshalJSON(data []byte) error { l.Type = handlerType.Type switch l.Type { - case "Http": + case TypeHTTP: handler := &HTTPGetAction{} err = json.Unmarshal(data, handler) l.HTTPGetAction = handler - case "Exec": + case TypeExec: handler := &ExecAction{} err = json.Unmarshal(data, handler) l.ExecAction = handler @@ -98,11 +98,11 @@ func (l *LifecycleHandler) UnmarshalYAML(unmarshal func(interface{}) error) erro l.Type = handlerType.Type switch l.Type { - case "Http": + case TypeHTTP: handler := &HTTPGetAction{} err = unmarshal(handler) l.HTTPGetAction = handler - case "Exec": + case TypeExec: handler := &ExecAction{} err = unmarshal(handler) l.ExecAction = handler @@ -132,7 +132,7 @@ func (w *Workload) UnmarshalJSON(data []byte) error { err = json.Unmarshal(data, &v) w.Service = &v default: - err = errors.New("unknown workload type") + err = errors.New("unknown workload type unmarshall") } return err diff --git a/pkg/apis/internal.kusion.io/v1/unmarshal_test.go b/pkg/apis/internal.kusion.io/v1/unmarshal_test.go index 466aad35..0d7f153c 100644 --- a/pkg/apis/internal.kusion.io/v1/unmarshal_test.go +++ b/pkg/apis/internal.kusion.io/v1/unmarshal_test.go @@ -32,12 +32,12 @@ func TestContainerUnmarshalJSON(t *testing.T) { }, }, { - input: `{"image":"nginx:v1","readinessProbe":{"probeHandler":{"_type":"Http","url":"http://localhost:80"},"initialDelaySeconds":10}}`, + input: `{"image":"nginx:v1","readinessProbe":{"probeHandler":{"_type":"kam.v1.workload.container.probe.Http","url":"http://localhost:80"},"initialDelaySeconds":10}}`, result: Container{ Image: "nginx:v1", ReadinessProbe: &Probe{ ProbeHandler: &ProbeHandler{ - TypeWrapper: TypeWrapper{Type: "Http"}, + TypeWrapper: TypeWrapper{Type: "kam.v1.workload.container.probe.Http"}, HTTPGetAction: &HTTPGetAction{ URL: "http://localhost:80", }, @@ -47,12 +47,12 @@ func TestContainerUnmarshalJSON(t *testing.T) { }, }, { - input: `{"image":"nginx:v1","readinessProbe":{"probeHandler":{"_type":"Exec","command":["cat","/tmp/healthy"]},"initialDelaySeconds":10}}`, + input: `{"image":"nginx:v1","readinessProbe":{"probeHandler":{"_type":"kam.v1.workload.container.probe.Exec","command":["cat","/tmp/healthy"]},"initialDelaySeconds":10}}`, result: Container{ Image: "nginx:v1", ReadinessProbe: &Probe{ ProbeHandler: &ProbeHandler{ - TypeWrapper: TypeWrapper{Type: "Exec"}, + TypeWrapper: TypeWrapper{Type: "kam.v1.workload.container.probe.Exec"}, ExecAction: &ExecAction{ Command: []string{"cat", "/tmp/healthy"}, }, @@ -62,12 +62,12 @@ func TestContainerUnmarshalJSON(t *testing.T) { }, }, { - input: `{"image":"nginx:v1","readinessProbe":{"probeHandler":{"_type":"Tcp","url":"127.0.0.1:8080"},"initialDelaySeconds":10}}`, + input: `{"image":"nginx:v1","readinessProbe":{"probeHandler":{"_type":"kam.v1.workload.container.probe.Tcp","url":"127.0.0.1:8080"},"initialDelaySeconds":10}}`, result: Container{ Image: "nginx:v1", ReadinessProbe: &Probe{ ProbeHandler: &ProbeHandler{ - TypeWrapper: TypeWrapper{Type: "Tcp"}, + TypeWrapper: TypeWrapper{Type: "kam.v1.workload.container.probe.Tcp"}, TCPSocketAction: &TCPSocketAction{ URL: "127.0.0.1:8080", }, @@ -77,18 +77,18 @@ func TestContainerUnmarshalJSON(t *testing.T) { }, }, { - input: `{"image":"nginx:v1","lifecycle":{"preStop":{"_type":"Exec","command":["/bin/sh","-c","echo Hello from the postStart handler \u003e /usr/share/message"]},"postStart":{"_type":"Exec","command":["/bin/sh","-c","nginx -s quit; while killall -0 nginx; do sleep 1; done"]}}}`, + input: `{"image":"nginx:v1","lifecycle":{"preStop":{"_type":"kam.v1.workload.container.probe.Exec","command":["/bin/sh","-c","echo Hello from the postStart handler \u003e /usr/share/message"]},"postStart":{"_type":"kam.v1.workload.container.probe.Exec","command":["/bin/sh","-c","nginx -s quit; while killall -0 nginx; do sleep 1; done"]}}}`, result: Container{ Image: "nginx:v1", Lifecycle: &Lifecycle{ PostStart: &LifecycleHandler{ - TypeWrapper: TypeWrapper{"Exec"}, + TypeWrapper: TypeWrapper{"kam.v1.workload.container.probe.Exec"}, ExecAction: &ExecAction{ Command: []string{"/bin/sh", "-c", "nginx -s quit; while killall -0 nginx; do sleep 1; done"}, }, }, PreStop: &LifecycleHandler{ - TypeWrapper: TypeWrapper{"Exec"}, + TypeWrapper: TypeWrapper{"kam.v1.workload.container.probe.Exec"}, ExecAction: &ExecAction{ Command: []string{"/bin/sh", "-c", "echo Hello from the postStart handler > /usr/share/message"}, }, @@ -97,18 +97,18 @@ func TestContainerUnmarshalJSON(t *testing.T) { }, }, { - input: `{"image":"nginx:v1","lifecycle":{"preStop":{"_type":"Http","url":"http://localhost:80"},"postStart":{"_type":"Http","url":"http://localhost:80"}}}`, + input: `{"image":"nginx:v1","lifecycle":{"preStop":{"_type":"kam.v1.workload.container.probe.Http","url":"http://localhost:80"},"postStart":{"_type":"kam.v1.workload.container.probe.Http","url":"http://localhost:80"}}}`, result: Container{ Image: "nginx:v1", Lifecycle: &Lifecycle{ PostStart: &LifecycleHandler{ - TypeWrapper: TypeWrapper{"Http"}, + TypeWrapper: TypeWrapper{"kam.v1.workload.container.probe.Http"}, HTTPGetAction: &HTTPGetAction{ URL: "http://localhost:80", }, }, PreStop: &LifecycleHandler{ - TypeWrapper: TypeWrapper{"Http"}, + TypeWrapper: TypeWrapper{"kam.v1.workload.container.probe.Http"}, HTTPGetAction: &HTTPGetAction{ URL: "http://localhost:80", }, @@ -176,7 +176,7 @@ env: workingDir: /tmp readinessProbe: probeHandler: - _type: Http + _type: kam.v1.workload.container.probe.Http url: http://localhost:80 initialDelaySeconds: 10 `, @@ -193,7 +193,7 @@ readinessProbe: WorkingDir: "/tmp", ReadinessProbe: &Probe{ ProbeHandler: &ProbeHandler{ - TypeWrapper: TypeWrapper{Type: "Http"}, + TypeWrapper: TypeWrapper{Type: "kam.v1.workload.container.probe.Http"}, HTTPGetAction: &HTTPGetAction{ URL: "http://localhost:80", }, @@ -217,7 +217,7 @@ env: workingDir: /tmp readinessProbe: probeHandler: - _type: Exec + _type: kam.v1.workload.container.probe.Exec command: - cat - /tmp/healthy @@ -236,7 +236,7 @@ readinessProbe: WorkingDir: "/tmp", ReadinessProbe: &Probe{ ProbeHandler: &ProbeHandler{ - TypeWrapper: TypeWrapper{Type: "Exec"}, + TypeWrapper: TypeWrapper{Type: "kam.v1.workload.container.probe.Exec"}, ExecAction: &ExecAction{ Command: []string{"cat", "/tmp/healthy"}, }, @@ -260,7 +260,7 @@ env: workingDir: /tmp readinessProbe: probeHandler: - _type: Tcp + _type: kam.v1.workload.container.probe.Tcp url: 127.0.0.1:8080 initialDelaySeconds: 10 `, @@ -277,7 +277,7 @@ readinessProbe: WorkingDir: "/tmp", ReadinessProbe: &Probe{ ProbeHandler: &ProbeHandler{ - TypeWrapper: TypeWrapper{Type: "Tcp"}, + TypeWrapper: TypeWrapper{Type: "kam.v1.workload.container.probe.Tcp"}, TCPSocketAction: &TCPSocketAction{ URL: "127.0.0.1:8080", }, @@ -301,13 +301,13 @@ env: workingDir: /tmp lifecycle: preStop: - _type: Exec + _type: kam.v1.workload.container.probe.Exec command: - /bin/sh - -c - echo Hello from the postStart handler > /usr/share/message postStart: - _type: Exec + _type: kam.v1.workload.container.probe.Exec command: - /bin/sh - -c @@ -326,13 +326,13 @@ lifecycle: WorkingDir: "/tmp", Lifecycle: &Lifecycle{ PostStart: &LifecycleHandler{ - TypeWrapper: TypeWrapper{"Exec"}, + TypeWrapper: TypeWrapper{"kam.v1.workload.container.probe.Exec"}, ExecAction: &ExecAction{ Command: []string{"/bin/sh", "-c", "nginx -s quit; while killall -0 nginx; do sleep 1; done"}, }, }, PreStop: &LifecycleHandler{ - TypeWrapper: TypeWrapper{"Exec"}, + TypeWrapper: TypeWrapper{"kam.v1.workload.container.probe.Exec"}, ExecAction: &ExecAction{ Command: []string{"/bin/sh", "-c", "echo Hello from the postStart handler > /usr/share/message"}, }, @@ -355,10 +355,10 @@ env: workingDir: /tmp lifecycle: preStop: - _type: Http + _type: kam.v1.workload.container.probe.Http url: http://localhost:80 postStart: - _type: Http + _type: kam.v1.workload.container.probe.Http url: http://localhost:80 `, result: Container{ @@ -374,13 +374,13 @@ lifecycle: WorkingDir: "/tmp", Lifecycle: &Lifecycle{ PostStart: &LifecycleHandler{ - TypeWrapper: TypeWrapper{"Http"}, + TypeWrapper: TypeWrapper{"kam.v1.workload.container.probe.Http"}, HTTPGetAction: &HTTPGetAction{ URL: "http://localhost:80", }, }, PreStop: &LifecycleHandler{ - TypeWrapper: TypeWrapper{"Http"}, + TypeWrapper: TypeWrapper{"kam.v1.workload.container.probe.Http"}, HTTPGetAction: &HTTPGetAction{ URL: "http://localhost:80", }, diff --git a/pkg/cmd/apply/apply.go b/pkg/cmd/apply/apply.go index 0c801cc2..2215bcf6 100644 --- a/pkg/cmd/apply/apply.go +++ b/pkg/cmd/apply/apply.go @@ -189,7 +189,7 @@ func (o *ApplyOptions) Run() error { } // summary preview table - changes.Summary(o.IOStreams.Out) + changes.Summary(o.IOStreams.Out, false) // detail detection if o.Detail && o.All { diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index cc2a38af..e83cfb71 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -18,6 +18,7 @@ import ( cmdinit "kusionstack.io/kusion/pkg/cmd/init" "kusionstack.io/kusion/pkg/cmd/mod" "kusionstack.io/kusion/pkg/cmd/preview" + "kusionstack.io/kusion/pkg/cmd/server" "kusionstack.io/kusion/pkg/cmd/version" "kusionstack.io/kusion/pkg/cmd/workspace" "kusionstack.io/kusion/pkg/util/i18n" @@ -94,6 +95,12 @@ func NewKusionctlCmd(o KusionctlOptions) *cobra.Command { addProfilingFlags(flags) groups := templates.CommandGroups{ + { + Message: "Server Commands:", + Commands: []*cobra.Command{ + server.NewCmdServer(), + }, + }, { Message: "Configuration Commands:", Commands: []*cobra.Command{ diff --git a/pkg/cmd/destroy/destroy.go b/pkg/cmd/destroy/destroy.go index 3357fb69..4292f8cd 100644 --- a/pkg/cmd/destroy/destroy.go +++ b/pkg/cmd/destroy/destroy.go @@ -170,7 +170,7 @@ func (o *DeleteOptions) Run() error { } // preview - changes.Summary(os.Stdout) + changes.Summary(os.Stdout, false) // detail detection if o.Detail { diff --git a/pkg/cmd/generate/generate.go b/pkg/cmd/generate/generate.go index bebae2ab..3c419cab 100644 --- a/pkg/cmd/generate/generate.go +++ b/pkg/cmd/generate/generate.go @@ -28,10 +28,10 @@ import ( "k8s.io/cli-runtime/pkg/genericiooptions" v1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" - "kusionstack.io/kusion/pkg/cmd/generate/generator" - "kusionstack.io/kusion/pkg/cmd/generate/run" "kusionstack.io/kusion/pkg/cmd/meta" cmdutil "kusionstack.io/kusion/pkg/cmd/util" + "kusionstack.io/kusion/pkg/engine/api/generate/generator" + "kusionstack.io/kusion/pkg/engine/api/generate/run" "kusionstack.io/kusion/pkg/util/i18n" "kusionstack.io/kusion/pkg/util/pretty" ) diff --git a/pkg/cmd/preview/preview.go b/pkg/cmd/preview/preview.go index 3b8332a6..55ebaf75 100644 --- a/pkg/cmd/preview/preview.go +++ b/pkg/cmd/preview/preview.go @@ -215,7 +215,7 @@ func (o *PreviewOptions) Run() error { } // summary preview table - changes.Summary(o.IOStreams.Out) + changes.Summary(o.IOStreams.Out, false) // detail detection if o.Detail { diff --git a/pkg/cmd/server/database.go b/pkg/cmd/server/database.go new file mode 100644 index 00000000..8b11b4f4 --- /dev/null +++ b/pkg/cmd/server/database.go @@ -0,0 +1,93 @@ +package server + +import ( + "encoding/json" + "os" + "strings" + + "kusionstack.io/kusion/pkg/server" + + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + "github.com/spf13/pflag" +) + +var _ Options = &DatabaseOptions{} + +// DatabaseOptions is a Database options struct +type DatabaseOptions struct { + DatabaseAccessOptions `json:",inline" yaml:",inline"` + // AutoMigrate will attempt to automatically migrate all tables + AutoMigrate bool `json:"autoMigrate,omitempty" yaml:"autoMigrate,omitempty"` + MigrateFile string `json:"migrateFile,omitempty" yaml:"migrateFile,omitempty"` +} + +// NewDatabaseOptions returns a DatabaseOptions instance with the default values +func NewDatabaseOptions() *DatabaseOptions { + return &DatabaseOptions{ + DatabaseAccessOptions: DatabaseAccessOptions{}, + AutoMigrate: false, + } +} + +// Validate checks DatabaseOptions and return a slice of found error(s) +func (o *DatabaseOptions) Validate() error { + if o == nil { + return errors.Errorf("options is nil") + } + + if o.AutoMigrate && len(o.MigrateFile) == 0 { + return errors.Errorf("when --auto-migrate is true, --migrate-file must be specified") + } + + return o.DatabaseAccessOptions.Validate() +} + +// ApplyTo apply database options to the server config +func (o *DatabaseOptions) ApplyTo(config *server.Config) { + if err := o.DatabaseAccessOptions.ApplyTo(&config.DB); err != nil { + logrus.Fatalf("Failed to apply database options to server.Config as: %+v", err) + } + + // AutoMigrate will attempt to automatically migrate all tables + if o.AutoMigrate { + logrus.Debugf("AutoMigrate will attempt to automatically migrate all tables from [%s]", o.MigrateFile) + // Read all content by migrate file + migrateSQL, err := os.ReadFile(o.MigrateFile) + if err != nil { + logrus.Fatalf("Failed to read migrate file: %+v", err) + } + + // Split multiple SQL statements into individual statements + stmts := strings.Split(string(migrateSQL), ";") + + // Iterate over all statements and execute them + for _, stmt := range stmts { + // Ignore empty statements + if len(strings.TrimSpace(stmt)) == 0 { + continue + } + + // Use gorm.Exec() function to execute SQL statement + if err = config.DB.Exec(stmt).Error; err != nil { + logrus.Warnf("Failed to exec migrate sql: %+v", err) + } + } + } +} + +// AddFlags adds flags for a specific Option to the specified FlagSet +func (o *DatabaseOptions) AddFlags(fs *pflag.FlagSet) { + o.DatabaseAccessOptions.AddFlags(fs) + + fs.BoolVar(&o.AutoMigrate, "auto-migrate", o.AutoMigrate, "Whether to enable automatic migration") + fs.StringVar(&o.MigrateFile, "migrate-file", o.MigrateFile, "The migrate sql file") +} + +// MarshalJSON is custom marshalling function for masking sensitive field values +func (o DatabaseOptions) MarshalJSON() ([]byte, error) { + type tempOptions DatabaseOptions + o2 := tempOptions(o) + o2.DBPassword = MaskString + return json.Marshal(&o2) +} diff --git a/pkg/cmd/server/database_access.go b/pkg/cmd/server/database_access.go new file mode 100644 index 00000000..b6e6cf06 --- /dev/null +++ b/pkg/cmd/server/database_access.go @@ -0,0 +1,102 @@ +package server + +import ( + "strconv" + + "github.com/pkg/errors" + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" + "kusionstack.io/kusion/pkg/cmd/server/util" + + gomysql "github.com/go-sql-driver/mysql" + "github.com/spf13/pflag" +) + +var ( + ErrDBHostNotSpecified = errors.New("--db-host must be specified") + ErrDBNameNotSpecified = errors.New("--db-name must be specified") + ErrDBUserNotSpecified = errors.New("--db-user must be specified") + ErrDBPortNotSpecified = errors.New("--db-port must be specified") +) + +// DatabaseAccessOptions holds the database access layer configurations. +type DatabaseAccessOptions struct { + DBName string `json:"dbName,omitempty" yaml:"dbName,omitempty"` + DBUser string `json:"dbUser,omitempty" yaml:"dbUser,omitempty"` + DBPassword string `json:"dbPassword,omitempty" yaml:"dbPassword,omitempty"` + DBHost string `json:"dbHost,omitempty" yaml:"dbHost,omitempty"` + DBPort int `json:"dbPort,omitempty" yaml:"dbPort,omitempty"` +} + +// NewDatabaseAccessOptions returns a DatabaseAccessOptions struct with the default values +func NewDatabaseAccessOptions() *DatabaseAccessOptions { + return &DatabaseAccessOptions{ + DBHost: "127.0.0.1", + DBPort: 3306, + } +} + +// InstallDB uses the run options to generate and open a db session. +func (o *DatabaseAccessOptions) InstallDB() (*gorm.DB, error) { + // Generate go-sql-driver.mysql config to format DSN + config := gomysql.NewConfig() + config.User = o.DBUser + config.Passwd = o.DBPassword + config.Addr = o.DBHost + ":" + strconv.Itoa(o.DBPort) + config.DBName = o.DBName + config.Net = "tcp" + config.ParseTime = true + config.InterpolateParams = true + config.Params = map[string]string{ + "charset": "utf8", + "loc": "Asia/Shanghai", + } + dsn := config.FormatDSN() + // silence log output + cfg := &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + } + return gorm.Open(mysql.Open(dsn), cfg) // todo: add db connection check to healthz check +} + +// ApplyTo uses the run options to generate and open a db session. +func (o *DatabaseAccessOptions) ApplyTo(db **gorm.DB) error { + d, err := o.InstallDB() + if err != nil { + return err + } + *db = d + return nil +} + +// Validate checks validation of DatabaseAccessOptions +func (o *DatabaseAccessOptions) Validate() error { + var errs []error + if len(o.DBHost) == 0 { + errs = append(errs, ErrDBHostNotSpecified) + } + if len(o.DBName) == 0 { + errs = append(errs, ErrDBNameNotSpecified) + } + if len(o.DBUser) == 0 { + errs = append(errs, ErrDBUserNotSpecified) + } + if o.DBPort == 0 { + errs = append(errs, ErrDBPortNotSpecified) + } + if errs != nil { + err := util.AggregateError(errs) + return errors.Wrap(err, "invalid db options") + } + return nil +} + +// AddFlags adds flags related to DB to a specified FlagSet +func (o *DatabaseAccessOptions) AddFlags(fs *pflag.FlagSet) { + fs.StringVar(&o.DBName, "db-name", o.DBName, "the database name") + fs.StringVar(&o.DBUser, "db-user", o.DBUser, "the user name used to access database") + fs.StringVar(&o.DBPassword, "db-pass", o.DBPassword, "the user password used to access database") + fs.StringVar(&o.DBHost, "db-host", o.DBHost, "database host") + fs.IntVar(&o.DBPort, "db-port", o.DBPort, "database port") +} diff --git a/pkg/cmd/server/options.go b/pkg/cmd/server/options.go new file mode 100644 index 00000000..62387bac --- /dev/null +++ b/pkg/cmd/server/options.go @@ -0,0 +1,36 @@ +package server + +import ( + "kusionstack.io/kusion/pkg/server" + "kusionstack.io/kusion/pkg/server/route" +) + +func NewServerOptions() *ServerOptions { + return &ServerOptions{ + Mode: "KCP", + Database: DatabaseOptions{}, + } +} + +func (o *ServerOptions) Complete(args []string) {} + +func (o *ServerOptions) Validate() error { + return nil +} + +func (o *ServerOptions) Config() (*server.Config, error) { + cfg := server.NewConfig() + o.Database.ApplyTo(cfg) + return cfg, nil +} + +func (o *ServerOptions) Run() error { + config, err := o.Config() + if err != nil { + return err + } + if _, err := route.NewCoreRoute(config); err == nil { + return nil + } + return nil +} diff --git a/pkg/cmd/server/server.go b/pkg/cmd/server/server.go new file mode 100644 index 00000000..9e3b15bc --- /dev/null +++ b/pkg/cmd/server/server.go @@ -0,0 +1,52 @@ +package server + +import ( + "github.com/spf13/cobra" + "k8s.io/kubectl/pkg/util/templates" + + "kusionstack.io/kusion/pkg/cmd/util" + "kusionstack.io/kusion/pkg/util/i18n" +) + +func NewCmdServer() *cobra.Command { + var ( + serverShort = i18n.T(`Start kusion server`) + + serverLong = i18n.T(`Start kusion server.`) + + serverExample = i18n.T(` + # Start kusion server + kusion server --mode kcp --db_host localhost:3306 --db_user root --db_pass 123456`) + ) + + o := NewServerOptions() + cmd := &cobra.Command{ + Use: "server", + Short: serverShort, + Long: templates.LongDesc(serverLong), + Example: templates.Examples(serverExample), + RunE: func(_ *cobra.Command, args []string) (err error) { + defer util.RecoverErr(&err) + o.Complete(args) + util.CheckErr(o.Validate()) + util.CheckErr(o.Run()) + return + }, + } + + o.AddServerFlags(cmd) + + return cmd +} + +func (o *ServerOptions) AddServerFlags(cmd *cobra.Command) { + cmd.Flags().StringVarP(&o.Mode, "mode", "", "", + i18n.T("Specify the mode")) + // cmd.Flags().StringVarP(&o.Database.DBHost, "db_host", "", "", + // i18n.T("Specify the DB Host")) + // cmd.Flags().StringVarP(&o.Database.DBUser, "db_user", "", "", + // i18n.T("Specify the DB User")) + // cmd.Flags().StringVarP(&o.Database.DBPassword, "db_pass", "", "", + // i18n.T("Specify the DB Password")) + o.Database.AddFlags(cmd.Flags()) +} diff --git a/pkg/cmd/server/types.go b/pkg/cmd/server/types.go new file mode 100644 index 00000000..d094bc44 --- /dev/null +++ b/pkg/cmd/server/types.go @@ -0,0 +1,22 @@ +package server + +import ( + "github.com/spf13/pflag" +) + +type ServerOptions struct { + Mode string + Database DatabaseOptions +} + +type Options interface { + // Validate checks Options and return a slice of found error(s) + Validate() error + // AddFlags adds flags for a specific Option to the specified FlagSet + AddFlags(fs *pflag.FlagSet) +} + +const ( + ProjectName = "kcp" + MaskString = "******" +) diff --git a/pkg/cmd/server/util/util.go b/pkg/cmd/server/util/util.go new file mode 100644 index 00000000..1ce953f6 --- /dev/null +++ b/pkg/cmd/server/util/util.go @@ -0,0 +1,21 @@ +package util + +import ( + "github.com/pkg/errors" +) + +func AggregateError(errs []error) error { + if len(errs) == 0 { + return nil + } + var errMsg string + for _, err := range errs { + if err != nil && err.Error() != "" { + errMsg = errMsg + err.Error() + "; " + } + } + if errMsg != "" { + errMsg = errMsg[:len(errMsg)-2] + } + return errors.New(errMsg) +} diff --git a/pkg/domain/constant/organization.go b/pkg/domain/constant/organization.go new file mode 100644 index 00000000..99d07d40 --- /dev/null +++ b/pkg/domain/constant/organization.go @@ -0,0 +1,26 @@ +package constant + +import "errors" + +// TODO: use v1.BackendType instead +// type BackendType string + +// const ( +// // SourceProviderTypeGithub represents github source provider type. +// BackendTypeOss BackendType = "oss" +// BackendTypeMysql BackendType = "mysql" +// BackendTypeLocal BackendType = "local" +// ) + +var ( + ErrOrgNil = errors.New("organization is nil") + ErrOrgNameEmpty = errors.New("organization must have a name") + ErrOrgOwnerNil = errors.New("org must have at least one owner") + ErrWorkspaceNil = errors.New("workspace is nil") + ErrWorkspaceNameEmpty = errors.New("workspace must have a name") + ErrWorkspaceBackendNil = errors.New("workspace must have a backend") + ErrWorkspaceOwnerNil = errors.New("workspace must have at least one owner") + ErrBackendNil = errors.New("backend is nil") + ErrBackendNameEmpty = errors.New("backend must have a name") + ErrBackendTypeEmpty = errors.New("backend must have a type") +) diff --git a/pkg/domain/constant/project.go b/pkg/domain/constant/project.go new file mode 100644 index 00000000..50746347 --- /dev/null +++ b/pkg/domain/constant/project.go @@ -0,0 +1,14 @@ +package constant + +import "errors" + +var ( + ErrProjectNil = errors.New("project is nil") + ErrProjectName = errors.New("project must have a name") + ErrProjectSource = errors.New("project must have a source") + ErrProjectSourceProvider = errors.New("project source must have a source provider") + ErrProjectRemote = errors.New("project source must have a remote") + ErrProjectCreationTimestamp = errors.New("project must have a creation timestamp") + ErrProjectUpdateTimestamp = errors.New("project must have a update timestamp") + ErrProjectPath = errors.New("project must have a path") +) diff --git a/pkg/domain/constant/source.go b/pkg/domain/constant/source.go new file mode 100644 index 00000000..0c04f8ac --- /dev/null +++ b/pkg/domain/constant/source.go @@ -0,0 +1,45 @@ +package constant + +import ( + "errors" + "fmt" +) + +// SourceProviderType represents the type of varying source providers, +// source provider is the general abstraction of version control systems (VCS), +// also known as source control systems (SCM). +type SourceProviderType string + +var ( + ErrSourceNil = errors.New("source is nil") + ErrDirectoryToCleanupEmpty = errors.New("temp kcp-kusion directory to clean up is empty") +) + +const ( + // SourceProviderTypeGithub represents github source provider type. + SourceProviderTypeGit SourceProviderType = "git" + SourceProviderTypeGithub SourceProviderType = "github" + + // SourceProviderTypeOCI represents oci source provider type. + SourceProviderTypeOCI SourceProviderType = "oci" + + // SourceProviderTypeLocal represents local source provider type. + SourceProviderTypeLocal SourceProviderType = "local" +) + +// ParseSourceProviderType parses a string into a SourceProviderType. +// If the string is not a valid SourceProviderType, it returns an error. +func ParseSourceProviderType(s string) (SourceProviderType, error) { + switch s { + case string(SourceProviderTypeGit): + return SourceProviderTypeGit, nil + case string(SourceProviderTypeGithub): + return SourceProviderTypeGithub, nil + case string(SourceProviderTypeOCI): + return SourceProviderTypeOCI, nil + case string(SourceProviderTypeLocal): + return SourceProviderTypeLocal, nil + default: + return SourceProviderType(""), fmt.Errorf("invalid SourceProviderType: %q", s) + } +} diff --git a/pkg/domain/constant/stack.go b/pkg/domain/constant/stack.go new file mode 100644 index 00000000..d4160bbc --- /dev/null +++ b/pkg/domain/constant/stack.go @@ -0,0 +1,50 @@ +package constant + +import ( + "errors" + "fmt" +) + +// StackState represents the state of a stack. +type StackState string + +// These constants represent the possible states of a stack. +const ( + // The stack has not been synced with the remote runtime. + StackStateUnSynced StackState = "UnSynced" + // The stack is synced with the remote runtime. + StackStateSynced StackState = "Synced" + // The stack has out of sync from the remote runtime. + StackStateOutOfSync StackState = "OutOfSync" +) + +var ( + ErrStackNil = errors.New("stack is nil") + ErrStackName = errors.New("stack must have a name") + ErrStackPath = errors.New("stack must have a path") + ErrStackFrameworkType = errors.New("stack must have a framework type") + ErrStackDesiredVersion = errors.New("stack must have a desired version") + ErrStackSource = errors.New("stack must have a source") + ErrStackSourceProvider = errors.New("stack source must have a source provider") + ErrStackRemote = errors.New("stack source must have a remote") + ErrStackSyncState = errors.New("stack must have a sync state") + ErrStackLastSyncTimestamp = errors.New("stack must have a last sync timestamp") + ErrStackCreationTimestamp = errors.New("stack must have a creation timestamp") + ErrStackUpdateTimestamp = errors.New("stack must have a update timestamp") + ErrStackHasNilProject = errors.New("stack must have a project") +) + +// ParseStackState parses a string into a StackState. +// If the string is not a valid StackState, it returns an error. +func ParseStackState(s string) (StackState, error) { + switch s { + case string(StackStateUnSynced): + return StackStateUnSynced, nil + case string(StackStateSynced): + return StackStateSynced, nil + case string(StackStateOutOfSync): + return StackStateOutOfSync, nil + default: + return StackState(""), fmt.Errorf("invalid StackState: %q", s) + } +} diff --git a/pkg/domain/entity/backend.go b/pkg/domain/entity/backend.go new file mode 100644 index 00000000..7fa4bc63 --- /dev/null +++ b/pkg/domain/entity/backend.go @@ -0,0 +1,44 @@ +package entity + +import ( + "time" + + v1 "kusionstack.io/kusion/pkg/apis/internal.kusion.io/v1" + "kusionstack.io/kusion/pkg/domain/constant" +) + +// Backend represents the specific configuration backend +type Backend struct { + // ID is the id of the backend. + ID uint `yaml:"id" json:"id"` + // Name is the name of the backend. + Name string `yaml:"name" json:"name"` + // // Type is the type of the backend. + // Type string `yaml:"type" json:"type"` + // Backend is the configuration of the backend. + BackendConfig v1.BackendConfig `yaml:"backendConfig" json:"backendConfig"` + // Description is a human-readable description of the backend. + Description string `yaml:"description,omitempty" json:"description,omitempty"` + // CreationTimestamp is the timestamp of the created for the backend. + CreationTimestamp time.Time `yaml:"creationTimestamp,omitempty" json:"creationTimestamp,omitempty"` + // UpdateTimestamp is the timestamp of the updated for the backend. + UpdateTimestamp time.Time `yaml:"updateTimestamp,omitempty" json:"updateTimestamp,omitempty"` +} + +// Validate checks if the backend is valid. +// It returns an error if the backend is not valid. +func (w *Backend) Validate() error { + if w == nil { + return constant.ErrBackendNil + } + + if w.Name == "" { + return constant.ErrBackendNameEmpty + } + + // if w.Type == "" { + // return constant.ErrBackendTypeEmpty + // } + + return nil +} diff --git a/pkg/domain/entity/organization.go b/pkg/domain/entity/organization.go new file mode 100644 index 00000000..67055fad --- /dev/null +++ b/pkg/domain/entity/organization.go @@ -0,0 +1,45 @@ +package entity + +import ( + "time" + + "kusionstack.io/kusion/pkg/domain/constant" +) + +// Organization represents the specific configuration organization +type Organization struct { + // ID is the id of the organization. + ID uint `yaml:"id" json:"id"` + // Name is the name of the organization. + Name string `yaml:"name" json:"name"` + // DisplayName is the human-readable display name. + DisplayName string `yaml:"displayName,omitempty" json:"displayName,omitempty"` + // Description is a human-readable description of the organization. + Description string `yaml:"description,omitempty" json:"description,omitempty"` + // Labels are custom labels associated with the organization. + Labels []string `yaml:"labels,omitempty" json:"labels,omitempty"` + // Owners is a list of owners for the organization. + Owners []string `yaml:"owners,omitempty" json:"owners,omitempty"` + // CreationTimestamp is the timestamp of the created for the organization. + CreationTimestamp time.Time `yaml:"creationTimestamp,omitempty" json:"creationTimestamp,omitempty"` + // UpdateTimestamp is the timestamp of the updated for the organization. + UpdateTimestamp time.Time `yaml:"updateTimestamp,omitempty" json:"updateTimestamp,omitempty"` +} + +// Validate checks if the organization is valid. +// It returns an error if the organization is not valid. +func (p *Organization) Validate() error { + if p == nil { + return constant.ErrOrgNil + } + + if p.Name == "" { + return constant.ErrOrgNameEmpty + } + + if len(p.Owners) == 0 { + return constant.ErrOrgOwnerNil + } + + return nil +} diff --git a/pkg/domain/entity/project.go b/pkg/domain/entity/project.go new file mode 100644 index 00000000..1d88c6c6 --- /dev/null +++ b/pkg/domain/entity/project.go @@ -0,0 +1,70 @@ +package entity + +import ( + "time" + + v1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" + "kusionstack.io/kusion/pkg/domain/constant" +) + +// Project represents the specific configuration project +type Project struct { + // ID is the id of the project. + ID uint `yaml:"id" json:"id"` + // Name is the name of the project. + Name string `yaml:"name" json:"name"` + // DisplayName is the human-readable display name. + DisplayName string `yaml:"displayName,omitempty" json:"displayName,omitempty"` + // Source is the configuration source associated with the project. + Source *Source `yaml:"source" json:"source"` + // Organization is the configuration source associated with the project. + Organization *Organization `yaml:"organization" json:"organization"` + // Description is a human-readable description of the project. + Description string `yaml:"description,omitempty" json:"description,omitempty"` + // Path is the relative path of the project within the sourcs. + Path string `yaml:"path,omitempty" json:"path,omitempty"` + // Labels are custom labels associated with the project. + Labels []string `yaml:"labels,omitempty" json:"labels,omitempty"` + // Owners is a list of owners for the project. + Owners []string `yaml:"owners,omitempty" json:"owners,omitempty"` + // CreationTimestamp is the timestamp of the created for the project. + CreationTimestamp time.Time `yaml:"creationTimestamp,omitempty" json:"creationTimestamp,omitempty"` + // UpdateTimestamp is the timestamp of the updated for the project. + UpdateTimestamp time.Time `yaml:"updateTimestamp,omitempty" json:"updateTimestamp,omitempty"` +} + +// Validate checks if the project is valid. +// It returns an error if the project is not valid. +func (p *Project) Validate() error { + if p == nil { + return constant.ErrProjectNil + } + + if p.Name == "" { + return constant.ErrProjectName + } + + if p.Path == "" { + return constant.ErrProjectPath + } + + if p.Source == nil { + return constant.ErrProjectSource + } + + if err := p.Source.Validate(); err != nil { + return constant.ErrProjectSource + } + + return nil +} + +// Convert Project to core Project +func (p *Project) ConvertToCore() (*v1.Project, error) { + return &v1.Project{ + Name: p.Name, + Description: &p.Description, + Path: p.Path, + Labels: map[string]string{}, + }, nil +} diff --git a/pkg/domain/entity/source.go b/pkg/domain/entity/source.go new file mode 100644 index 00000000..e3cf31ef --- /dev/null +++ b/pkg/domain/entity/source.go @@ -0,0 +1,52 @@ +package entity + +import ( + "fmt" + "net/url" + "time" + + "kusionstack.io/kusion/pkg/domain/constant" +) + +// Source represents the specific configuration code source, +// which should be a specific instance of the source provider. +type Source struct { + // ID is the id of the source. + ID uint `yaml:"id" json:"id"` + // SourceProvider is the type of the source provider. + SourceProvider constant.SourceProviderType `yaml:"sourceProvider" json:"sourceProvider"` + // Remote is the source URL, including scheme. + Remote *url.URL `yaml:"remote" json:"remote"` + // Description is a human-readable description of the source. + Description string `yaml:"description,omitempty" json:"description,omitempty"` + // Labels are custom labels associated with the source. + Labels []string `yaml:"labels,omitempty" json:"labels,omitempty"` + // Owners is a list of owners for the source. + Owners []string `yaml:"owners,omitempty" json:"owners,omitempty"` + // CreationTimestamp is the timestamp of the created for the source. + CreationTimestamp time.Time `yaml:"creationTimestamp,omitempty" json:"creationTimestamp,omitempty"` + // UpdateTimestamp is the timestamp of the updated for the source. + UpdateTimestamp time.Time `yaml:"updateTimestamp,omitempty" json:"updateTimestamp,omitempty"` +} + +// Validate checks if the source is valid. +// It returns an error if the source is not valid. +func (s *Source) Validate() error { + if s == nil { + return fmt.Errorf("source is nil") + } + + if s.SourceProvider == "" { + return fmt.Errorf("source must have a source provider") + } + + // if e.Remote == nil { + // return fmt.Errorf("source must have a remote") + // } + + return nil +} + +func (s *Source) Summary() string { + return fmt.Sprintf("[%s][%s]", string(s.SourceProvider), s.Remote.String()) +} diff --git a/pkg/domain/entity/source_provider.go b/pkg/domain/entity/source_provider.go new file mode 100644 index 00000000..7bfc3f81 --- /dev/null +++ b/pkg/domain/entity/source_provider.go @@ -0,0 +1,37 @@ +package entity + +import ( + "context" + + "kusionstack.io/kusion/pkg/domain/constant" +) + +// The SourceProvider represents the abstraction of the source provider(s) +// management framework. +type SourceProvider interface { + // Get the type of the source provider. + Type() constant.SourceProviderType + // Get source and return directory. + Get(ctx context.Context, opts ...GetOption) (string, error) + // Cleanup is invoked to cleanup temp resources for the source. + Cleanup(ctx context.Context) +} + +type GetConfig struct { + Paths []string + Type *constant.SourceProviderType +} + +type GetOption func(opt *GetConfig) + +func WithPaths(paths ...string) GetOption { + return func(opt *GetConfig) { + opt.Paths = paths + } +} + +func WithType(typ constant.SourceProviderType) GetOption { + return func(opt *GetConfig) { + opt.Type = &typ + } +} diff --git a/pkg/domain/entity/source_providers/git.go b/pkg/domain/entity/source_providers/git.go new file mode 100644 index 00000000..20a72e9f --- /dev/null +++ b/pkg/domain/entity/source_providers/git.go @@ -0,0 +1,116 @@ +package sourceproviders + +// This file should contain the git implementation for the sourceProvider interface + +import ( + "context" + "fmt" + "os" + "path/filepath" + + git "github.com/go-git/go-git/v5" + + "kusionstack.io/kusion/pkg/domain/constant" + "kusionstack.io/kusion/pkg/domain/entity" + "kusionstack.io/kusion/pkg/log" + "kusionstack.io/kusion/pkg/server/util" +) + +var _ entity.SourceProvider = &GitSourceProvider{} + +// GitSourceProvider is the implementation of the SourceProvider interface +type GitSourceProvider struct { + // The remote URL of the git repository + Remote string + // The directory to clone the git repository + Directory string + // The version of the git repository + Version string +} + +// NewGitSourceProvider creates a new GitSourceProvider +func NewGitSourceProvider(remote, directory, version string) *GitSourceProvider { + return &GitSourceProvider{ + Remote: remote, + Directory: directory, + Version: version, + } +} + +// Type returns the type of the source provider +func (g *GitSourceProvider) Type() constant.SourceProviderType { + return constant.SourceProviderTypeGit +} + +// Get clones the git repository and returns the directory +func (g *GitSourceProvider) Get(ctx context.Context, opts ...entity.GetOption) (string, error) { + // Create the directory if it does not exist + if _, err := os.Stat(g.Directory); os.IsNotExist(err) { + if err := os.MkdirAll(g.Directory, os.ModePerm); err != nil { + return "", fmt.Errorf("failed to create directory: %w", err) + } + } + + // TODO: Add basic and ssh authentication + // token := os.Getenv("GITHUB_TOKEN") + // Use golang git library to clone the repository + repo, err := git.PlainCloneContext(ctx, g.Directory, false, &git.CloneOptions{ + URL: g.Remote, + Progress: os.Stdout, + // Auth: &http.TokenAuth{ + // Token: token, + // }, + }) + if err != nil { + return "", err + } + log.Info("Successfully cloned git repository: %s", repo) + + if g.Version != "" { + err := checkoutRevision(g.Directory, g.Version) + if err != nil { + return "", ErrCheckingOutRevision + } + } + + // // Clone the git repository + // cmd := exec.CommandContext(ctx, "git", "clone", g.Remote, g.Directory) + // if err := cmd.Run(); err != nil { + // return "", fmt.Errorf("failed to clone git repository: %w", err) + // } + + // Checkout the version + // if g.Version != "" { + // cmd = exec.CommandContext(ctx, "git", "-C", g.Directory, "checkout", g.Version) + // if err := cmd.Run(); err != nil { + // return "", fmt.Errorf("failed to checkout version: %w", err) + // } + // } + + log.Infof("Successfully cloned git repository: %s", g.Remote) + + return g.Directory, nil +} + +// Cleanup cleans up the resources of the provider +func (g *GitSourceProvider) Cleanup(ctx context.Context) { + logger := util.GetLogger(ctx) + logger.Info("Cleaning up temp kcp-kusion directory...") + + // Remove the directory + if err := os.RemoveAll(g.Directory); err != nil { + log.Errorf("failed to remove directory: %v", err) + } + logger.Info("temp directory removed", "directory", g.Directory) +} + +// GetGitSourceProvider returns a GitSourceProvider +func GetGitSourceProvider(ctx context.Context, remote, directory, version string) (*GitSourceProvider, error) { + // Get the absolute path of the directory + absPath, err := filepath.Abs(directory) + if err != nil { + return nil, fmt.Errorf("failed to get absolute path: %w", err) + } + + return NewGitSourceProvider(remote, absPath, version), nil +} diff --git a/pkg/domain/entity/source_providers/types.go b/pkg/domain/entity/source_providers/types.go new file mode 100644 index 00000000..b0418be2 --- /dev/null +++ b/pkg/domain/entity/source_providers/types.go @@ -0,0 +1,11 @@ +package sourceproviders + +import ( + "errors" +) + +var ( + ErrCheckingOutBranch = errors.New("err checking out branch") + ErrCheckingOutRevision = errors.New("err checking out revision") + ErrCheckingOutRepository = errors.New("err checking out repository") +) diff --git a/pkg/domain/entity/source_providers/util.go b/pkg/domain/entity/source_providers/util.go new file mode 100644 index 00000000..5eb7adb5 --- /dev/null +++ b/pkg/domain/entity/source_providers/util.go @@ -0,0 +1,60 @@ +package sourceproviders + +import ( + "fmt" + + "github.com/go-git/go-git/v5" // with go modules enabled (GO111MODULE=on or outside GOPATH) + "github.com/go-git/go-git/v5/plumbing" +) + +// func switchBranch(repoPath string, branchName string) error { +// // Open an existing repository +// r, err := git.PlainOpen(repoPath) +// if err != nil { +// return err +// } + +// // Get the worktree for the repository +// w, err := r.Worktree() +// if err != nil { +// return err +// } + +// // Checkout the specified branch +// err = w.Checkout(&git.CheckoutOptions{ +// Branch: plumbing.NewBranchReferenceName(branchName), +// }) + +// if err != nil { +// return err +// } + +// fmt.Println("Switched to branch:", branchName) +// return nil +// } + +func checkoutRevision(repoPath string, revision string) error { + // Open an existing repository + r, err := git.PlainOpen(repoPath) + if err != nil { + return err + } + + // Get the worktree for the repository + w, err := r.Worktree() + if err != nil { + return err + } + + // Checkout the specified revision + // For a commit or tag, use `plumbing.Revision` to resolve the hash + err = w.Checkout(&git.CheckoutOptions{ + Hash: plumbing.NewHash(revision), + }) + if err != nil { + return err + } + + fmt.Println("Checked out to revision:", revision) + return nil +} diff --git a/pkg/domain/entity/stack.go b/pkg/domain/entity/stack.go new file mode 100644 index 00000000..7d3746a8 --- /dev/null +++ b/pkg/domain/entity/stack.go @@ -0,0 +1,94 @@ +package entity + +import ( + "time" + + v1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" + "kusionstack.io/kusion/pkg/domain/constant" +) + +// Stack represents the specific configuration stack +type Stack struct { + // ID is the id of the stack. + ID uint `yaml:"id" json:"id"` + // Name is the name of the stack. + Name string `yaml:"name" json:"name"` + // DisplayName is the human-readable display nams. + DisplayName string `yaml:"displayName,omitempty" json:"displayName,omitempty"` + // Source is the configuration source associated with the stack. + // Source *Source `yaml:"source" json:"source"` + // Project is the project associated with the stack. + Project *Project `yaml:"project" json:"project"` + // Org is the org associated with the stack. + // Organization *Organization `yaml:"organization" json:"organization"` + // Desired is the desired version of stack. + DesiredVersion string `yaml:"desiredVersion" json:"desiredVersion"` + // Description is a human-readable description of the stack. + Description string `yaml:"description,omitempty" json:"description,omitempty"` + // Path is the relative path of the stack within the sourcs. + Path string `yaml:"path,omitempty" json:"path,omitempty"` + // Labels are custom labels associated with the stack. + Labels []string `yaml:"labels,omitempty" json:"labels,omitempty"` + // Owners is a list of owners for the stack. + Owners []string `yaml:"owners,omitempty" json:"owners,omitempty"` + // SyncState is the current state of the stack. + SyncState constant.StackState `yaml:"syncState" json:"syncState"` + // LastSyncTimestamp is the timestamp of the last sync operation for the stack. + LastSyncTimestamp time.Time `yaml:"lastSyncTimestamp,omitempty" json:"lastSyncTimestamp,omitempty"` + // CreationTimestamp is the timestamp of the created for the stack. + CreationTimestamp time.Time `yaml:"creationTimestamp,omitempty" json:"creationTimestamp,omitempty"` + // UpdateTimestamp is the timestamp of the updated for the stack. + UpdateTimestamp time.Time `yaml:"updateTimestamp,omitempty" json:"updateTimestamp,omitempty"` +} + +// Validate checks if the stack is valid. +// It returns an error if the stack is not valid. +func (s *Stack) Validate() error { + if s == nil { + return constant.ErrStackNil + } + + if s.Name == "" { + return constant.ErrStackName + } + + if s.Project == nil { + return constant.ErrStackHasNilProject + } + + if err := s.Project.Validate(); err != nil { + return err + } + + if s.Path == "" { + return constant.ErrStackPath + } + + if s.SyncState == "" { + return constant.ErrStackSyncState + } + + // if s.Source == nil { + // return constant.ErrStackSource + // } + + // if err := s.Source.Validate(); err != nil { + // return err + // } + + if len(s.DesiredVersion) == 0 { + return constant.ErrStackDesiredVersion + } + + return nil +} + +// Convert stack to core stack +func (s *Stack) ConvertToCore() (*v1.Stack, error) { + return &v1.Stack{ + Name: s.Name, + Description: &s.Description, + Path: s.Path, + Labels: map[string]string{}, + }, nil +} diff --git a/pkg/domain/entity/workspace.go b/pkg/domain/entity/workspace.go new file mode 100644 index 00000000..6edab983 --- /dev/null +++ b/pkg/domain/entity/workspace.go @@ -0,0 +1,51 @@ +package entity + +import ( + "time" + + "kusionstack.io/kusion/pkg/domain/constant" +) + +// Workspace represents the specific configuration workspace +type Workspace struct { + // ID is the id of the workspace. + ID uint `yaml:"id" json:"id"` + // Name is the name of the workspace. + Name string `yaml:"name" json:"name"` + // DisplayName is the human-readable display name. + DisplayName string `yaml:"displayName,omitempty" json:"displayName,omitempty"` + // Description is a human-readable description of the workspace. + Description string `yaml:"description,omitempty" json:"description,omitempty"` + // Labels are custom labels associated with the workspace. + Labels []string `yaml:"labels,omitempty" json:"labels,omitempty"` + // Owners is a list of owners for the workspace. + Owners []string `yaml:"owners,omitempty" json:"owners,omitempty"` + // CreationTimestamp is the timestamp of the created for the workspace. + CreationTimestamp time.Time `yaml:"creationTimestamp,omitempty" json:"creationTimestamp,omitempty"` + // UpdateTimestamp is the timestamp of the updated for the workspace. + UpdateTimestamp time.Time `yaml:"updateTimestamp,omitempty" json:"updateTimestamp,omitempty"` + // Backend is the corresponding backend for this workspace. + Backend *Backend `yaml:"backend,omitempty" json:"backend,omitempty"` +} + +// Validate checks if the workspace is valid. +// It returns an error if the workspace is not valid. +func (w *Workspace) Validate() error { + if w == nil { + return constant.ErrWorkspaceNil + } + + if w.Name == "" { + return constant.ErrWorkspaceNameEmpty + } + + if w.Backend == nil { + return constant.ErrWorkspaceBackendNil + } + + if len(w.Owners) == 0 { + return constant.ErrWorkspaceOwnerNil + } + + return nil +} diff --git a/pkg/domain/repository/repository.go b/pkg/domain/repository/repository.go new file mode 100644 index 00000000..859b70d6 --- /dev/null +++ b/pkg/domain/repository/repository.go @@ -0,0 +1,109 @@ +package repository + +import ( + "context" + + "kusionstack.io/kusion/pkg/domain/entity" +) + +// OrganizationRepository is an interface that defines the repository operations +// for organizations. It follows the principles of domain-driven design (DDD). +type OrganizationRepository interface { + // Create creates a new organization. + Create(ctx context.Context, organization *entity.Organization) error + // Delete deletes a organization by its ID. + Delete(ctx context.Context, id uint) error + // Update updates an existing organization. + Update(ctx context.Context, organization *entity.Organization) error + // Get retrieves a organization by its ID. + Get(ctx context.Context, id uint) (*entity.Organization, error) + // List retrieves all existing organizations. + List(ctx context.Context) ([]*entity.Organization, error) +} + +// ProjectRepository is an interface that defines the repository operations +// for projects. It follows the principles of domain-driven design (DDD). +type ProjectRepository interface { + // Create creates a new project. + Create(ctx context.Context, project *entity.Project) error + // Delete deletes a project by its ID. + Delete(ctx context.Context, id uint) error + // Update updates an existing project. + Update(ctx context.Context, project *entity.Project) error + // Get retrieves a project by its ID. + Get(ctx context.Context, id uint) (*entity.Project, error) + // List retrieves all existing projects. + List(ctx context.Context) ([]*entity.Project, error) +} + +// StackRepository is an interface that defines the repository operations +// for stacks. It follows the principles of domain-driven design (DDD). +type StackRepository interface { + // Create creates a new stack. + Create(ctx context.Context, stack *entity.Stack) error + // Delete deletes a stack by its ID. + Delete(ctx context.Context, id uint) error + // Update updates an existing stack. + Update(ctx context.Context, stack *entity.Stack) error + // Get retrieves a stack by its ID. + Get(ctx context.Context, id uint) (*entity.Stack, error) + // List retrieves all existing stacks. + List(ctx context.Context) ([]*entity.Stack, error) + // // GetBy retrieves a stack by project and stack name. + // GetBy(ctx context.Context, project string, stack string) (*entity.Stack, error) + // // Find returns a list of specified stacks. + // Find(ctx context.Context, query StackQuery) ([]*entity.Stack, error) + // // Count returns the total of stacks. + // Count(ctx context.Context, condition StackCondition) (int, error) +} + +// SourceRepository is an interface that defines the repository operations +// for sources. It follows the principles of domain-driven design (DDD). +type SourceRepository interface { + // Get retrieves a source by its ID. + Get(ctx context.Context, id uint) (*entity.Source, error) + // GetByRemote retrieves a source by its remote. + GetByRemote(ctx context.Context, remote string) (*entity.Source, error) + // List retrieves all existing sources. + List(ctx context.Context) ([]*entity.Source, error) + // Create creates a new source. + Create(ctx context.Context, source *entity.Source) error + // CreateOrUpdate creates a new stack. + CreateOrUpdate(ctx context.Context, stack *entity.Source) error + // Delete deletes a stack by its ID. + Delete(ctx context.Context, id uint) error + // Update updates an existing stack. + Update(ctx context.Context, stack *entity.Source) error +} + +// WorkspaceRepository is an interface that defines the repository operations +// for workspaces. It follows the principles of domain-driven design (DDD). +type WorkspaceRepository interface { + // Create creates a new workspace. + Create(ctx context.Context, workspace *entity.Workspace) error + // Delete deletes a workspace by its ID. + Delete(ctx context.Context, id uint) error + // Update updates an existing workspace. + Update(ctx context.Context, workspace *entity.Workspace) error + // Get retrieves a workspace by its ID. + Get(ctx context.Context, id uint) (*entity.Workspace, error) + // GetByName retrieves a workspace by its name. + GetByName(ctx context.Context, name string) (*entity.Workspace, error) + // List retrieves all existing workspace. + List(ctx context.Context) ([]*entity.Workspace, error) +} + +// BackendRepository is an interface that defines the repository operations +// for backends. It follows the principles of domain-driven design (DDD). +type BackendRepository interface { + // Create creates a new backend. + Create(ctx context.Context, backend *entity.Backend) error + // Delete deletes a backend by its ID. + Delete(ctx context.Context, id uint) error + // Update updates an existing backend. + Update(ctx context.Context, backend *entity.Backend) error + // Get retrieves a backend by its ID. + Get(ctx context.Context, id uint) (*entity.Backend, error) + // List retrieves all existing backend. + List(ctx context.Context) ([]*entity.Backend, error) +} diff --git a/pkg/domain/repository/types.go b/pkg/domain/repository/types.go new file mode 100644 index 00000000..4ebe23a0 --- /dev/null +++ b/pkg/domain/repository/types.go @@ -0,0 +1,49 @@ +package repository + +// Bound represents the query bound for a database access. +type Bound struct { + // Offset is the number of items to skip. + Offset int + // Limit is the maximum number of items to return. + Limit int +} + +// Condition represents the query conditions for a database access. +type Condition struct { + // Keyword is the keyword to search for. + Keyword string +} + +// Query represents the query criteria for a database access. +type Query struct { + Bound + Condition +} + +// StackCondition represents the stack query conditions for a database +// access. +type StackCondition struct { + Condition + SourceIDs []string + Desired string + Framework string + State string +} + +// SourceCondition represents the source query conditions for a database access. +type SourceCondition struct { + Condition + SourceProvider string +} + +// StackQuery represents the stack query criteria for a database access. +type StackQuery struct { + Bound + StackCondition +} + +// SourceQuery represents the source query criteria for a database access. +type SourceQuery struct { + Bound + SourceCondition +} diff --git a/pkg/domain/request/backend_request.go b/pkg/domain/request/backend_request.go new file mode 100644 index 00000000..103a3840 --- /dev/null +++ b/pkg/domain/request/backend_request.go @@ -0,0 +1,27 @@ +package request + +import v1 "kusionstack.io/kusion/pkg/apis/internal.kusion.io/v1" + +// CreateBackendRequest represents the create request structure for +// backend. +type CreateBackendRequest struct { + // Name is the name of the backend. + Name string `json:"name" binding:"required"` + // Description is a human-readable description of the backend. + Description string `json:"description"` + // BackendConfig is the configuration of the backend. + BackendConfig v1.BackendConfig `json:"backendConfig"` +} + +// UpdateBackendRequest represents the update request structure for +// backend. +type UpdateBackendRequest struct { + // ID is the id of the backend. + ID uint `json:"id" binding:"required"` + // Name is the name of the backend. + Name string `json:"name"` + // Description is a human-readable description of the backend. + Description string `json:"description"` + // BackendConfig is the configuration of the backend. + BackendConfig v1.BackendConfig `json:"backendConfig"` +} diff --git a/pkg/domain/request/organization_request.go b/pkg/domain/request/organization_request.go new file mode 100644 index 00000000..ba055f89 --- /dev/null +++ b/pkg/domain/request/organization_request.go @@ -0,0 +1,29 @@ +package request + +// CreateOrganizationRequest represents the create request structure for +// organization. +type CreateOrganizationRequest struct { + // Name is the name of the organization. + Name string `json:"name" binding:"required"` + // Description is a human-readable description of the organization. + Description string `json:"description"` + // Labels are custom labels associated with the organization. + Labels []string `json:"labels"` + // Owners is a list of owners for the organization. + Owners []string `json:"owners" binding:"required"` +} + +// UpdateOrganizationRequest represents the update request structure for +// organization. +type UpdateOrganizationRequest struct { + // ID is the id of the organization. + ID uint `json:"id" binding:"required"` + // Name is the name of the organization. + Name string `json:"name"` + // Description is a human-readable description of the organization. + Description string `json:"description"` + // Labels are custom labels associated with the organization. + Labels map[string]string `json:"labels"` + // Owners is a list of owners for the organization. + Owners []string `json:"owners" binding:"required"` +} diff --git a/pkg/domain/request/project_request.go b/pkg/domain/request/project_request.go new file mode 100644 index 00000000..84e5dadf --- /dev/null +++ b/pkg/domain/request/project_request.go @@ -0,0 +1,41 @@ +package request + +// CreateProjectRequest represents the create request structure for +// project. +type CreateProjectRequest struct { + // Name is the name of the project. + Name string `json:"name" binding:"required"` + // SourceID is the configuration source id associated with the project. + SourceID uint `json:"sourceID,string" binding:"required"` + // OrganizationID is the organization id associated with the project. + OrganizationID uint `json:"organizationID,string" binding:"required"` + // Description is a human-readable description of the project. + Description string `json:"description"` + // Path is the relative path of the project within the sourcs.. + Path string `json:"path" binding:"required"` + // Labels are custom labels associated with the project. + Labels []string `json:"labels"` + // Owners is a list of owners for the project. + Owners []string `json:"owners"` +} + +// UpdateProjectRequest represents the update request structure for +// project. +type UpdateProjectRequest struct { + // ID is the id of the project. + ID uint `json:"id" binding:"required"` + // SourceID is the configuration source id associated with the project. + SourceID uint `json:"sourceID,string"` + // OrganizationID is the organization id associated with the project. + OrganizationID uint `json:"organizationID,string"` + // Name is the name of the project. + Name string `json:"name"` + // Description is a human-readable description of the project. + Description string `json:"description"` + // Path is the relative path of the project within the sourcs.. + Path string `json:"path"` + // Labels are custom labels associated with the project. + Labels map[string]string `json:"labels"` + // Owners is a list of owners for the project. + Owners []string `json:"owners"` +} diff --git a/pkg/domain/request/source_request.go b/pkg/domain/request/source_request.go new file mode 100644 index 00000000..a82276a6 --- /dev/null +++ b/pkg/domain/request/source_request.go @@ -0,0 +1,33 @@ +package request + +// CreateSourceRequest represents the create request structure for +// source. +type CreateSourceRequest struct { + // SourceProvider is the type of the source provider. + SourceProvider string `json:"sourceProvider" binding:"required"` + // Remote is the source URL, including scheme. + Remote string `json:"remote" binding:"required"` + // Description is a human-readable description of the source. + Description string `json:"description"` + // Labels are custom labels associated with the source. + Labels []string `json:"labels"` + // Owners is a list of owners for the source. + Owners []string `json:"owners"` +} + +// UpdateSourceRequest represents the update request structure for +// source. +type UpdateSourceRequest struct { + // ID is the id of the source. + ID uint `json:"id" binding:"required"` + // SourceProvider is the type of the source provider. + SourceProvider string `json:"sourceProvider"` + // Remote is the source URL, including scheme. + Remote string `json:"remote"` + // Description is a human-readable description of the source. + Description string `json:"description"` + // Labels are custom labels associated with the source. + Labels []string `json:"labels"` + // Owners is a list of owners for the source. + Owners []string `json:"owners"` +} diff --git a/pkg/domain/request/stack_request.go b/pkg/domain/request/stack_request.go new file mode 100644 index 00000000..67ef7043 --- /dev/null +++ b/pkg/domain/request/stack_request.go @@ -0,0 +1,116 @@ +package request + +import ( + "kusionstack.io/kusion/pkg/domain/service" +) + +// CreateStackRequest represents the create request structure for +// stack. +type CreateStackRequest struct { + // Name is the name of the stack. + Name string `json:"name" binding:"required"` + // SourceID is the configuration source id associated with the stack. + // SourceID uint `json:"sourceID,string" binding:"required"` + // ProjectID is the project id of the stack within the source. + ProjectID uint `json:"projectID,string" binding:"required"` + // OrganizationID is the organization id associated with the stack. + // OrganizationID uint `json:"organizationID,string" binding:"required"` + // Path is the relative path of the stack within the source. + Path string `json:"path" binding:"required"` + // DesiredVersion is the desired revision of stack. + DesiredVersion string `json:"desiredVersion" binding:"required"` + // Description is a human-readable description of the stack. + Description string `json:"description"` + // Labels are custom labels associated with the stack. + Labels []string `json:"labels"` + // Owners is a list of owners for the stack. + Owners []string `json:"owners"` +} + +// UpdateStackRequest represents the update request structure for +// stack. +type UpdateStackRequest struct { + // ID is the id of the stack. + ID uint `json:"id" binding:"required"` + // Name is the name of the stack. + Name string `json:"name"` + // SourceID is the configuration source id associated with the stack. + // SourceID uint `json:"sourceID,string"` + // ProjectID is the project id of the stack within the stack. + ProjectID uint `json:"projectID,string"` + // OrganizationID is the organization id associated with the stack. + // OrganizationID uint `json:"organizationID,string"` + // Path is the relative path of the stack within the stack. + Path string `json:"path"` + // DesiredVersion is the desired revision of stack. + DesiredVersion string `json:"desiredVersion"` + // Description is a human-readable description of the stack. + Description string `json:"description"` + // Labels are custom labels associated with the stack. + Labels map[string]string `json:"labels"` + // Owners is a list of owners for the stack. + Owners []string `json:"owners"` +} + +// ExecuteStackRequest is the common request for preview and sync operation. +type ExecuteStackRequest struct { + // SourceProviderType is the type of the source provider. + SourceProviderType string `json:"sourceProviderType"` + // Remote is the remote url of the stack to be pulled. + Remote string `json:"remote" binding:"required"` + // Version is the version of the stack to be pulled. + Version string `json:"version" binding:"required"` + // Envs lets you set the env when executes in the form "key=value". + Envs []string `json:"envs,omitempty" yaml:"envs,omitempty"` + // AdditionalPaths is the additional paths to be added to the stack. + AdditionalPaths []string `json:"additionalPaths,omitempty"` + // DisableState is the flag to disable state management. + DisableState bool `json:"disableState,omitempty"` + // Extensions is the extensions for the stack request. + Extensions service.Extensions `json:"extensions,omitempty"` +} + +type ExecutePreviewStackRequest struct { + // OutputFormat specify the output format, one of "", "json". + OutputFormat string `json:"outputFormat,omitempty" binding:"oneof='' 'json'"` + // DriftMode is a boolean field used to represent the state of the drift mode. + DriftMode bool `json:"driftMode,omitempty" yaml:"driftMode,omitempty"` +} + +// PreviewStackRequest represents the preview request structure +// for stack. +type PreviewStackRequest struct { + ExecuteStackRequest `json:",inline"` + ExecutePreviewStackRequest `json:",inline"` + StackPath string `json:"stackPath" binding:"required"` +} + +// SyncStackRequest represents the sync request structure +// for stack. +type SyncStackRequest struct { + ExecuteStackRequest `json:",inline"` + StackPath string `json:"stackPath" binding:"required"` +} + +// PreviewStacksRequest represents the preview request structure +// for stacks. +type PreviewStacksRequest struct { + ExecuteStackRequest `json:",inline"` + ExecutePreviewStackRequest `json:",inline"` + StackPaths []string `json:"stackPaths" binding:"required"` +} + +// SyncStacksRequest represents the sync request structure +// for stacks. +type SyncStacksRequest struct { + ExecuteStackRequest `json:",inline"` + StackPaths []string `json:"stackPaths" binding:"required"` +} + +// InspectStacksRequest represents the inspect request structure +// for stacks. +type InspectStacksRequest struct { + Verbose bool `json:"verbose,omitempty" yaml:"verbose,omitempty"` + Remote string `json:"remote" yaml:"remote" binding:"required"` + StackPaths []string `json:"stackPaths" yaml:"stackPaths" binding:"required"` +} diff --git a/pkg/domain/request/util.go b/pkg/domain/request/util.go new file mode 100644 index 00000000..5dace250 --- /dev/null +++ b/pkg/domain/request/util.go @@ -0,0 +1,74 @@ +package request + +import ( + "errors" + "net/http" + + "github.com/go-chi/render" +) + +// decode detects the correct decoder for use on an HTTP request and +// marshals into a given interface. +func decode(r *http.Request, payload interface{}) error { + // Check if the content type is plain text, read it as such. + contentType := render.GetRequestContentType(r) + switch contentType { + case render.ContentTypeJSON: + // For non-plain text, decode the JSON body into the payload. + if err := render.DecodeJSON(r.Body, payload); err != nil { + return err + } + default: + return errors.New("unsupported media type") + } + + return nil +} + +func (payload *CreateProjectRequest) Decode(r *http.Request) error { + return decode(r, payload) +} + +func (payload *UpdateProjectRequest) Decode(r *http.Request) error { + return decode(r, payload) +} + +func (payload *CreateStackRequest) Decode(r *http.Request) error { + return decode(r, payload) +} + +func (payload *UpdateStackRequest) Decode(r *http.Request) error { + return decode(r, payload) +} + +func (payload *CreateSourceRequest) Decode(r *http.Request) error { + return decode(r, payload) +} + +func (payload *UpdateSourceRequest) Decode(r *http.Request) error { + return decode(r, payload) +} + +func (payload *CreateOrganizationRequest) Decode(r *http.Request) error { + return decode(r, payload) +} + +func (payload *UpdateOrganizationRequest) Decode(r *http.Request) error { + return decode(r, payload) +} + +func (payload *CreateWorkspaceRequest) Decode(r *http.Request) error { + return decode(r, payload) +} + +func (payload *UpdateWorkspaceRequest) Decode(r *http.Request) error { + return decode(r, payload) +} + +func (payload *CreateBackendRequest) Decode(r *http.Request) error { + return decode(r, payload) +} + +func (payload *UpdateBackendRequest) Decode(r *http.Request) error { + return decode(r, payload) +} diff --git a/pkg/domain/request/workspace_request.go b/pkg/domain/request/workspace_request.go new file mode 100644 index 00000000..c3cc49df --- /dev/null +++ b/pkg/domain/request/workspace_request.go @@ -0,0 +1,33 @@ +package request + +// CreateWorkspaceRequest represents the create request structure for +// workspace. +type CreateWorkspaceRequest struct { + // Name is the name of the workspace. + Name string `json:"name" binding:"required"` + // Description is a human-readable description of the workspace. + Description string `json:"description"` + // Labels are custom labels associated with the workspace. + Labels []string `json:"labels"` + // Owners is a list of owners for the workspace. + Owners []string `json:"owners" binding:"required"` + // BackendID is the configuration backend id associated with the workspace. + BackendID uint `json:"backendID,string" binding:"required"` +} + +// UpdateWorkspaceRequest represents the update request structure for +// workspace. +type UpdateWorkspaceRequest struct { + // ID is the id of the workspace. + ID uint `json:"id" binding:"required"` + // Name is the name of the workspace. + Name string `json:"name"` + // Description is a human-readable description of the workspace. + Description string `json:"description"` + // Labels are custom labels associated with the workspace. + Labels map[string]string `json:"labels"` + // Owners is a list of owners for the workspace. + Owners []string `json:"owners" binding:"required"` + // BackendID is the configuration backend id associated with the workspace. + BackendID uint `json:"backendID,string" binding:"required"` +} diff --git a/pkg/domain/service/stack.go b/pkg/domain/service/stack.go new file mode 100644 index 00000000..b4ce17ef --- /dev/null +++ b/pkg/domain/service/stack.go @@ -0,0 +1,140 @@ +package service + +import "github.com/hashicorp/go-multierror" + +const DefaultMaxRoutines = 10 + +// CommonOptions is the common options for preview and sync operation. +type CommonOptions struct { + PullOptions `json:",inline" yaml:",inline"` + // Envs lets you set the env when executes in the form "key=value". + Envs []string `json:"envs,omitempty" yaml:"envs,omitempty"` + // DisableState is the flag to disable state management. + DisableState bool `json:"disableState,omitempty"` + // Extensions is the extensions for the stack request. + Extensions Extensions `json:"extensions,omitempty"` +} + +// Extensions is the extensions for the stack request. +type Extensions struct { + Kusion KusionExtensions `json:"kusion,omitempty"` + Terraform TerraformExtensions `json:"terraform,omitempty"` +} + +// KusionExtensions is the extensions for the Kusion stack request. +type KusionExtensions struct { + IgnoreFields []string `json:"ignoreFields,omitempty"` + KCLArguments []string `json:"kclArguments,omitempty" yaml:"kclArguments,omitempty"` + Color bool `json:"color,omitempty" yaml:"color,omitempty"` + SpecFile string `json:"specFile,omitempty" yaml:"specFile,omitempty"` + Version string `json:"version,omitempty" yaml:"version,omitempty"` +} + +// TerraformExtensions is the extensions for the Terraform stack request. +type TerraformExtensions struct{} + +// PullOptions is the options for pull. +type PullOptions struct { + // SourceProviderType is the type of the source provider. + SourceProviderType string `json:"sourceProviderType,omitempty" yaml:"sourceProviderType,omitempty"` + // The version of the stack to be pulled. If not specified, + // use the desired of stack. + Version string `json:"version,omitempty" yaml:"version,omitempty"` + AdditionalPaths []string `json:"additionalPaths,omitempty" yaml:"additionalPaths,omitempty"` +} + +// PreviewOptions is the options for preview. +type PreviewOptions struct { + CommonOptions `json:",inline" yaml:",inline"` + // TODO: need to implement + DriftMode bool `json:"driftMode,omitempty" yaml:"driftMode,omitempty"` + // OutputFormat specify the output format, one of '', 'json', + // default is empty (''). + OutputFormat string `json:"outputFormat,omitempty" yaml:"outputFormat,omitempty"` +} + +// SyncOptions is the options for sync. +type SyncOptions struct { + CommonOptions `json:",inline" yaml:",inline"` +} + +// // setupResult represents the result of a setup operation. +// type setupResult struct { +// *pullResult `json:",inline" yaml:",inline"` +// *getStateResult `json:",inline" yaml:",inline"` +// } + +// // pullResult represents the result of a pull operation. +// type pullResult struct { +// SourceRoot string `json:"sourceRoot" yaml:"sourceRoot"` +// StackAbsPath string `json:"stackAbsPath" yaml:"stackAbsPath"` +// } + +// // getStateResult represents the result of a getState operation. +// type getStateResult struct { +// StorageKey string `json:"storageKey" yaml:"storageKey"` +// StateAbsPath string `json:"stateAbsPath" yaml:"stateAbsPath"` +// } + +// PreviewResult represents the result of a preview operation. +type PreviewResult struct { + HasChange bool `json:"hasChange" yaml:"hasChange"` + Raw string `json:"raw" yaml:"raw"` + JSON string `json:"json,omitempty" yaml:"json,omitempty"` + Error error `json:"error,omitempty" yaml:"error,omitempty" swaggertype:"string"` +} + +// NewEmptyPreviewResult creates an empty PreviewResult struct. +func NewEmptyPreviewResult() PreviewResult { + return PreviewResult{} +} + +// NewPreviewResultOnlyError creates a PreviewResult struct containing +// only an error. +func NewPreviewResultOnlyError(err error) PreviewResult { + return PreviewResult{Error: err} +} + +// SyncResult represents the result of a sync operation. +type SyncResult struct { + Raw string `json:"raw" yaml:"raw"` + Error error `json:"error,omitempty" yaml:"error,omitempty" swaggertype:"string"` +} + +// NewEmptySyncResult creates an empty SyncResult struct. +func NewEmptySyncResult() SyncResult { + return SyncResult{} +} + +// NewSyncResultOnlyError creates a SyncResult struct containing +// only an error. +func NewSyncResultOnlyError(err error) SyncResult { + return SyncResult{Error: err} +} + +type ( + // Represents a group of PreviewResult structs + PreviewResults []PreviewResult + // Represents a group of SyncResult structs + SyncResults []SyncResult +) + +// Error returns the error of all PreviewResult structs. +func (r PreviewResults) Error() error { + var errs *multierror.Error + for _, v := range []PreviewResult(r) { + errs = multierror.Append(errs, v.Error) + } + + return errs.ErrorOrNil() +} + +// Error returns the error of all SyncResult structs. +func (r SyncResults) Error() error { + var errs *multierror.Error + for _, v := range []SyncResult(r) { + errs = multierror.Append(errs, v.Error) + } + + return errs.ErrorOrNil() +} diff --git a/pkg/engine/api/apply.go b/pkg/engine/api/apply.go new file mode 100644 index 00000000..2c2999fa --- /dev/null +++ b/pkg/engine/api/apply.go @@ -0,0 +1,222 @@ +package api + +import ( + "fmt" + "io" + "strings" + "sync" + + "github.com/pterm/pterm" + + apiv1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" + v1 "kusionstack.io/kusion/pkg/apis/status/v1" + "kusionstack.io/kusion/pkg/engine/operation" + "kusionstack.io/kusion/pkg/engine/operation/models" + "kusionstack.io/kusion/pkg/engine/state" + "kusionstack.io/kusion/pkg/log" +) + +// The Apply function will apply the resources changes +// through the execution Kusion Engine, and will save +// the state to specified storage. +// +// You can customize the runtime of engine and the state +// storage through `runtime` and `storage` parameters. +// +// Example: +// +// o := NewApplyOptions() +// stateStorage := &states.FileSystemState{ +// Path: filepath.Join(o.WorkDir, states.KusionState) +// } +// kubernetesRuntime, err := runtime.NewKubernetesRuntime() +// if err != nil { +// return err +// } +// +// err = Apply(o, kubernetesRuntime, stateStorage, planResources, changes, os.Stdout) +// if err != nil { +// return err +// } +func Apply( + o *APIOptions, + storage state.Storage, + planResources *apiv1.Spec, + changes *models.Changes, + out io.Writer, +) error { + // construct the apply operation + ac := &operation.ApplyOperation{ + Operation: models.Operation{ + Stack: changes.Stack(), + StateStorage: storage, + MsgCh: make(chan models.Message), + IgnoreFields: o.IgnoreFields, + }, + } + + // line summary + var ls lineSummary + + // progress bar, print dag walk detail + progressbar, err := pterm.DefaultProgressbar. + WithMaxWidth(0). // Set to 0, the terminal width will be used + WithTotal(len(changes.StepKeys)). + WithWriter(out). + Start() + if err != nil { + return err + } + // wait msgCh close + var wg sync.WaitGroup + // receive msg and print detail + go func() { + defer func() { + if p := recover(); p != nil { + log.Errorf("failed to receive msg and print detail as %v", p) + } + }() + wg.Add(1) + + for { + select { + case msg, ok := <-ac.MsgCh: + if !ok { + wg.Done() + return + } + changeStep := changes.Get(msg.ResourceID) + + switch msg.OpResult { + case models.Success, models.Skip: + var title string + if changeStep.Action == models.UnChanged { + title = fmt.Sprintf("%s %s, %s", + changeStep.Action.String(), + pterm.Bold.Sprint(changeStep.ID), + strings.ToLower(string(models.Skip)), + ) + } else { + title = fmt.Sprintf("%s %s %s", + changeStep.Action.String(), + pterm.Bold.Sprint(changeStep.ID), + strings.ToLower(string(msg.OpResult)), + ) + } + pterm.Success.WithWriter(out).Println(title) + progressbar.UpdateTitle(title) + progressbar.Increment() + ls.Count(changeStep.Action) + case models.Failed: + title := fmt.Sprintf("%s %s %s", + changeStep.Action.String(), + pterm.Bold.Sprint(changeStep.ID), + strings.ToLower(string(msg.OpResult)), + ) + pterm.Error.WithWriter(out).Printf("%s\n", title) + default: + title := fmt.Sprintf("%s %s %s", + changeStep.Action.Ing(), + pterm.Bold.Sprint(changeStep.ID), + strings.ToLower(string(msg.OpResult)), + ) + progressbar.UpdateTitle(title) + } + } + } + }() + + if o.DryRun { + for _, r := range planResources.Resources { + ac.MsgCh <- models.Message{ + ResourceID: r.ResourceKey(), + OpResult: models.Success, + OpErr: nil, + } + } + close(ac.MsgCh) + } else { + // parse cluster in arguments + _, st := ac.Apply(&operation.ApplyRequest{ + Request: models.Request{ + Project: changes.Project(), + Stack: changes.Stack(), + Operator: o.Operator, + Intent: planResources, + }, + }) + if v1.IsErr(st) { + return fmt.Errorf("apply failed, status:\n%v", st) + } + } + + // wait for msgCh closed + wg.Wait() + // print summary + pterm.Fprintln(out, fmt.Sprintf("Apply complete! Resources: %d created, %d updated, %d deleted.", ls.created, ls.updated, ls.deleted)) + return nil +} + +// Watch function will observe the changes of each resource +// by the execution engine. +// +// Example: +// +// o := NewApplyOptions() +// kubernetesRuntime, err := runtime.NewKubernetesRuntime() +// if err != nil { +// return err +// } +// +// Watch(o, kubernetesRuntime, planResources, changes, os.Stdout) +// if err != nil { +// return err +// } +func Watch( + o *APIOptions, + planResources *apiv1.Spec, + changes *models.Changes, +) error { + if o.DryRun { + fmt.Println("NOTE: Watch doesn't work in DryRun mode") + return nil + } + + // filter out unchanged resources + toBeWatched := apiv1.Resources{} + for _, res := range planResources.Resources { + if changes.ChangeOrder.ChangeSteps[res.ResourceKey()].Action != models.UnChanged { + toBeWatched = append(toBeWatched, res) + } + } + + // watch operation + wo := &operation.WatchOperation{} + if err := wo.Watch(&operation.WatchRequest{ + Request: models.Request{ + Project: changes.Project(), + Stack: changes.Stack(), + Intent: &apiv1.Spec{Resources: toBeWatched}, + }, + }); err != nil { + return err + } + + fmt.Println("Watch Finish! All resources have been reconciled.") + return nil +} + +type lineSummary struct { + created, updated, deleted int +} + +func (ls *lineSummary) Count(op models.ActionType) { + switch op { + case models.Create: + ls.created++ + case models.Update: + ls.updated++ + case models.Delete: + ls.deleted++ + } +} diff --git a/pkg/engine/api/apply_test.go b/pkg/engine/api/apply_test.go new file mode 100644 index 00000000..2639e74f --- /dev/null +++ b/pkg/engine/api/apply_test.go @@ -0,0 +1,125 @@ +// Copyright 2024 KusionStack Authors +// +// 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. + +package api + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/bytedance/mockey" + "github.com/stretchr/testify/assert" + + apiv1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" + v1 "kusionstack.io/kusion/pkg/apis/status/v1" + "kusionstack.io/kusion/pkg/engine/operation" + "kusionstack.io/kusion/pkg/engine/operation/models" + statestorages "kusionstack.io/kusion/pkg/engine/state/storages" +) + +func TestApply(t *testing.T) { + stateStorage := statestorages.NewLocalStorage(filepath.Join("", "state.yaml")) + mockey.PatchConvey("dry run", t, func() { + planResources := &apiv1.Spec{Resources: []apiv1.Resource{sa1}} + order := &models.ChangeOrder{ + StepKeys: []string{sa1.ID}, + ChangeSteps: map[string]*models.ChangeStep{ + sa1.ID: { + ID: sa1.ID, + Action: models.Create, + From: sa1, + }, + }, + } + changes := models.NewChanges(proj, stack, order) + o := &APIOptions{} + o.DryRun = true + err := Apply(o, stateStorage, planResources, changes, os.Stdout) + assert.Nil(t, err) + }) + mockey.PatchConvey("apply success", t, func() { + mockOperationApply(models.Success) + o := &APIOptions{} + planResources := &apiv1.Spec{Resources: []apiv1.Resource{sa1, sa2}} + order := &models.ChangeOrder{ + StepKeys: []string{sa1.ID, sa2.ID}, + ChangeSteps: map[string]*models.ChangeStep{ + sa1.ID: { + ID: sa1.ID, + Action: models.Create, + From: &sa1, + }, + sa2.ID: { + ID: sa2.ID, + Action: models.UnChanged, + From: &sa2, + }, + }, + } + changes := models.NewChanges(proj, stack, order) + + err := Apply(o, stateStorage, planResources, changes, os.Stdout) + assert.Nil(t, err) + }) + mockey.PatchConvey("apply failed", t, func() { + mockOperationApply(models.Failed) + + o := &APIOptions{} + planResources := &apiv1.Spec{Resources: []apiv1.Resource{sa1}} + order := &models.ChangeOrder{ + StepKeys: []string{sa1.ID}, + ChangeSteps: map[string]*models.ChangeStep{ + sa1.ID: { + ID: sa1.ID, + Action: models.Create, + From: &sa1, + }, + }, + } + changes := models.NewChanges(proj, stack, order) + + err := Apply(o, stateStorage, planResources, changes, os.Stdout) + assert.NotNil(t, err) + }) +} + +func mockOperationApply(res models.OpResult) { + mockey.Mock((*operation.ApplyOperation).Apply).To( + func(o *operation.ApplyOperation, request *operation.ApplyRequest) (*operation.ApplyResponse, v1.Status) { + var err error + if res == models.Failed { + err = errors.New("mock error") + } + for _, r := range request.Intent.Resources { + // ing -> $res + o.MsgCh <- models.Message{ + ResourceID: r.ResourceKey(), + OpResult: "", + OpErr: nil, + } + o.MsgCh <- models.Message{ + ResourceID: r.ResourceKey(), + OpResult: res, + OpErr: err, + } + } + close(o.MsgCh) + if res == models.Failed { + return nil, v1.NewErrorStatus(err) + } + return &operation.ApplyResponse{}, nil + }).Build() +} diff --git a/pkg/cmd/generate/builders/appconfig_builder.go b/pkg/engine/api/builders/appconfig_builder.go similarity index 100% rename from pkg/cmd/generate/builders/appconfig_builder.go rename to pkg/engine/api/builders/appconfig_builder.go diff --git a/pkg/cmd/generate/builders/appconfig_builder_test.go b/pkg/engine/api/builders/appconfig_builder_test.go similarity index 100% rename from pkg/cmd/generate/builders/appconfig_builder_test.go rename to pkg/engine/api/builders/appconfig_builder_test.go diff --git a/pkg/cmd/generate/builders/testdata/kcl.mod b/pkg/engine/api/builders/testdata/kcl.mod similarity index 100% rename from pkg/cmd/generate/builders/testdata/kcl.mod rename to pkg/engine/api/builders/testdata/kcl.mod diff --git a/pkg/engine/api/destroy.go b/pkg/engine/api/destroy.go new file mode 100644 index 00000000..b3147f53 --- /dev/null +++ b/pkg/engine/api/destroy.go @@ -0,0 +1,162 @@ +package api + +import ( + "fmt" + "strings" + "sync" + + "github.com/pterm/pterm" + + apiv1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" + v1 "kusionstack.io/kusion/pkg/apis/status/v1" + "kusionstack.io/kusion/pkg/engine/operation" + "kusionstack.io/kusion/pkg/engine/operation/models" + "kusionstack.io/kusion/pkg/engine/runtime/terraform" + "kusionstack.io/kusion/pkg/engine/state" + "kusionstack.io/kusion/pkg/log" +) + +func DestroyPreview( + o *APIOptions, + planResources *apiv1.Spec, + proj *apiv1.Project, + stack *apiv1.Stack, + stateStorage state.Storage, +) (*models.Changes, error) { + log.Info("Start compute preview changes ...") + + // check and install terraform executable binary for + // resources with the type of Terraform. + tfInstaller := terraform.CLIInstaller{ + Intent: planResources, + } + if err := tfInstaller.CheckAndInstall(); err != nil { + return nil, err + } + + pc := &operation.PreviewOperation{ + Operation: models.Operation{ + OperationType: models.DestroyPreview, + Stack: stack, + StateStorage: stateStorage, + ChangeOrder: &models.ChangeOrder{StepKeys: []string{}, ChangeSteps: map[string]*models.ChangeStep{}}, + }, + } + + log.Info("Start call pc.Preview() ...") + + rsp, s := pc.Preview(&operation.PreviewRequest{ + Request: models.Request{ + Project: proj, + Stack: stack, + Operator: o.Operator, + Intent: planResources, + }, + }) + if v1.IsErr(s) { + return nil, fmt.Errorf("preview failed, status: %v", s) + } + + return models.NewChanges(proj, stack, rsp.Order), nil +} + +func Destroy( + o *APIOptions, + planResources *apiv1.Spec, + changes *models.Changes, + stateStorage state.Storage, +) error { + do := &operation.DestroyOperation{ + Operation: models.Operation{ + Stack: changes.Stack(), + StateStorage: stateStorage, + MsgCh: make(chan models.Message), + }, + } + + // line summary + var deleted int + + // progress bar, print dag walk detail + progressbar, err := pterm.DefaultProgressbar.WithTotal(len(changes.StepKeys)).Start() + if err != nil { + return err + } + // wait msgCh close + var wg sync.WaitGroup + // receive msg and print detail + go func() { + defer func() { + if p := recover(); p != nil { + log.Errorf("failed to receive msg and print detail as %v", p) + } + }() + wg.Add(1) + + for { + select { + case msg, ok := <-do.MsgCh: + if !ok { + wg.Done() + return + } + changeStep := changes.Get(msg.ResourceID) + + switch msg.OpResult { + case models.Success, models.Skip: + var title string + if changeStep.Action == models.UnChanged { + title = fmt.Sprintf("%s %s, %s", + changeStep.Action.String(), + pterm.Bold.Sprint(changeStep.ID), + strings.ToLower(string(models.Skip)), + ) + } else { + title = fmt.Sprintf("%s %s %s", + changeStep.Action.String(), + pterm.Bold.Sprint(changeStep.ID), + strings.ToLower(string(msg.OpResult)), + ) + } + pterm.Success.Println(title) + progressbar.UpdateTitle(title) + progressbar.Increment() + deleted++ + case models.Failed: + title := fmt.Sprintf("%s %s %s", + changeStep.Action.String(), + pterm.Bold.Sprint(changeStep.ID), + strings.ToLower(string(msg.OpResult)), + ) + pterm.Error.Printf("%s\n", title) + default: + title := fmt.Sprintf("%s %s %s", + changeStep.Action.Ing(), + pterm.Bold.Sprint(changeStep.ID), + strings.ToLower(string(msg.OpResult)), + ) + progressbar.UpdateTitle(title) + } + } + } + }() + + st := do.Destroy(&operation.DestroyRequest{ + Request: models.Request{ + Project: changes.Project(), + Stack: changes.Stack(), + Operator: o.Operator, + Intent: planResources, + }, + }) + if v1.IsErr(st) { + return fmt.Errorf("destroy failed, status: %v", st) + } + + // wait for msgCh closed + wg.Wait() + // print summary + pterm.Println() + pterm.Printf("Destroy complete! Resources: %d deleted.\n", deleted) + return nil +} diff --git a/pkg/engine/api/destroy_test.go b/pkg/engine/api/destroy_test.go new file mode 100644 index 00000000..b610bde4 --- /dev/null +++ b/pkg/engine/api/destroy_test.go @@ -0,0 +1,166 @@ +// Copyright 2024 KusionStack Authors +// +// 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. + +package api + +import ( + "context" + "errors" + "path/filepath" + "testing" + + "github.com/bytedance/mockey" + "github.com/stretchr/testify/assert" + + apiv1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" + v1 "kusionstack.io/kusion/pkg/apis/status/v1" + "kusionstack.io/kusion/pkg/engine/operation" + "kusionstack.io/kusion/pkg/engine/operation/models" + "kusionstack.io/kusion/pkg/engine/runtime" + "kusionstack.io/kusion/pkg/engine/runtime/kubernetes" + statestorages "kusionstack.io/kusion/pkg/engine/state/storages" +) + +func TestDestroyPreview(t *testing.T) { + stateStorage := statestorages.NewLocalStorage(filepath.Join("", "state.yaml")) + mockey.PatchConvey("preview success", t, func() { + mockNewKubernetesRuntime() + mockOperationPreview() + + o := &APIOptions{} + _, err := DestroyPreview(o, &apiv1.Spec{Resources: []apiv1.Resource{sa1}}, proj, stack, stateStorage) + assert.Nil(t, err) + }) +} + +func mockNewKubernetesRuntime() { + mockey.Mock(kubernetes.NewKubernetesRuntime).To(func() (runtime.Runtime, error) { + return &fakerRuntime{}, nil + }).Build() +} + +var _ runtime.Runtime = (*fakerRuntime)(nil) + +type fakerRuntime struct{} + +func (f *fakerRuntime) Import(_ context.Context, request *runtime.ImportRequest) *runtime.ImportResponse { + return &runtime.ImportResponse{Resource: request.PlanResource} +} + +func (f *fakerRuntime) Apply(_ context.Context, request *runtime.ApplyRequest) *runtime.ApplyResponse { + return &runtime.ApplyResponse{ + Resource: request.PlanResource, + Status: nil, + } +} + +func (f *fakerRuntime) Read(_ context.Context, request *runtime.ReadRequest) *runtime.ReadResponse { + if request.PlanResource.ResourceKey() == "fake-id" { + return &runtime.ReadResponse{ + Resource: nil, + Status: nil, + } + } + return &runtime.ReadResponse{ + Resource: request.PlanResource, + Status: nil, + } +} + +func (f *fakerRuntime) Delete(_ context.Context, _ *runtime.DeleteRequest) *runtime.DeleteResponse { + return nil +} + +func (f *fakerRuntime) Watch(_ context.Context, _ *runtime.WatchRequest) *runtime.WatchResponse { + return nil +} + +func TestDestroy(t *testing.T) { + stateStorage := statestorages.NewLocalStorage(filepath.Join("", "state.yaml")) + mockey.PatchConvey("destroy success", t, func() { + mockNewKubernetesRuntime() + mockOperationDestroy(models.Success) + + o := &APIOptions{} + planResources := &apiv1.Spec{Resources: []apiv1.Resource{sa2}} + order := &models.ChangeOrder{ + StepKeys: []string{sa1.ID, sa2.ID}, + ChangeSteps: map[string]*models.ChangeStep{ + sa1.ID: { + ID: sa1.ID, + Action: models.Delete, + From: nil, + }, + sa2.ID: { + ID: sa2.ID, + Action: models.UnChanged, + From: &sa2, + }, + }, + } + changes := models.NewChanges(proj, stack, order) + + err := Destroy(o, planResources, changes, stateStorage) + assert.Nil(t, err) + }) + mockey.PatchConvey("destroy failed", t, func() { + mockNewKubernetesRuntime() + mockOperationDestroy(models.Failed) + + o := &APIOptions{} + planResources := &apiv1.Spec{Resources: []apiv1.Resource{sa1}} + order := &models.ChangeOrder{ + StepKeys: []string{sa1.ID}, + ChangeSteps: map[string]*models.ChangeStep{ + sa1.ID: { + ID: sa1.ID, + Action: models.Delete, + From: nil, + }, + }, + } + changes := models.NewChanges(proj, stack, order) + + err := Destroy(o, planResources, changes, stateStorage) + assert.NotNil(t, err) + }) +} + +func mockOperationDestroy(res models.OpResult) { + mockey.Mock((*operation.DestroyOperation).Destroy).To( + func(o *operation.DestroyOperation, request *operation.DestroyRequest) v1.Status { + var err error + if res == models.Failed { + err = errors.New("mock error") + } + for _, r := range request.Intent.Resources { + // ing -> $res + o.MsgCh <- models.Message{ + ResourceID: r.ResourceKey(), + OpResult: "", + OpErr: nil, + } + o.MsgCh <- models.Message{ + ResourceID: r.ResourceKey(), + OpResult: res, + OpErr: err, + } + } + close(o.MsgCh) + if res == models.Failed { + return v1.NewErrorStatus(err) + } + return nil + }).Build() +} diff --git a/pkg/engine/api/generate.go b/pkg/engine/api/generate.go new file mode 100644 index 00000000..aeef57cc --- /dev/null +++ b/pkg/engine/api/generate.go @@ -0,0 +1,145 @@ +package api + +import ( + "bytes" + "fmt" + "io" + "os" + + "github.com/pterm/pterm" + yamlv3 "gopkg.in/yaml.v3" + + v1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" + "kusionstack.io/kusion/pkg/engine/api/generate/generator" + "kusionstack.io/kusion/pkg/engine/api/generate/run" + + // "kusionstack.io/kusion/pkg/engine/api/builders/kcl" + + "kusionstack.io/kusion/pkg/util/pretty" +) + +const JSONOutput = "json" + +// TODO: This switch logic may still be needed for KCL Builder +// func Intent(o *builders.Options, p *v1.Project, s *v1.Stack, ws *v1.Workspace) (*v1.Intent, error) { +// // Choose the generator +// var builder builders.Builder +// pg := p.Generator + +// // default AppsConfigBuilder +// var bt v1.BuilderType +// if pg == nil { +// bt = v1.AppConfigurationBuilder +// } else { +// bt = pg.Type +// } + +// // we can add more generators here +// switch bt { +// case v1.KCLBuilder: +// builder = &kcl.Builder{} +// case v1.AppConfigurationBuilder: +// appConfigs, err := buildAppConfigs(o, s) +// if err != nil { +// return nil, err +// } +// builder = &builders.AppsConfigBuilder{ +// Apps: appConfigs, +// Workspace: ws, +// } +// default: +// return nil, fmt.Errorf("unknow generator type:%s", bt) +// } + +// i, err := builder.Build(o, p, s) +// if err != nil { +// return nil, errors.New(stripansi.Strip(err.Error())) +// } +// return i, nil +// } + +// GenerateSpecWithSpinner calls generator to generate versioned Spec. Add a method wrapper for testing purposes. +func GenerateSpecWithSpinner(project *v1.Project, stack *v1.Stack, workspace *v1.Workspace, noStyle bool) (*v1.Spec, error) { + // Construct generator instance + defaultGenerator := &generator.DefaultGenerator{ + Project: project, + Stack: stack, + Workspace: workspace, + Runner: &run.KPMRunner{}, + } + + var sp *pterm.SpinnerPrinter + if noStyle { + fmt.Printf("Generating Spec in the Stack %s...\n", stack.Name) + } else { + sp = &pretty.SpinnerT + sp, _ = sp.Start(fmt.Sprintf("Generating Spec in the Stack %s...", stack.Name)) + } + + // style means color and prompt here. Currently, sp will be nil only when o.NoStyle is true + style := !noStyle && sp != nil + + versionedSpec, err := defaultGenerator.Generate(stack.Path, nil) + if err != nil { + if style { + sp.Fail() + return nil, err + } else { + return nil, err + } + } + + // success + if style { + sp.Success() + } else { + fmt.Println() + } + + return versionedSpec, nil +} + +func SpecFromFile(filePath string) (*v1.Spec, error) { + b, err := os.ReadFile(filePath) + if err != nil { + return nil, err + } + + // TODO: here we use decoder in yaml.v3 to parse resources because it converts + // map into map[string]interface{} by default which is inconsistent with yaml.v2. + // The use of yaml.v2 and yaml.v3 should be unified in the future. + decoder := yamlv3.NewDecoder(bytes.NewBuffer(b)) + decoder.KnownFields(true) + i := &v1.Spec{} + if err = decoder.Decode(i); err != nil && err != io.EOF { + return nil, fmt.Errorf("failed to parse the intent file, please check if the file content is valid") + } + return i, nil +} + +// func buildAppConfigs(o *builders.Options, stack *v1.Stack) (map[string]v1.AppConfiguration, error) { +// o.Arguments[kcl.IncludeSchemaTypePath] = "true" +// compileResult, err := kcl.Run(o, stack) +// if err != nil { +// return nil, err +// } + +// documents := compileResult.Documents +// if len(documents) == 0 { +// return nil, fmt.Errorf("no AppConfiguration is found in the compile result") +// } + +// out := documents[0].YAMLString() + +// log.Debugf("unmarshal %s to app configs", out) +// appConfigs := map[string]v1.AppConfiguration{} + +// // Note: we use the type of MapSlice in yaml.v2 to maintain the order of container +// // environment variables, thus we unmarshal appConfigs with yaml.v2 rather than yaml.v3. +// err = yaml.Unmarshal([]byte(out), appConfigs) +// if err != nil { +// return nil, err +// } + +// return appConfigs, nil +// } diff --git a/pkg/cmd/generate/generator/fake/fake.go b/pkg/engine/api/generate/generator/fake/fake.go similarity index 100% rename from pkg/cmd/generate/generator/fake/fake.go rename to pkg/engine/api/generate/generator/fake/fake.go diff --git a/pkg/cmd/generate/generator/generator.go b/pkg/engine/api/generate/generator/generator.go similarity index 97% rename from pkg/cmd/generate/generator/generator.go rename to pkg/engine/api/generate/generator/generator.go index 5ed5f5a7..d21c80a7 100644 --- a/pkg/cmd/generate/generator/generator.go +++ b/pkg/engine/api/generate/generator/generator.go @@ -28,8 +28,8 @@ import ( v1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" internalv1 "kusionstack.io/kusion/pkg/apis/internal.kusion.io/v1" - "kusionstack.io/kusion/pkg/cmd/generate/builders" - "kusionstack.io/kusion/pkg/cmd/generate/run" + "kusionstack.io/kusion/pkg/engine/api/builders" + "kusionstack.io/kusion/pkg/engine/api/generate/run" "kusionstack.io/kusion/pkg/util/io" "kusionstack.io/kusion/pkg/util/kfile" ) diff --git a/pkg/cmd/generate/run/fake/fake.go b/pkg/engine/api/generate/run/fake/fake.go similarity index 81% rename from pkg/cmd/generate/run/fake/fake.go rename to pkg/engine/api/generate/run/fake/fake.go index 56c27c96..b111dade 100644 --- a/pkg/cmd/generate/run/fake/fake.go +++ b/pkg/engine/api/generate/run/fake/fake.go @@ -1,6 +1,6 @@ package fake -import "kusionstack.io/kusion/pkg/cmd/generate/run" +import "kusionstack.io/kusion/pkg/engine/api/generate/run" var _ run.CodeRunner = &KPMRunner{} diff --git a/pkg/cmd/generate/run/run.go b/pkg/engine/api/generate/run/run.go similarity index 100% rename from pkg/cmd/generate/run/run.go rename to pkg/engine/api/generate/run/run.go diff --git a/pkg/cmd/generate/run/run_test.go b/pkg/engine/api/generate/run/run_test.go similarity index 100% rename from pkg/cmd/generate/run/run_test.go rename to pkg/engine/api/generate/run/run_test.go diff --git a/pkg/cmd/generate/run/testdata/base/base.k b/pkg/engine/api/generate/run/testdata/base/base.k similarity index 100% rename from pkg/cmd/generate/run/testdata/base/base.k rename to pkg/engine/api/generate/run/testdata/base/base.k diff --git a/pkg/cmd/generate/run/testdata/prod/kcl.mod b/pkg/engine/api/generate/run/testdata/prod/kcl.mod similarity index 100% rename from pkg/cmd/generate/run/testdata/prod/kcl.mod rename to pkg/engine/api/generate/run/testdata/prod/kcl.mod diff --git a/pkg/cmd/generate/run/testdata/prod/main.k b/pkg/engine/api/generate/run/testdata/prod/main.k similarity index 100% rename from pkg/cmd/generate/run/testdata/prod/main.k rename to pkg/engine/api/generate/run/testdata/prod/main.k diff --git a/pkg/cmd/generate/run/testdata/prod/stack.yaml b/pkg/engine/api/generate/run/testdata/prod/stack.yaml similarity index 100% rename from pkg/cmd/generate/run/testdata/prod/stack.yaml rename to pkg/engine/api/generate/run/testdata/prod/stack.yaml diff --git a/pkg/cmd/generate/run/testdata/project.yaml b/pkg/engine/api/generate/run/testdata/project.yaml similarity index 100% rename from pkg/cmd/generate/run/testdata/project.yaml rename to pkg/engine/api/generate/run/testdata/project.yaml diff --git a/pkg/engine/api/preview.go b/pkg/engine/api/preview.go new file mode 100644 index 00000000..5b56743e --- /dev/null +++ b/pkg/engine/api/preview.go @@ -0,0 +1,98 @@ +package api + +import ( + "fmt" + + apiv1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" + v1 "kusionstack.io/kusion/pkg/apis/status/v1" + "kusionstack.io/kusion/pkg/engine/operation" + opsmodels "kusionstack.io/kusion/pkg/engine/operation/models" + "kusionstack.io/kusion/pkg/engine/runtime/terraform" + "kusionstack.io/kusion/pkg/engine/state" + "kusionstack.io/kusion/pkg/log" +) + +type APIOptions struct { + Operator string + Cluster string + IgnoreFields []string + DryRun bool +} + +func NewAPIOptions() APIOptions { + apiOptions := APIOptions{ + // Operator: "operator", + // Cluster: "cluster", + // IgnoreFields: []string{}, + DryRun: false, + } + return apiOptions +} + +// The Preview function calculates the upcoming actions of each resource +// through the execution Kusion Engine, and you can customize the +// runtime of engine and the state storage through `runtime` and +// `storage` parameters. +// +// Example: +// +// o := NewPreviewOptions() +// stateStorage := &states.FileSystemState{ +// Path: filepath.Join(o.WorkDir, states.KusionState) +// } +// kubernetesRuntime, err := runtime.NewKubernetesRuntime() +// if err != nil { +// return err +// } +// +// changes, err := Preview(o, kubernetesRuntime, stateStorage, +// planResources, project, stack, os.Stdout) +// if err != nil { +// return err +// } +func Preview( + o *APIOptions, + storage state.Storage, + planResources *apiv1.Spec, + proj *apiv1.Project, + stack *apiv1.Stack, +) (*opsmodels.Changes, error) { + log.Info("Start compute preview changes ...") + + // check and install terraform executable binary for + // resources with the type of Terraform. + tfInstaller := terraform.CLIInstaller{ + Intent: planResources, + } + if err := tfInstaller.CheckAndInstall(); err != nil { + return nil, err + } + + // construct the preview operation + pc := &operation.PreviewOperation{ + Operation: opsmodels.Operation{ + OperationType: opsmodels.ApplyPreview, + Stack: stack, + StateStorage: storage, + IgnoreFields: o.IgnoreFields, + ChangeOrder: &opsmodels.ChangeOrder{StepKeys: []string{}, ChangeSteps: map[string]*opsmodels.ChangeStep{}}, + }, + } + + log.Info("Start call pc.Preview() ...") + + // parse cluster in arguments + rsp, s := pc.Preview(&operation.PreviewRequest{ + Request: opsmodels.Request{ + Project: proj, + Stack: stack, + Operator: o.Operator, + Intent: planResources, + }, + }) + if v1.IsErr(s) { + return nil, fmt.Errorf("preview failed.\n%s", s.String()) + } + + return opsmodels.NewChanges(proj, stack, rsp.Order), nil +} diff --git a/pkg/engine/api/preview_test.go b/pkg/engine/api/preview_test.go new file mode 100644 index 00000000..dd2ce974 --- /dev/null +++ b/pkg/engine/api/preview_test.go @@ -0,0 +1,104 @@ +// Copyright 2024 KusionStack Authors +// +// 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. + +package api + +import ( + "path/filepath" + "testing" + + "github.com/bytedance/mockey" + "github.com/stretchr/testify/assert" + + apiv1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" + v1 "kusionstack.io/kusion/pkg/apis/status/v1" + "kusionstack.io/kusion/pkg/engine" + "kusionstack.io/kusion/pkg/engine/operation" + "kusionstack.io/kusion/pkg/engine/operation/models" + statestorages "kusionstack.io/kusion/pkg/engine/state/storages" +) + +var ( + apiVersion = "v1" + kind = "ServiceAccount" + namespace = "test-ns" + + proj = &apiv1.Project{ + Name: "testdata", + } + stack = &apiv1.Stack{ + Name: "dev", + } + + sa1 = newSA("sa1") + sa2 = newSA("sa2") + sa3 = newSA("sa3") +) + +func TestPreview(t *testing.T) { + stateStorage := statestorages.NewLocalStorage(filepath.Join("", "state.yaml")) + t.Run("preview success", func(t *testing.T) { + m := mockOperationPreview() + defer m.UnPatch() + + o := &APIOptions{} + _, err := Preview(o, stateStorage, &apiv1.Spec{Resources: []apiv1.Resource{sa1, sa2, sa3}}, proj, stack) + assert.Nil(t, err) + }) +} + +func mockOperationPreview() *mockey.Mocker { + return mockey.Mock((*operation.PreviewOperation).Preview).To(func( + *operation.PreviewOperation, + *operation.PreviewRequest, + ) (rsp *operation.PreviewResponse, s v1.Status) { + return &operation.PreviewResponse{ + Order: &models.ChangeOrder{ + StepKeys: []string{sa1.ID, sa2.ID, sa3.ID}, + ChangeSteps: map[string]*models.ChangeStep{ + sa1.ID: { + ID: sa1.ID, + Action: models.Create, + From: &sa1, + }, + sa2.ID: { + ID: sa2.ID, + Action: models.UnChanged, + From: &sa2, + }, + sa3.ID: { + ID: sa3.ID, + Action: models.Undefined, + From: &sa1, + }, + }, + }, + }, nil + }).Build() +} + +func newSA(name string) apiv1.Resource { + return apiv1.Resource{ + ID: engine.BuildID(apiVersion, kind, namespace, name), + Type: "Kubernetes", + Attributes: map[string]interface{}{ + "apiVersion": apiVersion, + "kind": kind, + "metadata": map[string]interface{}{ + "name": name, + "namespace": namespace, + }, + }, + } +} diff --git a/pkg/engine/api/source/source.go b/pkg/engine/api/source/source.go new file mode 100644 index 00000000..2f0be4d0 --- /dev/null +++ b/pkg/engine/api/source/source.go @@ -0,0 +1,44 @@ +package source + +import ( + "context" + "os" + + "kusionstack.io/kusion/pkg/domain/constant" + "kusionstack.io/kusion/pkg/domain/entity" + sp "kusionstack.io/kusion/pkg/domain/entity/source_providers" + "kusionstack.io/kusion/pkg/server/util" +) + +// Pull() is a method that pulls the source code from the git source provider. +func Pull(ctx context.Context, source *entity.Source) (string, error) { + if source == nil { + return "", constant.ErrSourceNil + } + + // Create a new GitSourceProvider with the remote URL and pulls into /tmp directory. + localDirectory, err := os.MkdirTemp("/tmp", "kcp-kusion-") + if err != nil { + return "", err + } + gsp := sp.NewGitSourceProvider(source.Remote.String(), localDirectory, "") + + // Call the Get() method of the source provider to pull the source code. + directory, err := gsp.Get(ctx, entity.WithType(constant.SourceProviderTypeGit)) + if err != nil { + return "", err + } + return directory, nil +} + +// Cleanup() is a method that cleans up the temporary source code from the source provider. +func Cleanup(ctx context.Context, localDirectory string) { + logger := util.GetLogger(ctx) + logger.Info("Cleaning up temp directory...") + if localDirectory == "" { + return + } + gsp := sp.NewGitSourceProvider("", localDirectory, "") + // Call the Cleanup() method of the source provider to clean up the source code. + gsp.Cleanup(ctx) +} diff --git a/pkg/engine/operation/models/change.go b/pkg/engine/operation/models/change.go index 80745112..bf0adb17 100644 --- a/pkg/engine/operation/models/change.go +++ b/pkg/engine/operation/models/change.go @@ -28,7 +28,61 @@ type ChangeStep struct { // Diff compares objects(from and to) which stores in ChangeStep, // and return a human-readable string report. -func (cs *ChangeStep) Diff() (string, error) { +func (cs *ChangeStep) Diff(noStyle bool) (string, error) { + // Generate diff report + diffReport, err := diff.ToReport(cs.From, cs.To) + if err != nil { + log.Errorf("failed to compute diff with ChangeStep ID: %s", cs.ID) + return "", err + } + + reportString, err := diff.ToHumanString(diff.NewHumanReport(diffReport)) + if err != nil { + log.Warn("diff to string error: %v", err) + return "", err + } + + buf := bytes.NewBufferString("") + + if noStyle { + if len(cs.ID) != 0 { + buf.WriteString("ID: ") + buf.WriteString(fmt.Sprintf("%s\n", cs.ID)) + } + if cs.Action != Undefined { + buf.WriteString("Plan: ") + buf.WriteString(fmt.Sprintf("%s\n", cs.Action.String())) + } + buf.WriteString("Diff: ") + if len(strings.TrimSpace(reportString)) == 0 && cs.Action == UnChanged { + buf.WriteString("") + } else { + // TODO: reportString is formatted with color, need to remove color eventually + buf.WriteString("\n" + strings.TrimSpace(reportString)) + } + } else { + if len(cs.ID) != 0 { + buf.WriteString(pretty.GreenBold("ID: ")) + buf.WriteString(pretty.Green("%s\n", cs.ID)) + } + if cs.Action != Undefined { + buf.WriteString(pretty.GreenBold("Plan: ")) + buf.WriteString(pterm.Sprintf("%s\n", cs.Action.PrettyString())) + } + buf.WriteString(pretty.GreenBold("Diff: ")) + if len(strings.TrimSpace(reportString)) == 0 && cs.Action == UnChanged { + buf.WriteString(pretty.Gray("")) + } else { + buf.WriteString("\n" + strings.TrimSpace(reportString)) + } + } + buf.WriteString("\n") + return buf.String(), nil +} + +// NoStyleDiff compares objects(from and to) which stores in ChangeStep, +// and return a string report with no style +func (cs *ChangeStep) NoStyleDiff() (string, error) { // Generate diff report diffReport, err := diff.ToReport(cs.From, cs.To) if err != nil { @@ -45,16 +99,16 @@ func (cs *ChangeStep) Diff() (string, error) { buf := bytes.NewBufferString("") if len(cs.ID) != 0 { - buf.WriteString(pretty.GreenBold("ID: ")) - buf.WriteString(pretty.Green("%s\n", cs.ID)) + buf.WriteString("ID: ") + buf.WriteString(cs.ID + "\n") } if cs.Action != Undefined { - buf.WriteString(pretty.GreenBold("Plan: ")) - buf.WriteString(pterm.Sprintf("%s\n", cs.Action.PrettyString())) + buf.WriteString("Plan: ") + buf.WriteString(cs.Action.String() + "\n") } - buf.WriteString(pretty.GreenBold("Diff: ")) + buf.WriteString("Diff: ") if len(strings.TrimSpace(reportString)) == 0 && cs.Action == UnChanged { - buf.WriteString(pretty.Gray("")) + buf.WriteString("") } else { buf.WriteString("\n" + strings.TrimSpace(reportString)) } @@ -135,13 +189,15 @@ func (p *Changes) Project() *v1.Project { return p.project } -func (o *ChangeOrder) Diffs() string { +func (o *ChangeOrder) Diffs(noStyle bool) string { buf := bytes.NewBufferString("") + var diffString string + var err error for _, key := range o.StepKeys { step := o.ChangeSteps[key] // Generate diff report - diffString, err := step.Diff() + diffString, err = step.Diff(noStyle) if err != nil { log.Errorf("failed to generate diff string with ChangeStep ID: %s", step.ID) continue @@ -162,12 +218,17 @@ func (p *Changes) AllUnChange() bool { return true } -func (p *Changes) Summary(writer io.Writer) { +func (p *Changes) Summary(writer io.Writer, noStyle bool) { // Create a fork of the default table, fill it with data and print it. // Data can also be generated and inserted later. tableHeader := []string{fmt.Sprintf("Stack: %s\nID", p.stack.Name), "\nAction"} tableData := pterm.TableData{tableHeader} + if noStyle { + pterm.DisableStyling() + pterm.DisableColor() + } + for _, step := range p.Values() { tableData = append(tableData, []string{step.ID, step.Action.String()}) } @@ -216,11 +277,11 @@ func (o *ChangeOrder) PromptDetails() (string, error) { func (o *ChangeOrder) OutputDiff(target string) { switch target { case "all": - fmt.Println(o.Diffs()) + fmt.Println(o.Diffs(false)) default: rinID := target if cs, ok := o.ChangeSteps[rinID]; ok { - diffString, err := cs.Diff() + diffString, err := cs.Diff(false) if err != nil { log.Error("failed to output specify diff with rinID: %s, err: %v", rinID, err) } diff --git a/pkg/engine/operation/models/change_test.go b/pkg/engine/operation/models/change_test.go index c9996092..24132ccb 100644 --- a/pkg/engine/operation/models/change_test.go +++ b/pkg/engine/operation/models/change_test.go @@ -133,7 +133,7 @@ func TestChangeStep_Diff(t *testing.T) { Action: tt.fields.Op, From: tt.fields.New, } - got, err := cs.Diff() + got, err := cs.Diff(false) if (err != nil) != tt.wantErr { t.Errorf("ChangeStep.Diff() error = %v, wantErr %v", err, tt.wantErr) return @@ -382,7 +382,7 @@ func TestChanges_Diffs(t *testing.T) { project: tt.fields.project, stack: tt.fields.stack, } - if got := p.Diffs(); got != tt.want { + if got := p.Diffs(false); got != tt.want { t.Errorf("Changes.Diffs() = %v, want %v", got, tt.want) } }) @@ -421,7 +421,7 @@ func TestChanges_Preview(t *testing.T) { project: tt.fields.project, stack: tt.fields.stack, } - p.Summary(os.Stdout) + p.Summary(os.Stdout, false) }) } } diff --git a/pkg/infra/persistence/backend.go b/pkg/infra/persistence/backend.go new file mode 100644 index 00000000..38287926 --- /dev/null +++ b/pkg/infra/persistence/backend.go @@ -0,0 +1,123 @@ +package persistence + +import ( + "context" + + "kusionstack.io/kusion/pkg/domain/entity" + "kusionstack.io/kusion/pkg/domain/repository" + + "gorm.io/gorm" +) + +// The backendRepository type implements the repository.BackendRepository interface. +// If the backendRepository type does not implement all the methods of the interface, +// the compiler will produce an error. +var _ repository.BackendRepository = &backendRepository{} + +// backendRepository is a repository that stores backends in a gorm database. +type backendRepository struct { + // db is the underlying gorm database where backends are stored. + db *gorm.DB +} + +// NewBackendRepository creates a new backend repository. +func NewBackendRepository(db *gorm.DB) repository.BackendRepository { + return &backendRepository{db: db} +} + +// Create saves a backend to the repository. +func (r *backendRepository) Create(ctx context.Context, dataEntity *entity.Backend) error { + // r.db.AutoMigrate(&BackendModel{}) + err := dataEntity.Validate() + if err != nil { + return err + } + + // Map the data from Entity to DO + var dataModel BackendModel + err = dataModel.FromEntity(dataEntity) + if err != nil { + return err + } + + return r.db.Transaction(func(tx *gorm.DB) error { + // Create new record in the store + err = tx.WithContext(ctx).Create(&dataModel).Error + if err != nil { + return err + } + + dataEntity.ID = dataModel.ID + + return nil + }) +} + +// Delete removes a backend from the repository. +func (r *backendRepository) Delete(ctx context.Context, id uint) error { + return r.db.Transaction(func(tx *gorm.DB) error { + var dataModel BackendModel + err := tx.WithContext(ctx).First(&dataModel, id).Error + if err != nil { + return err + } + + return tx.WithContext(ctx).Delete(&dataModel).Error + }) +} + +// Update updates an existing backend in the repository. +func (r *backendRepository) Update(ctx context.Context, dataEntity *entity.Backend) error { + // Map the data from Entity to DO + var dataModel BackendModel + err := dataModel.FromEntity(dataEntity) + if err != nil { + return err + } + + err = r.db.WithContext(ctx).Updates(&dataModel).Error + if err != nil { + return err + } + + return nil +} + +// Get retrieves a backend by its ID. +func (r *backendRepository) Get(ctx context.Context, id uint) (*entity.Backend, error) { + var dataModel BackendModel + err := r.db.WithContext(ctx).First(&dataModel, id).Error + if err != nil { + return nil, err + } + + return dataModel.ToEntity() +} + +// GetByName retrieves a backend by its name. +func (r *backendRepository) GetByName(ctx context.Context, name string) (*entity.Backend, error) { + var dataModel BackendModel + err := r.db.WithContext(ctx).Where("name = ?", name).First(&dataModel).Error + if err != nil { + return nil, err + } + return dataModel.ToEntity() +} + +// List retrieves all backends. +func (r *backendRepository) List(ctx context.Context) ([]*entity.Backend, error) { + var dataModel []BackendModel + backendEntityList := make([]*entity.Backend, 0) + result := r.db.WithContext(ctx).Find(&dataModel) + if result.Error != nil { + return nil, result.Error + } + for _, backend := range dataModel { + backendEntity, err := backend.ToEntity() + if err != nil { + return nil, err + } + backendEntityList = append(backendEntityList, backendEntity) + } + return backendEntityList, nil +} diff --git a/pkg/infra/persistence/backend_model.go b/pkg/infra/persistence/backend_model.go new file mode 100644 index 00000000..2119d012 --- /dev/null +++ b/pkg/infra/persistence/backend_model.go @@ -0,0 +1,74 @@ +package persistence + +import ( + v1 "kusionstack.io/kusion/pkg/apis/internal.kusion.io/v1" + "kusionstack.io/kusion/pkg/domain/entity" + + "gorm.io/gorm" +) + +// type KusionBackend v1.BackendConfig + +// func (b *KusionBackend) Value() (driver.Value, error) { +// if b == nil { +// return nil, nil +// } +// return json.Marshal(b) +// } + +// func (b *KusionBackend) Scan(value interface{}) error { +// bytes, ok := value.([]byte) +// if !ok { +// return errors.New(fmt.Sprint("Failed to unmarshal KusionBackend value:", value)) +// } + +// return json.Unmarshal(bytes, b) +// } + +// BackendModel is a DO used to map the entity to the database. +type BackendModel struct { + gorm.Model + Name string `gorm:"index:unique_backend,unique"` + Type string `gorm:"index:unique_backend,unique"` + Description string + Labels MultiString + Owners MultiString + BackendConfig v1.BackendConfig `gorm:"serializer:json" json:"backendConfig"` +} + +// The TableName method returns the name of the database table that the struct is mapped to. +func (m *BackendModel) TableName() string { + return "backend" +} + +// ToEntity converts the DO to an entity. +func (m *BackendModel) ToEntity() (*entity.Backend, error) { + if m == nil { + return nil, ErrBackendModelNil + } + + return &entity.Backend{ + ID: m.ID, + Name: m.Name, + Description: m.Description, + CreationTimestamp: m.CreatedAt, + UpdateTimestamp: m.UpdatedAt, + BackendConfig: m.BackendConfig, + }, nil +} + +// FromEntity converts an entity to a DO. +func (m *BackendModel) FromEntity(e *entity.Backend) error { + if m == nil { + return ErrBackendModelNil + } + + m.ID = e.ID + m.Name = e.Name + m.Description = e.Description + m.CreatedAt = e.CreationTimestamp + m.UpdatedAt = e.UpdateTimestamp + m.BackendConfig = e.BackendConfig + + return nil +} diff --git a/pkg/infra/persistence/backend_test.go b/pkg/infra/persistence/backend_test.go new file mode 100644 index 00000000..9625b6e4 --- /dev/null +++ b/pkg/infra/persistence/backend_test.go @@ -0,0 +1,152 @@ +package persistence + +import ( + "context" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + v1 "kusionstack.io/kusion/pkg/apis/internal.kusion.io/v1" + "kusionstack.io/kusion/pkg/domain/entity" +) + +func TestBackendRepository(t *testing.T) { + t.Run("Create", func(t *testing.T) { + fakeGDB, sqlMock, err := GetMockDB() + require.NoError(t, err) + repo := NewBackendRepository(fakeGDB) + defer CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + var ( + expectedID, expectedRows uint = 1, 1 + actual = entity.Backend{ + Name: "mockedBackend", + BackendConfig: v1.BackendConfig{ + Type: v1.BackendTypeS3, + Configs: map[string]any{ + "accessKeyID": "mockedAccessKeyID", + "secretKeyID": "mockedSecretKeyID", + }, + }, + } + ) + sqlMock.ExpectBegin() + sqlMock.ExpectExec("INSERT"). + WillReturnResult(sqlmock.NewResult(int64(expectedID), int64(expectedRows))) + sqlMock.ExpectCommit() + err = repo.Create(context.Background(), &actual) + require.NoError(t, err) + require.Equal(t, expectedID, actual.ID) + }) + + t.Run("Delete existing record", func(t *testing.T) { + fakeGDB, sqlMock, err := GetMockDB() + require.NoError(t, err) + repo := NewBackendRepository(fakeGDB) + defer CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + var expectedID, expectedRows uint = 1, 1 + sqlMock.ExpectBegin() + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id"}). + AddRow(1)) + sqlMock.ExpectExec("UPDATE"). + WillReturnResult(sqlmock.NewResult(int64(expectedID), int64(expectedRows))) + sqlMock.ExpectCommit() + err = repo.Delete(context.Background(), expectedID) + require.NoError(t, err) + }) + + t.Run("Delete not existing record", func(t *testing.T) { + fakeGDB, sqlMock, err := GetMockDB() + require.NoError(t, err) + repo := NewBackendRepository(fakeGDB) + defer CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + sqlMock.ExpectBegin() + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id"})) + err = repo.Delete(context.Background(), 1) + require.ErrorIs(t, err, gorm.ErrRecordNotFound) + }) + + t.Run("Update existing record", func(t *testing.T) { + fakeGDB, sqlMock, err := GetMockDB() + require.NoError(t, err) + repo := NewBackendRepository(fakeGDB) + + var ( + expectedID, expectedRows uint = 1, 1 + actual = entity.Backend{ + ID: 1, + } + ) + sqlMock.ExpectExec("UPDATE"). + WillReturnResult(sqlmock.NewResult(int64(expectedID), int64(expectedRows))) + err = repo.Update(context.Background(), &actual) + require.NoError(t, err) + }) + + t.Run("Update not existing record", func(t *testing.T) { + fakeGDB, sqlMock, err := GetMockDB() + require.NoError(t, err) + repo := NewBackendRepository(fakeGDB) + defer CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + actual := entity.Backend{ + Name: "NonExistentBackend", + } + err = repo.Update(context.Background(), &actual) + require.ErrorIs(t, err, gorm.ErrMissingWhereClause) + }) + + t.Run("Get", func(t *testing.T) { + fakeGDB, sqlMock, err := GetMockDB() + require.NoError(t, err) + repo := NewBackendRepository(fakeGDB) + defer CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + var ( + expectedID uint = 1 + expectedName = "mockedBackend" + ) + sqlMock.ExpectQuery("SELECT .* FROM `backend`"). + WillReturnRows(sqlmock.NewRows([]string{"id", "name"}). + AddRow(expectedID, expectedName)) + + actual, err := repo.Get(context.Background(), expectedID) + require.NoError(t, err) + require.Equal(t, expectedID, actual.ID) + require.Equal(t, expectedName, actual.Name) + }) + + t.Run("List", func(t *testing.T) { + fakeGDB, sqlMock, err := GetMockDB() + require.NoError(t, err) + repo := NewBackendRepository(fakeGDB) + defer CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + var ( + expectedIDFirst uint = 1 + expectedNameFirst = "mockedBackend" + expectedIDSecond uint = 2 + expectedNameSecond = "mockedBackend2" + ) + sqlMock.ExpectQuery("SELECT .* FROM `backend`"). + WillReturnRows( + sqlmock.NewRows([]string{"id", "name"}). + AddRow(expectedIDFirst, expectedNameFirst). + AddRow(expectedIDSecond, expectedNameSecond)) + + actual, err := repo.List(context.Background()) + require.NoError(t, err) + require.Len(t, actual, 2) + }) +} diff --git a/pkg/infra/persistence/organization.go b/pkg/infra/persistence/organization.go new file mode 100644 index 00000000..b3aa31c3 --- /dev/null +++ b/pkg/infra/persistence/organization.go @@ -0,0 +1,123 @@ +package persistence + +import ( + "context" + + "kusionstack.io/kusion/pkg/domain/entity" + "kusionstack.io/kusion/pkg/domain/repository" + + "gorm.io/gorm" +) + +// The organizationRepository type implements the repository.OrganizationRepository interface. +// If the organizationRepository type does not implement all the methods of the interface, +// the compiler will produce an error. +var _ repository.OrganizationRepository = &organizationRepository{} + +// organizationRepository is a repository that stores organizations in a gorm database. +type organizationRepository struct { + // db is the underlying gorm database where organizations are stored. + db *gorm.DB +} + +// NewOrganizationRepository creates a new organization repository. +func NewOrganizationRepository(db *gorm.DB) repository.OrganizationRepository { + return &organizationRepository{db: db} +} + +// Create saves a organization to the repository. +func (r *organizationRepository) Create(ctx context.Context, dataEntity *entity.Organization) error { + // r.db.AutoMigrate(&OrganizationModel{}) + err := dataEntity.Validate() + if err != nil { + return err + } + + // Map the data from Entity to DO + var dataModel OrganizationModel + err = dataModel.FromEntity(dataEntity) + if err != nil { + return err + } + + return r.db.Transaction(func(tx *gorm.DB) error { + // Create new record in the store + err = tx.WithContext(ctx).Create(&dataModel).Error + if err != nil { + return err + } + + dataEntity.ID = dataModel.ID + + return nil + }) +} + +// Delete removes a organization from the repository. +func (r *organizationRepository) Delete(ctx context.Context, id uint) error { + return r.db.Transaction(func(tx *gorm.DB) error { + var dataModel OrganizationModel + err := tx.WithContext(ctx).First(&dataModel, id).Error + if err != nil { + return err + } + + return tx.WithContext(ctx).Delete(&dataModel).Error + }) +} + +// Update updates an existing organization in the repository. +func (r *organizationRepository) Update(ctx context.Context, dataEntity *entity.Organization) error { + // Map the data from Entity to DO + var dataModel OrganizationModel + err := dataModel.FromEntity(dataEntity) + if err != nil { + return err + } + + err = r.db.WithContext(ctx).Updates(&dataModel).Error + if err != nil { + return err + } + + return nil +} + +// Get retrieves a organization by its ID. +func (r *organizationRepository) Get(ctx context.Context, id uint) (*entity.Organization, error) { + var dataModel OrganizationModel + err := r.db.WithContext(ctx).First(&dataModel, id).Error + if err != nil { + return nil, err + } + + return dataModel.ToEntity() +} + +// // GetByRemote retrieves a organization by its remote. +// func (r *organizationRepository) GetByRemote(ctx context.Context, remote string) (*entity.Organization, error) { +// var dataModel OrganizationModel +// err := r.db.WithContext(ctx).Where("remote = ?", remote).First(&dataModel).Error +// if err != nil { +// return nil, err +// } +// return dataModel.ToEntity() +// } + +// List retrieves all organizations. +func (r *organizationRepository) List(ctx context.Context) ([]*entity.Organization, error) { + var dataModel []OrganizationModel + organizationEntityList := make([]*entity.Organization, 0) + result := r.db.WithContext(ctx).Find(&dataModel) + if result.Error != nil { + return nil, result.Error + } + for _, organization := range dataModel { + organizationEntity, err := organization.ToEntity() + if err != nil { + return nil, err + } + organizationEntityList = append(organizationEntityList, organizationEntity) + } + return organizationEntityList, nil +} diff --git a/pkg/infra/persistence/organization_model.go b/pkg/infra/persistence/organization_model.go new file mode 100644 index 00000000..89690622 --- /dev/null +++ b/pkg/infra/persistence/organization_model.go @@ -0,0 +1,72 @@ +package persistence + +import ( + "kusionstack.io/kusion/pkg/domain/entity" + + "gorm.io/gorm" +) + +// OrganizationModel is a DO used to map the entity to the database. +type OrganizationModel struct { + gorm.Model + Name string `gorm:"index:unique_org,unique"` + Description string + Labels MultiString + Owners MultiString +} + +// The TableName method returns the name of the database table that the struct is mapped to. +func (m *OrganizationModel) TableName() string { + return "organization" +} + +// ToEntity converts the DO to an entity. +func (m *OrganizationModel) ToEntity() (*entity.Organization, error) { + if m == nil { + return nil, ErrOrganizationModelNil + } + + return &entity.Organization{ + ID: m.ID, + Name: m.Name, + Description: m.Description, + Labels: []string(m.Labels), + Owners: []string(m.Owners), + CreationTimestamp: m.CreatedAt, + UpdateTimestamp: m.UpdatedAt, + }, nil +} + +// ToEntity converts the DO to an entity. +func (m *OrganizationModel) ToEntityWithSource(sourceEntity *entity.Source) (*entity.Organization, error) { + if m == nil { + return nil, ErrOrganizationModelNil + } + + return &entity.Organization{ + ID: m.ID, + Name: m.Name, + Description: m.Description, + Labels: []string(m.Labels), + Owners: []string(m.Owners), + CreationTimestamp: m.CreatedAt, + UpdateTimestamp: m.UpdatedAt, + }, nil +} + +// FromEntity converts an entity to a DO. +func (m *OrganizationModel) FromEntity(e *entity.Organization) error { + if m == nil { + return ErrOrganizationModelNil + } + + m.ID = e.ID + m.Name = e.Name + m.Description = e.Description + m.Labels = MultiString(e.Labels) + m.Owners = MultiString(e.Owners) + m.CreatedAt = e.CreationTimestamp + m.UpdatedAt = e.UpdateTimestamp + + return nil +} diff --git a/pkg/infra/persistence/organization_test.go b/pkg/infra/persistence/organization_test.go new file mode 100644 index 00000000..06c8b0e5 --- /dev/null +++ b/pkg/infra/persistence/organization_test.go @@ -0,0 +1,149 @@ +package persistence + +import ( + "context" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + "kusionstack.io/kusion/pkg/domain/entity" +) + +func TestOrganizationRepository(t *testing.T) { + t.Run("Create", func(t *testing.T) { + fakeGDB, sqlMock, err := GetMockDB() + require.NoError(t, err) + repo := NewOrganizationRepository(fakeGDB) + defer CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + var ( + expectedID, expectedRows uint = 1, 1 + actual = entity.Organization{ + Name: "mockedOrganization", + DisplayName: "mockedDisplayName", + Owners: []string{"hua.li", "xiaoming.li"}, + } + ) + sqlMock.ExpectBegin() + sqlMock.ExpectExec("INSERT"). + WillReturnResult(sqlmock.NewResult(int64(expectedID), int64(expectedRows))) + sqlMock.ExpectCommit() + err = repo.Create(context.Background(), &actual) + require.NoError(t, err) + require.Equal(t, expectedID, actual.ID) + }) + + t.Run("Delete existing record", func(t *testing.T) { + fakeGDB, sqlMock, err := GetMockDB() + require.NoError(t, err) + repo := NewOrganizationRepository(fakeGDB) + defer CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + var expectedID, expectedRows uint = 1, 1 + sqlMock.ExpectBegin() + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id"}). + AddRow(1)) + sqlMock.ExpectExec("UPDATE"). + WillReturnResult(sqlmock.NewResult(int64(expectedID), int64(expectedRows))) + sqlMock.ExpectCommit() + err = repo.Delete(context.Background(), expectedID) + require.NoError(t, err) + }) + + t.Run("Delete not existing record", func(t *testing.T) { + fakeGDB, sqlMock, err := GetMockDB() + require.NoError(t, err) + repo := NewOrganizationRepository(fakeGDB) + defer CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + sqlMock.ExpectBegin() + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id"})) + err = repo.Delete(context.Background(), 1) + require.ErrorIs(t, err, gorm.ErrRecordNotFound) + }) + + t.Run("Update existing record", func(t *testing.T) { + fakeGDB, sqlMock, err := GetMockDB() + require.NoError(t, err) + repo := NewOrganizationRepository(fakeGDB) + + var ( + expectedID, expectedRows uint = 1, 1 + actual = entity.Organization{ + ID: 1, + } + ) + sqlMock.ExpectExec("UPDATE"). + WillReturnResult(sqlmock.NewResult(int64(expectedID), int64(expectedRows))) + err = repo.Update(context.Background(), &actual) + require.NoError(t, err) + }) + + t.Run("Update not existing record", func(t *testing.T) { + fakeGDB, sqlMock, err := GetMockDB() + require.NoError(t, err) + repo := NewOrganizationRepository(fakeGDB) + defer CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + actual := entity.Organization{ + Name: "NonExistentOrganization", + } + err = repo.Update(context.Background(), &actual) + require.ErrorIs(t, err, gorm.ErrMissingWhereClause) + }) + + t.Run("Get", func(t *testing.T) { + fakeGDB, sqlMock, err := GetMockDB() + require.NoError(t, err) + repo := NewOrganizationRepository(fakeGDB) + defer CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + var ( + expectedID uint = 1 + expectedName = "mockedOrganization" + expectedDisplayName = "mockedDisplayName" + ) + sqlMock.ExpectQuery("SELECT .* FROM `organization`"). + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "display_name"}). + AddRow(expectedID, expectedName, expectedDisplayName)) + + actual, err := repo.Get(context.Background(), expectedID) + require.NoError(t, err) + require.Equal(t, expectedID, actual.ID) + require.Equal(t, expectedName, actual.Name) + }) + + t.Run("List", func(t *testing.T) { + fakeGDB, sqlMock, err := GetMockDB() + require.NoError(t, err) + repo := NewOrganizationRepository(fakeGDB) + defer CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + var ( + expectedIDFirst uint = 1 + expectedNameFirst = "mockedOrganization" + expectedDisplayNameFirst = "mockedDisplayName" + expectedIDSecond uint = 2 + expectedNameSecond = "mockedOrganization2" + expectedDisplayNameSecond = "mockedDisplayName2" + ) + sqlMock.ExpectQuery("SELECT .* FROM `organization`"). + WillReturnRows( + sqlmock.NewRows([]string{"id", "name", "display_name"}). + AddRow(expectedIDFirst, expectedNameFirst, expectedDisplayNameFirst). + AddRow(expectedIDSecond, expectedNameSecond, expectedDisplayNameSecond)) + + actual, err := repo.List(context.Background()) + require.NoError(t, err) + require.Len(t, actual, 2) + }) +} diff --git a/pkg/infra/persistence/project.go b/pkg/infra/persistence/project.go new file mode 100644 index 00000000..eb1a312e --- /dev/null +++ b/pkg/infra/persistence/project.go @@ -0,0 +1,126 @@ +package persistence + +import ( + "context" + + "kusionstack.io/kusion/pkg/domain/entity" + "kusionstack.io/kusion/pkg/domain/repository" + + "gorm.io/gorm" +) + +// The projectRepository type implements the repository.ProjectRepository interface. +// If the projectRepository type does not implement all the methods of the interface, +// the compiler will produce an error. +var _ repository.ProjectRepository = &projectRepository{} + +// projectRepository is a repository that stores projects in a gorm database. +type projectRepository struct { + // db is the underlying gorm database where projects are stored. + db *gorm.DB +} + +// NewProjectRepository creates a new project repository. +func NewProjectRepository(db *gorm.DB) repository.ProjectRepository { + return &projectRepository{db: db} +} + +// Create saves a project to the repository. +func (r *projectRepository) Create(ctx context.Context, dataEntity *entity.Project) error { + // r.db.AutoMigrate(&ProjectModel{}) + err := dataEntity.Validate() + if err != nil { + return err + } + + // Map the data from Entity to DO + var dataModel ProjectModel + err = dataModel.FromEntity(dataEntity) + if err != nil { + return err + } + + return r.db.Transaction(func(tx *gorm.DB) error { + // Create new record in the store + err = tx.WithContext(ctx).Create(&dataModel).Error + if err != nil { + return err + } + + dataEntity.ID = dataModel.ID + + return nil + }) +} + +// Delete removes a project from the repository. +func (r *projectRepository) Delete(ctx context.Context, id uint) error { + return r.db.Transaction(func(tx *gorm.DB) error { + var dataModel ProjectModel + err := tx.WithContext(ctx).First(&dataModel, id).Error + if err != nil { + return err + } + + return tx.WithContext(ctx).Delete(&dataModel).Error + }) +} + +// Update updates an existing project in the repository. +func (r *projectRepository) Update(ctx context.Context, dataEntity *entity.Project) error { + // Map the data from Entity to DO + var dataModel ProjectModel + err := dataModel.FromEntity(dataEntity) + if err != nil { + return err + } + + err = r.db.WithContext(ctx).Updates(&dataModel).Error + if err != nil { + return err + } + + return nil +} + +// Get retrieves a project by its ID. +func (r *projectRepository) Get(ctx context.Context, id uint) (*entity.Project, error) { + var dataModel ProjectModel + err := r.db.WithContext(ctx). + Preload("Source"). + Preload("Organization"). + First(&dataModel, id).Error + if err != nil { + return nil, err + } + + return dataModel.ToEntity() +} + +// // GetByRemote retrieves a project by its remote. +// func (r *projectRepository) GetByRemote(ctx context.Context, remote string) (*entity.Project, error) { +// var dataModel ProjectModel +// err := r.db.WithContext(ctx).Where("remote = ?", remote).First(&dataModel).Error +// if err != nil { +// return nil, err +// } +// return dataModel.ToEntity() +// } + +// List retrieves all projects. +func (r *projectRepository) List(ctx context.Context) ([]*entity.Project, error) { + var dataModel []ProjectModel + projectEntityList := make([]*entity.Project, 0) + result := r.db.WithContext(ctx).Preload("Source").Preload("Organization").Find(&dataModel) + if result.Error != nil { + return nil, result.Error + } + for _, project := range dataModel { + projectEntity, err := project.ToEntity() + if err != nil { + return nil, err + } + projectEntityList = append(projectEntityList, projectEntity) + } + return projectEntityList, nil +} diff --git a/pkg/infra/persistence/project_model.go b/pkg/infra/persistence/project_model.go new file mode 100644 index 00000000..0fa01878 --- /dev/null +++ b/pkg/infra/persistence/project_model.go @@ -0,0 +1,102 @@ +package persistence + +import ( + "kusionstack.io/kusion/pkg/domain/entity" + + "gorm.io/gorm" +) + +// ProjectModel is a DO used to map the entity to the database. +type ProjectModel struct { + gorm.Model + Name string `gorm:"index:unique_project,unique"` + SourceID uint + Source *SourceModel `gorm:"foreignKey:ID;references:SourceID"` + OrganizationID uint + Organization *OrganizationModel `gorm:"foreignKey:ID;references:OrganizationID"` + Path string `gorm:"index:unique_project,unique"` + Description string + Labels MultiString + Owners MultiString +} + +// The TableName method returns the name of the database table that the struct is mapped to. +func (m *ProjectModel) TableName() string { + return "project" +} + +// ToEntity converts the DO to an entity. +func (m *ProjectModel) ToEntity() (*entity.Project, error) { + if m == nil { + return nil, ErrProjectModelNil + } + + sourceEntity, err := m.Source.ToEntity() + if err != nil { + return nil, ErrFailedToConvertSourceToEntity + } + + organizationEntity, err := m.Organization.ToEntity() + if err != nil { + return nil, ErrFailedToConvertOrgToEntity + } + + return &entity.Project{ + ID: m.ID, + Name: m.Name, + Source: sourceEntity, + Organization: organizationEntity, + Path: m.Path, + Description: m.Description, + Labels: []string(m.Labels), + Owners: []string(m.Owners), + CreationTimestamp: m.CreatedAt, + UpdateTimestamp: m.UpdatedAt, + }, nil +} + +// ToEntity converts the DO to an entity. +func (m *ProjectModel) ToEntityWithSourceAndOrg(sourceEntity *entity.Source, organizationEntity *entity.Organization) (*entity.Project, error) { + if m == nil { + return nil, ErrProjectModelNil + } + + return &entity.Project{ + ID: m.ID, + Name: m.Name, + Source: sourceEntity, + Organization: organizationEntity, + Path: m.Path, + Description: m.Description, + Labels: []string(m.Labels), + Owners: []string(m.Owners), + CreationTimestamp: m.CreatedAt, + UpdateTimestamp: m.UpdatedAt, + }, nil +} + +// FromEntity converts an entity to a DO. +func (m *ProjectModel) FromEntity(e *entity.Project) error { + if m == nil { + return ErrProjectModelNil + } + + m.ID = e.ID + m.Name = e.Name + m.Description = e.Description + m.Path = e.Path + m.Labels = MultiString(e.Labels) + m.Owners = MultiString(e.Owners) + m.CreatedAt = e.CreationTimestamp + m.UpdatedAt = e.UpdateTimestamp + if e.Source != nil { + m.SourceID = e.Source.ID + m.Source.FromEntity(e.Source) + } + if e.Organization != nil { + m.OrganizationID = e.Organization.ID + m.Organization.FromEntity(e.Organization) + } + + return nil +} diff --git a/pkg/infra/persistence/project_test.go b/pkg/infra/persistence/project_test.go new file mode 100644 index 00000000..5d496578 --- /dev/null +++ b/pkg/infra/persistence/project_test.go @@ -0,0 +1,208 @@ +package persistence + +import ( + "context" + "net/url" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + "kusionstack.io/kusion/pkg/domain/constant" + "kusionstack.io/kusion/pkg/domain/entity" +) + +func TestProjectRepository(t *testing.T) { + mockRemote := "https://github.com/mockorg/mockrepo" + mockRemoteURL, err := url.Parse(mockRemote) + require.NoError(t, err) + + t.Run("Create", func(t *testing.T) { + fakeGDB, sqlMock, err := GetMockDB() + require.NoError(t, err) + repo := NewProjectRepository(fakeGDB) + defer CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + var ( + expectedID, expectedRows uint = 1, 1 + actual = entity.Project{ + Name: "mockedProject", + Source: &entity.Source{ + ID: 1, + SourceProvider: constant.SourceProviderTypeGithub, + Remote: mockRemoteURL, + }, + Organization: &entity.Organization{ + ID: 1, + }, + Path: "/path/to/project", + Labels: []string{"testLabel"}, + Owners: []string{"hua.li", "xiaoming.li"}, + } + ) + sqlMock.ExpectBegin() + sqlMock.ExpectExec("INSERT"). + WillReturnResult(sqlmock.NewResult(int64(expectedID), int64(expectedRows))) + sqlMock.ExpectCommit() + err = repo.Create(context.Background(), &actual) + require.NoError(t, err) + require.Equal(t, expectedID, actual.ID) + }) + + t.Run("Delete existing record", func(t *testing.T) { + fakeGDB, sqlMock, err := GetMockDB() + require.NoError(t, err) + repo := NewProjectRepository(fakeGDB) + defer CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + var expectedID, expectedRows uint = 1, 1 + sqlMock.ExpectBegin() + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id"}). + AddRow(1)) + sqlMock.ExpectExec("UPDATE"). + WillReturnResult(sqlmock.NewResult(int64(expectedID), int64(expectedRows))) + sqlMock.ExpectCommit() + err = repo.Delete(context.Background(), expectedID) + require.NoError(t, err) + }) + + t.Run("Delete not existing record", func(t *testing.T) { + fakeGDB, sqlMock, err := GetMockDB() + require.NoError(t, err) + repo := NewProjectRepository(fakeGDB) + defer CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + sqlMock.ExpectBegin() + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id"})) + err = repo.Delete(context.Background(), 1) + require.ErrorIs(t, err, gorm.ErrRecordNotFound) + }) + + t.Run("Update existing record", func(t *testing.T) { + fakeGDB, sqlMock, err := GetMockDB() + require.NoError(t, err) + repo := NewProjectRepository(fakeGDB) + + var ( + expectedID, expectedRows uint = 1, 1 + actual = entity.Project{ + ID: 1, + Name: "mockedProject", + } + ) + sqlMock.ExpectExec("UPDATE"). + WillReturnResult(sqlmock.NewResult(int64(expectedID), int64(expectedRows))) + err = repo.Update(context.Background(), &actual) + require.NoError(t, err) + }) + + t.Run("Update not existing record", func(t *testing.T) { + fakeGDB, sqlMock, err := GetMockDB() + require.NoError(t, err) + repo := NewProjectRepository(fakeGDB) + defer CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + actual := entity.Project{ + Name: "mockedProject", + } + err = repo.Update(context.Background(), &actual) + require.ErrorIs(t, err, gorm.ErrMissingWhereClause) + }) + + t.Run("Get", func(t *testing.T) { + fakeGDB, sqlMock, err := GetMockDB() + require.NoError(t, err) + repo := NewProjectRepository(fakeGDB) + defer CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + var ( + expectedID uint = 1 + expectedName = "mockedProject" + expectedPath = "/path/to/project" + expectedOwners = MultiString{"hua.li", "xiaoming.li"} + ) + sqlMock.ExpectQuery("SELECT.*FROM `project`"). + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "path", "Organization__id", "Organization__name", "Organization__owners", "Source__id", "Source__remote", "Source__source_provider"}). + AddRow(expectedID, expectedName, expectedPath, 1, "mockedOrg", expectedOwners, 1, "https://github.com/test/repo", constant.SourceProviderTypeGithub)) + + actual, err := repo.Get(context.Background(), expectedID) + require.NoError(t, err) + require.Equal(t, expectedID, actual.ID) + require.Equal(t, expectedName, actual.Name) + }) + + t.Run("List", func(t *testing.T) { + fakeGDB, sqlMock, err := GetMockDB() + require.NoError(t, err) + repo := NewProjectRepository(fakeGDB) + defer CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + var ( + expectedID uint = 1 + expectedName = "mockedProject" + expectedPath = "/path/to/project" + expectedOrgOwners = MultiString{"hua.li", "xiaoming.li"} + expectedIDSecond uint = 2 + expectedNameSecond = "mockedProject2" + expectedPathSecond = "/path/to/project/2" + ) + sqlMock.ExpectQuery("SELECT .* FROM `project`"). + WillReturnRows( + sqlmock.NewRows([]string{"id", "name", "path", "Organization__id", "Organization__name", "Organization__owners", "Source__id", "Source__remote", "Source__source_provider"}). + AddRow(expectedID, expectedName, expectedPath, 1, "mockedOrg", expectedOrgOwners, 1, "https://github.com/test/repo", constant.SourceProviderTypeGithub). + AddRow(expectedIDSecond, expectedNameSecond, expectedPathSecond, 1, "mockedOrg", expectedOrgOwners, 2, "https://github.com/test/repo2", constant.SourceProviderTypeGithub)) + + actual, err := repo.List(context.Background()) + require.NoError(t, err) + require.Len(t, actual, 2) + }) + + // t.Run("Get stack entity by source id and path", func(t *testing.T) { + // fakeGDB, sqlMock, err := GetMockDB() + // require.NoError(t, err) + // repo := NewProjectRepository(fakeGDB) + // defer CloseDB(t, fakeGDB) + // defer sqlMock.ExpectClose() + + // var ( + // expectedID uint = 1 + // expectedState = constant.ProjectStateUnSynced + // ) + // sqlMock.ExpectQuery("SELECT.*FROM "stack""). + // WillReturnRows(sqlmock.NewRows([]string{"id", "source_id", "path", "sync_state", "Source__source_provider"}). + // AddRow(expectedID, 2, "/path/to/ws", string(expectedState), string(constant.SourceProviderTypeGithub))) + // actual, err := repo.GetBy(context.Background(), 2, "/path/to/ws") + // require.NoError(t, err) + // require.Equal(t, expectedID, actual.ID) + // require.Equal(t, expectedState, actual.State) + // }) + + // t.Run("Find", func(t *testing.T) { + // fakeGDB, sqlMock, err := GetMockDB() + // require.NoError(t, err) + // repo := NewProjectRepository(fakeGDB) + // defer CloseDB(t, fakeGDB) + // defer sqlMock.ExpectClose() + + // sqlMock.ExpectQuery("SELECT"). + // WillReturnRows(sqlmock.NewRows([]string{"id", "state", "framework", "Source__source_provider"}). + // AddRow(1, string(constant.ProjectStateUnSynced), string(constant.FrameworkTypeKusion), string(constant.SourceProviderTypeRepoServer)). + // AddRow(2, string(constant.ProjectStateUnSynced), string(constant.FrameworkTypeTerraform), string(constant.SourceProviderTypeRepoServer))) + // actuals, err := repo.Find(context.Background(), repository.ProjectQuery{ + // Bound: repository.Bound{ + // Offset: 1, + // Limit: 10, + // }, + // }) + // require.NoError(t, err) + // require.Equal(t, 2, len(actuals)) + // }) +} diff --git a/pkg/infra/persistence/source.go b/pkg/infra/persistence/source.go new file mode 100644 index 00000000..1d8dd3dd --- /dev/null +++ b/pkg/infra/persistence/source.go @@ -0,0 +1,149 @@ +package persistence + +import ( + "context" + + "gorm.io/gorm" + "kusionstack.io/kusion/pkg/domain/entity" + "kusionstack.io/kusion/pkg/domain/repository" +) + +// The sourceRepository type implements the repository.SourceRepository interface. +// If the sourceRepository type does not implement all the methods of the interface, +// the compiler will produce an error. +var _ repository.SourceRepository = &sourceRepository{} + +// sourceRepository is a repository that stores sources in a gorm database. +type sourceRepository struct { + // db is the underlying gorm database where sources are stored. + db *gorm.DB +} + +// NewSourceRepository creates a new source repository. +func NewSourceRepository(db *gorm.DB) repository.SourceRepository { + return &sourceRepository{db: db} +} + +// Create saves a source to the repository. +func (r *sourceRepository) Create(ctx context.Context, dataEntity *entity.Source) error { + // r.db.AutoMigrate(&SourceModel{}) + err := dataEntity.Validate() + if err != nil { + return err + } + + // Map the data from Entity to DO + var dataModel SourceModel + err = dataModel.FromEntity(dataEntity) + if err != nil { + return err + } + + return r.db.Transaction(func(tx *gorm.DB) error { + // Create new record in the store + err = tx.WithContext(ctx).Create(&dataModel).Error + if err != nil { + return err + } + + // Map fresh record's data into Entity + newEntity, err := dataModel.ToEntity() + if err != nil { + return err + } + *dataEntity = *newEntity + + return nil + }) +} + +// Create saves a source to the repository. +func (r *sourceRepository) CreateOrUpdate(ctx context.Context, dataEntity *entity.Source) error { + err := dataEntity.Validate() + if err != nil { + return err + } + + // Map the data from Entity to DO + var dataModel SourceModel + err = dataModel.FromEntity(dataEntity) + if err != nil { + return err + } + + // Check if the source exists and update it if it does + if existingSource, err := r.GetByRemote(ctx, dataModel.Remote); err == nil && existingSource != nil { + return r.Update(ctx, dataEntity) + } else { + return r.Create(ctx, dataEntity) + } +} + +// Delete removes a source from the repository. +func (r *sourceRepository) Delete(ctx context.Context, id uint) error { + return r.db.Transaction(func(tx *gorm.DB) error { + var dataModel SourceModel + err := tx.WithContext(ctx).First(&dataModel, id).Error + if err != nil { + return err + } + + return tx.WithContext(ctx).Delete(&dataModel).Error + }) +} + +// Update updates an existing source in the repository. +func (r *sourceRepository) Update(ctx context.Context, dataEntity *entity.Source) error { + // Map the data from Entity to DO + var dataModel SourceModel + err := dataModel.FromEntity(dataEntity) + if err != nil { + return err + } + + err = r.db.WithContext(ctx).Updates(&dataModel).Error + if err != nil { + return err + } + + return nil +} + +// Get retrieves a source by its ID. +func (r *sourceRepository) Get(ctx context.Context, id uint) (*entity.Source, error) { + var dataModel SourceModel + err := r.db.WithContext(ctx).First(&dataModel, id).Error + if err != nil { + return nil, err + } + + return dataModel.ToEntity() +} + +// GetByRemote retrieves a source by its remote. +func (r *sourceRepository) GetByRemote(ctx context.Context, remote string) (*entity.Source, error) { + var dataModel SourceModel + err := r.db.WithContext(ctx).Where("remote = ?", remote).First(&dataModel).Error + if err != nil { + return nil, err + } + return dataModel.ToEntity() +} + +// List retrieves all sources. +func (r *sourceRepository) List(ctx context.Context) ([]*entity.Source, error) { + var dataModel []SourceModel + sourceEntityList := make([]*entity.Source, 0) + result := r.db.WithContext(ctx).Find(&dataModel) + if result.Error != nil { + return nil, result.Error + } + for _, source := range dataModel { + sourceEntity, err := source.ToEntity() + if err != nil { + return nil, err + } + sourceEntityList = append(sourceEntityList, sourceEntity) + } + return sourceEntityList, nil +} diff --git a/pkg/infra/persistence/source_model.go b/pkg/infra/persistence/source_model.go new file mode 100644 index 00000000..7835a46f --- /dev/null +++ b/pkg/infra/persistence/source_model.go @@ -0,0 +1,87 @@ +package persistence + +import ( + "net/url" + + "kusionstack.io/kusion/pkg/domain/constant" + "kusionstack.io/kusion/pkg/domain/entity" + + "gorm.io/gorm" +) + +// SourceModel is a DO used to map the entity to the database. +type SourceModel struct { + gorm.Model + // SourceProvider is the type of the source provider. + SourceProvider string `gorm:"index:unique_source,unique"` + // Remote is the source URL, including scheme. + Remote string `gorm:"index:unique_source,unique"` + // Description is a human-readable description of the source. + Description string + // Labels are custom labels associated with the source. + Labels MultiString + // Owners is a list of owners for the source. + Owners MultiString +} + +// The TableName method returns the name of the database table that the struct is mapped to. +func (m *SourceModel) TableName() string { + return "source" +} + +// ToEntity converts the DO to an entity. +func (m *SourceModel) ToEntity() (*entity.Source, error) { + if m == nil { + return nil, ErrSourceModelNil + } + + sourceProvider, err := constant.ParseSourceProviderType(m.SourceProvider) + if err != nil { + return nil, ErrFailedToGetSourceProviderType + } + + var remote *url.URL + if m.Remote == "local" { + // convert string to url.URL + remote, err = remote.Parse("local://file") + } else { + remote, err = url.Parse(m.Remote) + } + if err != nil { + return nil, ErrFailedToGetSourceRemote + } + + return &entity.Source{ + ID: m.ID, + SourceProvider: sourceProvider, + Remote: remote, + Description: m.Description, + Labels: []string(m.Labels), + Owners: []string(m.Owners), + CreationTimestamp: m.CreatedAt, + UpdateTimestamp: m.UpdatedAt, + }, nil +} + +// FromEntity converts an entity to a DO. +func (m *SourceModel) FromEntity(e *entity.Source) error { + if m == nil { + return ErrSourceModelNil + } + + if e.Remote == nil || e.Remote.String() == "local://file" { + m.Remote = "local" + } else { + m.Remote = e.Remote.String() + } + + m.ID = e.ID + m.SourceProvider = string(e.SourceProvider) + m.Description = e.Description + m.Labels = MultiString(e.Labels) + m.Owners = MultiString(e.Owners) + m.CreatedAt = e.CreationTimestamp + m.UpdatedAt = e.UpdateTimestamp + + return nil +} diff --git a/pkg/infra/persistence/source_test.go b/pkg/infra/persistence/source_test.go new file mode 100644 index 00000000..5a81d154 --- /dev/null +++ b/pkg/infra/persistence/source_test.go @@ -0,0 +1,182 @@ +package persistence + +import ( + "context" + "net/url" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + "kusionstack.io/kusion/pkg/domain/constant" + "kusionstack.io/kusion/pkg/domain/entity" +) + +func TestSourceRepository(t *testing.T) { + mockRemote := "https://github.com/mockorg/mockrepo" + mockRemoteURL, err := url.Parse(mockRemote) + require.NoError(t, err) + + t.Run("Create", func(t *testing.T) { + fakeGDB, sqlMock, err := GetMockDB() + require.NoError(t, err) + repo := NewSourceRepository(fakeGDB) + defer CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + var ( + expectedID, expectedRows uint = 1, 1 + actual = entity.Source{ + SourceProvider: constant.SourceProviderTypeOCI, + Remote: mockRemoteURL, + Description: "i am a description", + Labels: []string{"testLabel"}, + Owners: []string{"hua.li", "xiaoming.li"}, + } + ) + sqlMock.ExpectBegin() + sqlMock.ExpectExec("INSERT"). + WillReturnResult(sqlmock.NewResult(int64(expectedID), int64(expectedRows))) + sqlMock.ExpectCommit() + err = repo.Create(context.Background(), &actual) + require.NoError(t, err) + require.Equal(t, expectedID, actual.ID) + }) + + t.Run("Delete existing record", func(t *testing.T) { + fakeGDB, sqlMock, err := GetMockDB() + require.NoError(t, err) + repo := NewSourceRepository(fakeGDB) + defer CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + var expectedID, expectedRows uint = 1, 1 + sqlMock.ExpectBegin() + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id"}). + AddRow(1)) + sqlMock.ExpectExec("UPDATE"). + WillReturnResult(sqlmock.NewResult(int64(expectedID), int64(expectedRows))) + sqlMock.ExpectCommit() + err = repo.Delete(context.Background(), expectedID) + require.NoError(t, err) + }) + + t.Run("Delete not existing record", func(t *testing.T) { + fakeGDB, sqlMock, err := GetMockDB() + require.NoError(t, err) + repo := NewSourceRepository(fakeGDB) + defer CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + sqlMock.ExpectBegin() + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id"})) + err = repo.Delete(context.Background(), 1) + require.ErrorIs(t, err, gorm.ErrRecordNotFound) + }) + + t.Run("Update existing record", func(t *testing.T) { + fakeGDB, sqlMock, err := GetMockDB() + require.NoError(t, err) + repo := NewSourceRepository(fakeGDB) + + var ( + expectedID, expectedRows uint = 1, 1 + actual = entity.Source{ + ID: 1, + SourceProvider: constant.SourceProviderTypeGithub, + Remote: mockRemoteURL, + } + ) + sqlMock.ExpectExec("UPDATE"). + WillReturnResult(sqlmock.NewResult(int64(expectedID), int64(expectedRows))) + err = repo.Update(context.Background(), &actual) + require.NoError(t, err) + }) + + t.Run("Update not existing record", func(t *testing.T) { + fakeGDB, sqlMock, err := GetMockDB() + require.NoError(t, err) + repo := NewSourceRepository(fakeGDB) + defer CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + actual := entity.Source{ + SourceProvider: constant.SourceProviderTypeGithub, + Remote: mockRemoteURL, + } + err = repo.Update(context.Background(), &actual) + require.ErrorIs(t, err, gorm.ErrMissingWhereClause) + }) + + t.Run("Get", func(t *testing.T) { + fakeGDB, sqlMock, err := GetMockDB() + require.NoError(t, err) + repo := NewSourceRepository(fakeGDB) + defer CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + var ( + expectedID uint = 1 + expectedSourceProviderType = constant.SourceProviderTypeGithub + expectedRemote = mockRemote + ) + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id", "source_provider", "remote"}). + AddRow(expectedID, string(expectedSourceProviderType), expectedRemote)) + actual, err := repo.Get(context.Background(), expectedID) + require.NoError(t, err) + require.Equal(t, expectedID, actual.ID) + require.Equal(t, expectedSourceProviderType, actual.SourceProvider) + require.Equal(t, expectedRemote, actual.Remote.String()) + }) + + t.Run("Get source entity by remote", func(t *testing.T) { + fakeGDB, sqlMock, err := GetMockDB() + require.NoError(t, err) + repo := NewSourceRepository(fakeGDB) + defer CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + var ( + expectedID uint = 1 + expectedSourceProviderType = constant.SourceProviderTypeOCI + expectedRemote = mockRemote + ) + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id", "source_provider", "remote"}). + AddRow(expectedID, string(expectedSourceProviderType), expectedRemote)) + actual, err := repo.GetByRemote(context.Background(), expectedRemote) + require.NoError(t, err) + require.Equal(t, expectedID, actual.ID) + require.Equal(t, expectedSourceProviderType, actual.SourceProvider) + require.Equal(t, expectedRemote, actual.Remote.String()) + }) + + t.Run("List", func(t *testing.T) { + fakeGDB, sqlMock, err := GetMockDB() + require.NoError(t, err) + repo := NewSourceRepository(fakeGDB) + defer CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + var ( + expectedIDFirst uint = 1 + expectedRemoteFirst = "https://remote/Mocked/Source" + expectedSourceProviderFirst = constant.SourceProviderTypeGithub + expectedIDSecond uint = 2 + expectedRemoteSecond = "local://mockedSource" + expectedSourceProviderSecond = constant.SourceProviderTypeGithub + ) + sqlMock.ExpectQuery("SELECT .* FROM `source`"). + WillReturnRows( + sqlmock.NewRows([]string{"id", "remote", "source_provider"}). + AddRow(expectedIDFirst, expectedRemoteFirst, expectedSourceProviderFirst). + AddRow(expectedIDSecond, expectedRemoteSecond, expectedSourceProviderSecond)) + + actual, err := repo.List(context.Background()) + require.NoError(t, err) + require.Len(t, actual, 2) + }) +} diff --git a/pkg/infra/persistence/stack.go b/pkg/infra/persistence/stack.go new file mode 100644 index 00000000..3f5a4a09 --- /dev/null +++ b/pkg/infra/persistence/stack.go @@ -0,0 +1,122 @@ +package persistence + +import ( + "context" + + "kusionstack.io/kusion/pkg/domain/entity" + "kusionstack.io/kusion/pkg/domain/repository" + + "gorm.io/gorm" +) + +// The stackRepository type implements the repository.StackRepository interface. +// If the stackRepository type does not implement all the methods of the interface, +// the compiler will produce an error. +var _ repository.StackRepository = &stackRepository{} + +// stackRepository is a repository that stores stacks in a gorm database. +type stackRepository struct { + // db is the underlying gorm database where stacks are stored. + db *gorm.DB +} + +// NewStackRepository creates a new stack repository. +func NewStackRepository(db *gorm.DB) repository.StackRepository { + return &stackRepository{db: db} +} + +// Create saves a stack to the repository. +func (r *stackRepository) Create(ctx context.Context, dataEntity *entity.Stack) error { + // r.db.AutoMigrate(&StackModel{}) + err := dataEntity.Validate() + if err != nil { + return err + } + + err = dataEntity.Validate() + if err != nil { + return err + } + + // Map the data from Entity to DO + var dataModel StackModel + err = dataModel.FromEntity(dataEntity) + if err != nil { + return err + } + + return r.db.Transaction(func(tx *gorm.DB) error { + // Create new record in the store + err = tx.WithContext(ctx).Create(&dataModel).Error + if err != nil { + return err + } + + dataEntity.ID = dataModel.ID + + return nil + }) +} + +// Delete removes a stack from the repository. +func (r *stackRepository) Delete(ctx context.Context, id uint) error { + return r.db.Transaction(func(tx *gorm.DB) error { + var dataModel StackModel + err := tx.WithContext(ctx).First(&dataModel, id).Error + if err != nil { + return err + } + + return tx.WithContext(ctx).Delete(&dataModel).Error + }) +} + +// Update updates an existing stack in the repository. +func (r *stackRepository) Update(ctx context.Context, dataEntity *entity.Stack) error { + // Map the data from Entity to DO + var dataModel StackModel + err := dataModel.FromEntity(dataEntity) + if err != nil { + return err + } + + err = r.db.WithContext(ctx).Updates(&dataModel).Error + if err != nil { + return err + } + + return nil +} + +// Get retrieves a stack by its ID. +func (r *stackRepository) Get(ctx context.Context, id uint) (*entity.Stack, error) { + var dataModel StackModel + err := r.db.WithContext(ctx). + Preload("Project"). + First(&dataModel, id).Error + if err != nil { + return nil, err + } + + return dataModel.ToEntity(ctx) +} + +// List retrieves all stacks. +func (r *stackRepository) List(ctx context.Context) ([]*entity.Stack, error) { + var dataModel []StackModel + stackEntityList := make([]*entity.Stack, 0) + result := r.db.WithContext(ctx). + Preload("Project"). + Find(&dataModel) + if result.Error != nil { + return nil, result.Error + } + for _, stack := range dataModel { + stackEntity, err := stack.ToEntity(ctx) + if err != nil { + return nil, err + } + stackEntityList = append(stackEntityList, stackEntity) + } + return stackEntityList, nil +} diff --git a/pkg/infra/persistence/stack_model.go b/pkg/infra/persistence/stack_model.go new file mode 100644 index 00000000..0b2f241b --- /dev/null +++ b/pkg/infra/persistence/stack_model.go @@ -0,0 +1,116 @@ +package persistence + +import ( + "context" + "time" + + "kusionstack.io/kusion/pkg/domain/constant" + "kusionstack.io/kusion/pkg/domain/entity" + + "gorm.io/gorm" +) + +// StackModel is a DO used to map the entity to the database. +type StackModel struct { + gorm.Model + Name string `gorm:"index:unique_project,unique"` + // SourceID uint + // Source *SourceModel + ProjectID uint + Project *ProjectModel + // OrganizationID uint + // Organization *OrganizationModel + Description string + Path string `gorm:"index:unique_project,unique"` + DesiredVersion string + Labels MultiString + Owners MultiString + SyncState string + LastSyncTimestamp time.Time +} + +// The TableName method returns the name of the database table that the struct is mapped to. +func (m *StackModel) TableName() string { + return "stack" +} + +// ToEntity converts the DO to an entity. +func (m *StackModel) ToEntity(ctx context.Context) (*entity.Stack, error) { + if m == nil { + return nil, ErrStackModelNil + } + + stackState, err := constant.ParseStackState(m.SyncState) + if err != nil { + return nil, ErrFailedToGetStackState + } + + // sourceEntity, err := m.Source.ToEntity() + // if err != nil { + // return nil, ErrFailedToConvertSourceToEntity + // } + + // organizationEntity, err := m.Organization.ToEntity() + // if err != nil { + // return nil, ErrFailedToConvertSourceToEntity + // } + + // projectEntity, err := m.Project.ToEntityWithSourceAndOrg(sourceEntity, organizationEntity) + projectEntity, err := m.Project.ToEntityWithSourceAndOrg(nil, nil) + if err != nil { + return nil, ErrFailedToConvertProjectToEntity + } + + return &entity.Stack{ + ID: m.ID, + Name: m.Name, + // Source: sourceEntity, + Project: projectEntity, + // Organization: organizationEntity, + Description: m.Description, + Path: m.Path, + DesiredVersion: m.DesiredVersion, + Labels: []string(m.Labels), + Owners: []string(m.Owners), + SyncState: stackState, + LastSyncTimestamp: m.LastSyncTimestamp, + CreationTimestamp: m.CreatedAt, + UpdateTimestamp: m.UpdatedAt, + }, nil +} + +// FromEntity converts an entity to a DO. +func (m *StackModel) FromEntity(e *entity.Stack) error { + if m == nil { + return ErrStackModelNil + } + + m.ID = e.ID + m.Name = e.Name + m.Description = e.Description + m.Path = e.Path + m.DesiredVersion = e.DesiredVersion + m.Labels = MultiString(e.Labels) + m.Owners = MultiString(e.Owners) + m.SyncState = string(e.SyncState) + m.LastSyncTimestamp = e.LastSyncTimestamp + m.CreatedAt = e.CreationTimestamp + m.UpdatedAt = e.UpdateTimestamp + // Convert the source to a DO + // if e.Source != nil { + // m.SourceID = e.Source.ID + // m.Source.FromEntity(e.Source) + // } + // Convert the project to a DO + if e.Project != nil { + m.ProjectID = e.Project.ID + m.Project.FromEntity(e.Project) + } + // Convert the org to a DO + // if e.Organization != nil { + // m.OrganizationID = e.Organization.ID + // m.Organization.FromEntity(e.Organization) + // } + + return nil +} diff --git a/pkg/infra/persistence/stack_test.go b/pkg/infra/persistence/stack_test.go new file mode 100644 index 00000000..0e13fae7 --- /dev/null +++ b/pkg/infra/persistence/stack_test.go @@ -0,0 +1,217 @@ +package persistence + +import ( + "context" + "net/url" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + "kusionstack.io/kusion/pkg/domain/constant" + "kusionstack.io/kusion/pkg/domain/entity" +) + +func TestStackRepository(t *testing.T) { + mockRemote := "https://github.com/mockorg/mockrepo" + mockRemoteURL, err := url.Parse(mockRemote) + require.NoError(t, err) + + t.Run("Create", func(t *testing.T) { + fakeGDB, sqlMock, err := GetMockDB() + require.NoError(t, err) + repo := NewStackRepository(fakeGDB) + defer CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + var ( + expectedID, expectedRows uint = 1, 1 + actual = entity.Stack{ + Name: "mockedStack", + Project: &entity.Project{ + ID: 1, + Name: "mockedProject", + Path: "/path/to/project", + Source: &entity.Source{ + ID: 1, + SourceProvider: constant.SourceProviderTypeGithub, + Remote: mockRemoteURL, + }, + Organization: &entity.Organization{ + ID: 1, + }, + }, + Path: "/path/to/stack", + DesiredVersion: "master", + Labels: []string{"testLabel"}, + Owners: []string{"hua.li", "xiaoming.li"}, + SyncState: constant.StackStateUnSynced, + LastSyncTimestamp: time.Now(), + } + ) + sqlMock.ExpectBegin() + sqlMock.ExpectExec("INSERT"). + WillReturnResult(sqlmock.NewResult(int64(expectedID), int64(expectedRows))) + sqlMock.ExpectCommit() + err = repo.Create(context.Background(), &actual) + require.NoError(t, err) + require.Equal(t, expectedID, actual.ID) + }) + + t.Run("Delete existing record", func(t *testing.T) { + fakeGDB, sqlMock, err := GetMockDB() + require.NoError(t, err) + repo := NewStackRepository(fakeGDB) + defer CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + var expectedID, expectedRows uint = 1, 1 + sqlMock.ExpectBegin() + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id"}). + AddRow(1)) + sqlMock.ExpectExec("UPDATE"). + WillReturnResult(sqlmock.NewResult(int64(expectedID), int64(expectedRows))) + sqlMock.ExpectCommit() + err = repo.Delete(context.Background(), expectedID) + require.NoError(t, err) + }) + + t.Run("Delete not existing record", func(t *testing.T) { + fakeGDB, sqlMock, err := GetMockDB() + require.NoError(t, err) + repo := NewStackRepository(fakeGDB) + defer CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + sqlMock.ExpectBegin() + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id"})) + err = repo.Delete(context.Background(), 1) + require.ErrorIs(t, err, gorm.ErrRecordNotFound) + }) + + t.Run("Update existing record", func(t *testing.T) { + fakeGDB, sqlMock, err := GetMockDB() + require.NoError(t, err) + repo := NewStackRepository(fakeGDB) + + var ( + expectedID, expectedRows uint = 1, 1 + actual = entity.Stack{ + ID: 1, + SyncState: constant.StackStateUnSynced, + } + ) + sqlMock.ExpectExec("UPDATE"). + WillReturnResult(sqlmock.NewResult(int64(expectedID), int64(expectedRows))) + err = repo.Update(context.Background(), &actual) + require.NoError(t, err) + }) + + t.Run("Update not existing record", func(t *testing.T) { + fakeGDB, sqlMock, err := GetMockDB() + require.NoError(t, err) + repo := NewStackRepository(fakeGDB) + defer CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + actual := entity.Stack{ + SyncState: constant.StackStateUnSynced, + } + err = repo.Update(context.Background(), &actual) + require.ErrorIs(t, err, gorm.ErrMissingWhereClause) + }) + + t.Run("Get", func(t *testing.T) { + fakeGDB, sqlMock, err := GetMockDB() + require.NoError(t, err) + repo := NewStackRepository(fakeGDB) + defer CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + var ( + expectedID uint = 1 + expectedPath = "/path/to/stack" + expectedState = constant.StackStateUnSynced + ) + sqlMock.ExpectQuery("SELECT .* FROM `stack`"). + WillReturnRows(sqlmock.NewRows([]string{"id", "path", "sync_state", "Project__id", "Project__name", "Project__path"}). + AddRow(expectedID, expectedPath, string(expectedState), 1, "mockedProject", "/path/to/project")) + + actual, err := repo.Get(context.Background(), expectedID) + require.NoError(t, err) + require.Equal(t, expectedID, actual.ID) + require.Equal(t, expectedState, actual.SyncState) + }) + + t.Run("List", func(t *testing.T) { + fakeGDB, sqlMock, err := GetMockDB() + require.NoError(t, err) + repo := NewStackRepository(fakeGDB) + defer CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + var ( + expectedIDFirst uint = 1 + expectedNameFirst = "mockedStack" + expectedPathFirst = "/path/to/stack" + expectedSyncStateFirst = constant.StackStateUnSynced + expectedIDSecond uint = 2 + expectedNameSecond = "mockedStack2" + expectedPathSecond = "/path/to/stack/2" + expectedSyncStateSecond = constant.StackStateSynced + ) + sqlMock.ExpectQuery("SELECT .* FROM `stack`"). + WillReturnRows( + sqlmock.NewRows([]string{"id", "name", "path", "sync_state", "Project__id", "Project__name", "Project__path"}). + AddRow(expectedIDFirst, expectedNameFirst, expectedPathFirst, expectedSyncStateFirst, 1, "mockedProject", "path/to/project"). + AddRow(expectedIDSecond, expectedNameSecond, expectedPathSecond, expectedSyncStateSecond, 2, "mockedProject2", "path/to/project2")) + + actual, err := repo.List(context.Background()) + require.NoError(t, err) + require.Len(t, actual, 2) + }) + + // t.Run("Get stack entity by source id and path", func(t *testing.T) { + // fakeGDB, sqlMock, err := GetMockDB() + // require.NoError(t, err) + // repo := NewStackRepository(fakeGDB) + // defer CloseDB(t, fakeGDB) + // defer sqlMock.ExpectClose() + + // var ( + // expectedID uint = 1 + // expectedState = constant.StackStateUnSynced + // ) + // sqlMock.ExpectQuery("SELECT.*FROM "stack""). + // WillReturnRows(sqlmock.NewRows([]string{"id", "source_id", "path", "sync_state", "Source__source_provider"}). + // AddRow(expectedID, 2, "/path/to/ws", string(expectedState), string(constant.SourceProviderTypeGithub))) + // actual, err := repo.GetBy(context.Background(), 2, "/path/to/ws") + // require.NoError(t, err) + // require.Equal(t, expectedID, actual.ID) + // require.Equal(t, expectedState, actual.State) + // }) + + // t.Run("Find", func(t *testing.T) { + // fakeGDB, sqlMock, err := GetMockDB() + // require.NoError(t, err) + // repo := NewStackRepository(fakeGDB) + // defer CloseDB(t, fakeGDB) + // defer sqlMock.ExpectClose() + + // sqlMock.ExpectQuery("SELECT"). + // WillReturnRows(sqlmock.NewRows([]string{"id", "state", "framework", "Source__source_provider"}). + // AddRow(1, string(constant.StackStateUnSynced), string(constant.FrameworkTypeKusion), string(constant.SourceProviderTypeRepoServer)). + // AddRow(2, string(constant.StackStateUnSynced), string(constant.FrameworkTypeTerraform), string(constant.SourceProviderTypeRepoServer))) + // actuals, err := repo.Find(context.Background(), repository.StackQuery{ + // Bound: repository.Bound{ + // Offset: 1, + // Limit: 10, + // }, + // }) + // require.NoError(t, err) + // require.Equal(t, 2, len(actuals)) + // }) +} diff --git a/pkg/infra/persistence/types.go b/pkg/infra/persistence/types.go new file mode 100644 index 00000000..53f00f1c --- /dev/null +++ b/pkg/infra/persistence/types.go @@ -0,0 +1,22 @@ +package persistence + +import ( + "errors" +) + +var ( + ErrSourceModelNil = errors.New("source model can't be nil") + ErrSystemConfigModelNil = errors.New("system config model can't be nil") + ErrStackModelNil = errors.New("stack model can't be nil") + ErrProjectModelNil = errors.New("project model can't be nil") + ErrFailedToGetSourceProviderType = errors.New("failed to parse source provider type") + ErrFailedToGetSourceRemote = errors.New("failed to parse source remote") + ErrFailedToGetStackState = errors.New("failed to parse stack state") + ErrFailedToConvertSourceToEntity = errors.New("failed to convert source model to entity") + ErrFailedToConvertProjectToEntity = errors.New("failed to convert project model to entity") + ErrFailedToConvertOrgToEntity = errors.New("failed to convert org model to entity") + ErrFailedToConvertBackendToEntity = errors.New("failed to convert backend model to entity") + ErrOrganizationModelNil = errors.New("organization model can't be nil") + ErrWorkspaceModelNil = errors.New("workspace model can't be nil") + ErrBackendModelNil = errors.New("backend model can't be nil") +) diff --git a/pkg/infra/persistence/util.go b/pkg/infra/persistence/util.go new file mode 100644 index 00000000..c9fc397f --- /dev/null +++ b/pkg/infra/persistence/util.go @@ -0,0 +1,88 @@ +package persistence + +import ( + "database/sql/driver" + "fmt" + "strings" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/require" + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +// MultiString is a custom type for handling arrays of strings with GORM. +type MultiString []string + +// Scan implements the Scanner interface for the MultiString type. +func (s *MultiString) Scan(src any) error { + switch src := src.(type) { + case []byte: + *s = strings.Split(string(src), ",") + case string: + *s = strings.Split(src, ",") + case nil: + *s = nil + default: + return fmt.Errorf("unsupported type %T", src) + } + return nil +} + +// Value implements the Valuer interface for the MultiString type. +func (s MultiString) Value() (driver.Value, error) { + if s == nil { + return nil, nil + } + return strings.Join(s, ","), nil +} + +// GormDataType gorm common data type +func (s MultiString) GormDataType() string { + return "text" +} + +// GormDBDataType gorm db data type +func (s MultiString) GormDBDataType(db *gorm.DB, field *schema.Field) string { + // returns different database type based on driver name + switch db.Dialector.Name() { + case "mysql", "sqlite": + return "text" + } + return "" +} + +// Create a mock database connection +func GetMockDB() (*gorm.DB, sqlmock.Sqlmock, error) { + // Create a sqlMock of sql.DB. + fakeDB, sqlMock, err := sqlmock.New() + if err != nil { + return nil, nil, err + } + + // common execution for orm + sqlMock.ExpectQuery("SELECT VERSION()").WillReturnRows(sqlmock.NewRows( + []string{"VERSION()"}).AddRow("5.7.35-log")) + + // Create the gorm database connection with fake db + fakeGDB, err := gorm.Open(mysql.New(mysql.Config{ + Conn: fakeDB, + SkipInitializeWithVersion: false, + }), &gorm.Config{ + SkipDefaultTransaction: true, + }) + if err != nil { + return nil, nil, err + } + + return fakeGDB, sqlMock, nil +} + +// Close the gorm database connection +func CloseDB(t *testing.T, gdb *gorm.DB) { + db, err := gdb.DB() + require.NoError(t, err) + require.NoError(t, db.Close()) +} diff --git a/pkg/infra/persistence/workspace.go b/pkg/infra/persistence/workspace.go new file mode 100644 index 00000000..5815a2e6 --- /dev/null +++ b/pkg/infra/persistence/workspace.go @@ -0,0 +1,129 @@ +package persistence + +import ( + "context" + + "kusionstack.io/kusion/pkg/domain/entity" + "kusionstack.io/kusion/pkg/domain/repository" + + "gorm.io/gorm" +) + +// The workspaceRepository type implements the repository.WorkspaceRepository interface. +// If the workspaceRepository type does not implement all the methods of the interface, +// the compiler will produce an error. +var _ repository.WorkspaceRepository = &workspaceRepository{} + +// workspaceRepository is a repository that stores workspaces in a gorm database. +type workspaceRepository struct { + // db is the underlying gorm database where workspaces are stored. + db *gorm.DB +} + +// NewWorkspaceRepository creates a new workspace repository. +func NewWorkspaceRepository(db *gorm.DB) repository.WorkspaceRepository { + return &workspaceRepository{db: db} +} + +// Create saves a workspace to the repository. +func (r *workspaceRepository) Create(ctx context.Context, dataEntity *entity.Workspace) error { + // r.db.AutoMigrate(&WorkspaceModel{}) + err := dataEntity.Validate() + if err != nil { + return err + } + + // Map the data from Entity to DO + var dataModel WorkspaceModel + err = dataModel.FromEntity(dataEntity) + if err != nil { + return err + } + + return r.db.Transaction(func(tx *gorm.DB) error { + // Create new record in the store + err = tx.WithContext(ctx).Create(&dataModel).Error + if err != nil { + return err + } + + dataEntity.ID = dataModel.ID + + return nil + }) +} + +// Delete removes a workspace from the repository. +func (r *workspaceRepository) Delete(ctx context.Context, id uint) error { + return r.db.Transaction(func(tx *gorm.DB) error { + var dataModel WorkspaceModel + err := tx.WithContext(ctx).First(&dataModel, id).Error + if err != nil { + return err + } + + return tx.WithContext(ctx).Delete(&dataModel).Error + }) +} + +// Update updates an existing workspace in the repository. +func (r *workspaceRepository) Update(ctx context.Context, dataEntity *entity.Workspace) error { + // Map the data from Entity to DO + var dataModel WorkspaceModel + err := dataModel.FromEntity(dataEntity) + if err != nil { + return err + } + + err = r.db.WithContext(ctx).Updates(&dataModel).Error + if err != nil { + return err + } + + return nil +} + +// Get retrieves a workspace by its ID. +func (r *workspaceRepository) Get(ctx context.Context, id uint) (*entity.Workspace, error) { + var dataModel WorkspaceModel + err := r.db.WithContext(ctx). + Preload("Backend"). + First(&dataModel, id).Error + if err != nil { + return nil, err + } + + return dataModel.ToEntity() +} + +// GetByName retrieves a workspace by its name. +func (r *workspaceRepository) GetByName(ctx context.Context, name string) (*entity.Workspace, error) { + var dataModel WorkspaceModel + err := r.db.WithContext(ctx). + Preload("Backend"). + Where("name = ?", name).First(&dataModel).Error + if err != nil { + return nil, err + } + return dataModel.ToEntity() +} + +// List retrieves all workspaces. +func (r *workspaceRepository) List(ctx context.Context) ([]*entity.Workspace, error) { + var dataModel []WorkspaceModel + workspaceEntityList := make([]*entity.Workspace, 0) + result := r.db.WithContext(ctx). + Preload("Backend"). + Find(&dataModel) + if result.Error != nil { + return nil, result.Error + } + for _, workspace := range dataModel { + workspaceEntity, err := workspace.ToEntity() + if err != nil { + return nil, err + } + workspaceEntityList = append(workspaceEntityList, workspaceEntity) + } + return workspaceEntityList, nil +} diff --git a/pkg/infra/persistence/workspace_model.go b/pkg/infra/persistence/workspace_model.go new file mode 100644 index 00000000..1fe5e873 --- /dev/null +++ b/pkg/infra/persistence/workspace_model.go @@ -0,0 +1,67 @@ +package persistence + +import ( + "kusionstack.io/kusion/pkg/domain/entity" + + "gorm.io/gorm" +) + +// WorkspaceModel is a DO used to map the entity to the database. +type WorkspaceModel struct { + gorm.Model + Name string `gorm:"index:unique_workspace,unique"` + Description string + Labels MultiString + Owners MultiString + BackendID uint + Backend *BackendModel `gorm:"foreignKey:ID;references:BackendID"` +} + +// The TableName method returns the name of the database table that the struct is mapped to. +func (m *WorkspaceModel) TableName() string { + return "workspace" +} + +// ToEntity converts the DO to an entity. +func (m *WorkspaceModel) ToEntity() (*entity.Workspace, error) { + if m == nil { + return nil, ErrWorkspaceModelNil + } + + backendEntity, err := m.Backend.ToEntity() + if err != nil { + return nil, ErrFailedToConvertBackendToEntity + } + + return &entity.Workspace{ + ID: m.ID, + Name: m.Name, + Description: m.Description, + Labels: []string(m.Labels), + Owners: []string(m.Owners), + CreationTimestamp: m.CreatedAt, + UpdateTimestamp: m.UpdatedAt, + Backend: backendEntity, + }, nil +} + +// FromEntity converts an entity to a DO. +func (m *WorkspaceModel) FromEntity(e *entity.Workspace) error { + if m == nil { + return ErrWorkspaceModelNil + } + + m.ID = e.ID + m.Name = e.Name + m.Description = e.Description + m.Labels = MultiString(e.Labels) + m.Owners = MultiString(e.Owners) + m.CreatedAt = e.CreationTimestamp + m.UpdatedAt = e.UpdateTimestamp + if e.Backend != nil { + m.BackendID = e.Backend.ID + m.Backend.FromEntity(e.Backend) + } + + return nil +} diff --git a/pkg/infra/persistence/workspace_test.go b/pkg/infra/persistence/workspace_test.go new file mode 100644 index 00000000..d7e4b6e7 --- /dev/null +++ b/pkg/infra/persistence/workspace_test.go @@ -0,0 +1,147 @@ +package persistence + +import ( + "context" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + "kusionstack.io/kusion/pkg/domain/entity" +) + +func TestWorkspaceRepository(t *testing.T) { + t.Run("Create", func(t *testing.T) { + fakeGDB, sqlMock, err := GetMockDB() + require.NoError(t, err) + repo := NewWorkspaceRepository(fakeGDB) + defer CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + var ( + expectedID, expectedRows uint = 1, 1 + actual = entity.Workspace{ + Name: "mockedWorkspace", + DisplayName: "mockedDisplayName", + Backend: &entity.Backend{ID: 1}, + Owners: []string{"hua.li", "xiaoming.li"}, + } + ) + sqlMock.ExpectBegin() + sqlMock.ExpectExec("INSERT"). + WillReturnResult(sqlmock.NewResult(int64(expectedID), int64(expectedRows))) + sqlMock.ExpectCommit() + err = repo.Create(context.Background(), &actual) + require.NoError(t, err) + require.Equal(t, expectedID, actual.ID) + }) + + t.Run("Delete existing record", func(t *testing.T) { + fakeGDB, sqlMock, err := GetMockDB() + require.NoError(t, err) + repo := NewWorkspaceRepository(fakeGDB) + defer CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + var expectedID, expectedRows uint = 1, 1 + sqlMock.ExpectBegin() + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id"}). + AddRow(1)) + sqlMock.ExpectExec("UPDATE"). + WillReturnResult(sqlmock.NewResult(int64(expectedID), int64(expectedRows))) + sqlMock.ExpectCommit() + err = repo.Delete(context.Background(), expectedID) + require.NoError(t, err) + }) + + t.Run("Delete not existing record", func(t *testing.T) { + fakeGDB, sqlMock, err := GetMockDB() + require.NoError(t, err) + repo := NewWorkspaceRepository(fakeGDB) + defer CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + sqlMock.ExpectBegin() + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id"})) + err = repo.Delete(context.Background(), 1) + require.ErrorIs(t, err, gorm.ErrRecordNotFound) + }) + + t.Run("Update existing record", func(t *testing.T) { + fakeGDB, sqlMock, err := GetMockDB() + require.NoError(t, err) + repo := NewWorkspaceRepository(fakeGDB) + + var ( + expectedID, expectedRows uint = 1, 1 + actual = entity.Workspace{ + ID: 1, + } + ) + sqlMock.ExpectExec("UPDATE"). + WillReturnResult(sqlmock.NewResult(int64(expectedID), int64(expectedRows))) + err = repo.Update(context.Background(), &actual) + require.NoError(t, err) + }) + + t.Run("Update not existing record", func(t *testing.T) { + fakeGDB, sqlMock, err := GetMockDB() + require.NoError(t, err) + repo := NewWorkspaceRepository(fakeGDB) + defer CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + actual := entity.Workspace{ + Name: "NonExistentWorkspace", + } + err = repo.Update(context.Background(), &actual) + require.ErrorIs(t, err, gorm.ErrMissingWhereClause) + }) + + t.Run("Get", func(t *testing.T) { + fakeGDB, sqlMock, err := GetMockDB() + require.NoError(t, err) + repo := NewWorkspaceRepository(fakeGDB) + defer CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + var ( + expectedID uint = 1 + expectedName = "mockedWorkspace" + ) + sqlMock.ExpectQuery("SELECT .* FROM `workspace`"). + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "Backend__id"}). + AddRow(expectedID, expectedName, 1)) + + actual, err := repo.Get(context.Background(), expectedID) + require.NoError(t, err) + require.Equal(t, expectedID, actual.ID) + require.Equal(t, expectedName, actual.Name) + }) + + t.Run("List", func(t *testing.T) { + fakeGDB, sqlMock, err := GetMockDB() + require.NoError(t, err) + repo := NewWorkspaceRepository(fakeGDB) + defer CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + var ( + expectedIDFirst uint = 1 + expectedNameFirst = "mockedWorkspace" + expectedIDSecond uint = 2 + expectedNameSecond = "mockedWorkspace2" + ) + sqlMock.ExpectQuery("SELECT .* FROM `workspace`"). + WillReturnRows( + sqlmock.NewRows([]string{"id", "name", "Backend__id"}). + AddRow(expectedIDFirst, expectedNameFirst, 1). + AddRow(expectedIDSecond, expectedNameSecond, 2)) + + actual, err := repo.List(context.Background()) + require.NoError(t, err) + require.Len(t, actual, 2) + }) +} diff --git a/pkg/server/config.go b/pkg/server/config.go new file mode 100644 index 00000000..3955e354 --- /dev/null +++ b/pkg/server/config.go @@ -0,0 +1,13 @@ +package server + +import ( + "gorm.io/gorm" +) + +type Config struct { + DB *gorm.DB +} + +func NewConfig() *Config { + return &Config{} +} diff --git a/pkg/server/handler/backend/handler.go b/pkg/server/handler/backend/handler.go new file mode 100644 index 00000000..9c23e934 --- /dev/null +++ b/pkg/server/handler/backend/handler.go @@ -0,0 +1,170 @@ +package backend + +import ( + "context" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" + "github.com/go-logr/logr" + "kusionstack.io/kusion/pkg/domain/request" + "kusionstack.io/kusion/pkg/server/handler" + backendmanager "kusionstack.io/kusion/pkg/server/manager/backend" + "kusionstack.io/kusion/pkg/server/util" +) + +// @Summary Create backend +// @Description Create a new backend +// @Accept json +// @Produce json +// @Param backend body CreateBackendRequest true "Created backend" +// @Success 200 {object} entity.Backend "Success" +// @Failure 400 {object} errors.DetailError "Bad Request" +// @Failure 401 {object} errors.DetailError "Unauthorized" +// @Failure 429 {object} errors.DetailError "Too Many Requests" +// @Failure 404 {object} errors.DetailError "Not Found" +// @Failure 500 {object} errors.DetailError "Internal Server Error" +// @Router /api/v1/backend/{backendName} [post] +func (h *Handler) CreateBackend() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Getting stuff from context + ctx := r.Context() + logger := util.GetLogger(ctx) + logger.Info("Creating backend...") + + // Decode the request body into the payload. + var requestPayload request.CreateBackendRequest + if err := requestPayload.Decode(r); err != nil { + render.Render(w, r, handler.FailureResponse(ctx, err)) + return + } + + createdEntity, err := h.backendManager.CreateBackend(ctx, requestPayload) + handler.HandleResult(w, r, ctx, err, createdEntity) + } +} + +// @Summary Delete backend +// @Description Delete specified backend by ID +// @Produce json +// @Param id path int true "Backend ID" +// @Success 200 {object} entity.Backend "Success" +// @Failure 400 {object} errors.DetailError "Bad Request" +// @Failure 401 {object} errors.DetailError "Unauthorized" +// @Failure 429 {object} errors.DetailError "Too Many Requests" +// @Failure 404 {object} errors.DetailError "Not Found" +// @Failure 500 {object} errors.DetailError "Internal Server Error" +// @Router /api/v1/backend/{backendName} [delete] +// @Router /api/v1/backend/{backendID} [delete] +func (h *Handler) DeleteBackend() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Getting stuff from context + ctx, logger, params, err := requestHelper(r) + if err != nil { + render.Render(w, r, handler.FailureResponse(ctx, err)) + return + } + logger.Info("Deleting backend...", "backendID", params.BackendID) + + err = h.backendManager.DeleteBackendByID(ctx, params.BackendID) + handler.HandleResult(w, r, ctx, err, "Deletion Success") + } +} + +// @Summary Update backend +// @Description Update the specified backend +// @Accept json +// @Produce json +// @Param backend body UpdateBackendRequest true "Updated backend" +// @Success 200 {object} entity.Backend "Success" +// @Failure 400 {object} errors.DetailError "Bad Request" +// @Failure 401 {object} errors.DetailError "Unauthorized" +// @Failure 429 {object} errors.DetailError "Too Many Requests" +// @Failure 404 {object} errors.DetailError "Not Found" +// @Failure 500 {object} errors.DetailError "Internal Server Error" +// @Router /api/v1/backend/{backendID} [put] +func (h *Handler) UpdateBackend() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Getting stuff from context + ctx, logger, params, err := requestHelper(r) + if err != nil { + render.Render(w, r, handler.FailureResponse(ctx, err)) + return + } + logger.Info("Updating backend..., backendID", params.BackendID) + + // Decode the request body into the payload. + var requestPayload request.UpdateBackendRequest + if err := requestPayload.Decode(r); err != nil { + render.Render(w, r, handler.FailureResponse(ctx, err)) + return + } + + updatedEntity, err := h.backendManager.UpdateBackendByID(ctx, params.BackendID, requestPayload) + handler.HandleResult(w, r, ctx, err, updatedEntity) + } +} + +// @Summary Get backend +// @Description Get backend information by backend ID +// @Produce json +// @Param id path int true "Backend ID" +// @Success 200 {object} entity.Backend "Success" +// @Failure 400 {object} errors.DetailError "Bad Request" +// @Failure 401 {object} errors.DetailError "Unauthorized" +// @Failure 429 {object} errors.DetailError "Too Many Requests" +// @Failure 404 {object} errors.DetailError "Not Found" +// @Failure 500 {object} errors.DetailError "Internal Server Error" +// @Router /api/v1/backend/{backendID} [get] +func (h *Handler) GetBackend() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Getting stuff from context + ctx, logger, params, err := requestHelper(r) + if err != nil { + render.Render(w, r, handler.FailureResponse(ctx, err)) + return + } + logger.Info("Getting backend...", "backendID", params.BackendID) + + existingEntity, err := h.backendManager.GetBackendByID(ctx, params.BackendID) + handler.HandleResult(w, r, ctx, err, existingEntity) + } +} + +// @Summary List backends +// @Description List all backends +// @Produce json +// @Success 200 {object} entity.Backend "Success" +// @Failure 400 {object} errors.DetailError "Bad Request" +// @Failure 401 {object} errors.DetailError "Unauthorized" +// @Failure 429 {object} errors.DetailError "Too Many Requests" +// @Failure 404 {object} errors.DetailError "Not Found" +// @Failure 500 {object} errors.DetailError "Internal Server Error" +// @Router /api/v1/backend [get] +func (h *Handler) ListBackends() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Getting stuff from context + ctx := r.Context() + logger := util.GetLogger(ctx) + logger.Info("Listing backend...") + + backendEntities, err := h.backendManager.ListBackends(ctx) + handler.HandleResult(w, r, ctx, err, backendEntities) + } +} + +func requestHelper(r *http.Request) (context.Context, *logr.Logger, *BackendRequestParams, error) { + ctx := r.Context() + backendID := chi.URLParam(r, "backendID") + // Get stack with repository + id, err := strconv.Atoi(backendID) + if err != nil { + return nil, nil, nil, backendmanager.ErrInvalidBackendID + } + logger := util.GetLogger(ctx) + params := BackendRequestParams{ + BackendID: uint(id), + } + return ctx, &logger, ¶ms, nil +} diff --git a/pkg/server/handler/backend/handler_test.go b/pkg/server/handler/backend/handler_test.go new file mode 100644 index 00000000..de75ae19 --- /dev/null +++ b/pkg/server/handler/backend/handler_test.go @@ -0,0 +1,299 @@ +package backend + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + "kusionstack.io/kusion/pkg/domain/request" + "kusionstack.io/kusion/pkg/infra/persistence" + "kusionstack.io/kusion/pkg/server/handler" + backendmanager "kusionstack.io/kusion/pkg/server/manager/backend" +) + +func TestBackendHandler(t *testing.T) { + backendName := "test-backend" + backendNameSecond := "test-backend-2" + backendNameUpdated := "test-backend-updated" + t.Run("ListBackends", func(t *testing.T) { + sqlMock, fakeGDB, recorder, backendHandler := setupTest(t) + defer persistence.CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id", "name"}). + AddRow(1, backendName). + AddRow(2, backendNameSecond)) + + // Create a new HTTP request + req, err := http.NewRequest("GET", "/backends", nil) + assert.NoError(t, err) + + // Call the ListBackends handler function + backendHandler.ListBackends()(recorder, req) + assert.Equal(t, http.StatusOK, recorder.Code) + + // Unmarshall the response body + var resp handler.Response + err = json.Unmarshal(recorder.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + // Assertion + assert.Equal(t, 2, len(resp.Data.([]any))) + }) + + t.Run("GetBackend", func(t *testing.T) { + sqlMock, fakeGDB, recorder, backendHandler := setupTest(t) + defer persistence.CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id", "name"}). + AddRow(1, backendName)) + + // Create a new HTTP request + req, err := http.NewRequest("GET", "/backend/{backendID}", nil) + assert.NoError(t, err) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("backendID", "1") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + // Call the ListBackends handler function + backendHandler.GetBackend()(recorder, req) + assert.Equal(t, http.StatusOK, recorder.Code) + + // Unmarshal the response body + var resp handler.Response + err = json.Unmarshal(recorder.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + // Assertion + assert.Equal(t, float64(1), resp.Data.(map[string]any)["id"]) + assert.Equal(t, backendName, resp.Data.(map[string]any)["name"]) + }) + + t.Run("CreateBackend", func(t *testing.T) { + sqlMock, fakeGDB, recorder, backendHandler := setupTest(t) + defer persistence.CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + // Create a new HTTP request + req, err := http.NewRequest("POST", "/backend/{backendID}", nil) + assert.NoError(t, err) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("backendID", "1") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + // Set request body + requestPayload := request.CreateBackendRequest{ + Name: backendName, + } + reqBody, err := json.Marshal(requestPayload) + assert.NoError(t, err) + req.Body = io.NopCloser(bytes.NewReader(reqBody)) + req.Header.Add("Content-Type", "application/json") + + sqlMock.ExpectBegin() + sqlMock.ExpectExec("INSERT"). + WillReturnResult(sqlmock.NewResult(int64(1), int64(1))) + sqlMock.ExpectCommit() + + // Call the CreateBackend handler function + backendHandler.CreateBackend()(recorder, req) + assert.Equal(t, http.StatusOK, recorder.Code) + + // Unmarshal the response body + var resp handler.Response + err = json.Unmarshal(recorder.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + // Assertion + assert.Equal(t, float64(1), resp.Data.(map[string]any)["id"]) + assert.Equal(t, backendName, resp.Data.(map[string]any)["name"]) + }) + + t.Run("UpdateExistingBackend", func(t *testing.T) { + sqlMock, fakeGDB, recorder, backendHandler := setupTest(t) + defer persistence.CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + // Update a new HTTP request + req, err := http.NewRequest("POST", "/backend/{backendID}", nil) + assert.NoError(t, err) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("backendID", "1") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + // Set request body + requestPayload := request.UpdateBackendRequest{ + // Set your request payload fields here + ID: 1, + Name: backendNameUpdated, + } + reqBody, err := json.Marshal(requestPayload) + assert.NoError(t, err) + req.Body = io.NopCloser(bytes.NewReader(reqBody)) + req.Header.Add("Content-Type", "application/json") + + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "Backend__id"}). + AddRow(1, "test-backend-updated", 1)) + sqlMock.ExpectExec("UPDATE"). + WillReturnResult(sqlmock.NewResult(int64(1), int64(1))) + + // Call the ListBackends handler function + backendHandler.UpdateBackend()(recorder, req) + assert.Equal(t, http.StatusOK, recorder.Code) + + // Unmarshall the response body + var resp handler.Response + err = json.Unmarshal(recorder.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + // Assertion + assert.Equal(t, float64(1), resp.Data.(map[string]any)["id"]) + assert.Equal(t, backendNameUpdated, resp.Data.(map[string]any)["name"]) + }) + + t.Run("Delete Existing Backend", func(t *testing.T) { + sqlMock, fakeGDB, recorder, backendHandler := setupTest(t) + defer persistence.CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + // Create a new HTTP request + req, err := http.NewRequest("DELETE", "/backend/{backendID}", nil) + assert.NoError(t, err) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("backendID", "1") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + // Mock the Delete method of the backend repository + sqlMock.ExpectBegin() + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id"}). + AddRow(1)) + sqlMock.ExpectExec("UPDATE"). + WillReturnResult(sqlmock.NewResult(1, 1)) + sqlMock.ExpectCommit() + + // Call the DeleteBackend handler function + backendHandler.DeleteBackend()(recorder, req) + assert.Equal(t, http.StatusOK, recorder.Code) + + // Unmarshall the response body + var resp handler.Response + err = json.Unmarshal(recorder.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + assert.Equal(t, "Deletion Success", resp.Data) + }) + + t.Run("Delete Nonexisting Backend", func(t *testing.T) { + sqlMock, fakeGDB, recorder, backendHandler := setupTest(t) + defer persistence.CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + // Create a new HTTP request + req, err := http.NewRequest("DELETE", "/backend/{backendID}", nil) + assert.NoError(t, err) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("backendID", "1") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + sqlMock.ExpectBegin() + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id"})) + + // Call the DeleteBackend handler function + backendHandler.DeleteBackend()(recorder, req) + // Unmarshall the response body + var resp handler.Response + err = json.Unmarshal(recorder.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + assert.Equal(t, http.StatusOK, recorder.Code) + assert.Equal(t, false, resp.Success) + assert.Equal(t, backendmanager.ErrGettingNonExistingBackend.Error(), resp.Message) + }) + + t.Run("Update Nonexisting Backend", func(t *testing.T) { + sqlMock, fakeGDB, recorder, backendHandler := setupTest(t) + defer persistence.CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + // Update a new HTTP request + req, err := http.NewRequest("POST", "/backend/{backendID}", nil) + assert.NoError(t, err) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("backendID", "1") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + // Set request body + requestPayload := request.UpdateBackendRequest{ + // Set your request payload fields here + ID: 1, + Name: "test-backend-updated", + } + reqBody, err := json.Marshal(requestPayload) + assert.NoError(t, err) + req.Body = io.NopCloser(bytes.NewReader(reqBody)) + req.Header.Add("Content-Type", "application/json") + + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id"})) + + // Call the UpdateBackend handler function + backendHandler.UpdateBackend()(recorder, req) + assert.Equal(t, http.StatusOK, recorder.Code) + + // Unmarshall the response body + var resp handler.Response + err = json.Unmarshal(recorder.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + // Assertion + assert.Equal(t, http.StatusOK, recorder.Code) + assert.Equal(t, false, resp.Success) + assert.Equal(t, backendmanager.ErrUpdatingNonExistingBackend.Error(), resp.Message) + }) +} + +func setupTest(t *testing.T) (sqlmock.Sqlmock, *gorm.DB, *httptest.ResponseRecorder, *Handler) { + fakeGDB, sqlMock, err := persistence.GetMockDB() + require.NoError(t, err) + backendRepo := persistence.NewBackendRepository(fakeGDB) + backendHandler := &Handler{ + backendManager: backendmanager.NewBackendManager(backendRepo), + } + recorder := httptest.NewRecorder() + return sqlMock, fakeGDB, recorder, backendHandler +} diff --git a/pkg/server/handler/backend/types.go b/pkg/server/handler/backend/types.go new file mode 100644 index 00000000..effccdba --- /dev/null +++ b/pkg/server/handler/backend/types.go @@ -0,0 +1,21 @@ +package backend + +import ( + backendmanager "kusionstack.io/kusion/pkg/server/manager/backend" +) + +func NewHandler( + backendManager *backendmanager.BackendManager, +) (*Handler, error) { + return &Handler{ + backendManager: backendManager, + }, nil +} + +type Handler struct { + backendManager *backendmanager.BackendManager +} + +type BackendRequestParams struct { + BackendID uint +} diff --git a/pkg/server/handler/endpoint/endpoint.go b/pkg/server/handler/endpoint/endpoint.go new file mode 100644 index 00000000..8b2ef6b0 --- /dev/null +++ b/pkg/server/handler/endpoint/endpoint.go @@ -0,0 +1,52 @@ +package endpoint + +import ( + "fmt" + "net/http" + "sort" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/go-logr/logr" + "kusionstack.io/kusion/pkg/server/util" +) + +// Endpoints provides an endpoint to list all available endpoints registered +// in the router. +// +// @Summary List all available endpoints +// @Description List all registered endpoints in the router +// @Tags debug +// @Accept plain +// @Produce plain +// @Success 200 {string} string "Endpoints listed successfully" +// @Router /endpoints [get] +func Endpoints(router chi.Router) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + log := util.GetLogger(r.Context()) + endpoints := listEndpoints(log, router) + w.Header().Set("Content-Type", "text/plain") + w.Write([]byte(strings.Join(endpoints, "\n"))) + } +} + +// listEndpoints generates a list of all routes registered in the router. +func listEndpoints(log logr.Logger, r chi.Router) []string { + var endpoints []string + + // Walk through the routes to collect endpoints + walkFunc := func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error { + endpoint := fmt.Sprintf("%s\t%s", method, route) + endpoints = append(endpoints, endpoint) + return nil + } + + // Populate the list of endpoints by walking through the router + if err := chi.Walk(r, walkFunc); err != nil { + log.Error(err, "Walking routes error") + } + + // Sort the collected endpoints alphabetically + sort.Strings(endpoints) + return endpoints +} diff --git a/pkg/server/handler/organization/handler.go b/pkg/server/handler/organization/handler.go new file mode 100644 index 00000000..fc6614a0 --- /dev/null +++ b/pkg/server/handler/organization/handler.go @@ -0,0 +1,169 @@ +package organization + +import ( + "context" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" + "github.com/go-logr/logr" + "kusionstack.io/kusion/pkg/domain/request" + "kusionstack.io/kusion/pkg/server/handler" + organizationmanager "kusionstack.io/kusion/pkg/server/manager/organization" + "kusionstack.io/kusion/pkg/server/util" +) + +// @Summary Create organization +// @Description Create a new organization +// @Accept json +// @Produce json +// @Param organization body CreateOrganizationRequest true "Created organization" +// @Success 200 {object} entity.Organization "Success" +// @Failure 400 {object} errors.DetailError "Bad Request" +// @Failure 401 {object} errors.DetailError "Unauthorized" +// @Failure 429 {object} errors.DetailError "Too Many Requests" +// @Failure 404 {object} errors.DetailError "Not Found" +// @Failure 500 {object} errors.DetailError "Internal Server Error" +// @Router /api/v1/organization/{organizationName} [post] +func (h *Handler) CreateOrganization() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Getting stuff from context + ctx := r.Context() + logger := util.GetLogger(ctx) + logger.Info("Creating organization...") + + // Decode the request body into the payload. + var requestPayload request.CreateOrganizationRequest + if err := requestPayload.Decode(r); err != nil { + render.Render(w, r, handler.FailureResponse(ctx, err)) + return + } + + createdEntity, err := h.organizationManager.CreateOrganization(ctx, requestPayload) + handler.HandleResult(w, r, ctx, err, createdEntity) + } +} + +// @Summary Delete organization +// @Description Delete specified organization by ID +// @Produce json +// @Param id path int true "Organization ID" +// @Success 200 {object} entity.Organization "Success" +// @Failure 400 {object} errors.DetailError "Bad Request" +// @Failure 401 {object} errors.DetailError "Unauthorized" +// @Failure 429 {object} errors.DetailError "Too Many Requests" +// @Failure 404 {object} errors.DetailError "Not Found" +// @Failure 500 {object} errors.DetailError "Internal Server Error" +// @Router /api/v1/organization/{organizationID} [delete] +func (h *Handler) DeleteOrganization() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Getting stuff from context + ctx, logger, params, err := requestHelper(r) + if err != nil { + render.Render(w, r, handler.FailureResponse(ctx, err)) + return + } + logger.Info("Deleting organization...") + + err = h.organizationManager.DeleteOrganizationByID(ctx, params.OrganizationID) + handler.HandleResult(w, r, ctx, err, "Deletion Success") + } +} + +// @Summary Update organization +// @Description Update the specified organization +// @Accept json +// @Produce json +// @Param organization body UpdateOrganizationRequest true "Updated organization" +// @Success 200 {object} entity.Organization "Success" +// @Failure 400 {object} errors.DetailError "Bad Request" +// @Failure 401 {object} errors.DetailError "Unauthorized" +// @Failure 429 {object} errors.DetailError "Too Many Requests" +// @Failure 404 {object} errors.DetailError "Not Found" +// @Failure 500 {object} errors.DetailError "Internal Server Error" +// @Router /api/v1/organization/{organizationID} [put] +func (h *Handler) UpdateOrganization() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Getting stuff from context + ctx, logger, params, err := requestHelper(r) + if err != nil { + render.Render(w, r, handler.FailureResponse(ctx, err)) + return + } + logger.Info("Updating organization...") + + // Decode the request body into the payload. + var requestPayload request.UpdateOrganizationRequest + if err := requestPayload.Decode(r); err != nil { + render.Render(w, r, handler.FailureResponse(ctx, err)) + return + } + + updatedEntity, err := h.organizationManager.UpdateOrganizationByID(ctx, params.OrganizationID, requestPayload) + handler.HandleResult(w, r, ctx, err, updatedEntity) + } +} + +// @Summary Get organization +// @Description Get organization information by organization ID +// @Produce json +// @Param id path int true "Organization ID" +// @Success 200 {object} entity.Organization "Success" +// @Failure 400 {object} errors.DetailError "Bad Request" +// @Failure 401 {object} errors.DetailError "Unauthorized" +// @Failure 429 {object} errors.DetailError "Too Many Requests" +// @Failure 404 {object} errors.DetailError "Not Found" +// @Failure 500 {object} errors.DetailError "Internal Server Error" +// @Router /api/v1/organization/{organizationID} [get] +func (h *Handler) GetOrganization() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Getting stuff from context + ctx, logger, params, err := requestHelper(r) + if err != nil { + render.Render(w, r, handler.FailureResponse(ctx, err)) + return + } + logger.Info("Getting organization...") + + existingEntity, err := h.organizationManager.GetOrganizationByID(ctx, params.OrganizationID) + handler.HandleResult(w, r, ctx, err, existingEntity) + } +} + +// @Summary List organizations +// @Description List all organizations +// @Produce json +// @Success 200 {object} entity.Organization "Success" +// @Failure 400 {object} errors.DetailError "Bad Request" +// @Failure 401 {object} errors.DetailError "Unauthorized" +// @Failure 429 {object} errors.DetailError "Too Many Requests" +// @Failure 404 {object} errors.DetailError "Not Found" +// @Failure 500 {object} errors.DetailError "Internal Server Error" +// @Router /api/v1/organization [get] +func (h *Handler) ListOrganizations() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Getting stuff from context + ctx := r.Context() + logger := util.GetLogger(ctx) + logger.Info("Listing organization...") + + organizationEntities, err := h.organizationManager.ListOrganizations(ctx) + handler.HandleResult(w, r, ctx, err, organizationEntities) + } +} + +func requestHelper(r *http.Request) (context.Context, *logr.Logger, *OrganizationRequestParams, error) { + ctx := r.Context() + organizationID := chi.URLParam(r, "organizationID") + // Get stack with repository + id, err := strconv.Atoi(organizationID) + if err != nil { + return nil, nil, nil, organizationmanager.ErrInvalidOrganizationID + } + logger := util.GetLogger(ctx) + params := OrganizationRequestParams{ + OrganizationID: uint(id), + } + return ctx, &logger, ¶ms, nil +} diff --git a/pkg/server/handler/organization/handler_test.go b/pkg/server/handler/organization/handler_test.go new file mode 100644 index 00000000..32794f49 --- /dev/null +++ b/pkg/server/handler/organization/handler_test.go @@ -0,0 +1,302 @@ +package organization + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + "kusionstack.io/kusion/pkg/domain/request" + "kusionstack.io/kusion/pkg/infra/persistence" + "kusionstack.io/kusion/pkg/server/handler" + organizationmanager "kusionstack.io/kusion/pkg/server/manager/organization" +) + +func TestOrganizationHandler(t *testing.T) { + var ( + orgName = "test-org" + orgNameSecond = "test-org-2" + orgNameUpdated = "test-org-updated" + ) + t.Run("ListOrganizations", func(t *testing.T) { + sqlMock, fakeGDB, recorder, organizationHandler := setupTest(t) + defer persistence.CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "Backend__id"}). + AddRow(1, orgName, 1). + AddRow(2, orgNameSecond, 2)) + + // Create a new HTTP request + req, err := http.NewRequest("GET", "/organizations", nil) + assert.NoError(t, err) + + // Call the ListOrganizations handler function + organizationHandler.ListOrganizations()(recorder, req) + assert.Equal(t, http.StatusOK, recorder.Code) + + // Unmarshall the response body + var resp handler.Response + err = json.Unmarshal(recorder.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + // Assertion + assert.Equal(t, 2, len(resp.Data.([]any))) + }) + + t.Run("GetOrganization", func(t *testing.T) { + sqlMock, fakeGDB, recorder, organizationHandler := setupTest(t) + defer persistence.CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id", "name"}). + AddRow(1, orgName)) + + // Create a new HTTP request + req, err := http.NewRequest("GET", "/organization/{organizationID}", nil) + assert.NoError(t, err) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("organizationID", "1") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + // Call the ListOrganizations handler function + organizationHandler.GetOrganization()(recorder, req) + assert.Equal(t, http.StatusOK, recorder.Code) + + // Unmarshal the response body + var resp handler.Response + err = json.Unmarshal(recorder.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + // Assertion + assert.Equal(t, float64(1), resp.Data.(map[string]any)["id"]) + assert.Equal(t, orgName, resp.Data.(map[string]any)["name"]) + }) + + t.Run("CreateOrganization", func(t *testing.T) { + sqlMock, fakeGDB, recorder, organizationHandler := setupTest(t) + defer persistence.CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + // Create a new HTTP request + req, err := http.NewRequest("POST", "/organization/{organizationID}", nil) + assert.NoError(t, err) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("organizationID", "1") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + // Set request body + requestPayload := request.CreateOrganizationRequest{ + Name: orgName, + Owners: []string{"hua.li", "xiaoming.li"}, + } + reqBody, err := json.Marshal(requestPayload) + assert.NoError(t, err) + req.Body = io.NopCloser(bytes.NewReader(reqBody)) + req.Header.Add("Content-Type", "application/json") + + sqlMock.ExpectBegin() + sqlMock.ExpectExec("INSERT"). + WillReturnResult(sqlmock.NewResult(int64(1), int64(1))) + sqlMock.ExpectCommit() + + // Call the CreateOrganization handler function + organizationHandler.CreateOrganization()(recorder, req) + assert.Equal(t, http.StatusOK, recorder.Code) + + // Unmarshal the response body + var resp handler.Response + err = json.Unmarshal(recorder.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + // Assertion + assert.Equal(t, float64(1), resp.Data.(map[string]any)["id"]) + assert.Equal(t, orgName, resp.Data.(map[string]any)["name"]) + }) + + t.Run("UpdateExistingOrganization", func(t *testing.T) { + sqlMock, fakeGDB, recorder, organizationHandler := setupTest(t) + defer persistence.CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + // Update a new HTTP request + req, err := http.NewRequest("POST", "/organization/{organizationID}", nil) + assert.NoError(t, err) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("organizationID", "1") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + // Set request body + requestPayload := request.UpdateOrganizationRequest{ + // Set your request payload fields here + ID: 1, + Name: orgNameUpdated, + } + reqBody, err := json.Marshal(requestPayload) + assert.NoError(t, err) + req.Body = io.NopCloser(bytes.NewReader(reqBody)) + req.Header.Add("Content-Type", "application/json") + + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "Backend__id"}). + AddRow(1, orgName, 1)) + sqlMock.ExpectExec("UPDATE"). + WillReturnResult(sqlmock.NewResult(int64(1), int64(1))) + + // Call the ListOrganizations handler function + organizationHandler.UpdateOrganization()(recorder, req) + assert.Equal(t, http.StatusOK, recorder.Code) + + // Unmarshall the response body + var resp handler.Response + err = json.Unmarshal(recorder.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + // Assertion + assert.Equal(t, float64(1), resp.Data.(map[string]any)["id"]) + assert.Equal(t, orgNameUpdated, resp.Data.(map[string]any)["name"]) + }) + + t.Run("Delete Existing Organization", func(t *testing.T) { + sqlMock, fakeGDB, recorder, organizationHandler := setupTest(t) + defer persistence.CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + // Create a new HTTP request + req, err := http.NewRequest("DELETE", "/organization/{organizationID}", nil) + assert.NoError(t, err) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("organizationID", "1") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + // Mock the Delete method of the organization repository + sqlMock.ExpectBegin() + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id"}). + AddRow(1)) + sqlMock.ExpectExec("UPDATE"). + WillReturnResult(sqlmock.NewResult(1, 1)) + sqlMock.ExpectCommit() + + // Call the DeleteOrganization handler function + organizationHandler.DeleteOrganization()(recorder, req) + assert.Equal(t, http.StatusOK, recorder.Code) + + // Unmarshall the response body + var resp handler.Response + err = json.Unmarshal(recorder.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + assert.Equal(t, "Deletion Success", resp.Data) + }) + + t.Run("Delete Nonexisting Organization", func(t *testing.T) { + sqlMock, fakeGDB, recorder, organizationHandler := setupTest(t) + defer persistence.CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + // Create a new HTTP request + req, err := http.NewRequest("DELETE", "/organization/{organizationID}", nil) + assert.NoError(t, err) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("organizationID", "1") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + sqlMock.ExpectBegin() + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id"})) + + // Call the DeleteOrganization handler function + organizationHandler.DeleteOrganization()(recorder, req) + // Unmarshall the response body + var resp handler.Response + err = json.Unmarshal(recorder.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + assert.Equal(t, http.StatusOK, recorder.Code) + assert.Equal(t, false, resp.Success) + assert.Equal(t, organizationmanager.ErrGettingNonExistingOrganization.Error(), resp.Message) + }) + + t.Run("Update Nonexisting Organization", func(t *testing.T) { + sqlMock, fakeGDB, recorder, organizationHandler := setupTest(t) + defer persistence.CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + // Update a new HTTP request + req, err := http.NewRequest("POST", "/organization/{organizationID}", nil) + assert.NoError(t, err) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("organizationID", "1") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + // Set request body + requestPayload := request.UpdateOrganizationRequest{ + // Set your request payload fields here + ID: 1, + Name: orgNameUpdated, + } + reqBody, err := json.Marshal(requestPayload) + assert.NoError(t, err) + req.Body = io.NopCloser(bytes.NewReader(reqBody)) + req.Header.Add("Content-Type", "application/json") + + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id"})) + + // Call the UpdateOrganization handler function + organizationHandler.UpdateOrganization()(recorder, req) + assert.Equal(t, http.StatusOK, recorder.Code) + + // Unmarshall the response body + var resp handler.Response + err = json.Unmarshal(recorder.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + // Assertion + assert.Equal(t, http.StatusOK, recorder.Code) + assert.Equal(t, false, resp.Success) + assert.Equal(t, organizationmanager.ErrUpdatingNonExistingOrganization.Error(), resp.Message) + }) +} + +func setupTest(t *testing.T) (sqlmock.Sqlmock, *gorm.DB, *httptest.ResponseRecorder, *Handler) { + fakeGDB, sqlMock, err := persistence.GetMockDB() + require.NoError(t, err) + organizationRepo := persistence.NewOrganizationRepository(fakeGDB) + organizationHandler := &Handler{ + organizationManager: organizationmanager.NewOrganizationManager(organizationRepo), + } + recorder := httptest.NewRecorder() + return sqlMock, fakeGDB, recorder, organizationHandler +} diff --git a/pkg/server/handler/organization/types.go b/pkg/server/handler/organization/types.go new file mode 100644 index 00000000..bdc1c8f0 --- /dev/null +++ b/pkg/server/handler/organization/types.go @@ -0,0 +1,21 @@ +package organization + +import ( + organizationmanager "kusionstack.io/kusion/pkg/server/manager/organization" +) + +func NewHandler( + organizationManager *organizationmanager.OrganizationManager, +) (*Handler, error) { + return &Handler{ + organizationManager: organizationManager, + }, nil +} + +type Handler struct { + organizationManager *organizationmanager.OrganizationManager +} + +type OrganizationRequestParams struct { + OrganizationID uint +} diff --git a/pkg/server/handler/project/handler.go b/pkg/server/handler/project/handler.go new file mode 100644 index 00000000..99e5a408 --- /dev/null +++ b/pkg/server/handler/project/handler.go @@ -0,0 +1,170 @@ +package project + +import ( + "context" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" + "github.com/go-logr/logr" + "kusionstack.io/kusion/pkg/domain/request" + "kusionstack.io/kusion/pkg/server/handler" + projectmanager "kusionstack.io/kusion/pkg/server/manager/project" + "kusionstack.io/kusion/pkg/server/util" +) + +// @Summary Create project +// @Description Create a new project +// @Accept json +// @Produce json +// @Param project body CreateProjectRequest true "Created project" +// @Success 200 {object} entity.Project "Success" +// @Failure 400 {object} errors.DetailError "Bad Request" +// @Failure 401 {object} errors.DetailError "Unauthorized" +// @Failure 429 {object} errors.DetailError "Too Many Requests" +// @Failure 404 {object} errors.DetailError "Not Found" +// @Failure 500 {object} errors.DetailError "Internal Server Error" +// @Router /api/v1/project/{projectName} [post] +func (h *Handler) CreateProject() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Getting stuff from context + ctx := r.Context() + logger := util.GetLogger(ctx) + logger.Info("Creating project...") + + // Decode the request body into the payload. + var requestPayload request.CreateProjectRequest + if err := requestPayload.Decode(r); err != nil { + render.Render(w, r, handler.FailureResponse(ctx, err)) + return + } + + createdEntity, err := h.projectManager.CreateProject(ctx, requestPayload) + handler.HandleResult(w, r, ctx, err, createdEntity) + } +} + +// @Summary Delete project +// @Description Delete specified project by ID +// @Produce json +// @Param id path int true "Project ID" +// @Success 200 {object} entity.Project "Success" +// @Failure 400 {object} errors.DetailError "Bad Request" +// @Failure 401 {object} errors.DetailError "Unauthorized" +// @Failure 429 {object} errors.DetailError "Too Many Requests" +// @Failure 404 {object} errors.DetailError "Not Found" +// @Failure 500 {object} errors.DetailError "Internal Server Error" +// @Router /api/v1/project/{projectName} [delete] +// @Router /api/v1/project/{projectID} [delete] +func (h *Handler) DeleteProject() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Getting stuff from context + ctx, logger, params, err := requestHelper(r) + if err != nil { + render.Render(w, r, handler.FailureResponse(ctx, err)) + return + } + logger.Info("Deleting source...", "projectID", params.ProjectID) + + err = h.projectManager.DeleteProjectByID(ctx, params.ProjectID) + handler.HandleResult(w, r, ctx, err, "Deletion Success") + } +} + +// @Summary Update project +// @Description Update the specified project +// @Accept json +// @Produce json +// @Param project body UpdateProjectRequest true "Updated project" +// @Success 200 {object} entity.Project "Success" +// @Failure 400 {object} errors.DetailError "Bad Request" +// @Failure 401 {object} errors.DetailError "Unauthorized" +// @Failure 429 {object} errors.DetailError "Too Many Requests" +// @Failure 404 {object} errors.DetailError "Not Found" +// @Failure 500 {object} errors.DetailError "Internal Server Error" +// @Router /api/v1/project/{projectID} [put] +func (h *Handler) UpdateProject() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Getting stuff from context + ctx, logger, params, err := requestHelper(r) + if err != nil { + render.Render(w, r, handler.FailureResponse(ctx, err)) + return + } + logger.Info("Updating project...", "projectID", params.ProjectID) + + // Decode the request body into the payload. + var requestPayload request.UpdateProjectRequest + if err := requestPayload.Decode(r); err != nil { + render.Render(w, r, handler.FailureResponse(ctx, err)) + return + } + + updatedEntity, err := h.projectManager.UpdateProjectByID(ctx, params.ProjectID, requestPayload) + handler.HandleResult(w, r, ctx, err, updatedEntity) + } +} + +// @Summary Get project +// @Description Get project information by project ID +// @Produce json +// @Param id path int true "Project ID" +// @Success 200 {object} entity.Project "Success" +// @Failure 400 {object} errors.DetailError "Bad Request" +// @Failure 401 {object} errors.DetailError "Unauthorized" +// @Failure 429 {object} errors.DetailError "Too Many Requests" +// @Failure 404 {object} errors.DetailError "Not Found" +// @Failure 500 {object} errors.DetailError "Internal Server Error" +// @Router /api/v1/project/{projectID} [get] +func (h *Handler) GetProject() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Getting stuff from context + ctx, logger, params, err := requestHelper(r) + if err != nil { + render.Render(w, r, handler.FailureResponse(ctx, err)) + return + } + logger.Info("Getting project...", "projectID", params.ProjectID) + + existingEntity, err := h.projectManager.GetProjectByID(ctx, params.ProjectID) + handler.HandleResult(w, r, ctx, err, existingEntity) + } +} + +// @Summary List projects +// @Description List all projects +// @Produce json +// @Success 200 {object} entity.Project "Success" +// @Failure 400 {object} errors.DetailError "Bad Request" +// @Failure 401 {object} errors.DetailError "Unauthorized" +// @Failure 429 {object} errors.DetailError "Too Many Requests" +// @Failure 404 {object} errors.DetailError "Not Found" +// @Failure 500 {object} errors.DetailError "Internal Server Error" +// @Router /api/v1/project [get] +func (h *Handler) ListProjects() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Getting stuff from context + ctx := r.Context() + logger := util.GetLogger(ctx) + logger.Info("Listing project...") + + projectEntities, err := h.projectManager.ListProjects(ctx) + handler.HandleResult(w, r, ctx, err, projectEntities) + } +} + +func requestHelper(r *http.Request) (context.Context, *logr.Logger, *ProjectRequestParams, error) { + ctx := r.Context() + projectID := chi.URLParam(r, "projectID") + // Get stack with repository + id, err := strconv.Atoi(projectID) + if err != nil { + return nil, nil, nil, projectmanager.ErrInvalidProjectID + } + logger := util.GetLogger(ctx) + params := ProjectRequestParams{ + ProjectID: uint(id), + } + return ctx, &logger, ¶ms, nil +} diff --git a/pkg/server/handler/project/handler_test.go b/pkg/server/handler/project/handler_test.go new file mode 100644 index 00000000..c2aa75cb --- /dev/null +++ b/pkg/server/handler/project/handler_test.go @@ -0,0 +1,333 @@ +package project + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + "kusionstack.io/kusion/pkg/domain/constant" + "kusionstack.io/kusion/pkg/domain/request" + "kusionstack.io/kusion/pkg/infra/persistence" + "kusionstack.io/kusion/pkg/server/handler" + projectmanager "kusionstack.io/kusion/pkg/server/manager/project" +) + +func TestProjectHandler(t *testing.T) { + var ( + projectName = "test-project" + projectNameSecond = "test-project-2" + projectPath = "/path/to/project" + projectNameUpdated = "test-project-updated" + projectPathUpdated = "/path/to/project/updated" + owners = persistence.MultiString{"hua.li", "xiaoming.li"} + ) + t.Run("ListProjects", func(t *testing.T) { + sqlMock, fakeGDB, recorder, projectHandler := setupTest(t) + defer persistence.CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "path", "Organization__id", "Organization__name", "Organization__owners", "Source__id", "Source__remote", "Source__source_provider"}). + AddRow(1, projectName, projectPath, 1, "test-org", owners, 1, "https://github.com/test/repo", constant.SourceProviderTypeGithub). + AddRow(2, projectNameSecond, projectPath, 2, "test-org-2", owners, 1, "https://github.com/test/repo", constant.SourceProviderTypeGithub)) + + // Create a new HTTP request + req, err := http.NewRequest("GET", "/projects", nil) + assert.NoError(t, err) + + // Call the ListProjects handler function + projectHandler.ListProjects()(recorder, req) + assert.Equal(t, http.StatusOK, recorder.Code) + + // Unmarshall the response body + var resp handler.Response + err = json.Unmarshal(recorder.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + // Assertion + assert.Equal(t, 2, len(resp.Data.([]any))) + }) + + t.Run("GetProject", func(t *testing.T) { + sqlMock, fakeGDB, recorder, projectHandler := setupTest(t) + defer persistence.CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "path", "Organization__id", "Organization__name", "Organization__owners", "Source__id", "Source__remote", "Source__source_provider"}). + AddRow(1, projectName, projectPath, 1, "test-org", owners, 1, "https://github.com/test/repo", constant.SourceProviderTypeGithub)) + + // Create a new HTTP request + req, err := http.NewRequest("GET", "/project/{projectID}", nil) + assert.NoError(t, err) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("projectID", "1") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + // Call the ListProjects handler function + projectHandler.GetProject()(recorder, req) + assert.Equal(t, http.StatusOK, recorder.Code) + + // Unmarshal the response body + var resp handler.Response + err = json.Unmarshal(recorder.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + // Assertion + assert.Equal(t, float64(1), resp.Data.(map[string]any)["id"]) + assert.Equal(t, projectName, resp.Data.(map[string]any)["name"]) + }) + + t.Run("CreateProject", func(t *testing.T) { + sqlMock, fakeGDB, recorder, projectHandler := setupTest(t) + defer persistence.CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + // Create a new HTTP request + req, err := http.NewRequest("POST", "/project/{projectID}", nil) + assert.NoError(t, err) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("projectID", "1") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + // Set request body + requestPayload := request.CreateProjectRequest{ + Name: projectName, + Path: projectPath, + SourceID: uint(1), + OrganizationID: uint(1), + } + reqBody, err := json.Marshal(requestPayload) + assert.NoError(t, err) + req.Body = io.NopCloser(bytes.NewReader(reqBody)) + req.Header.Add("Content-Type", "application/json") + + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id", "remote", "source_provider"}). + AddRow(1, "https://github.com/test/repo", constant.SourceProviderTypeGithub)) + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "owners"}). + AddRow(1, "test-org", owners)) + sqlMock.ExpectBegin() + sqlMock.ExpectExec("INSERT"). + WillReturnResult(sqlmock.NewResult(int64(1), int64(1))) + sqlMock.ExpectCommit() + + // Call the CreateProject handler function + projectHandler.CreateProject()(recorder, req) + assert.Equal(t, http.StatusOK, recorder.Code) + + // Unmarshal the response body + var resp handler.Response + err = json.Unmarshal(recorder.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + // Assertion + assert.Equal(t, float64(1), resp.Data.(map[string]any)["id"]) + assert.Equal(t, projectName, resp.Data.(map[string]any)["name"]) + }) + + t.Run("UpdateExistingProject", func(t *testing.T) { + sqlMock, fakeGDB, recorder, projectHandler := setupTest(t) + defer persistence.CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + // Update a new HTTP request + req, err := http.NewRequest("PUT", "/project/{projectID}", nil) + assert.NoError(t, err) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("projectID", "1") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + // Set request body + requestPayload := request.UpdateProjectRequest{ + // Set your request payload fields here + ID: 1, + Name: projectNameUpdated, + Path: projectPathUpdated, + OrganizationID: 1, + SourceID: 1, + } + reqBody, err := json.Marshal(requestPayload) + assert.NoError(t, err) + req.Body = io.NopCloser(bytes.NewReader(reqBody)) + req.Header.Add("Content-Type", "application/json") + + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id", "remote", "source_provider"}). + AddRow(1, "https://github.com/test/repo", constant.SourceProviderTypeGithub)) + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "owners"}). + AddRow(1, "test-org", owners)) + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "path", "Organization__id", "Organization__name", "Organization__owners", "Source__id", "Source__remote", "Source__source_provider"}). + AddRow(1, projectName, projectPath, 1, "test-org", owners, 1, "https://github.com/test/repo", constant.SourceProviderTypeGithub)) + sqlMock.ExpectExec("UPDATE"). + WillReturnResult(sqlmock.NewResult(int64(1), int64(1))) + + // Call the ListProjects handler function + projectHandler.UpdateProject()(recorder, req) + assert.Equal(t, http.StatusOK, recorder.Code) + + // Unmarshall the response body + var resp handler.Response + err = json.Unmarshal(recorder.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + // Assertion + assert.Equal(t, float64(1), resp.Data.(map[string]any)["id"]) + assert.Equal(t, projectNameUpdated, resp.Data.(map[string]any)["name"]) + assert.Equal(t, projectPathUpdated, resp.Data.(map[string]any)["path"]) + }) + + t.Run("Delete Existing Project", func(t *testing.T) { + sqlMock, fakeGDB, recorder, projectHandler := setupTest(t) + defer persistence.CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + // Create a new HTTP request + req, err := http.NewRequest("DELETE", "/project/{projectID}", nil) + assert.NoError(t, err) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("projectID", "1") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + // Mock the Delete method of the project repository + sqlMock.ExpectBegin() + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id"}). + AddRow(1)) + sqlMock.ExpectExec("UPDATE"). + WillReturnResult(sqlmock.NewResult(1, 1)) + sqlMock.ExpectCommit() + + // Call the DeleteProject handler function + projectHandler.DeleteProject()(recorder, req) + assert.Equal(t, http.StatusOK, recorder.Code) + + // Unmarshall the response body + var resp handler.Response + err = json.Unmarshal(recorder.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + assert.Equal(t, "Deletion Success", resp.Data) + }) + + t.Run("Delete Nonexisting Project", func(t *testing.T) { + sqlMock, fakeGDB, recorder, projectHandler := setupTest(t) + defer persistence.CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + // Create a new HTTP request + req, err := http.NewRequest("DELETE", "/project/{projectID}", nil) + assert.NoError(t, err) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("projectID", "1") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + sqlMock.ExpectBegin() + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id"})) + + // Call the DeleteProject handler function + projectHandler.DeleteProject()(recorder, req) + // Unmarshall the response body + var resp handler.Response + err = json.Unmarshal(recorder.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + assert.Equal(t, http.StatusOK, recorder.Code) + assert.Equal(t, false, resp.Success) + assert.Equal(t, projectmanager.ErrGettingNonExistingProject.Error(), resp.Message) + }) + + t.Run("Update Nonexisting Project", func(t *testing.T) { + sqlMock, fakeGDB, recorder, projectHandler := setupTest(t) + defer persistence.CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + // Update a new HTTP request + req, err := http.NewRequest("POST", "/project/{projectID}", nil) + assert.NoError(t, err) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("projectID", "1") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + // Set request body + requestPayload := request.UpdateProjectRequest{ + // Set your request payload fields here + ID: 1, + Name: "test-project-updated", + Path: projectPathUpdated, + } + reqBody, err := json.Marshal(requestPayload) + assert.NoError(t, err) + req.Body = io.NopCloser(bytes.NewReader(reqBody)) + req.Header.Add("Content-Type", "application/json") + + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id", "remote", "source_provider"}). + AddRow(1, "https://github.com/test/repo", constant.SourceProviderTypeGithub)) + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "owners"}). + AddRow(1, "test-org", owners)) + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id"})) + + // Call the UpdateProject handler function + projectHandler.UpdateProject()(recorder, req) + assert.Equal(t, http.StatusOK, recorder.Code) + + // Unmarshall the response body + var resp handler.Response + err = json.Unmarshal(recorder.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + // Assertion + assert.Equal(t, http.StatusOK, recorder.Code) + assert.Equal(t, false, resp.Success) + assert.Equal(t, projectmanager.ErrUpdatingNonExistingProject.Error(), resp.Message) + }) +} + +func setupTest(t *testing.T) (sqlmock.Sqlmock, *gorm.DB, *httptest.ResponseRecorder, *Handler) { + fakeGDB, sqlMock, err := persistence.GetMockDB() + require.NoError(t, err) + projectRepo := persistence.NewProjectRepository(fakeGDB) + sourceRepo := persistence.NewSourceRepository(fakeGDB) + organizationRepo := persistence.NewOrganizationRepository(fakeGDB) + projectHandler := &Handler{ + projectManager: projectmanager.NewProjectManager(projectRepo, organizationRepo, sourceRepo), + } + recorder := httptest.NewRecorder() + return sqlMock, fakeGDB, recorder, projectHandler +} diff --git a/pkg/server/handler/project/types.go b/pkg/server/handler/project/types.go new file mode 100644 index 00000000..214db948 --- /dev/null +++ b/pkg/server/handler/project/types.go @@ -0,0 +1,21 @@ +package project + +import ( + projectmanager "kusionstack.io/kusion/pkg/server/manager/project" +) + +func NewHandler( + projectManager *projectmanager.ProjectManager, +) (*Handler, error) { + return &Handler{ + projectManager: projectManager, + }, nil +} + +type Handler struct { + projectManager *projectmanager.ProjectManager +} + +type ProjectRequestParams struct { + ProjectID uint +} diff --git a/pkg/server/handler/render.go b/pkg/server/handler/render.go new file mode 100644 index 00000000..afc55cca --- /dev/null +++ b/pkg/server/handler/render.go @@ -0,0 +1,53 @@ +package handler + +import ( + "context" + "time" + + "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/render" + appmiddleware "kusionstack.io/kusion/pkg/server/middleware" +) + +// SuccessMessage is the default success message for successful responses. +const SuccessMessage = "OK" + +// Response creates a standard API response renderer. +func GenerateResponse(ctx context.Context, data any, err error) render.Renderer { + resp := &Response{} + + // Set the Success and Message fields based on the error parameter. + if err == nil { + resp.Success = true + resp.Message = SuccessMessage + resp.Data = data + } else { + resp.Success = false + resp.Message = err.Error() + } + + // Include the request trace ID if available. + if requestID := middleware.GetReqID(ctx); len(requestID) > 0 { + resp.TraceID = requestID + } + + // Calculate and include timing details if a start time is set. + if startTime := appmiddleware.GetStartTime(ctx); !startTime.IsZero() { + endTime := time.Now() + resp.StartTime = &startTime + resp.EndTime = &endTime + resp.CostTime = Duration(endTime.Sub(startTime)) + } + + return resp +} + +// FailureResponse creates a response renderer for a failed request. +func FailureResponse(ctx context.Context, err error) render.Renderer { + return GenerateResponse(ctx, nil, err) +} + +// SuccessResponse creates a response renderer for a successful request. +func SuccessResponse(ctx context.Context, data any) render.Renderer { + return GenerateResponse(ctx, data, nil) +} diff --git a/pkg/server/handler/source/handler.go b/pkg/server/handler/source/handler.go new file mode 100644 index 00000000..e223a986 --- /dev/null +++ b/pkg/server/handler/source/handler.go @@ -0,0 +1,172 @@ +package source + +import ( + "context" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" + "github.com/go-logr/logr" + "kusionstack.io/kusion/pkg/domain/request" + "kusionstack.io/kusion/pkg/server/handler" + sourcemanager "kusionstack.io/kusion/pkg/server/manager/source" + "kusionstack.io/kusion/pkg/server/util" +) + +// @Summary Create source +// @Description Create a new source +// @Accept json +// @Produce json +// @Param source body CreateSourceRequest true "Created source" +// @Success 200 {object} entity.Source "Success" +// @Failure 400 {object} errors.DetailError "Bad Request" +// @Failure 401 {object} errors.DetailError "Unauthorized" +// @Failure 429 {object} errors.DetailError "Too Many Requests" +// @Failure 404 {object} errors.DetailError "Not Found" +// @Failure 500 {object} errors.DetailError "Internal Server Error" +// @Router /api/v1/sourceID [post] +func (h *Handler) CreateSource() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Getting stuff from context + ctx := r.Context() + logger := util.GetLogger(ctx) + logger.Info("Creating source...") + + // Decode the request body into the payload. + var requestPayload request.CreateSourceRequest + if err := requestPayload.Decode(r); err != nil { + render.Render(w, r, handler.FailureResponse(ctx, err)) + return + } + + // Return created entity + createdEntity, err := h.sourceManager.CreateSource(ctx, requestPayload) + handler.HandleResult(w, r, ctx, err, createdEntity) + } +} + +// @Summary Delete source +// @Description Delete specified source by ID +// @Produce json +// @Param id path int true "Source ID" +// @Success 200 {object} entity.Source "Success" +// @Failure 400 {object} errors.DetailError "Bad Request" +// @Failure 401 {object} errors.DetailError "Unauthorized" +// @Failure 429 {object} errors.DetailError "Too Many Requests" +// @Failure 404 {object} errors.DetailError "Not Found" +// @Failure 500 {object} errors.DetailError "Internal Server Error" +// @Router /api/v1/source/{id} [delete] +func (h *Handler) DeleteSource() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Getting stuff from context + ctx, logger, params, err := requestHelper(r) + if err != nil { + render.Render(w, r, handler.FailureResponse(ctx, err)) + return + } + logger.Info("Deleting source...") + + err = h.sourceManager.DeleteSourceByID(ctx, params.SourceID) + handler.HandleResult(w, r, ctx, err, "Deletion Success") + } +} + +// @Summary Update source +// @Description Update the specified source +// @Accept json +// @Produce json +// @Param source body UpdateSourceRequest true "Updated source" +// @Success 200 {object} entity.Source "Success" +// @Failure 400 {object} errors.DetailError "Bad Request" +// @Failure 401 {object} errors.DetailError "Unauthorized" +// @Failure 429 {object} errors.DetailError "Too Many Requests" +// @Failure 404 {object} errors.DetailError "Not Found" +// @Failure 500 {object} errors.DetailError "Internal Server Error" +// @Router /api/v1/source/{id} [put] +func (h *Handler) UpdateSource() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Getting stuff from context + ctx, logger, params, err := requestHelper(r) + if err != nil { + render.Render(w, r, handler.FailureResponse(ctx, err)) + return + } + logger.Info("Updating source...") + + // Decode the request body into the payload. + var requestPayload request.UpdateSourceRequest + if err := requestPayload.Decode(r); err != nil { + render.Render(w, r, handler.FailureResponse(ctx, err)) + return + } + + // Return updated source + updatedEntity, err := h.sourceManager.UpdateSourceByID(ctx, params.SourceID, requestPayload) + handler.HandleResult(w, r, ctx, err, updatedEntity) + } +} + +// @Summary Get source +// @Description Get source information by source ID +// @Produce json +// @Param id path int true "Source ID" +// @Success 200 {object} entity.Source "Success" +// @Failure 400 {object} errors.DetailError "Bad Request" +// @Failure 401 {object} errors.DetailError "Unauthorized" +// @Failure 429 {object} errors.DetailError "Too Many Requests" +// @Failure 404 {object} errors.DetailError "Not Found" +// @Failure 500 {object} errors.DetailError "Internal Server Error" +// @Router /api/v1/source/{sourceID} [get] +func (h *Handler) GetSource() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Getting stuff from context + ctx, logger, params, err := requestHelper(r) + if err != nil { + render.Render(w, r, handler.FailureResponse(ctx, err)) + return + } + logger.Info("Getting source...") + + existingEntity, err := h.sourceManager.GetSourceByID(ctx, params.SourceID) + handler.HandleResult(w, r, ctx, err, existingEntity) + } +} + +// @Summary List source +// @Description List source information by source ID +// @Produce json +// @Success 200 {object} entity.Source "Success" +// @Failure 400 {object} errors.DetailError "Bad Request" +// @Failure 401 {object} errors.DetailError "Unauthorized" +// @Failure 429 {object} errors.DetailError "Too Many Requests" +// @Failure 404 {object} errors.DetailError "Not Found" +// @Failure 500 {object} errors.DetailError "Internal Server Error" +// @Router /api/v1/source [get] +func (h *Handler) ListSources() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Getting stuff from context + ctx := r.Context() + logger := util.GetLogger(ctx) + logger.Info("Listing source...") + + // List sources + sourceEntities, err := h.sourceManager.ListSources(ctx) + handler.HandleResult(w, r, ctx, err, sourceEntities) + } +} + +func requestHelper(r *http.Request) (context.Context, *logr.Logger, *SourceRequestParams, error) { + ctx := r.Context() + sourceID := chi.URLParam(r, "sourceID") + // Get stack with repository + id, err := strconv.Atoi(sourceID) + if err != nil { + return nil, nil, nil, sourcemanager.ErrInvalidSourceID + } + logger := util.GetLogger(ctx) + params := SourceRequestParams{ + SourceID: uint(id), + } + return ctx, &logger, ¶ms, nil +} diff --git a/pkg/server/handler/source/handler_test.go b/pkg/server/handler/source/handler_test.go new file mode 100644 index 00000000..23376a60 --- /dev/null +++ b/pkg/server/handler/source/handler_test.go @@ -0,0 +1,304 @@ +package source + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + "kusionstack.io/kusion/pkg/domain/constant" + "kusionstack.io/kusion/pkg/domain/request" + "kusionstack.io/kusion/pkg/infra/persistence" + "kusionstack.io/kusion/pkg/server/handler" + sourcemanager "kusionstack.io/kusion/pkg/server/manager/source" +) + +func TestSourceHandler(t *testing.T) { + t.Run("ListSources", func(t *testing.T) { + sqlMock, fakeGDB, recorder, sourceHandler := setupTest(t) + defer persistence.CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id", "source_provider"}). + AddRow(1, string(constant.SourceProviderTypeGithub)). + AddRow(2, string(constant.SourceProviderTypeLocal))) + + // Create a new HTTP request + req, err := http.NewRequest("GET", "/sources", nil) + assert.NoError(t, err) + + // Call the ListSources handler function + sourceHandler.ListSources()(recorder, req) + assert.Equal(t, http.StatusOK, recorder.Code) + + // Unmarshall the response body + var resp handler.Response + err = json.Unmarshal(recorder.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + // Assertion + assert.Equal(t, 2, len(resp.Data.([]any))) + assert.Equal(t, string(constant.SourceProviderTypeGithub), resp.Data.([]any)[0].(map[string]any)["sourceProvider"]) + assert.Equal(t, string(constant.SourceProviderTypeLocal), resp.Data.([]any)[1].(map[string]any)["sourceProvider"]) + }) + + t.Run("GetSource", func(t *testing.T) { + sqlMock, fakeGDB, recorder, sourceHandler := setupTest(t) + defer persistence.CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id", "source_provider"}). + AddRow(1, string(constant.SourceProviderTypeGithub))) + + // Create a new HTTP request + req, err := http.NewRequest("GET", "/source/{sourceID}", nil) + assert.NoError(t, err) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("sourceID", "1") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + // Call the ListSources handler function + sourceHandler.GetSource()(recorder, req) + assert.Equal(t, http.StatusOK, recorder.Code) + + // Unmarshal the response body + var resp handler.Response + err = json.Unmarshal(recorder.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + // Assertion + assert.Equal(t, float64(1), resp.Data.(map[string]any)["id"]) + assert.Equal(t, string(constant.SourceProviderTypeGithub), resp.Data.(map[string]any)["sourceProvider"]) + }) + + t.Run("CreateSource", func(t *testing.T) { + sqlMock, fakeGDB, recorder, sourceHandler := setupTest(t) + defer persistence.CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + // Create a new HTTP request + req, err := http.NewRequest("POST", "/source/{sourceID}", nil) + assert.NoError(t, err) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("sourceID", "1") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + // Set request body + requestPayload := request.CreateSourceRequest{ + // Set your request payload fields here + SourceProvider: string(constant.SourceProviderTypeGithub), + Remote: "https://github.com/test/remote", + } + reqBody, err := json.Marshal(requestPayload) + assert.NoError(t, err) + req.Body = io.NopCloser(bytes.NewReader(reqBody)) + req.Header.Add("Content-Type", "application/json") + + sqlMock.ExpectBegin() + sqlMock.ExpectExec("INSERT"). + WillReturnResult(sqlmock.NewResult(int64(1), int64(1))) + sqlMock.ExpectCommit() + + // Call the CreateSource handler function + sourceHandler.CreateSource()(recorder, req) + assert.Equal(t, http.StatusOK, recorder.Code) + + // Unmarshal the response body + var resp handler.Response + err = json.Unmarshal(recorder.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + // Assertion + assert.Equal(t, float64(1), resp.Data.(map[string]any)["id"]) + assert.Equal(t, string(constant.SourceProviderTypeGithub), resp.Data.(map[string]any)["sourceProvider"]) + }) + + t.Run("UpdateExistingSource", func(t *testing.T) { + sqlMock, fakeGDB, recorder, sourceHandler := setupTest(t) + defer persistence.CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + // Update a new HTTP request + req, err := http.NewRequest("POST", "/source/{sourceID}", nil) + assert.NoError(t, err) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("sourceID", "1") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + // Set request body + requestPayload := request.UpdateSourceRequest{ + // Set your request payload fields here + ID: 1, + SourceProvider: string(constant.SourceProviderTypeGithub), + Remote: "https://github.com/test/updated-remote", + } + reqBody, err := json.Marshal(requestPayload) + assert.NoError(t, err) + req.Body = io.NopCloser(bytes.NewReader(reqBody)) + req.Header.Add("Content-Type", "application/json") + + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id", "source_provider"}). + AddRow(1, constant.SourceProviderTypeGithub)) + sqlMock.ExpectExec("UPDATE"). + WillReturnResult(sqlmock.NewResult(int64(1), int64(1))) + + // Call the ListSources handler function + sourceHandler.UpdateSource()(recorder, req) + assert.Equal(t, http.StatusOK, recorder.Code) + + // Unmarshall the response body + var resp handler.Response + err = json.Unmarshal(recorder.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + // Assertion + assert.Equal(t, float64(1), resp.Data.(map[string]any)["id"]) + assert.Equal(t, string(constant.SourceProviderTypeGithub), resp.Data.(map[string]any)["sourceProvider"]) + assert.Equal(t, "/test/updated-remote", resp.Data.(map[string]any)["remote"].(map[string]any)["Path"]) + }) + + t.Run("Delete Existing Source", func(t *testing.T) { + sqlMock, fakeGDB, recorder, sourceHandler := setupTest(t) + defer persistence.CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + // Create a new HTTP request + req, err := http.NewRequest("DELETE", "/source/{sourceID}", nil) + assert.NoError(t, err) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("sourceID", "1") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + // Mock the Delete method of the source repository + sqlMock.ExpectBegin() + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id"}). + AddRow(1)) + sqlMock.ExpectExec("UPDATE"). + WillReturnResult(sqlmock.NewResult(1, 1)) + sqlMock.ExpectCommit() + + // Call the DeleteSource handler function + sourceHandler.DeleteSource()(recorder, req) + assert.Equal(t, http.StatusOK, recorder.Code) + + // Unmarshall the response body + var resp handler.Response + err = json.Unmarshal(recorder.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + assert.Equal(t, "Deletion Success", resp.Data) + }) + + t.Run("Delete Nonexisting Source", func(t *testing.T) { + sqlMock, fakeGDB, recorder, sourceHandler := setupTest(t) + defer persistence.CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + // Create a new HTTP request + req, err := http.NewRequest("DELETE", "/source/{sourceID}", nil) + assert.NoError(t, err) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("sourceID", "1") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + sqlMock.ExpectBegin() + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id"})) + + // Call the DeleteSource handler function + sourceHandler.DeleteSource()(recorder, req) + // Unmarshall the response body + var resp handler.Response + err = json.Unmarshal(recorder.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + assert.Equal(t, http.StatusOK, recorder.Code) + assert.Equal(t, resp.Success, false) + assert.Equal(t, resp.Message, sourcemanager.ErrGettingNonExistingSource.Error()) + }) + + t.Run("Update Nonexisting Source", func(t *testing.T) { + sqlMock, fakeGDB, recorder, sourceHandler := setupTest(t) + defer persistence.CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + // Update a new HTTP request + req, err := http.NewRequest("POST", "/source/{sourceID}", nil) + assert.NoError(t, err) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("sourceID", "1") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + // Set request body + requestPayload := request.UpdateSourceRequest{ + // Set your request payload fields here + ID: 1, + SourceProvider: string(constant.SourceProviderTypeGithub), + Remote: "https://github.com/test/updated-remote", + } + reqBody, err := json.Marshal(requestPayload) + assert.NoError(t, err) + req.Body = io.NopCloser(bytes.NewReader(reqBody)) + req.Header.Add("Content-Type", "application/json") + + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id"})) + + // Call the UpdateSource handler function + sourceHandler.UpdateSource()(recorder, req) + assert.Equal(t, http.StatusOK, recorder.Code) + + // Unmarshall the response body + var resp handler.Response + err = json.Unmarshal(recorder.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + // Assertion + assert.Equal(t, http.StatusOK, recorder.Code) + assert.Equal(t, resp.Success, false) + assert.Equal(t, resp.Message, sourcemanager.ErrUpdatingNonExistingSource.Error()) + }) +} + +func setupTest(t *testing.T) (sqlmock.Sqlmock, *gorm.DB, *httptest.ResponseRecorder, *Handler) { + fakeGDB, sqlMock, err := persistence.GetMockDB() + require.NoError(t, err) + repo := persistence.NewSourceRepository(fakeGDB) + sourceHandler := &Handler{ + sourceManager: sourcemanager.NewSourceManager(repo), + } + recorder := httptest.NewRecorder() + return sqlMock, fakeGDB, recorder, sourceHandler +} diff --git a/pkg/server/handler/source/types.go b/pkg/server/handler/source/types.go new file mode 100644 index 00000000..1f58b1b5 --- /dev/null +++ b/pkg/server/handler/source/types.go @@ -0,0 +1,21 @@ +package source + +import ( + sourcemanager "kusionstack.io/kusion/pkg/server/manager/source" +) + +func NewHandler( + sourceManager *sourcemanager.SourceManager, +) (*Handler, error) { + return &Handler{ + sourceManager: sourceManager, + }, nil +} + +type Handler struct { + sourceManager *sourcemanager.SourceManager +} + +type SourceRequestParams struct { + SourceID uint +} diff --git a/pkg/server/handler/stack/execute.go b/pkg/server/handler/stack/execute.go new file mode 100644 index 00000000..304ca44e --- /dev/null +++ b/pkg/server/handler/stack/execute.go @@ -0,0 +1,194 @@ +package stack + +import ( + "context" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" + "github.com/go-logr/logr" + yamlv2 "gopkg.in/yaml.v2" + "kusionstack.io/kusion/pkg/server/handler" + stackmanager "kusionstack.io/kusion/pkg/server/manager/stack" + "kusionstack.io/kusion/pkg/server/util" +) + +// @Summary Preview stack +// @Description Preview stack information by stack ID +// @Produce json +// @Param id path int true "Stack ID" +// @Success 200 {object} entity.Stack "Success" +// @Failure 400 {object} errors.DetailError "Bad Request" +// @Failure 401 {object} errors.DetailError "Unauthorized" +// @Failure 429 {object} errors.DetailError "Too Many Requests" +// @Failure 404 {object} errors.DetailError "Not Found" +// @Failure 500 {object} errors.DetailError "Internal Server Error" +// @Router /api/v1/stack/{stackID}/preview [post] +func (h *Handler) PreviewStack() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Getting stuff from context + ctx, logger, params, err := requestHelper(r) + if err != nil { + render.Render(w, r, handler.FailureResponse(ctx, err)) + return + } + logger.Info("Previewing stack...", "stackID", params.StackID) + + // Call preview stack + changes, err := h.stackManager.PreviewStack(ctx, params.StackID, params.Workspace) + if err != nil { + render.Render(w, r, handler.FailureResponse(ctx, err)) + return + } + + previewChanges, err := stackmanager.ProcessChanges(ctx, w, changes, params.Format, params.Detail) + if err != nil { + render.Render(w, r, handler.FailureResponse(ctx, err)) + return + } + render.Render(w, r, handler.SuccessResponse(ctx, previewChanges)) + } +} + +// @Summary Generate stack +// @Description Generate stack information by stack ID +// @Produce json +// @Param id path int true "Stack ID" +// @Success 200 {object} entity.Stack "Success" +// @Failure 400 {object} errors.DetailError "Bad Request" +// @Failure 401 {object} errors.DetailError "Unauthorized" +// @Failure 429 {object} errors.DetailError "Too Many Requests" +// @Failure 404 {object} errors.DetailError "Not Found" +// @Failure 500 {object} errors.DetailError "Internal Server Error" +// @Router /api/v1/stack/{stackID}/generate [post] +func (h *Handler) GenerateStack() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Getting stuff from context + ctx, logger, params, err := requestHelper(r) + if err != nil { + render.Render(w, r, handler.FailureResponse(ctx, err)) + return + } + logger.Info("Generating stack...", "stackID", params.StackID) + + // Call generate stack + sp, err := h.stackManager.GenerateStack(ctx, params.StackID, params.Workspace) + if err != nil { + render.Render(w, r, handler.FailureResponse(ctx, err)) + return + } + + yaml, err := yamlv2.Marshal(sp) + handler.HandleResult(w, r, ctx, err, string(yaml)) + } +} + +// @Summary Apply stack +// @Description Apply stack information by stack ID +// @Produce json +// @Param id path int true "Stack ID" +// @Success 200 {object} entity.Stack "Success" +// @Failure 400 {object} errors.DetailError "Bad Request" +// @Failure 401 {object} errors.DetailError "Unauthorized" +// @Failure 429 {object} errors.DetailError "Too Many Requests" +// @Failure 404 {object} errors.DetailError "Not Found" +// @Failure 500 {object} errors.DetailError "Internal Server Error" +// @Router /api/v1/stack/{stackID}/apply [post] +func (h *Handler) ApplyStack() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Getting stuff from context + ctx, logger, params, err := requestHelper(r) + if err != nil { + render.Render(w, r, handler.FailureResponse(ctx, err)) + return + } + logger.Info("Applying stack...", "stackID", params.StackID) + + err = h.stackManager.ApplyStack(ctx, params.StackID, params.Workspace, params.Format, params.Detail, params.Dryrun, w) + if err != nil { + if err == stackmanager.ErrDryrunDestroy { + render.Render(w, r, handler.SuccessResponse(ctx, "Dry-run mode enabled, the above resources will be destroyed if dryrun is set to false")) + return + } else { + render.Render(w, r, handler.FailureResponse(ctx, err)) + return + } + } + + // Apply completed + logger.Info("apply completed") + render.Render(w, r, handler.SuccessResponse(ctx, "apply completed")) + + // TODO: How to implement watch? + // if o.Watch { + // fmt.Println("Start watching changes ...") + // if err = Watch(o, sp, changes); err != nil { + // return err + // } + // } + } +} + +// @Summary Destroy stack +// @Description Destroy stack information by stack ID +// @Produce json +// @Param id path int true "Stack ID" +// @Success 200 {object} entity.Stack "Success" +// @Failure 400 {object} errors.DetailError "Bad Request" +// @Failure 401 {object} errors.DetailError "Unauthorized" +// @Failure 429 {object} errors.DetailError "Too Many Requests" +// @Failure 404 {object} errors.DetailError "Not Found" +// @Failure 500 {object} errors.DetailError "Internal Server Error" +// @Router /api/v1/stack/{stackID}/destroy [post] +func (h *Handler) DestroyStack() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Getting stuff from context + ctx, logger, params, err := requestHelper(r) + if err != nil { + render.Render(w, r, handler.FailureResponse(ctx, err)) + return + } + logger.Info("Destroying stack...", "stackID", params.StackID) + + err = h.stackManager.DestroyStack(ctx, params.StackID, params.Workspace, params.Detail, params.Dryrun, w) + if err != nil { + if err == stackmanager.ErrDryrunDestroy { + render.Render(w, r, handler.SuccessResponse(ctx, "Dry-run mode enabled, the above resources will be destroyed if dryrun is set to false")) + return + } else { + render.Render(w, r, handler.FailureResponse(ctx, err)) + return + } + } + + // Destroy completed + logger.Info("destroy completed") + render.Render(w, r, handler.SuccessResponse(ctx, "destroy completed")) + } +} + +func requestHelper(r *http.Request) (context.Context, *logr.Logger, *StackRequestParams, error) { + ctx := r.Context() + stackID := chi.URLParam(r, "stackID") + // Get stack with repository + id, err := strconv.Atoi(stackID) + if err != nil { + return nil, nil, nil, stackmanager.ErrInvalidStackID + } + logger := util.GetLogger(ctx) + // Get Params + detailParam, _ := strconv.ParseBool(r.URL.Query().Get("detail")) + dryrunParam, _ := strconv.ParseBool(r.URL.Query().Get("dryrun")) + outputParam := r.URL.Query().Get("output") + // TODO: Should match automatically eventually??? + workspaceParam := r.URL.Query().Get("workspace") + params := StackRequestParams{ + StackID: uint(id), + Workspace: workspaceParam, + Detail: detailParam, + Dryrun: dryrunParam, + Format: outputParam, + } + return ctx, &logger, ¶ms, nil +} diff --git a/pkg/server/handler/stack/handler.go b/pkg/server/handler/stack/handler.go new file mode 100644 index 00000000..920e6521 --- /dev/null +++ b/pkg/server/handler/stack/handler.go @@ -0,0 +1,150 @@ +package stack + +import ( + "net/http" + + "github.com/go-chi/render" + "kusionstack.io/kusion/pkg/domain/request" + "kusionstack.io/kusion/pkg/server/handler" + "kusionstack.io/kusion/pkg/server/util" +) + +// @Summary Create stack +// @Description Create a new stack +// @Accept json +// @Produce json +// @Param stack body CreateStackRequest true "Created stack" +// @Success 200 {object} entity.Stack "Success" +// @Failure 400 {object} errors.DetailError "Bad Request" +// @Failure 401 {object} errors.DetailError "Unauthorized" +// @Failure 429 {object} errors.DetailError "Too Many Requests" +// @Failure 404 {object} errors.DetailError "Not Found" +// @Failure 500 {object} errors.DetailError "Internal Server Error" +// @Router /api/v1/project/{projectName}/stack/{stackName} [post] +func (h *Handler) CreateStack() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Getting stuff from context + ctx := r.Context() + logger := util.GetLogger(ctx) + logger.Info("Creating stack...") + + // Decode the request body into the payload. + var requestPayload request.CreateStackRequest + if err := requestPayload.Decode(r); err != nil { + render.Render(w, r, handler.FailureResponse(ctx, err)) + return + } + + createdEntity, err := h.stackManager.CreateStack(ctx, requestPayload) + handler.HandleResult(w, r, ctx, err, createdEntity) + } +} + +// @Summary Delete stack +// @Description Delete specified stack by ID +// @Produce json +// @Param id path int true "Stack ID" +// @Success 200 {object} entity.Stack "Success" +// @Failure 400 {object} errors.DetailError "Bad Request" +// @Failure 401 {object} errors.DetailError "Unauthorized" +// @Failure 429 {object} errors.DetailError "Too Many Requests" +// @Failure 404 {object} errors.DetailError "Not Found" +// @Failure 500 {object} errors.DetailError "Internal Server Error" +// @Router /api/v1/project/{projectName}/stack/{stackName} [delete] +// @Router /api/v1/stack/{stackID} [delete] +func (h *Handler) DeleteStack() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Getting stuff from context + ctx, logger, params, err := requestHelper(r) + if err != nil { + render.Render(w, r, handler.FailureResponse(ctx, err)) + return + } + logger.Info("Deleting source...", "stackID", params.StackID) + + err = h.stackManager.DeleteStackByID(ctx, params.StackID) + handler.HandleResult(w, r, ctx, err, "Deletion Success") + } +} + +// @Summary Update stack +// @Description Update the specified stack +// @Accept json +// @Produce json +// @Param stack body UpdateStackRequest true "Updated stack" +// @Success 200 {object} entity.Stack "Success" +// @Failure 400 {object} errors.DetailError "Bad Request" +// @Failure 401 {object} errors.DetailError "Unauthorized" +// @Failure 429 {object} errors.DetailError "Too Many Requests" +// @Failure 404 {object} errors.DetailError "Not Found" +// @Failure 500 {object} errors.DetailError "Internal Server Error" +// @Router /api/v1/stack/{stackID} [put] +func (h *Handler) UpdateStack() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Getting stuff from context + ctx, logger, params, err := requestHelper(r) + if err != nil { + render.Render(w, r, handler.FailureResponse(ctx, err)) + return + } + logger.Info("Updating stack...", "stackID", params.StackID) + + // Decode the request body into the payload. + var requestPayload request.UpdateStackRequest + if err := requestPayload.Decode(r); err != nil { + render.Render(w, r, handler.FailureResponse(ctx, err)) + return + } + + updatedEntity, err := h.stackManager.UpdateStackByID(ctx, params.StackID, requestPayload) + handler.HandleResult(w, r, ctx, err, updatedEntity) + } +} + +// @Summary Get stack +// @Description Get stack information by stack ID +// @Produce json +// @Param id path int true "Stack ID" +// @Success 200 {object} entity.Stack "Success" +// @Failure 400 {object} errors.DetailError "Bad Request" +// @Failure 401 {object} errors.DetailError "Unauthorized" +// @Failure 429 {object} errors.DetailError "Too Many Requests" +// @Failure 404 {object} errors.DetailError "Not Found" +// @Failure 500 {object} errors.DetailError "Internal Server Error" +// @Router /api/v1/stack/{stackID} [get] +func (h *Handler) GetStack() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Getting stuff from context + ctx, logger, params, err := requestHelper(r) + if err != nil { + render.Render(w, r, handler.FailureResponse(ctx, err)) + return + } + logger.Info("Getting stack...", "stackID", params.StackID) + + existingEntity, err := h.stackManager.GetStackByID(ctx, params.StackID) + handler.HandleResult(w, r, ctx, err, existingEntity) + } +} + +// @Summary List stacks +// @Description List all stacks +// @Produce json +// @Success 200 {object} entity.Stack "Success" +// @Failure 400 {object} errors.DetailError "Bad Request" +// @Failure 401 {object} errors.DetailError "Unauthorized" +// @Failure 429 {object} errors.DetailError "Too Many Requests" +// @Failure 404 {object} errors.DetailError "Not Found" +// @Failure 500 {object} errors.DetailError "Internal Server Error" +// @Router /api/v1/stack [get] +func (h *Handler) ListStacks() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Getting stuff from context + ctx := r.Context() + logger := util.GetLogger(ctx) + logger.Info("Listing stack...") + + stackEntities, err := h.stackManager.ListStacks(ctx) + handler.HandleResult(w, r, ctx, err, stackEntities) + } +} diff --git a/pkg/server/handler/stack/handler_test.go b/pkg/server/handler/stack/handler_test.go new file mode 100644 index 00000000..acf18350 --- /dev/null +++ b/pkg/server/handler/stack/handler_test.go @@ -0,0 +1,330 @@ +package stack + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + "kusionstack.io/kusion/pkg/domain/constant" + "kusionstack.io/kusion/pkg/domain/request" + "kusionstack.io/kusion/pkg/infra/persistence" + "kusionstack.io/kusion/pkg/server/handler" + stackmanager "kusionstack.io/kusion/pkg/server/manager/stack" +) + +func TestStackHandler(t *testing.T) { + var ( + stackName = "test-stack" + stackNameSecond = "test-stack-2" + projectName = "test-project" + projectPath = "/path/to/project" + stackPath = "/path/to/stack" + stackNameUpdated = "test-stack-updated" + stackPathUpdated = "/path/to/stack/updated" + owners = persistence.MultiString{"hua.li", "xiaoming.li"} + ) + t.Run("ListStacks", func(t *testing.T) { + sqlMock, fakeGDB, recorder, stackHandler := setupTest(t) + defer persistence.CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "path", "sync_state", "Project__id", "Project__name", "Project__path"}). + AddRow(1, stackName, stackPath, constant.StackStateUnSynced, 1, projectName, projectPath). + AddRow(2, stackNameSecond, stackPath, constant.StackStateUnSynced, 2, projectName, projectPath)) + + // Create a new HTTP request + req, err := http.NewRequest("GET", "/stacks", nil) + assert.NoError(t, err) + + // Call the ListStacks handler function + stackHandler.ListStacks()(recorder, req) + assert.Equal(t, http.StatusOK, recorder.Code) + + // Unmarshall the response body + var resp handler.Response + err = json.Unmarshal(recorder.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + // Assertion + assert.Equal(t, 2, len(resp.Data.([]any))) + }) + + t.Run("GetStack", func(t *testing.T) { + sqlMock, fakeGDB, recorder, stackHandler := setupTest(t) + defer persistence.CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "path", "sync_state", "Project__id", "Project__name", "Project__path"}). + AddRow(1, stackName, stackPath, constant.StackStateUnSynced, 1, projectName, projectPath)) + + // Create a new HTTP request + req, err := http.NewRequest("GET", "/stack/{stackID}", nil) + assert.NoError(t, err) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("stackID", "1") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + // Call the ListStacks handler function + stackHandler.GetStack()(recorder, req) + assert.Equal(t, http.StatusOK, recorder.Code) + + // Unmarshal the response body + var resp handler.Response + err = json.Unmarshal(recorder.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + // Assertion + assert.Equal(t, float64(1), resp.Data.(map[string]any)["id"]) + assert.Equal(t, stackName, resp.Data.(map[string]any)["name"]) + assert.Equal(t, stackPath, resp.Data.(map[string]any)["path"]) + assert.Equal(t, float64(1), resp.Data.(map[string]any)["project"].(map[string]any)["id"]) + }) + + t.Run("CreateStack", func(t *testing.T) { + sqlMock, fakeGDB, recorder, stackHandler := setupTest(t) + defer persistence.CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + // Create a new HTTP request + req, err := http.NewRequest("POST", "/stack/{stackID}", nil) + assert.NoError(t, err) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("stackID", "1") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + // Set request body + requestPayload := request.CreateStackRequest{ + Name: stackName, + Path: stackPath, + DesiredVersion: "latest", + ProjectID: 1, + } + reqBody, err := json.Marshal(requestPayload) + assert.NoError(t, err) + req.Body = io.NopCloser(bytes.NewReader(reqBody)) + req.Header.Add("Content-Type", "application/json") + + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "path", "Organization__id", "Organization__name", "Organization__owners", "Source__id", "Source__remote", "Source__source_provider"}). + AddRow(1, projectName, projectPath, 1, "test-org", owners, 1, "https://github.com/test/repo", constant.SourceProviderTypeGithub)) + sqlMock.ExpectBegin() + sqlMock.ExpectExec("INSERT"). + WillReturnResult(sqlmock.NewResult(int64(1), int64(1))) + sqlMock.ExpectCommit() + + // Call the CreateStack handler function + stackHandler.CreateStack()(recorder, req) + assert.Equal(t, http.StatusOK, recorder.Code) + + // Unmarshal the response body + var resp handler.Response + err = json.Unmarshal(recorder.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + // Assertion + assert.Equal(t, float64(1), resp.Data.(map[string]any)["id"]) + assert.Equal(t, stackName, resp.Data.(map[string]any)["name"]) + assert.Equal(t, stackPath, resp.Data.(map[string]any)["path"]) + assert.Equal(t, "latest", resp.Data.(map[string]any)["desiredVersion"]) + assert.Equal(t, float64(1), resp.Data.(map[string]any)["project"].(map[string]any)["id"]) + }) + + t.Run("UpdateExistingStack", func(t *testing.T) { + sqlMock, fakeGDB, recorder, stackHandler := setupTest(t) + defer persistence.CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + // Update a new HTTP request + req, err := http.NewRequest("PUT", "/stack/{stackID}", nil) + assert.NoError(t, err) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("stackID", "1") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + // Set request body + requestPayload := request.UpdateStackRequest{ + // Set your request payload fields here + ID: 1, + Name: stackNameUpdated, + Path: stackPathUpdated, + ProjectID: 1, + } + reqBody, err := json.Marshal(requestPayload) + assert.NoError(t, err) + req.Body = io.NopCloser(bytes.NewReader(reqBody)) + req.Header.Add("Content-Type", "application/json") + + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "path", "Organization__id", "Organization__name", "Organization__owners", "Source__id", "Source__remote", "Source__source_provider"}). + AddRow(1, stackName, stackPath, 1, "test-org", owners, 1, "https://github.com/test/repo", constant.SourceProviderTypeGithub)) + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "path", "sync_state", "Project__id", "Project__name", "Project__path"}). + AddRow(1, stackName, stackPath, constant.StackStateUnSynced, 1, projectName, projectPath)) + sqlMock.ExpectExec("UPDATE"). + WillReturnResult(sqlmock.NewResult(int64(1), int64(1))) + + // Call the ListStacks handler function + stackHandler.UpdateStack()(recorder, req) + assert.Equal(t, http.StatusOK, recorder.Code) + + // Unmarshall the response body + var resp handler.Response + err = json.Unmarshal(recorder.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + // Assertion + assert.Equal(t, float64(1), resp.Data.(map[string]any)["id"]) + assert.Equal(t, stackNameUpdated, resp.Data.(map[string]any)["name"]) + assert.Equal(t, stackPathUpdated, resp.Data.(map[string]any)["path"]) + }) + + t.Run("Delete Existing Stack", func(t *testing.T) { + sqlMock, fakeGDB, recorder, stackHandler := setupTest(t) + defer persistence.CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + // Create a new HTTP request + req, err := http.NewRequest("DELETE", "/stack/{stackID}", nil) + assert.NoError(t, err) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("stackID", "1") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + // Mock the Delete method of the stack repository + sqlMock.ExpectBegin() + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id"}). + AddRow(1)) + sqlMock.ExpectExec("UPDATE"). + WillReturnResult(sqlmock.NewResult(1, 1)) + sqlMock.ExpectCommit() + + // Call the DeleteStack handler function + stackHandler.DeleteStack()(recorder, req) + assert.Equal(t, http.StatusOK, recorder.Code) + + // Unmarshall the response body + var resp handler.Response + err = json.Unmarshal(recorder.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + assert.Equal(t, "Deletion Success", resp.Data) + }) + + t.Run("Delete Nonexisting Stack", func(t *testing.T) { + sqlMock, fakeGDB, recorder, stackHandler := setupTest(t) + defer persistence.CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + // Create a new HTTP request + req, err := http.NewRequest("DELETE", "/stack/{stackID}", nil) + assert.NoError(t, err) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("stackID", "1") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + sqlMock.ExpectBegin() + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id"})) + + // Call the DeleteStack handler function + stackHandler.DeleteStack()(recorder, req) + // Unmarshall the response body + var resp handler.Response + err = json.Unmarshal(recorder.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + assert.Equal(t, http.StatusOK, recorder.Code) + assert.Equal(t, false, resp.Success) + assert.Equal(t, stackmanager.ErrGettingNonExistingStack.Error(), resp.Message) + }) + + t.Run("Update Nonexisting Stack", func(t *testing.T) { + sqlMock, fakeGDB, recorder, stackHandler := setupTest(t) + defer persistence.CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + // Update a new HTTP request + req, err := http.NewRequest("POST", "/stack/{stackID}", nil) + assert.NoError(t, err) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("stackID", "1") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + // Set request body + requestPayload := request.UpdateStackRequest{ + // Set your request payload fields here + ID: 1, + Name: "test-stack-updated", + Path: stackPathUpdated, + } + reqBody, err := json.Marshal(requestPayload) + assert.NoError(t, err) + req.Body = io.NopCloser(bytes.NewReader(reqBody)) + req.Header.Add("Content-Type", "application/json") + + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "path", "Organization__id", "Organization__name", "Organization__owners", "Source__id", "Source__remote", "Source__source_provider"}). + AddRow(1, stackName, stackPath, 1, "test-org", owners, 1, "https://github.com/test/repo", constant.SourceProviderTypeGithub)) + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id"})) + + // Call the UpdateStack handler function + stackHandler.UpdateStack()(recorder, req) + assert.Equal(t, http.StatusOK, recorder.Code) + + // Unmarshall the response body + var resp handler.Response + err = json.Unmarshal(recorder.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + // Assertion + assert.Equal(t, http.StatusOK, recorder.Code) + assert.Equal(t, false, resp.Success) + assert.Equal(t, stackmanager.ErrUpdatingNonExistingStack.Error(), resp.Message) + }) +} + +func setupTest(t *testing.T) (sqlmock.Sqlmock, *gorm.DB, *httptest.ResponseRecorder, *Handler) { + fakeGDB, sqlMock, err := persistence.GetMockDB() + require.NoError(t, err) + stackRepo := persistence.NewStackRepository(fakeGDB) + projectRepo := persistence.NewProjectRepository(fakeGDB) + workspaceRepo := persistence.NewWorkspaceRepository(fakeGDB) + stackHandler := &Handler{ + stackManager: stackmanager.NewStackManager(stackRepo, projectRepo, workspaceRepo), + } + recorder := httptest.NewRecorder() + return sqlMock, fakeGDB, recorder, stackHandler +} diff --git a/pkg/server/handler/stack/types.go b/pkg/server/handler/stack/types.go new file mode 100644 index 00000000..5f7c41f5 --- /dev/null +++ b/pkg/server/handler/stack/types.go @@ -0,0 +1,25 @@ +package stack + +import ( + stackmanager "kusionstack.io/kusion/pkg/server/manager/stack" +) + +func NewHandler( + stackManager *stackmanager.StackManager, +) (*Handler, error) { + return &Handler{ + stackManager: stackManager, + }, nil +} + +type Handler struct { + stackManager *stackmanager.StackManager +} + +type StackRequestParams struct { + StackID uint + Workspace string + Format string + Detail bool + Dryrun bool +} diff --git a/pkg/server/handler/types.go b/pkg/server/handler/types.go new file mode 100644 index 00000000..3d55744d --- /dev/null +++ b/pkg/server/handler/types.go @@ -0,0 +1,45 @@ +package handler + +import ( + "errors" + "fmt" + "net/http" + "time" +) + +var ( + ErrProjectDoesNotExist = errors.New("the project does not exist") + ErrOrganizationDoesNotExist = errors.New("the organization does not exist") + ErrStackDoesNotExist = errors.New("the stack does not exist") +) + +// Payload is an interface for incoming requests payloads +// Each handler should implement this interface to parse payloads +type Payload interface { + Decode(*http.Request) error // Decode returns the payload object with the decoded +} + +// response defines the structure for API response payloads. +type Response struct { + Success bool `json:"success" yaml:"success"` // Indicates success status. + Message string `json:"message" yaml:"message"` // Descriptive message. + Data any `json:"data,omitempty" yaml:"data,omitempty"` // Data payload. + TraceID string `json:"traceID,omitempty" yaml:"traceID,omitempty"` // Trace identifier. + StartTime *time.Time `json:"startTime,omitempty" yaml:"startTime,omitempty"` // Request start time. + EndTime *time.Time `json:"endTime,omitempty" yaml:"endTime,omitempty"` // Request end time. + CostTime Duration `json:"costTime,omitempty" yaml:"costTime,omitempty"` // Time taken for the request. +} + +// Render is a no-op method that satisfies the render.Renderer interface. +func (rep *Response) Render(w http.ResponseWriter, r *http.Request) error { + return nil +} + +// Duration is a custom type that represents a duration of time. +type Duration time.Duration + +// MarshalJSON customizes JSON representation of the Duration type. +func (d Duration) MarshalJSON() (b []byte, err error) { + // Format the duration as a string. + return []byte(fmt.Sprintf(`"%s"`, time.Duration(d).String())), nil +} diff --git a/pkg/server/handler/util.go b/pkg/server/handler/util.go new file mode 100644 index 00000000..37ce1cec --- /dev/null +++ b/pkg/server/handler/util.go @@ -0,0 +1,64 @@ +package handler + +import ( + "context" + "net/http" + + "github.com/go-chi/render" + "gorm.io/gorm" + "kusionstack.io/kusion/pkg/domain/entity" + "kusionstack.io/kusion/pkg/domain/repository" + sourcemanager "kusionstack.io/kusion/pkg/server/manager/source" +) + +func HandleResult(w http.ResponseWriter, r *http.Request, ctx context.Context, err error, data any) { + if err != nil { + render.Render(w, r, FailureResponse(ctx, err)) + return + } + render.JSON(w, r, SuccessResponse(ctx, data)) +} + +func GetSourceByID(ctx context.Context, sourceRepo repository.SourceRepository, id uint) (*entity.Source, error) { + // Get source by id + sourceEntity, err := sourceRepo.Get(ctx, id) + if err != nil && err == gorm.ErrRecordNotFound { + return nil, sourcemanager.ErrGettingNonExistingSource + } else if err != nil { + return nil, err + } + return sourceEntity, nil +} + +func GetProjectByID(ctx context.Context, projectRepo repository.ProjectRepository, id uint) (*entity.Project, error) { + // Get project by id + projectEntity, err := projectRepo.Get(ctx, id) + if err != nil && err == gorm.ErrRecordNotFound { + return nil, ErrProjectDoesNotExist + } else if err != nil { + return nil, err + } + return projectEntity, nil +} + +func GetOrganizationByID(ctx context.Context, organizationRepo repository.OrganizationRepository, id uint) (*entity.Organization, error) { + // Get organization by id + organizationEntity, err := organizationRepo.Get(ctx, id) + if err != nil && err == gorm.ErrRecordNotFound { + return nil, ErrOrganizationDoesNotExist + } else if err != nil { + return nil, err + } + return organizationEntity, nil +} + +func GetStackByID(ctx context.Context, stackRepo repository.StackRepository, id uint) (*entity.Stack, error) { + // Get stack by id + stackEntity, err := stackRepo.Get(ctx, id) + if err != nil && err == gorm.ErrRecordNotFound { + return nil, ErrStackDoesNotExist + } else if err != nil { + return nil, err + } + return stackEntity, nil +} diff --git a/pkg/server/handler/workspace/handler.go b/pkg/server/handler/workspace/handler.go new file mode 100644 index 00000000..4050ee39 --- /dev/null +++ b/pkg/server/handler/workspace/handler.go @@ -0,0 +1,172 @@ +package workspace + +import ( + "context" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" + "github.com/go-logr/logr" + "kusionstack.io/kusion/pkg/domain/request" + "kusionstack.io/kusion/pkg/server/handler" + workspacemanager "kusionstack.io/kusion/pkg/server/manager/workspace" + "kusionstack.io/kusion/pkg/server/util" +) + +// @Summary Create workspace +// @Description Create a new workspace +// @Accept json +// @Produce json +// @Param workspace body CreateWorkspaceRequest true "Created workspace" +// @Success 200 {object} entity.Workspace "Success" +// @Failure 400 {object} errors.DetailError "Bad Request" +// @Failure 401 {object} errors.DetailError "Unauthorized" +// @Failure 429 {object} errors.DetailError "Too Many Requests" +// @Failure 404 {object} errors.DetailError "Not Found" +// @Failure 500 {object} errors.DetailError "Internal Server Error" +// @Router /api/v1/workspace/{workspaceName} [post] +func (h *Handler) CreateWorkspace() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Getting stuff from context + ctx := r.Context() + logger := util.GetLogger(ctx) + logger.Info("Creating workspace...") + + // Decode the request body into the payload. + var requestPayload request.CreateWorkspaceRequest + if err := requestPayload.Decode(r); err != nil { + render.Render(w, r, handler.FailureResponse(ctx, err)) + return + } + + createdEntity, err := h.workspaceManager.CreateWorkspace(ctx, requestPayload) + handler.HandleResult(w, r, ctx, err, createdEntity) + } +} + +// @Summary Delete workspace +// @Description Delete specified workspace by ID +// @Produce json +// @Param id path int true "Workspace ID" +// @Success 200 {object} entity.Workspace "Success" +// @Failure 400 {object} errors.DetailError "Bad Request" +// @Failure 401 {object} errors.DetailError "Unauthorized" +// @Failure 429 {object} errors.DetailError "Too Many Requests" +// @Failure 404 {object} errors.DetailError "Not Found" +// @Failure 500 {object} errors.DetailError "Internal Server Error" +// @Router /api/v1/workspace/{workspaceName} [delete] +// @Router /api/v1/workspace/{workspaceID} [delete] +func (h *Handler) DeleteWorkspace() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Getting stuff from context + ctx, logger, params, err := requestHelper(r) + if err != nil { + render.Render(w, r, handler.FailureResponse(ctx, err)) + return + } + logger.Info("Deleting source...", "workspaceID", params.WorkspaceID) + + err = h.workspaceManager.DeleteWorkspaceByID(ctx, params.WorkspaceID) + handler.HandleResult(w, r, ctx, err, "Deletion Success") + } +} + +// @Summary Update workspace +// @Description Update the specified workspace +// @Accept json +// @Produce json +// @Param workspace body UpdateWorkspaceRequest true "Updated workspace" +// @Success 200 {object} entity.Workspace "Success" +// @Failure 400 {object} errors.DetailError "Bad Request" +// @Failure 401 {object} errors.DetailError "Unauthorized" +// @Failure 429 {object} errors.DetailError "Too Many Requests" +// @Failure 404 {object} errors.DetailError "Not Found" +// @Failure 500 {object} errors.DetailError "Internal Server Error" +// @Router /api/v1/workspace/{workspaceID} [put] +func (h *Handler) UpdateWorkspace() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Getting stuff from context + ctx, logger, params, err := requestHelper(r) + if err != nil { + render.Render(w, r, handler.FailureResponse(ctx, err)) + return + } + logger.Info("Updating workspace...", "workspaceID", params.WorkspaceID) + + // Decode the request body into the payload. + var requestPayload request.UpdateWorkspaceRequest + if err := requestPayload.Decode(r); err != nil { + render.Render(w, r, handler.FailureResponse(ctx, err)) + return + } + + updatedEntity, err := h.workspaceManager.UpdateWorkspaceByID(ctx, params.WorkspaceID, requestPayload) + handler.HandleResult(w, r, ctx, err, updatedEntity) + } +} + +// @Summary Get workspace +// @Description Get workspace information by workspace ID +// @Produce json +// @Param id path int true "Workspace ID" +// @Success 200 {object} entity.Workspace "Success" +// @Failure 400 {object} errors.DetailError "Bad Request" +// @Failure 401 {object} errors.DetailError "Unauthorized" +// @Failure 429 {object} errors.DetailError "Too Many Requests" +// @Failure 404 {object} errors.DetailError "Not Found" +// @Failure 500 {object} errors.DetailError "Internal Server Error" +// @Router /api/v1/workspace/{workspaceID} [get] +func (h *Handler) GetWorkspace() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Getting stuff from context + ctx, logger, params, err := requestHelper(r) + if err != nil { + render.Render(w, r, handler.FailureResponse(ctx, err)) + return + } + logger.Info("Getting workspace...", "workspaceID", params.WorkspaceID) + + // Return found workspace + existingEntity, err := h.workspaceManager.GetWorkspaceByID(ctx, params.WorkspaceID) + handler.HandleResult(w, r, ctx, err, existingEntity) + } +} + +// @Summary List workspaces +// @Description List all workspaces +// @Produce json +// @Success 200 {object} entity.Workspace "Success" +// @Failure 400 {object} errors.DetailError "Bad Request" +// @Failure 401 {object} errors.DetailError "Unauthorized" +// @Failure 429 {object} errors.DetailError "Too Many Requests" +// @Failure 404 {object} errors.DetailError "Not Found" +// @Failure 500 {object} errors.DetailError "Internal Server Error" +// @Router /api/v1/workspace [get] +func (h *Handler) ListWorkspaces() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Getting stuff from context + ctx := r.Context() + logger := util.GetLogger(ctx) + logger.Info("Listing workspace...") + + // Return found workspaces + workspaceEntities, err := h.workspaceManager.ListWorkspaces(ctx) + handler.HandleResult(w, r, ctx, err, workspaceEntities) + } +} + +func requestHelper(r *http.Request) (context.Context, *logr.Logger, *WorkspaceRequestParams, error) { + ctx := r.Context() + workspaceID := chi.URLParam(r, "workspaceID") + // Get stack with repository + id, err := strconv.Atoi(workspaceID) + if err != nil { + return nil, nil, nil, workspacemanager.ErrInvalidWorkspaceID + } + logger := util.GetLogger(ctx) + params := WorkspaceRequestParams{ + WorkspaceID: uint(id), + } + return ctx, &logger, ¶ms, nil +} diff --git a/pkg/server/handler/workspace/handler_test.go b/pkg/server/handler/workspace/handler_test.go new file mode 100644 index 00000000..12a499b9 --- /dev/null +++ b/pkg/server/handler/workspace/handler_test.go @@ -0,0 +1,307 @@ +package workspace + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + "kusionstack.io/kusion/pkg/domain/request" + "kusionstack.io/kusion/pkg/infra/persistence" + "kusionstack.io/kusion/pkg/server/handler" + workspacemanager "kusionstack.io/kusion/pkg/server/manager/workspace" +) + +func TestWorkspaceHandler(t *testing.T) { + var ( + wsName = "test-ws" + wsNameUpdated = "test-ws-updated" + ) + t.Run("ListWorkspaces", func(t *testing.T) { + sqlMock, fakeGDB, recorder, workspaceHandler := setupTest(t) + defer persistence.CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "Backend__id"}). + AddRow(1, "test-ws", 1). + AddRow(2, "test-ws-2", 2)) + + // Create a new HTTP request + req, err := http.NewRequest("GET", "/workspaces", nil) + assert.NoError(t, err) + + // Call the ListWorkspaces handler function + workspaceHandler.ListWorkspaces()(recorder, req) + assert.Equal(t, http.StatusOK, recorder.Code) + + // Unmarshall the response body + var resp handler.Response + err = json.Unmarshal(recorder.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + // Assertion + assert.Equal(t, 2, len(resp.Data.([]any))) + }) + + t.Run("GetWorkspace", func(t *testing.T) { + sqlMock, fakeGDB, recorder, workspaceHandler := setupTest(t) + defer persistence.CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "Backend__id"}). + AddRow(1, wsName, 1)) + + // Create a new HTTP request + req, err := http.NewRequest("GET", "/workspace/{workspaceID}", nil) + assert.NoError(t, err) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("workspaceID", "1") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + // Call the ListWorkspaces handler function + workspaceHandler.GetWorkspace()(recorder, req) + assert.Equal(t, http.StatusOK, recorder.Code) + + // Unmarshal the response body + var resp handler.Response + err = json.Unmarshal(recorder.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + // Assertion + assert.Equal(t, float64(1), resp.Data.(map[string]any)["id"]) + assert.Equal(t, wsName, resp.Data.(map[string]any)["name"]) + }) + + t.Run("CreateWorkspace", func(t *testing.T) { + sqlMock, fakeGDB, recorder, workspaceHandler := setupTest(t) + defer persistence.CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + // Create a new HTTP request + req, err := http.NewRequest("POST", "/workspace/{workspaceID}", nil) + assert.NoError(t, err) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("workspaceID", "1") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + // Set request body + requestPayload := request.CreateWorkspaceRequest{ + Name: wsName, + BackendID: 1, + Owners: []string{"hua.li", "xiaoming.li"}, + } + reqBody, err := json.Marshal(requestPayload) + assert.NoError(t, err) + req.Body = io.NopCloser(bytes.NewReader(reqBody)) + req.Header.Add("Content-Type", "application/json") + + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id"}). + AddRow(1)) + sqlMock.ExpectBegin() + sqlMock.ExpectExec("INSERT"). + WillReturnResult(sqlmock.NewResult(int64(1), int64(1))) + sqlMock.ExpectCommit() + + // Call the CreateWorkspace handler function + workspaceHandler.CreateWorkspace()(recorder, req) + assert.Equal(t, http.StatusOK, recorder.Code) + + // Unmarshal the response body + var resp handler.Response + err = json.Unmarshal(recorder.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + // Assertion + assert.Equal(t, float64(1), resp.Data.(map[string]any)["id"]) + assert.Equal(t, wsName, resp.Data.(map[string]any)["name"]) + }) + + t.Run("UpdateExistingWorkspace", func(t *testing.T) { + sqlMock, fakeGDB, recorder, workspaceHandler := setupTest(t) + defer persistence.CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + // Update a new HTTP request + req, err := http.NewRequest("POST", "/workspace/{workspaceID}", nil) + assert.NoError(t, err) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("workspaceID", "1") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + // Set request body + requestPayload := request.UpdateWorkspaceRequest{ + ID: 1, + Name: wsNameUpdated, + BackendID: 1, + } + reqBody, err := json.Marshal(requestPayload) + assert.NoError(t, err) + req.Body = io.NopCloser(bytes.NewReader(reqBody)) + req.Header.Add("Content-Type", "application/json") + + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "Backend__id"}). + AddRow(1, "test-ws-updated", 1)) + sqlMock.ExpectExec("UPDATE"). + WillReturnResult(sqlmock.NewResult(int64(1), int64(1))) + + // Call the ListWorkspaces handler function + workspaceHandler.UpdateWorkspace()(recorder, req) + assert.Equal(t, http.StatusOK, recorder.Code) + + // Unmarshall the response body + var resp handler.Response + err = json.Unmarshal(recorder.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + // Assertion + assert.Equal(t, float64(1), resp.Data.(map[string]any)["id"]) + assert.Equal(t, wsNameUpdated, resp.Data.(map[string]any)["name"]) + }) + + t.Run("Delete Existing Workspace", func(t *testing.T) { + sqlMock, fakeGDB, recorder, workspaceHandler := setupTest(t) + defer persistence.CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + // Create a new HTTP request + req, err := http.NewRequest("DELETE", "/workspace/{workspaceID}", nil) + assert.NoError(t, err) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("workspaceID", "1") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + // Mock the Delete method of the workspace repository + sqlMock.ExpectBegin() + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id"}). + AddRow(1)) + sqlMock.ExpectExec("UPDATE"). + WillReturnResult(sqlmock.NewResult(1, 1)) + sqlMock.ExpectCommit() + + // Call the DeleteWorkspace handler function + workspaceHandler.DeleteWorkspace()(recorder, req) + assert.Equal(t, http.StatusOK, recorder.Code) + + // Unmarshall the response body + var resp handler.Response + err = json.Unmarshal(recorder.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + assert.Equal(t, "Deletion Success", resp.Data) + }) + + t.Run("Delete Nonexisting Workspace", func(t *testing.T) { + sqlMock, fakeGDB, recorder, workspaceHandler := setupTest(t) + defer persistence.CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + // Create a new HTTP request + req, err := http.NewRequest("DELETE", "/workspace/{workspaceID}", nil) + assert.NoError(t, err) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("workspaceID", "1") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + sqlMock.ExpectBegin() + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id"})) + + // Call the DeleteWorkspace handler function + workspaceHandler.DeleteWorkspace()(recorder, req) + // Unmarshall the response body + var resp handler.Response + err = json.Unmarshal(recorder.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + assert.Equal(t, http.StatusOK, recorder.Code) + assert.Equal(t, false, resp.Success) + assert.Equal(t, workspacemanager.ErrGettingNonExistingWorkspace.Error(), resp.Message) + }) + + t.Run("Update Nonexisting Workspace", func(t *testing.T) { + sqlMock, fakeGDB, recorder, workspaceHandler := setupTest(t) + defer persistence.CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + // Update a new HTTP request + req, err := http.NewRequest("POST", "/workspace/{workspaceID}", nil) + assert.NoError(t, err) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("workspaceID", "1") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + // Set request body + requestPayload := request.UpdateWorkspaceRequest{ + // Set your request payload fields here + ID: 1, + Name: "test-ws-updated", + BackendID: 1, + } + reqBody, err := json.Marshal(requestPayload) + assert.NoError(t, err) + req.Body = io.NopCloser(bytes.NewReader(reqBody)) + req.Header.Add("Content-Type", "application/json") + + sqlMock.ExpectQuery("SELECT"). + WillReturnRows(sqlmock.NewRows([]string{"id"})) + + // Call the UpdateWorkspace handler function + workspaceHandler.UpdateWorkspace()(recorder, req) + assert.Equal(t, http.StatusOK, recorder.Code) + + // Unmarshall the response body + var resp handler.Response + err = json.Unmarshal(recorder.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + // Assertion + assert.Equal(t, http.StatusOK, recorder.Code) + assert.Equal(t, false, resp.Success) + assert.Equal(t, workspacemanager.ErrUpdatingNonExistingWorkspace.Error(), resp.Message) + }) +} + +func setupTest(t *testing.T) (sqlmock.Sqlmock, *gorm.DB, *httptest.ResponseRecorder, *Handler) { + fakeGDB, sqlMock, err := persistence.GetMockDB() + require.NoError(t, err) + workspaceRepo := persistence.NewWorkspaceRepository(fakeGDB) + backendRepo := persistence.NewBackendRepository(fakeGDB) + workspaceHandler := &Handler{ + workspaceManager: workspacemanager.NewWorkspaceManager(workspaceRepo, backendRepo), + } + recorder := httptest.NewRecorder() + return sqlMock, fakeGDB, recorder, workspaceHandler +} diff --git a/pkg/server/handler/workspace/types.go b/pkg/server/handler/workspace/types.go new file mode 100644 index 00000000..01291793 --- /dev/null +++ b/pkg/server/handler/workspace/types.go @@ -0,0 +1,21 @@ +package workspace + +import ( + workspacemanager "kusionstack.io/kusion/pkg/server/manager/workspace" +) + +func NewHandler( + workspaceManager *workspacemanager.WorkspaceManager, +) (*Handler, error) { + return &Handler{ + workspaceManager: workspaceManager, + }, nil +} + +type Handler struct { + workspaceManager *workspacemanager.WorkspaceManager +} + +type WorkspaceRequestParams struct { + WorkspaceID uint +} diff --git a/pkg/server/manager/backend/backend_manager.go b/pkg/server/manager/backend/backend_manager.go new file mode 100644 index 00000000..cd6e6813 --- /dev/null +++ b/pkg/server/manager/backend/backend_manager.go @@ -0,0 +1,86 @@ +package backend + +import ( + "context" + "errors" + + "github.com/jinzhu/copier" + "gorm.io/gorm" + "kusionstack.io/kusion/pkg/domain/entity" + "kusionstack.io/kusion/pkg/domain/request" +) + +func (m *BackendManager) ListBackends(ctx context.Context) ([]*entity.Backend, error) { + backendEntities, err := m.backendRepo.List(ctx) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrGettingNonExistingBackend + } + return nil, err + } + return backendEntities, nil +} + +func (m *BackendManager) GetBackendByID(ctx context.Context, id uint) (*entity.Backend, error) { + existingEntity, err := m.backendRepo.Get(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrGettingNonExistingBackend + } + return nil, err + } + return existingEntity, nil +} + +func (m *BackendManager) DeleteBackendByID(ctx context.Context, id uint) error { + err := m.backendRepo.Delete(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrGettingNonExistingBackend + } + return err + } + return nil +} + +func (m *BackendManager) UpdateBackendByID(ctx context.Context, id uint, requestPayload request.UpdateBackendRequest) (*entity.Backend, error) { + // Convert request payload to domain model + var requestEntity entity.Backend + if err := copier.Copy(&requestEntity, &requestPayload); err != nil { + return nil, err + } + + // Get the existing backend by id + updatedEntity, err := m.backendRepo.Get(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrUpdatingNonExistingBackend + } + return nil, err + } + + // Overwrite non-zero values in request entity to existing entity + copier.CopyWithOption(updatedEntity, requestEntity, copier.Option{IgnoreEmpty: true}) + + // Update backend with repository + err = m.backendRepo.Update(ctx, updatedEntity) + if err != nil { + return nil, err + } + return updatedEntity, nil +} + +func (m *BackendManager) CreateBackend(ctx context.Context, requestPayload request.CreateBackendRequest) (*entity.Backend, error) { + // Convert request payload to domain model + var createdEntity entity.Backend + if err := copier.Copy(&createdEntity, &requestPayload); err != nil { + return nil, err + } + + // Create backend with repository + err := m.backendRepo.Create(ctx, &createdEntity) + if err != nil { + return nil, err + } + return &createdEntity, nil +} diff --git a/pkg/server/manager/backend/types.go b/pkg/server/manager/backend/types.go new file mode 100644 index 00000000..bf137f91 --- /dev/null +++ b/pkg/server/manager/backend/types.go @@ -0,0 +1,23 @@ +package backend + +import ( + "errors" + + "kusionstack.io/kusion/pkg/domain/repository" +) + +var ( + ErrGettingNonExistingBackend = errors.New("the backend does not exist") + ErrUpdatingNonExistingBackend = errors.New("the backend to update does not exist") + ErrInvalidBackendID = errors.New("the backend ID should be a uuid") +) + +type BackendManager struct { + backendRepo repository.BackendRepository +} + +func NewBackendManager(backendRepo repository.BackendRepository) *BackendManager { + return &BackendManager{ + backendRepo: backendRepo, + } +} diff --git a/pkg/server/manager/organization/organization_manager.go b/pkg/server/manager/organization/organization_manager.go new file mode 100644 index 00000000..8e7d62c0 --- /dev/null +++ b/pkg/server/manager/organization/organization_manager.go @@ -0,0 +1,91 @@ +package organization + +import ( + "context" + "errors" + "time" + + "github.com/jinzhu/copier" + "gorm.io/gorm" + "kusionstack.io/kusion/pkg/domain/entity" + "kusionstack.io/kusion/pkg/domain/request" +) + +func (m *OrganizationManager) ListOrganizations(ctx context.Context) ([]*entity.Organization, error) { + organizationEntities, err := m.organizationRepo.List(ctx) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrGettingNonExistingOrganization + } + return nil, err + } + return organizationEntities, nil +} + +func (m *OrganizationManager) GetOrganizationByID(ctx context.Context, id uint) (*entity.Organization, error) { + existingEntity, err := m.organizationRepo.Get(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrGettingNonExistingOrganization + } + return nil, err + } + return existingEntity, nil +} + +func (m *OrganizationManager) DeleteOrganizationByID(ctx context.Context, id uint) error { + err := m.organizationRepo.Delete(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrGettingNonExistingOrganization + } + return err + } + return nil +} + +func (m *OrganizationManager) UpdateOrganizationByID(ctx context.Context, id uint, requestPayload request.UpdateOrganizationRequest) (*entity.Organization, error) { + // Convert request payload to domain model + var requestEntity entity.Organization + if err := copier.Copy(&requestEntity, &requestPayload); err != nil { + return nil, err + } + + // Get the existing organization by id + updatedEntity, err := m.organizationRepo.Get(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrUpdatingNonExistingOrganization + } + return nil, err + } + + // Overwrite non-zero values in request entity to existing entity + copier.CopyWithOption(updatedEntity, requestEntity, copier.Option{IgnoreEmpty: true}) + + // Update organization with repository + err = m.organizationRepo.Update(ctx, updatedEntity) + if err != nil { + return nil, err + } + + return updatedEntity, nil +} + +func (m *OrganizationManager) CreateOrganization(ctx context.Context, requestPayload request.CreateOrganizationRequest) (*entity.Organization, error) { + // Convert request payload to domain model + var createdEntity entity.Organization + if err := copier.Copy(&createdEntity, &requestPayload); err != nil { + return nil, err + } + // The default state is UnSynced + createdEntity.CreationTimestamp = time.Now() + createdEntity.UpdateTimestamp = time.Now() + + // Create organization with repository + err := m.organizationRepo.Create(ctx, &createdEntity) + if err != nil { + return nil, err + } + return &createdEntity, nil +} diff --git a/pkg/server/manager/organization/types.go b/pkg/server/manager/organization/types.go new file mode 100644 index 00000000..bb72fbd5 --- /dev/null +++ b/pkg/server/manager/organization/types.go @@ -0,0 +1,23 @@ +package organization + +import ( + "errors" + + "kusionstack.io/kusion/pkg/domain/repository" +) + +var ( + ErrGettingNonExistingOrganization = errors.New("the organization does not exist") + ErrUpdatingNonExistingOrganization = errors.New("the organization to update does not exist") + ErrInvalidOrganizationID = errors.New("the organization ID should be a uuid") +) + +type OrganizationManager struct { + organizationRepo repository.OrganizationRepository +} + +func NewOrganizationManager(organizationRepo repository.OrganizationRepository) *OrganizationManager { + return &OrganizationManager{ + organizationRepo: organizationRepo, + } +} diff --git a/pkg/server/manager/project/project_manager.go b/pkg/server/manager/project/project_manager.go new file mode 100644 index 00000000..fe55d376 --- /dev/null +++ b/pkg/server/manager/project/project_manager.go @@ -0,0 +1,120 @@ +package project + +import ( + "context" + "errors" + + "github.com/jinzhu/copier" + "gorm.io/gorm" + "kusionstack.io/kusion/pkg/domain/entity" + "kusionstack.io/kusion/pkg/domain/request" + "kusionstack.io/kusion/pkg/server/handler" +) + +func (m *ProjectManager) ListProjects(ctx context.Context) ([]*entity.Project, error) { + projectEntities, err := m.projectRepo.List(ctx) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrGettingNonExistingProject + } + return nil, err + } + return projectEntities, nil +} + +func (m *ProjectManager) GetProjectByID(ctx context.Context, id uint) (*entity.Project, error) { + existingEntity, err := m.projectRepo.Get(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrGettingNonExistingProject + } + return nil, err + } + return existingEntity, nil +} + +func (m *ProjectManager) DeleteProjectByID(ctx context.Context, id uint) error { + err := m.projectRepo.Delete(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrGettingNonExistingProject + } + return err + } + return nil +} + +func (m *ProjectManager) UpdateProjectByID(ctx context.Context, id uint, requestPayload request.UpdateProjectRequest) (*entity.Project, error) { + // Convert request payload to domain model + var requestEntity entity.Project + if err := copier.Copy(&requestEntity, &requestPayload); err != nil { + return nil, err + } + + // Get source by id + sourceEntity, err := handler.GetSourceByID(ctx, m.sourceRepo, requestPayload.SourceID) + if err != nil { + return nil, err + } + requestEntity.Source = sourceEntity + + // Get organization by id + organizationEntity, err := handler.GetOrganizationByID(ctx, m.organizationRepo, requestPayload.OrganizationID) + if err != nil { + return nil, err + } + requestEntity.Organization = organizationEntity + + // Get the existing project by id + updatedEntity, err := m.projectRepo.Get(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrUpdatingNonExistingProject + } + return nil, err + } + + // Overwrite non-zero values in request entity to existing entity + copier.CopyWithOption(updatedEntity, requestEntity, copier.Option{IgnoreEmpty: true}) + // fmt.Printf("updatedEntity.Source: %v; updatedEntity.Organization: %v", updatedEntity.Source, updatedEntity.Organization) + + // Update project with repository + err = m.projectRepo.Update(ctx, updatedEntity) + if err != nil { + return nil, err + } + return updatedEntity, nil +} + +func (m *ProjectManager) CreateProject(ctx context.Context, requestPayload request.CreateProjectRequest) (*entity.Project, error) { + // Convert request payload to domain model + var createdEntity entity.Project + if err := copier.Copy(&createdEntity, &requestPayload); err != nil { + return nil, err + } + + // Get source by id + sourceEntity, err := m.sourceRepo.Get(ctx, requestPayload.SourceID) + if err != nil && err == gorm.ErrRecordNotFound { + return nil, ErrSourceNotFound + } else if err != nil { + return nil, err + } + createdEntity.Source = sourceEntity + + // Get org by id + organizationEntity, err := m.organizationRepo.Get(ctx, requestPayload.OrganizationID) + if err != nil && err == gorm.ErrRecordNotFound { + return nil, ErrOrgNotFound + } else if err != nil { + return nil, err + } + createdEntity.Organization = organizationEntity + + // Create project with repository + err = m.projectRepo.Create(ctx, &createdEntity) + if err != nil { + return nil, err + } + return &createdEntity, nil +} diff --git a/pkg/server/manager/project/types.go b/pkg/server/manager/project/types.go new file mode 100644 index 00000000..da18ceed --- /dev/null +++ b/pkg/server/manager/project/types.go @@ -0,0 +1,29 @@ +package project + +import ( + "errors" + + "kusionstack.io/kusion/pkg/domain/repository" +) + +var ( + ErrGettingNonExistingProject = errors.New("the project does not exist") + ErrUpdatingNonExistingProject = errors.New("the project to update does not exist") + ErrSourceNotFound = errors.New("the specified source does not exist") + ErrOrgNotFound = errors.New("the specified org does not exist") + ErrInvalidProjectID = errors.New("the project ID should be a uuid") +) + +type ProjectManager struct { + projectRepo repository.ProjectRepository + organizationRepo repository.OrganizationRepository + sourceRepo repository.SourceRepository +} + +func NewProjectManager(projectRepo repository.ProjectRepository, organizationRepo repository.OrganizationRepository, sourceRepo repository.SourceRepository) *ProjectManager { + return &ProjectManager{ + projectRepo: projectRepo, + organizationRepo: organizationRepo, + sourceRepo: sourceRepo, + } +} diff --git a/pkg/server/manager/source/source_manager.go b/pkg/server/manager/source/source_manager.go new file mode 100644 index 00000000..d38235ae --- /dev/null +++ b/pkg/server/manager/source/source_manager.go @@ -0,0 +1,101 @@ +package source + +import ( + "context" + "errors" + "net/url" + + "github.com/jinzhu/copier" + "gorm.io/gorm" + "kusionstack.io/kusion/pkg/domain/entity" + "kusionstack.io/kusion/pkg/domain/request" +) + +func (m *SourceManager) ListSources(ctx context.Context) ([]*entity.Source, error) { + sourceEntities, err := m.sourceRepo.List(ctx) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrGettingNonExistingSource + } + return nil, err + } + return sourceEntities, nil +} + +func (m *SourceManager) GetSourceByID(ctx context.Context, id uint) (*entity.Source, error) { + existingEntity, err := m.sourceRepo.Get(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrGettingNonExistingSource + } + return nil, err + } + return existingEntity, nil +} + +func (m *SourceManager) DeleteSourceByID(ctx context.Context, id uint) error { + err := m.sourceRepo.Delete(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrGettingNonExistingSource + } + return err + } + return nil +} + +func (m *SourceManager) UpdateSourceByID(ctx context.Context, id uint, requestPayload request.UpdateSourceRequest) (*entity.Source, error) { + // Convert request payload to domain model + var requestEntity entity.Source + if err := copier.Copy(&requestEntity, &requestPayload); err != nil { + return nil, err + } + + // Convert Remote string to URL + remote, err := url.Parse(requestPayload.Remote) + if err != nil { + return nil, err + } + requestEntity.Remote = remote + + // Get the existing source by id + updatedEntity, err := m.sourceRepo.Get(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrUpdatingNonExistingSource + } + return nil, err + } + + // Overwrite non-zero values in request entity to existing entity + copier.CopyWithOption(updatedEntity, requestEntity, copier.Option{IgnoreEmpty: true}) + + // Update source with repository + err = m.sourceRepo.Update(ctx, updatedEntity) + if err != nil { + return nil, err + } + return updatedEntity, nil +} + +func (m *SourceManager) CreateSource(ctx context.Context, requestPayload request.CreateSourceRequest) (*entity.Source, error) { + // Convert request payload to domain model + var createdEntity entity.Source + if err := copier.Copy(&createdEntity, &requestPayload); err != nil { + return nil, err + } + + // Convert Remote string to URL + remote, err := url.Parse(requestPayload.Remote) + if err != nil { + return nil, err + } + createdEntity.Remote = remote + + // Create source with repository + err = m.sourceRepo.Create(ctx, &createdEntity) + if err != nil { + return nil, err + } + return &createdEntity, nil +} diff --git a/pkg/server/manager/source/types.go b/pkg/server/manager/source/types.go new file mode 100644 index 00000000..a8e5741d --- /dev/null +++ b/pkg/server/manager/source/types.go @@ -0,0 +1,23 @@ +package source + +import ( + "errors" + + "kusionstack.io/kusion/pkg/domain/repository" +) + +var ( + ErrGettingNonExistingSource = errors.New("the source does not exist") + ErrUpdatingNonExistingSource = errors.New("the source to update does not exist") + ErrInvalidSourceID = errors.New("the source ID should be a uuid") +) + +type SourceManager struct { + sourceRepo repository.SourceRepository +} + +func NewSourceManager(sourceRepo repository.SourceRepository) *SourceManager { + return &SourceManager{ + sourceRepo: sourceRepo, + } +} diff --git a/pkg/server/manager/stack/stack_manager.go b/pkg/server/manager/stack/stack_manager.go new file mode 100644 index 00000000..0efbf38e --- /dev/null +++ b/pkg/server/manager/stack/stack_manager.go @@ -0,0 +1,334 @@ +package stack + +import ( + "context" + "errors" + "net/http" + "os" + "time" + + "github.com/jinzhu/copier" + "gorm.io/gorm" + v1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" + "kusionstack.io/kusion/pkg/domain/constant" + "kusionstack.io/kusion/pkg/domain/entity" + "kusionstack.io/kusion/pkg/domain/repository" + "kusionstack.io/kusion/pkg/domain/request" + + engineapi "kusionstack.io/kusion/pkg/engine/api" + "kusionstack.io/kusion/pkg/engine/operation/models" + + sourceapi "kusionstack.io/kusion/pkg/engine/api/source" + "kusionstack.io/kusion/pkg/server/handler" + "kusionstack.io/kusion/pkg/server/util" +) + +func NewStackManager(stackRepo repository.StackRepository, projectRepo repository.ProjectRepository, workspaceRepo repository.WorkspaceRepository) *StackManager { + return &StackManager{ + stackRepo: stackRepo, + projectRepo: projectRepo, + workspaceRepo: workspaceRepo, + } +} + +func (m *StackManager) GenerateStack(ctx context.Context, id uint, workspaceName string) (*v1.Spec, error) { + logger := util.GetLogger(ctx) + logger.Info("Starting generating spec in StackManager ...") + + // Generate a stack + stackEntity, err := m.stackRepo.Get(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrGettingNonExistingStack + } + return nil, err + } + + // Get project by id + project, err := stackEntity.Project.ConvertToCore() + if err != nil { + return nil, err + } + + // Get stack by id + stack, err := stackEntity.ConvertToCore() + if err != nil { + return nil, err + } + + // Get workspace configurations from backend + wsBackend, err := m.getBackendFromWorkspaceName(ctx, workspaceName) + if err != nil { + return nil, err + } + wsStorage, err := wsBackend.WorkspaceStorage() + if err != nil { + return nil, err + } + ws, err := wsStorage.Get(workspaceName) + if err != nil { + return nil, err + } + + // Build API inputs + // get project to get source and workdir + projectEntity, err := handler.GetProjectByID(ctx, m.projectRepo, stackEntity.Project.ID) + if err != nil { + return nil, err + } + + directory, workDir, err := GetWorkDirFromSource(ctx, stackEntity, projectEntity) + logger.Info("workDir derived", "workDir", workDir) + logger.Info("directory derived", "directory", directory) + + stack.Path = workDir + if err != nil { + return nil, err + } + // intentOptions, _ := buildOptions(workDir, kpmParam, false) + // Cleanup + defer sourceapi.Cleanup(ctx, directory) + + // Generate spec + return engineapi.GenerateSpecWithSpinner(project, stack, ws, true) +} + +func (m *StackManager) PreviewStack(ctx context.Context, id uint, workspaceName string) (*models.Changes, error) { + logger := util.GetLogger(ctx) + logger.Info("Starting previewing stack in StackManager ...") + _, changes, _, err := m.previewHelper(ctx, id, workspaceName) + return changes, err +} + +func (m *StackManager) ApplyStack(ctx context.Context, id uint, workspaceName, format string, detail, dryrun bool, w http.ResponseWriter) error { + logger := util.GetLogger(ctx) + logger.Info("Starting applying stack in StackManager ...") + + // Get the stack entity by id + stackEntity, err := m.stackRepo.Get(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrGettingNonExistingStack + } + return err + } + + // Preview a stack + sp, changes, stateStorage, err := m.previewHelper(ctx, id, workspaceName) + if err != nil { + return err + } + + _, err = ProcessChanges(ctx, w, changes, format, detail) + if err != nil { + return err + } + + // if dry run, print the hint + if dryrun { + logger.Info("NOTE: Currently running in the --dry-run mode, the above configuration does not really take effect") + return ErrDryrunDestroy + } + + logger.Info("Dryrun set to false. Start applying diffs ...") + executeOptions := BuildOptions(dryrun) + if err = engineapi.Apply(executeOptions, stateStorage, sp, changes, os.Stdout); err != nil { + return err + } + + // Update LastSyncTimestamp to current time and set stack syncState to synced + stackEntity.LastSyncTimestamp = time.Now() + stackEntity.SyncState = constant.StackStateSynced + + // Update stack with repository + err = m.stackRepo.Update(ctx, stackEntity) + if err != nil { + return err + } + + return nil +} + +func (m *StackManager) DestroyStack(ctx context.Context, id uint, workspaceName string, detail, dryrun bool, w http.ResponseWriter) error { + logger := util.GetLogger(ctx) + logger.Info("Starting applying stack in StackManager ...") + + // Get the stack entity by id + stackEntity, err := m.stackRepo.Get(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrGettingNonExistingStack + } + return err + } + + // Get project by id + project, err := stackEntity.Project.ConvertToCore() + if err != nil { + return err + } + + // Get stack by id + stack, err := stackEntity.ConvertToCore() + if err != nil { + return err + } + + stateBackend, err := m.getBackendFromWorkspaceName(ctx, workspaceName) + if err != nil { + return err + } + + // Build API inputs + // get project to get source and workdir + projectEntity, err := handler.GetProjectByID(ctx, m.projectRepo, stackEntity.Project.ID) + if err != nil { + return err + } + + directory, workDir, err := GetWorkDirFromSource(ctx, stackEntity, projectEntity) + if err != nil { + return err + } + destroyOptions := BuildOptions(dryrun) + stack.Path = workDir + + // Cleanup + defer sourceapi.Cleanup(ctx, directory) + + // Compute state storage + stateStorage := stateBackend.StateStorage(project.Name, workspaceName) + logger.Info("Remote state storage found", "Remote", stateStorage) + + priorState, err := stateStorage.Get() + if err != nil || priorState == nil { + logger.Info("can't find state", "project", project.Name, "stack", stack.Name, "workspace", workspaceName) + return ErrGettingNonExistingStateForStack + } + destroyResources := priorState.Resources + + if destroyResources == nil || len(priorState.Resources) == 0 { + return ErrNoManagedResourceToDestroy + } + + // compute changes for preview + i := &v1.Spec{Resources: destroyResources} + changes, err := engineapi.DestroyPreview(destroyOptions, i, project, stack, stateStorage) + if err != nil { + return err + } + + // Summary preview table + changes.Summary(w, true) + // detail detection + if detail { + changes.OutputDiff("all") + } + + // if dryrun, print the hint + if dryrun { + logger.Info("Dry-run mode enabled, the above resources will be destroyed if dryrun is set to false") + return ErrDryrunDestroy + } + + // Destroy + logger.Info("Start destroying resources......") + if err = engineapi.Destroy(destroyOptions, i, changes, stateStorage); err != nil { + return err + } + return nil +} + +func (m *StackManager) ListStacks(ctx context.Context) ([]*entity.Stack, error) { + stackEntities, err := m.stackRepo.List(ctx) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrGettingNonExistingStack + } + return nil, err + } + return stackEntities, nil +} + +func (m *StackManager) GetStackByID(ctx context.Context, id uint) (*entity.Stack, error) { + existingEntity, err := m.stackRepo.Get(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrGettingNonExistingStack + } + return nil, err + } + return existingEntity, nil +} + +func (m *StackManager) DeleteStackByID(ctx context.Context, id uint) error { + err := m.stackRepo.Delete(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrGettingNonExistingStack + } + return err + } + return nil +} + +func (m *StackManager) UpdateStackByID(ctx context.Context, id uint, requestPayload request.UpdateStackRequest) (*entity.Stack, error) { + // Convert request payload to domain model + var requestEntity entity.Stack + if err := copier.Copy(&requestEntity, &requestPayload); err != nil { + return nil, err + } + + // Get project by id + projectEntity, err := handler.GetProjectByID(ctx, m.projectRepo, requestPayload.ProjectID) + if err != nil { + return nil, err + } + requestEntity.Project = projectEntity + + // Get the existing stack by id + updatedEntity, err := m.stackRepo.Get(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrUpdatingNonExistingStack + } + return nil, err + } + + // Overwrite non-zero values in request entity to existing entity + copier.CopyWithOption(updatedEntity, requestEntity, copier.Option{IgnoreEmpty: true}) + + // Update stack with repository + err = m.stackRepo.Update(ctx, updatedEntity) + if err != nil { + return nil, err + } + return updatedEntity, nil +} + +func (m *StackManager) CreateStack(ctx context.Context, requestPayload request.CreateStackRequest) (*entity.Stack, error) { + // Convert request payload to domain model + var createdEntity entity.Stack + if err := copier.Copy(&createdEntity, &requestPayload); err != nil { + return nil, err + } + // The default state is UnSynced + createdEntity.SyncState = constant.StackStateUnSynced + createdEntity.CreationTimestamp = time.Now() + createdEntity.UpdateTimestamp = time.Now() + createdEntity.LastSyncTimestamp = time.Unix(0, 0) // default to none + + // Get project by id + projectEntity, err := handler.GetProjectByID(ctx, m.projectRepo, requestPayload.ProjectID) + if err != nil { + return nil, err + } + createdEntity.Project = projectEntity + + // Create stack with repository + err = m.stackRepo.Create(ctx, &createdEntity) + if err != nil { + return nil, err + } + return &createdEntity, nil +} diff --git a/pkg/server/manager/stack/types.go b/pkg/server/manager/stack/types.go new file mode 100644 index 00000000..114f31b4 --- /dev/null +++ b/pkg/server/manager/stack/types.go @@ -0,0 +1,30 @@ +package stack + +import ( + "errors" + + "kusionstack.io/kusion/pkg/domain/repository" +) + +const ( + Stdout = "stdout" + NoDiffFound = "All resources are reconciled. No diff found" +) + +var ( + ErrGettingNonExistingStack = errors.New("the stack does not exist") + ErrUpdatingNonExistingStack = errors.New("the stack to update does not exist") + ErrSourceNotFound = errors.New("the specified source does not exist") + ErrWorkspaceNotFound = errors.New("the specified workspace does not exist") + ErrProjectNotFound = errors.New("the specified project does not exist") + ErrInvalidStackID = errors.New("the stack ID should be a uuid") + ErrGettingNonExistingStateForStack = errors.New("can not find State in this stack") + ErrNoManagedResourceToDestroy = errors.New("no managed resources to destroy") + ErrDryrunDestroy = errors.New("dryrun-mode is enabled, no resources will be destroyed") +) + +type StackManager struct { + stackRepo repository.StackRepository + projectRepo repository.ProjectRepository + workspaceRepo repository.WorkspaceRepository +} diff --git a/pkg/server/manager/stack/util.go b/pkg/server/manager/stack/util.go new file mode 100644 index 00000000..d3c26aea --- /dev/null +++ b/pkg/server/manager/stack/util.go @@ -0,0 +1,229 @@ +package stack + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "path/filepath" + + "gorm.io/gorm" + apiv1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" + v1 "kusionstack.io/kusion/pkg/apis/internal.kusion.io/v1" + "kusionstack.io/kusion/pkg/backend" + "kusionstack.io/kusion/pkg/backend/storages" + "kusionstack.io/kusion/pkg/domain/constant" + "kusionstack.io/kusion/pkg/domain/entity" + engineapi "kusionstack.io/kusion/pkg/engine/api" + sourceapi "kusionstack.io/kusion/pkg/engine/api/source" + "kusionstack.io/kusion/pkg/engine/operation/models" + "kusionstack.io/kusion/pkg/engine/state" + "kusionstack.io/kusion/pkg/server/handler" + "kusionstack.io/kusion/pkg/server/util" +) + +func BuildOptions(dryrun bool) *engineapi.APIOptions { + executeOptions := &engineapi.APIOptions{ + // Operator: "operator", + // Cluster: "cluster", + // IgnoreFields: []string{}, + DryRun: dryrun, + } + return executeOptions +} + +// getWorkDirFromSource returns the workdir based on the source +// if the source type is local, it will return the path as an absolute path on the local filesystem +// if the source type is remote (git for example), it will pull the source and return the path to the pulled source +func GetWorkDirFromSource(ctx context.Context, stack *entity.Stack, project *entity.Project) (string, string, error) { + logger := util.GetLogger(ctx) + logger.Info("Getting workdir from stack source...") + // TODO: Also copy the local workdir to /tmp directory? + var err error + directory := "" + workDir := stack.Path + + if project.Source != nil && project.Source.SourceProvider != constant.SourceProviderTypeLocal { + logger.Info("Non-local source provider, locating pulled source directory") + // pull the latest source code + directory, err = sourceapi.Pull(ctx, project.Source) + if err != nil { + return "", "", err + } + logger.Info("config pulled from source successfully", "directory", directory) + workDir = filepath.Join(directory, stack.Path) + } + return directory, workDir, nil +} + +func NewBackendFromEntity(backendEntity entity.Backend) (backend.Backend, error) { + // TODO: refactor this so backend.NewBackend() share the same common logic + var storage backend.Backend + var err error + switch backendEntity.BackendConfig.Type { + case v1.BackendTypeLocal: + bkConfig := backendEntity.BackendConfig.ToLocalBackend() + if err = storages.CompleteLocalConfig(bkConfig); err != nil { + return nil, fmt.Errorf("complete local config failed, %w", err) + } + return storages.NewLocalStorage(bkConfig), nil + case v1.BackendTypeMysql: + bkConfig := backendEntity.BackendConfig.ToMysqlBackend() + storages.CompleteMysqlConfig(bkConfig) + if err = storages.ValidateMysqlConfig(bkConfig); err != nil { + return nil, fmt.Errorf("invalid config of backend %s, %w", backendEntity.Name, err) + } + storage, err = storages.NewMysqlStorage(bkConfig) + if err != nil { + return nil, fmt.Errorf("new mysql storage of backend %s failed, %w", backendEntity.Name, err) + } + case v1.BackendTypeOss: + bkConfig := backendEntity.BackendConfig.ToOssBackend() + storages.CompleteOssConfig(bkConfig) + if err = storages.ValidateOssConfig(bkConfig); err != nil { + return nil, fmt.Errorf("invalid config of backend %s, %w", backendEntity.Name, err) + } + storage, err = storages.NewOssStorage(bkConfig) + if err != nil { + return nil, fmt.Errorf("new oss storage of backend %s failed, %w", backendEntity.Name, err) + } + case v1.BackendTypeS3: + bkConfig := backendEntity.BackendConfig.ToS3Backend() + storages.CompleteS3Config(bkConfig) + if err = storages.ValidateS3Config(bkConfig); err != nil { + return nil, fmt.Errorf("invalid config of backend %s: %w", backendEntity.Name, err) + } + storage, err = storages.NewS3Storage(bkConfig) + if err != nil { + return nil, fmt.Errorf("new s3 storage of backend %s failed, %w", backendEntity.Name, err) + } + default: + return nil, fmt.Errorf("invalid type %s of backend %s", backendEntity.BackendConfig.Type, backendEntity.Name) + } + return storage, nil +} + +func ProcessChanges(ctx context.Context, w http.ResponseWriter, changes *models.Changes, format string, detail bool) (string, error) { + logger := util.GetLogger(ctx) + logger.Info("Starting previewing stack in StackManager ...") + + if format == engineapi.JSONOutput { + previewChanges, err := json.Marshal(changes) + if err != nil { + return "", err + } + logger.Info(string(previewChanges)) + return string(previewChanges), nil + } + + if changes.AllUnChange() { + logger.Info(NoDiffFound) + return NoDiffFound, nil + } + + // Summary preview table + changes.Summary(w, true) + // detail detection + if detail { + return changes.Diffs(true), nil + } + return "", nil +} + +func (m *StackManager) getBackendFromWorkspaceName(ctx context.Context, workspaceName string) (backend.Backend, error) { + logger := util.GetLogger(ctx) + logger.Info("Getting backend based on workspace name...") + // Get backend by id + workspaceEntity, err := m.workspaceRepo.GetByName(ctx, workspaceName) + if err != nil && err == gorm.ErrRecordNotFound { + return nil, err + } else if err != nil { + return nil, err + } + // Generate backend from entity + remoteBackend, err := NewBackendFromEntity(*workspaceEntity.Backend) + if err != nil { + return nil, err + } + return remoteBackend, nil +} + +func (m *StackManager) previewHelper(ctx context.Context, id uint, workspaceName string) (*apiv1.Spec, *models.Changes, state.Storage, error) { + logger := util.GetLogger(ctx) + logger.Info("Starting previewing stack in StackManager ...") + + // Get the stack entity by id + stackEntity, err := m.stackRepo.Get(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil, nil, ErrGettingNonExistingStack + } + return nil, nil, nil, err + } + + // Get project by id + project, err := stackEntity.Project.ConvertToCore() + if err != nil { + return nil, nil, nil, err + } + + // Get stack by id + stack, err := stackEntity.ConvertToCore() + if err != nil { + return nil, nil, nil, err + } + + // Get backend from workspace name + stackBackend, err := m.getBackendFromWorkspaceName(ctx, workspaceName) + if err != nil { + return nil, nil, nil, err + } + + // Get workspace configurations from backend + // TODO: temporarily local for now, should be replaced by variable sets + wsStorage, err := stackBackend.WorkspaceStorage() + if err != nil { + return nil, nil, nil, err + } + ws, err := wsStorage.Get(workspaceName) + if err != nil { + return nil, nil, nil, err + } + // Compute state storage + stateStorage := stackBackend.StateStorage(project.Name, ws.Name) + logger.Info("Local state storage found", "Path", stateStorage) + + // Build API inputs + // get project to get source and workdir + projectEntity, err := handler.GetProjectByID(ctx, m.projectRepo, stackEntity.Project.ID) + if err != nil { + return nil, nil, nil, err + } + + directory, workDir, err := GetWorkDirFromSource(ctx, stackEntity, projectEntity) + if err != nil { + return nil, nil, nil, err + } + executeOptions := BuildOptions(false) + stack.Path = workDir + + // Cleanup + defer sourceapi.Cleanup(ctx, directory) + + // Generate spec + sp, err := engineapi.GenerateSpecWithSpinner(project, stack, ws, true) + if err != nil { + return nil, nil, nil, err + } + + // return immediately if no resource found in stack + // todo: if there is no resource, should still do diff job; for now, if output is json format, there is no hint + if sp == nil || len(sp.Resources) == 0 { + logger.Info("No resource change found in this stack...") + return nil, nil, nil, nil + } + + changes, err := engineapi.Preview(executeOptions, stateStorage, sp, project, stack) + return sp, changes, stateStorage, err +} diff --git a/pkg/server/manager/workspace/types.go b/pkg/server/manager/workspace/types.go new file mode 100644 index 00000000..7c78d256 --- /dev/null +++ b/pkg/server/manager/workspace/types.go @@ -0,0 +1,26 @@ +package workspace + +import ( + "errors" + + "kusionstack.io/kusion/pkg/domain/repository" +) + +var ( + ErrGettingNonExistingWorkspace = errors.New("the workspace does not exist") + ErrUpdatingNonExistingWorkspace = errors.New("the workspace to update does not exist") + ErrInvalidWorkspaceID = errors.New("the workspace ID should be a uuid") + ErrBackendNotFound = errors.New("the specified backend does not exist") +) + +type WorkspaceManager struct { + workspaceRepo repository.WorkspaceRepository + backendRepo repository.BackendRepository +} + +func NewWorkspaceManager(workspaceRepo repository.WorkspaceRepository, backendRepo repository.BackendRepository) *WorkspaceManager { + return &WorkspaceManager{ + workspaceRepo: workspaceRepo, + backendRepo: backendRepo, + } +} diff --git a/pkg/server/manager/workspace/workspace_manager.go b/pkg/server/manager/workspace/workspace_manager.go new file mode 100644 index 00000000..1adafa28 --- /dev/null +++ b/pkg/server/manager/workspace/workspace_manager.go @@ -0,0 +1,95 @@ +package workspace + +import ( + "context" + "errors" + + "github.com/jinzhu/copier" + "gorm.io/gorm" + "kusionstack.io/kusion/pkg/domain/entity" + "kusionstack.io/kusion/pkg/domain/request" +) + +func (m *WorkspaceManager) ListWorkspaces(ctx context.Context) ([]*entity.Workspace, error) { + workspaceEntities, err := m.workspaceRepo.List(ctx) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrGettingNonExistingWorkspace + } + return nil, err + } + return workspaceEntities, nil +} + +func (m *WorkspaceManager) GetWorkspaceByID(ctx context.Context, id uint) (*entity.Workspace, error) { + existingEntity, err := m.workspaceRepo.Get(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrGettingNonExistingWorkspace + } + return nil, err + } + return existingEntity, nil +} + +func (m *WorkspaceManager) DeleteWorkspaceByID(ctx context.Context, id uint) error { + err := m.workspaceRepo.Delete(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrGettingNonExistingWorkspace + } + return err + } + return nil +} + +func (m *WorkspaceManager) UpdateWorkspaceByID(ctx context.Context, id uint, requestPayload request.UpdateWorkspaceRequest) (*entity.Workspace, error) { + // Convert request payload to domain model + var requestEntity entity.Workspace + if err := copier.Copy(&requestEntity, &requestPayload); err != nil { + return nil, err + } + + // Get the existing workspace by id + updatedEntity, err := m.workspaceRepo.Get(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrUpdatingNonExistingWorkspace + } + return nil, err + } + + // Overwrite non-zero values in request entity to existing entity + copier.CopyWithOption(updatedEntity, requestEntity, copier.Option{IgnoreEmpty: true}) + + // Update workspace with repository + err = m.workspaceRepo.Update(ctx, updatedEntity) + if err != nil { + return nil, err + } + return updatedEntity, nil +} + +func (m *WorkspaceManager) CreateWorkspace(ctx context.Context, requestPayload request.CreateWorkspaceRequest) (*entity.Workspace, error) { + // Convert request payload to domain model + var createdEntity entity.Workspace + if err := copier.Copy(&createdEntity, &requestPayload); err != nil { + return nil, err + } + + // Get backend by id + backendEntity, err := m.backendRepo.Get(ctx, requestPayload.BackendID) + if err != nil && err == gorm.ErrRecordNotFound { + return nil, ErrBackendNotFound + } else if err != nil { + return nil, err + } + createdEntity.Backend = backendEntity + + // Create workspace with repository + err = m.workspaceRepo.Create(ctx, &createdEntity) + if err != nil { + return nil, err + } + return &createdEntity, nil +} diff --git a/pkg/server/middleware/logger.go b/pkg/server/middleware/logger.go new file mode 100644 index 00000000..58c4306f --- /dev/null +++ b/pkg/server/middleware/logger.go @@ -0,0 +1,35 @@ +package middleware + +import ( + "context" + "net/http" + + "github.com/go-chi/chi/v5/middleware" + "k8s.io/klog/v2" +) + +// APILoggerKey is a context key used for associating a logger with a request. +var APILoggerKey = &contextKey{"logger"} + +// APILogger is a middleware that injects a logger, configured with a request ID, +// into the request context for use throughout the request's lifecycle. +func APILogger(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Retrieve the request ID from the context and create a logger with it. + if requestID := middleware.GetReqID(ctx); len(requestID) > 0 { + logger := klog.FromContext(ctx).WithValues("requestID", requestID) + ctx = context.WithValue(ctx, APILoggerKey, logger) + } + + // Continue serving the request with the new context. + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// DefaultLogger is a middleware that provides basic request logging using chi's +// built-in Logger middleware. +func DefaultLogger(next http.Handler) http.Handler { + return middleware.Logger(next) +} diff --git a/pkg/server/middleware/readonly.go b/pkg/server/middleware/readonly.go new file mode 100644 index 00000000..52c27438 --- /dev/null +++ b/pkg/server/middleware/readonly.go @@ -0,0 +1,18 @@ +package middleware + +import ( + "net/http" +) + +// ReadOnlyMode disallows non-GET requests in read-only mode. +func ReadOnlyMode(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "The server is currently in read-only mode.", http.StatusMethodNotAllowed) + return + } + + // If the request method is allowed, pass the request to the next handler. + next.ServeHTTP(w, r) + }) +} diff --git a/pkg/server/middleware/timing.go b/pkg/server/middleware/timing.go new file mode 100644 index 00000000..3acd261f --- /dev/null +++ b/pkg/server/middleware/timing.go @@ -0,0 +1,42 @@ +package middleware + +import ( + "context" + "net/http" + "time" +) + +// StartTimeKey is a context key used for storing the start time of a request. +var StartTimeKey = &contextKey{"startTime"} + +// Timing is a middleware that captures the current time at the start of a request +// and stores it in the request context. This start time can be used to measure +// request processing duration. +func Timing(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Set the start time in the context if it hasn't already been set. + if GetStartTime(ctx).IsZero() { + ctx = context.WithValue(ctx, StartTimeKey, time.Now()) + } + + // Continue serving the request with the updated context. + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// GetStartTime returns the start time from the given context if one is present. +// If the start time is not present or the context is nil, returns the zero time. +func GetStartTime(ctx context.Context) time.Time { + if ctx == nil { + // Return zero time if the context is nil. + return time.Time{} + } + if startTime, ok := ctx.Value(StartTimeKey).(time.Time); ok { + // Return the start time if it's present in the context. + return startTime + } + // Return zero time if the start time is not found in the context. + return time.Time{} +} diff --git a/pkg/server/middleware/types.go b/pkg/server/middleware/types.go new file mode 100644 index 00000000..a4e9177b --- /dev/null +++ b/pkg/server/middleware/types.go @@ -0,0 +1,7 @@ +package middleware + +// contextKey is a type used to define keys for context values. The name +// property is used to uniquely identify the context value. +type contextKey struct { + name string // name is the identifier for the context value. +} diff --git a/pkg/server/route/route.go b/pkg/server/route/route.go new file mode 100644 index 00000000..b5703443 --- /dev/null +++ b/pkg/server/route/route.go @@ -0,0 +1,194 @@ +package route + +import ( + "context" + "expvar" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/cors" + httpswagger "github.com/swaggo/http-swagger" + "github.com/swaggo/swag/example/basic/docs" + "kusionstack.io/kusion/pkg/infra/persistence" + "kusionstack.io/kusion/pkg/server" + "kusionstack.io/kusion/pkg/server/handler/backend" + "kusionstack.io/kusion/pkg/server/handler/endpoint" + "kusionstack.io/kusion/pkg/server/handler/organization" + "kusionstack.io/kusion/pkg/server/handler/project" + "kusionstack.io/kusion/pkg/server/handler/source" + "kusionstack.io/kusion/pkg/server/handler/stack" + "kusionstack.io/kusion/pkg/server/handler/workspace" + backendmanager "kusionstack.io/kusion/pkg/server/manager/backend" + organizationmanager "kusionstack.io/kusion/pkg/server/manager/organization" + projectmanager "kusionstack.io/kusion/pkg/server/manager/project" + sourcemanager "kusionstack.io/kusion/pkg/server/manager/source" + stackmanager "kusionstack.io/kusion/pkg/server/manager/stack" + workspacemanager "kusionstack.io/kusion/pkg/server/manager/workspace" + appmiddleware "kusionstack.io/kusion/pkg/server/middleware" + + "kusionstack.io/kusion/pkg/server/util" +) + +// NewCoreRoute creates and configures an instance of chi.Mux with the given +// configuration and extra configuration parameters. +func NewCoreRoute(config *server.Config) (*chi.Mux, error) { + router := chi.NewRouter() + + // Set up middlewares for logging, recovery, and timing, etc. + router.Use(middleware.RequestID) + router.Use(appmiddleware.DefaultLogger) + router.Use(appmiddleware.APILogger) + router.Use(appmiddleware.Timing) + router.Use(middleware.Recoverer) + + router.Use(cors.Handler(cors.Options{ + // AllowedOrigins: []string{"https://foo.com"}, // Use this to allow specific origin hosts + AllowedOrigins: []string{"https://*", "http://*"}, + // AllowOriginFunc: func(r *http.Request, origin string) bool { return true }, + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}, + ExposedHeaders: []string{"Link"}, + AllowCredentials: false, + MaxAge: 300, // Maximum value not ignored by any of major browsers + })) + + // Set up the API routes for version 1 of the API. + router.Route("/api/v1", func(r chi.Router) { + setupRestAPIV1(r, config) + }) + + // Set up the root routes. + docs.SwaggerInfo.BasePath = "/" + router.Get("/docs/*", httpswagger.Handler()) + + // Endpoint to list all available endpoints in the router. + router.Get("/endpoints", endpoint.Endpoints(router)) + + // Endpoint to list all available endpoints in the router. + router.Get("/server-configs", expvar.Handler().ServeHTTP) + http.ListenAndServe("localhost:8888", router) + + logger := util.GetLogger(context.TODO()) + logger.Info("Server Started...") + + return router, nil +} + +// setupRestAPIV1 configures routing for the API version 1, grouping routes by +// resource type and setting up proper handlers. +func setupRestAPIV1( + r chi.Router, + config *server.Config, +) { + // Set up the logger for the API. + logger := util.GetLogger(context.TODO()) + logger.Info("Setting up REST API v1...") + + // Set up the persistence layer. + organizationRepo := persistence.NewOrganizationRepository(config.DB) + projectRepo := persistence.NewProjectRepository(config.DB) + stackRepo := persistence.NewStackRepository(config.DB) + sourceRepo := persistence.NewSourceRepository(config.DB) + workspaceRepo := persistence.NewWorkspaceRepository(config.DB) + backendRepo := persistence.NewBackendRepository(config.DB) + + stackManager := stackmanager.NewStackManager(stackRepo, projectRepo, workspaceRepo) + sourceManager := sourcemanager.NewSourceManager(sourceRepo) + organizationManager := organizationmanager.NewOrganizationManager(organizationRepo) + backendManager := backendmanager.NewBackendManager(backendRepo) + workspaceManager := workspacemanager.NewWorkspaceManager(workspaceRepo, backendRepo) + projectManager := projectmanager.NewProjectManager(projectRepo, organizationRepo, sourceRepo) + + // Set up the handlers for the resources. + sourceHandler, err := source.NewHandler(sourceManager) + if err != nil { + logger.Error(err, "Error creating source handler...", "error", err) + return + } + orgHandler, err := organization.NewHandler(organizationManager) + if err != nil { + logger.Error(err, "Error creating org handler...", "error", err) + return + } + projectHandler, err := project.NewHandler(projectManager) + if err != nil { + logger.Error(err, "Error creating project handler...", "error", err) + return + } + stackHandler, err := stack.NewHandler(stackManager) + if err != nil { + logger.Error(err, "Error creating stack handler...", "error", err) + return + } + workspaceHandler, err := workspace.NewHandler(workspaceManager) + if err != nil { + logger.Error(err, "Error creating workspace handler...", "error", err) + return + } + backendHandler, err := backend.NewHandler(backendManager) + if err != nil { + logger.Error(err, "Error creating backend handler...", "error", err) + return + } + + // Set up the routes for the resources. + r.Route("/source", func(r chi.Router) { + r.Route("/{sourceID}", func(r chi.Router) { + r.Post("/", sourceHandler.CreateSource()) + r.Get("/", sourceHandler.GetSource()) + r.Put("/", sourceHandler.UpdateSource()) + r.Delete("/", sourceHandler.DeleteSource()) + }) + r.Get("/", sourceHandler.ListSources()) + }) + r.Route("/stack", func(r chi.Router) { + r.Route("/{stackID}", func(r chi.Router) { + r.Post("/", stackHandler.CreateStack()) + r.Post("/generate", stackHandler.GenerateStack()) + r.Post("/preview", stackHandler.PreviewStack()) + r.Post("/apply", stackHandler.ApplyStack()) + r.Post("/destroy", stackHandler.DestroyStack()) + r.Get("/", stackHandler.GetStack()) + r.Put("/", stackHandler.UpdateStack()) + r.Delete("/", stackHandler.DeleteStack()) + }) + r.Get("/", stackHandler.ListStacks()) + }) + r.Route("/project", func(r chi.Router) { + r.Route("/{projectID}", func(r chi.Router) { + r.Post("/", projectHandler.CreateProject()) + r.Get("/", projectHandler.GetProject()) + r.Put("/", projectHandler.UpdateProject()) + r.Delete("/", projectHandler.DeleteProject()) + }) + r.Get("/", projectHandler.ListProjects()) + }) + r.Route("/org", func(r chi.Router) { + r.Route("/{organizationID}", func(r chi.Router) { + r.Post("/", orgHandler.CreateOrganization()) + r.Get("/", orgHandler.GetOrganization()) + r.Put("/", orgHandler.UpdateOrganization()) + r.Delete("/", orgHandler.DeleteOrganization()) + }) + r.Get("/", orgHandler.ListOrganizations()) + }) + r.Route("/workspace", func(r chi.Router) { + r.Route("/{workspaceID}", func(r chi.Router) { + r.Post("/", workspaceHandler.CreateWorkspace()) + r.Get("/", workspaceHandler.GetWorkspace()) + r.Put("/", workspaceHandler.UpdateWorkspace()) + r.Delete("/", workspaceHandler.DeleteWorkspace()) + }) + r.Get("/", workspaceHandler.ListWorkspaces()) + }) + r.Route("/backend", func(r chi.Router) { + r.Route("/{backendID}", func(r chi.Router) { + r.Post("/", backendHandler.CreateBackend()) + r.Get("/", backendHandler.GetBackend()) + r.Put("/", backendHandler.UpdateBackend()) + r.Delete("/", backendHandler.DeleteBackend()) + }) + r.Get("/", backendHandler.ListBackends()) + }) +} diff --git a/pkg/server/route/route_test.go b/pkg/server/route/route_test.go new file mode 100644 index 00000000..8b85b199 --- /dev/null +++ b/pkg/server/route/route_test.go @@ -0,0 +1,66 @@ +package route + +// import ( +// "encoding/json" +// "fmt" +// "net/http" +// "net/http/httptest" +// "testing" + +// "github.com/stretchr/testify/require" +// "kusionstack.io/kusion/pkg/infra/persistence" +// "kusionstack.io/kusion/pkg/server" +// ) + +// TestNewCoreRoute will test the NewCoreRoute function with different +// configurations. +// func TestNewCoreRoute(t *testing.T) { +// // Mock the NewSearchStorage function to return a mock storage instead of +// // actual implementation. + +// fakeGDB, _, err := persistence.GetMockDB() +// require.NoError(t, err) +// tests := []struct { +// name string +// config server.Config +// expectError bool +// expectRoutes []string +// }{ +// { +// name: "route test", +// config: server.Config{ +// DB: fakeGDB, +// }, +// expectError: false, +// expectRoutes: []string{ +// "/endpoints", +// "/server-configs", +// "/api/v1/stack", +// }, +// }, +// } + +// for _, tt := range tests { +// t.Run(tt.name, func(t *testing.T) { +// router, err := NewCoreRoute(&tt.config) +// if tt.expectError { +// require.Error(t, err) +// } else { +// require.NoError(t, err) +// for _, route := range tt.expectRoutes { +// req := httptest.NewRequest(http.MethodGet, route, nil) +// request, _ := json.Marshal(req.URL) +// fmt.Println(string(request)) +// rr := httptest.NewRecorder() +// router.ServeHTTP(rr, req) +// fmt.Println(rr.Code) +// fmt.Println(rr.Header()) + +// // Assert status code is not 404 to ensure the route exists. +// require.Equal(t, http.StatusOK, rr.Code) +// require.NotEqual(t, http.StatusNotFound, rr.Code, "Route should exist: %s", route) +// } +// } +// }) +// } +// } diff --git a/pkg/server/util/ctxutil.go b/pkg/server/util/ctxutil.go new file mode 100644 index 00000000..d4557fb5 --- /dev/null +++ b/pkg/server/util/ctxutil.go @@ -0,0 +1,22 @@ +package util + +import ( + "context" + + "github.com/go-logr/logr" + "k8s.io/klog/v2" + "kusionstack.io/kusion/pkg/server/middleware" +) + +// GetLogger returns the logger from the given context. +// +// Example: +// +// logger := ctxutil.GetLogger(ctx) +func GetLogger(ctx context.Context) logr.Logger { + if logger, ok := ctx.Value(middleware.APILoggerKey).(logr.Logger); ok { + return logger + } + + return klog.NewKlogr() +}