diff --git a/api/openapispec/docs.go b/api/openapispec/docs.go index c5aa0371..f7291295 100644 --- a/api/openapispec/docs.go +++ b/api/openapispec/docs.go @@ -2565,6 +2565,61 @@ const docTemplate = `{ } } }, + "/api/v1/workspaces/configs/validate": { + "post": { + "description": "Validate the configurations in the specified workspace", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "workspace" + ], + "summary": "Validate workspace configurations", + "operationId": "validateWorkspaceConfigs", + "parameters": [ + { + "description": "Workspace configurations to be validated", + "name": "workspace", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/request.WorkspaceConfigs" + } + } + ], + "responses": { + "200": { + "description": "Success", + "schema": { + "$ref": "#/definitions/request.WorkspaceConfigs" + } + }, + "400": { + "description": "Bad Request", + "schema": {} + }, + "401": { + "description": "Unauthorized", + "schema": {} + }, + "404": { + "description": "Not Found", + "schema": {} + }, + "429": { + "description": "Too Many Requests", + "schema": {} + }, + "500": { + "description": "Internal Server Error", + "schema": {} + } + } + } + }, "/api/v1/workspaces/{id}": { "get": { "description": "Get workspace information by workspace ID", @@ -2723,6 +2778,172 @@ const docTemplate = `{ } } }, + "/api/v1/workspaces/{id}/configs": { + "get": { + "description": "Get configurations in the specified workspace", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "workspace" + ], + "summary": "get workspace configurations", + "operationId": "getWorkspaceConfigs", + "parameters": [ + { + "type": "integer", + "description": "Workspace ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Success", + "schema": { + "$ref": "#/definitions/request.WorkspaceConfigs" + } + }, + "400": { + "description": "Bad Request", + "schema": {} + }, + "401": { + "description": "Unauthorized", + "schema": {} + }, + "404": { + "description": "Not Found", + "schema": {} + }, + "429": { + "description": "Too Many Requests", + "schema": {} + }, + "500": { + "description": "Internal Server Error", + "schema": {} + } + } + }, + "put": { + "description": "Update the configurations in the specified workspace", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "workspace" + ], + "summary": "Update workspace configurations", + "operationId": "updateWorkspaceConfigs", + "parameters": [ + { + "type": "integer", + "description": "Workspace ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Updated workspace configurations", + "name": "workspace", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/request.WorkspaceConfigs" + } + } + ], + "responses": { + "200": { + "description": "Success", + "schema": { + "$ref": "#/definitions/request.WorkspaceConfigs" + } + }, + "400": { + "description": "Bad Request", + "schema": {} + }, + "401": { + "description": "Unauthorized", + "schema": {} + }, + "404": { + "description": "Not Found", + "schema": {} + }, + "429": { + "description": "Too Many Requests", + "schema": {} + }, + "500": { + "description": "Internal Server Error", + "schema": {} + } + } + } + }, + "/api/v1/workspaces/{id}/configs/mod-deps": { + "post": { + "description": "Create the module dependencies in kcl.mod of the specified workspace", + "consumes": [ + "application/json" + ], + "produces": [ + "text/plain" + ], + "tags": [ + "workspace" + ], + "summary": "Create the module dependencies of the workspace", + "operationId": "createWorkspaceModDeps", + "parameters": [ + { + "type": "integer", + "description": "Workspace ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Success", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Bad Request", + "schema": {} + }, + "401": { + "description": "Unauthorized", + "schema": {} + }, + "404": { + "description": "Not Found", + "schema": {} + }, + "429": { + "description": "Too Many Requests", + "schema": {} + }, + "500": { + "description": "Internal Server Error", + "schema": {} + } + } + } + }, "/endpoints": { "get": { "description": "List all registered endpoints in the router", @@ -4045,6 +4266,35 @@ const docTemplate = `{ } } }, + "request.WorkspaceConfigs": { + "type": "object", + "properties": { + "context": { + "description": "Context contains workspace-level configurations, such as runtimes, topologies, and metadata, etc.", + "allOf": [ + { + "$ref": "#/definitions/v1.GenericConfig" + } + ] + }, + "modules": { + "description": "Modules are the configs of a set of modules.", + "allOf": [ + { + "$ref": "#/definitions/v1.ModuleConfigs" + } + ] + }, + "secretStore": { + "description": "SecretStore represents a secure external location for storing secrets.", + "allOf": [ + { + "$ref": "#/definitions/v1.SecretStore" + } + ] + } + } + }, "url.URL": { "type": "object", "properties": { @@ -4172,6 +4422,19 @@ const docTemplate = `{ } } }, + "v1.Configs": { + "type": "object", + "properties": { + "default": { + "description": "Default is default block of the module config.", + "allOf": [ + { + "$ref": "#/definitions/v1.GenericConfig" + } + ] + } + } + }, "v1.FakeProvider": { "type": "object", "properties": { @@ -4207,6 +4470,45 @@ const docTemplate = `{ "type": "object", "additionalProperties": {} }, + "v1.ModuleConfig": { + "type": "object", + "properties": { + "configs": { + "description": "Configs contains all levels of module configs", + "allOf": [ + { + "$ref": "#/definitions/v1.Configs" + } + ] + }, + "path": { + "description": "Path is the path of the module. It can be a local path or a remote URL", + "type": "string" + }, + "version": { + "description": "Version is the version of the module.", + "type": "string" + } + } + }, + "v1.ModuleConfigs": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/v1.ModuleConfig" + } + }, + "v1.ModulePatcherConfig": { + "type": "object", + "properties": { + "projectSelector": { + "description": "ProjectSelector contains the selected projects.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, "v1.OnPremisesProvider": { "type": "object", "properties": { diff --git a/api/openapispec/swagger.json b/api/openapispec/swagger.json index 817ee50d..f2cab74d 100644 --- a/api/openapispec/swagger.json +++ b/api/openapispec/swagger.json @@ -2554,6 +2554,61 @@ } } }, + "/api/v1/workspaces/configs/validate": { + "post": { + "description": "Validate the configurations in the specified workspace", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "workspace" + ], + "summary": "Validate workspace configurations", + "operationId": "validateWorkspaceConfigs", + "parameters": [ + { + "description": "Workspace configurations to be validated", + "name": "workspace", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/request.WorkspaceConfigs" + } + } + ], + "responses": { + "200": { + "description": "Success", + "schema": { + "$ref": "#/definitions/request.WorkspaceConfigs" + } + }, + "400": { + "description": "Bad Request", + "schema": {} + }, + "401": { + "description": "Unauthorized", + "schema": {} + }, + "404": { + "description": "Not Found", + "schema": {} + }, + "429": { + "description": "Too Many Requests", + "schema": {} + }, + "500": { + "description": "Internal Server Error", + "schema": {} + } + } + } + }, "/api/v1/workspaces/{id}": { "get": { "description": "Get workspace information by workspace ID", @@ -2712,6 +2767,172 @@ } } }, + "/api/v1/workspaces/{id}/configs": { + "get": { + "description": "Get configurations in the specified workspace", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "workspace" + ], + "summary": "get workspace configurations", + "operationId": "getWorkspaceConfigs", + "parameters": [ + { + "type": "integer", + "description": "Workspace ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Success", + "schema": { + "$ref": "#/definitions/request.WorkspaceConfigs" + } + }, + "400": { + "description": "Bad Request", + "schema": {} + }, + "401": { + "description": "Unauthorized", + "schema": {} + }, + "404": { + "description": "Not Found", + "schema": {} + }, + "429": { + "description": "Too Many Requests", + "schema": {} + }, + "500": { + "description": "Internal Server Error", + "schema": {} + } + } + }, + "put": { + "description": "Update the configurations in the specified workspace", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "workspace" + ], + "summary": "Update workspace configurations", + "operationId": "updateWorkspaceConfigs", + "parameters": [ + { + "type": "integer", + "description": "Workspace ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Updated workspace configurations", + "name": "workspace", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/request.WorkspaceConfigs" + } + } + ], + "responses": { + "200": { + "description": "Success", + "schema": { + "$ref": "#/definitions/request.WorkspaceConfigs" + } + }, + "400": { + "description": "Bad Request", + "schema": {} + }, + "401": { + "description": "Unauthorized", + "schema": {} + }, + "404": { + "description": "Not Found", + "schema": {} + }, + "429": { + "description": "Too Many Requests", + "schema": {} + }, + "500": { + "description": "Internal Server Error", + "schema": {} + } + } + } + }, + "/api/v1/workspaces/{id}/configs/mod-deps": { + "post": { + "description": "Create the module dependencies in kcl.mod of the specified workspace", + "consumes": [ + "application/json" + ], + "produces": [ + "text/plain" + ], + "tags": [ + "workspace" + ], + "summary": "Create the module dependencies of the workspace", + "operationId": "createWorkspaceModDeps", + "parameters": [ + { + "type": "integer", + "description": "Workspace ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Success", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Bad Request", + "schema": {} + }, + "401": { + "description": "Unauthorized", + "schema": {} + }, + "404": { + "description": "Not Found", + "schema": {} + }, + "429": { + "description": "Too Many Requests", + "schema": {} + }, + "500": { + "description": "Internal Server Error", + "schema": {} + } + } + } + }, "/endpoints": { "get": { "description": "List all registered endpoints in the router", @@ -4034,6 +4255,35 @@ } } }, + "request.WorkspaceConfigs": { + "type": "object", + "properties": { + "context": { + "description": "Context contains workspace-level configurations, such as runtimes, topologies, and metadata, etc.", + "allOf": [ + { + "$ref": "#/definitions/v1.GenericConfig" + } + ] + }, + "modules": { + "description": "Modules are the configs of a set of modules.", + "allOf": [ + { + "$ref": "#/definitions/v1.ModuleConfigs" + } + ] + }, + "secretStore": { + "description": "SecretStore represents a secure external location for storing secrets.", + "allOf": [ + { + "$ref": "#/definitions/v1.SecretStore" + } + ] + } + } + }, "url.URL": { "type": "object", "properties": { @@ -4161,6 +4411,19 @@ } } }, + "v1.Configs": { + "type": "object", + "properties": { + "default": { + "description": "Default is default block of the module config.", + "allOf": [ + { + "$ref": "#/definitions/v1.GenericConfig" + } + ] + } + } + }, "v1.FakeProvider": { "type": "object", "properties": { @@ -4196,6 +4459,45 @@ "type": "object", "additionalProperties": {} }, + "v1.ModuleConfig": { + "type": "object", + "properties": { + "configs": { + "description": "Configs contains all levels of module configs", + "allOf": [ + { + "$ref": "#/definitions/v1.Configs" + } + ] + }, + "path": { + "description": "Path is the path of the module. It can be a local path or a remote URL", + "type": "string" + }, + "version": { + "description": "Version is the version of the module.", + "type": "string" + } + } + }, + "v1.ModuleConfigs": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/v1.ModuleConfig" + } + }, + "v1.ModulePatcherConfig": { + "type": "object", + "properties": { + "projectSelector": { + "description": "ProjectSelector contains the selected projects.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, "v1.OnPremisesProvider": { "type": "object", "properties": { diff --git a/api/openapispec/swagger.yaml b/api/openapispec/swagger.yaml index 03273944..b9b1d639 100644 --- a/api/openapispec/swagger.yaml +++ b/api/openapispec/swagger.yaml @@ -930,6 +930,23 @@ definitions: - id - owners type: object + request.WorkspaceConfigs: + properties: + context: + allOf: + - $ref: '#/definitions/v1.GenericConfig' + description: Context contains workspace-level configurations, such as runtimes, + topologies, and metadata, etc. + modules: + allOf: + - $ref: '#/definitions/v1.ModuleConfigs' + description: Modules are the configs of a set of modules. + secretStore: + allOf: + - $ref: '#/definitions/v1.SecretStore' + description: SecretStore represents a secure external location for storing + secrets. + type: object url.URL: properties: forceQuery: @@ -1030,6 +1047,13 @@ definitions: BackendTypeS3. type: string type: object + v1.Configs: + properties: + default: + allOf: + - $ref: '#/definitions/v1.GenericConfig' + description: Default is default block of the module config. + type: object v1.FakeProvider: properties: data: @@ -1053,6 +1077,32 @@ definitions: v1.GenericConfig: additionalProperties: {} type: object + v1.ModuleConfig: + properties: + configs: + allOf: + - $ref: '#/definitions/v1.Configs' + description: Configs contains all levels of module configs + path: + description: Path is the path of the module. It can be a local path or a remote + URL + type: string + version: + description: Version is the version of the module. + type: string + type: object + v1.ModuleConfigs: + additionalProperties: + $ref: '#/definitions/v1.ModuleConfig' + type: object + v1.ModulePatcherConfig: + properties: + projectSelector: + description: ProjectSelector contains the selected projects. + items: + type: string + type: array + type: object v1.OnPremisesProvider: properties: attributes: @@ -3088,6 +3138,160 @@ paths: summary: Update workspace tags: - workspace + /api/v1/workspaces/{id}/configs: + get: + consumes: + - application/json + description: Get configurations in the specified workspace + operationId: getWorkspaceConfigs + parameters: + - description: Workspace ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/request.WorkspaceConfigs' + "400": + description: Bad Request + schema: {} + "401": + description: Unauthorized + schema: {} + "404": + description: Not Found + schema: {} + "429": + description: Too Many Requests + schema: {} + "500": + description: Internal Server Error + schema: {} + summary: get workspace configurations + tags: + - workspace + put: + consumes: + - application/json + description: Update the configurations in the specified workspace + operationId: updateWorkspaceConfigs + parameters: + - description: Workspace ID + in: path + name: id + required: true + type: integer + - description: Updated workspace configurations + in: body + name: workspace + required: true + schema: + $ref: '#/definitions/request.WorkspaceConfigs' + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/request.WorkspaceConfigs' + "400": + description: Bad Request + schema: {} + "401": + description: Unauthorized + schema: {} + "404": + description: Not Found + schema: {} + "429": + description: Too Many Requests + schema: {} + "500": + description: Internal Server Error + schema: {} + summary: Update workspace configurations + tags: + - workspace + /api/v1/workspaces/{id}/configs/mod-deps: + post: + consumes: + - application/json + description: Create the module dependencies in kcl.mod of the specified workspace + operationId: createWorkspaceModDeps + parameters: + - description: Workspace ID + in: path + name: id + required: true + type: integer + produces: + - text/plain + responses: + "200": + description: Success + schema: + type: string + "400": + description: Bad Request + schema: {} + "401": + description: Unauthorized + schema: {} + "404": + description: Not Found + schema: {} + "429": + description: Too Many Requests + schema: {} + "500": + description: Internal Server Error + schema: {} + summary: Create the module dependencies of the workspace + tags: + - workspace + /api/v1/workspaces/configs/validate: + post: + consumes: + - application/json + description: Validate the configurations in the specified workspace + operationId: validateWorkspaceConfigs + parameters: + - description: Workspace configurations to be validated + in: body + name: workspace + required: true + schema: + $ref: '#/definitions/request.WorkspaceConfigs' + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/request.WorkspaceConfigs' + "400": + description: Bad Request + schema: {} + "401": + description: Unauthorized + schema: {} + "404": + description: Not Found + schema: {} + "429": + description: Too Many Requests + schema: {} + "500": + description: Internal Server Error + schema: {} + summary: Validate workspace configurations + tags: + - workspace /endpoints: get: consumes: diff --git a/pkg/domain/request/workspace_request.go b/pkg/domain/request/workspace_request.go index baaf95a0..c6f705a2 100644 --- a/pkg/domain/request/workspace_request.go +++ b/pkg/domain/request/workspace_request.go @@ -1,6 +1,10 @@ package request -import "net/http" +import ( + "net/http" + + v1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" +) // CreateWorkspaceRequest represents the create request structure for // workspace. @@ -45,6 +49,10 @@ type WorkspaceCredentials struct { AwsRegion string `json:"awsRegion,omitempty"` } +type WorkspaceConfigs struct { + *v1.Workspace `yaml:",inline" json:",inline"` +} + func (payload *CreateWorkspaceRequest) Decode(r *http.Request) error { return decode(r, payload) } @@ -56,3 +64,7 @@ func (payload *UpdateWorkspaceRequest) Decode(r *http.Request) error { func (payload *WorkspaceCredentials) Decode(r *http.Request) error { return decode(r, payload) } + +func (payload *WorkspaceConfigs) Decode(r *http.Request) error { + return decode(r, payload) +} diff --git a/pkg/server/handler/module/handler.go b/pkg/server/handler/module/handler.go index 68714d9b..33776aff 100644 --- a/pkg/server/handler/module/handler.go +++ b/pkg/server/handler/module/handler.go @@ -90,7 +90,7 @@ func (h *Handler) DeleteModule() http.HandlerFunc { // @Failure 429 {object} error "Too Many Requests" // @Failure 404 {object} error "Not Found" // @Failure 500 {object} error "Internal Server Error" -// @Router /api/v1/modules/{name} [put] +// @Router /api/v1/modules/{name} [put] func (h *Handler) UpdateModule() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // Getting stuff from context. @@ -126,7 +126,7 @@ func (h *Handler) UpdateModule() http.HandlerFunc { // @Failure 429 {object} error "Too Many Requests" // @Failure 404 {object} error "Not Found" // @Failure 500 {object} error "Internal Server Error" -// @Router /api/v1/modules/{name} [get] +// @Router /api/v1/modules/{name} [get] func (h *Handler) GetModule() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // Getting stuff from context. diff --git a/pkg/server/handler/workspace/configs.go b/pkg/server/handler/workspace/configs.go new file mode 100644 index 00000000..120749e2 --- /dev/null +++ b/pkg/server/handler/workspace/configs.go @@ -0,0 +1,133 @@ +package workspace + +import ( + "context" + "net/http" + + "github.com/go-chi/render" + "kusionstack.io/kusion/pkg/domain/request" + "kusionstack.io/kusion/pkg/server/handler" +) + +// @Id getWorkspaceConfigs +// @Summary get workspace configurations +// @Description Get configurations in the specified workspace +// @Tags workspace +// @Accept json +// @Produce json +// @Param id path int true "Workspace ID" +// @Success 200 {object} request.WorkspaceConfigs "Success" +// @Failure 400 {object} error "Bad Request" +// @Failure 401 {object} error "Unauthorized" +// @Failure 429 {object} error "Too Many Requests" +// @Failure 404 {object} error "Not Found" +// @Failure 500 {object} error "Internal Server Error" +// @Router /api/v1/workspaces/{id}/configs [get] +func (h *Handler) GetWorkspaceConfigs() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Getting stuff from the context. + ctx, logger, params, err := requestHelper(r) + if err != nil { + render.Render(w, r, handler.FailureResponse(ctx, err)) + return + } + logger.Info("Getting workspace configs...", "workspaceID", params.WorkspaceID) + + wsConfigs, err := h.workspaceManager.GetWorkspaceConfigs(ctx, params.WorkspaceID) + handler.HandleResult(w, r, ctx, err, wsConfigs) + } +} + +// @Id validateWorkspaceConfigs +// @Summary Validate workspace configurations +// @Description Validate the configurations in the specified workspace +// @Tags workspace +// @Accept json +// @Produce json +// @Param workspace body request.WorkspaceConfigs true "Workspace configurations to be validated" +// @Success 200 {object} request.WorkspaceConfigs "Success" +// @Failure 400 {object} error "Bad Request" +// @Failure 401 {object} error "Unauthorized" +// @Failure 429 {object} error "Too Many Requests" +// @Failure 404 {object} error "Not Found" +// @Failure 500 {object} error "Internal Server Error" +// @Router /api/v1/workspaces/configs/validate [post] +func (h *Handler) ValidateWorkspaceConfigs() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Decode the request body into the payload. + var requestPayload request.WorkspaceConfigs + if err := requestPayload.Decode(r); err != nil { + render.Render(w, r, handler.FailureResponse(context.Background(), err)) + return + } + + wsConfigs, err := h.workspaceManager.ValidateWorkspaceConfigs(context.Background(), requestPayload) + handler.HandleResult(w, r, context.Background(), err, wsConfigs) + } +} + +// @Id updateWorkspaceConfigs +// @Summary Update workspace configurations +// @Description Update the configurations in the specified workspace +// @Tags workspace +// @Accept json +// @Produce json +// @Param id path int true "Workspace ID" +// @Param workspace body request.WorkspaceConfigs true "Updated workspace configurations" +// @Success 200 {object} request.WorkspaceConfigs "Success" +// @Failure 400 {object} error "Bad Request" +// @Failure 401 {object} error "Unauthorized" +// @Failure 429 {object} error "Too Many Requests" +// @Failure 404 {object} error "Not Found" +// @Failure 500 {object} error "Internal Server Error" +// @Router /api/v1/workspaces/{id}/configs [put] +func (h *Handler) UpdateWorkspaceConfigs() 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 configurations...", "workspaceID", params.WorkspaceID) + + // Decode the request body into the payload. + var requestPayload request.WorkspaceConfigs + if err := requestPayload.Decode(r); err != nil { + render.Render(w, r, handler.FailureResponse(ctx, err)) + return + } + + wsConfigs, err := h.workspaceManager.UpdateWorkspaceConfigs(ctx, params.WorkspaceID, requestPayload) + handler.HandleResult(w, r, ctx, err, wsConfigs) + } +} + +// @Id createWorkspaceModDeps +// @Summary Create the module dependencies of the workspace +// @Description Create the module dependencies in kcl.mod of the specified workspace +// @Tags workspace +// @Accept json +// @Produce plain +// @Param id path int true "Workspace ID" +// @Success 200 {object} string "Success" +// @Failure 400 {object} error "Bad Request" +// @Failure 401 {object} error "Unauthorized" +// @Failure 429 {object} error "Too Many Requests" +// @Failure 404 {object} error "Not Found" +// @Failure 500 {object} error "Internal Server Error" +// @Router /api/v1/workspaces/{id}/configs/mod-deps [post] +func (h *Handler) CreateWorkspaceModDeps() 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("Creating kusion module dependencies...", "workspaceID", params.WorkspaceID) + + deps, err := h.workspaceManager.CreateKCLModDependencies(ctx, params.WorkspaceID) + handler.HandleResult(w, r, ctx, err, deps) + } +} diff --git a/pkg/server/handler/workspace/handler_test.go b/pkg/server/handler/workspace/handler_test.go index 34480b69..7c8922b6 100644 --- a/pkg/server/handler/workspace/handler_test.go +++ b/pkg/server/handler/workspace/handler_test.go @@ -10,15 +10,21 @@ import ( "testing" "github.com/DATA-DOG/go-sqlmock" + "github.com/bytedance/mockey" "github.com/go-chi/chi/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/gorm" + v1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" + "kusionstack.io/kusion/pkg/backend" "kusionstack.io/kusion/pkg/domain/entity" "kusionstack.io/kusion/pkg/domain/request" + "kusionstack.io/kusion/pkg/engine/release" + "kusionstack.io/kusion/pkg/engine/resource/graph" "kusionstack.io/kusion/pkg/infra/persistence" "kusionstack.io/kusion/pkg/server/handler" workspacemanager "kusionstack.io/kusion/pkg/server/manager/workspace" + "kusionstack.io/kusion/pkg/workspace" ) func TestWorkspaceHandler(t *testing.T) { @@ -89,51 +95,56 @@ func TestWorkspaceHandler(t *testing.T) { }) 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", "/workspaces", 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"]) + mockey.PatchConvey("mock creating initiated workspace config", t, func() { + sqlMock, fakeGDB, recorder, workspaceHandler := setupTest(t) + defer persistence.CloseDB(t, fakeGDB) + defer sqlMock.ExpectClose() + + // Mock creating an initiated workspace config. + mockCreateInitiatedWorkspaceConfig() + + // Create a new HTTP request + req, err := http.NewRequest("POST", "/workspaces", 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) { @@ -299,9 +310,68 @@ func setupTest(t *testing.T) (sqlmock.Sqlmock, *gorm.DB, *httptest.ResponseRecor require.NoError(t, err) workspaceRepo := persistence.NewWorkspaceRepository(fakeGDB) backendRepo := persistence.NewBackendRepository(fakeGDB) + moduleRepo := persistence.NewModuleRepository(fakeGDB) workspaceHandler := &Handler{ - workspaceManager: workspacemanager.NewWorkspaceManager(workspaceRepo, backendRepo, entity.Backend{}), + workspaceManager: workspacemanager.NewWorkspaceManager(workspaceRepo, backendRepo, moduleRepo, entity.Backend{}), } recorder := httptest.NewRecorder() return sqlMock, fakeGDB, recorder, workspaceHandler } + +func mockCreateInitiatedWorkspaceConfig() { + mockey.Mock(workspacemanager.NewBackendFromEntity).To(func(_ entity.Backend) (backend.Backend, error) { + return &mockBackend{}, nil + }).Build() +} + +type mockBackend struct{} + +func (m *mockBackend) WorkspaceStorage() (workspace.Storage, error) { + return &mockStorage{}, nil +} + +func (m *mockBackend) ReleaseStorage(string, string) (release.Storage, error) { + return nil, nil +} + +func (m *mockBackend) StateStorageWithPath(path string) (release.Storage, error) { + return nil, nil +} + +func (m *mockBackend) GraphStorage(project, workspace string) (graph.Storage, error) { + return nil, nil +} + +func (m *mockBackend) ProjectStorage() (map[string][]string, error) { + return nil, nil +} + +type mockStorage struct{} + +func (m *mockStorage) Get(name string) (*v1.Workspace, error) { + return nil, nil +} + +func (m *mockStorage) Create(ws *v1.Workspace) error { + return nil +} + +func (m *mockStorage) Update(ws *v1.Workspace) error { + return nil +} + +func (m *mockStorage) Delete(name string) error { + return nil +} + +func (m *mockStorage) GetNames() ([]string, error) { + return nil, nil +} + +func (m *mockStorage) GetCurrent() (string, error) { + return "", nil +} + +func (m *mockStorage) SetCurrent(name string) error { + return nil +} diff --git a/pkg/server/manager/workspace/configs.go b/pkg/server/manager/workspace/configs.go new file mode 100644 index 00000000..a0624823 --- /dev/null +++ b/pkg/server/manager/workspace/configs.go @@ -0,0 +1,255 @@ +package workspace + +import ( + "context" + "errors" + "fmt" + "net/url" + "strings" + + "gorm.io/gorm" + v1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" + "kusionstack.io/kusion/pkg/backend" + "kusionstack.io/kusion/pkg/domain/constant" + "kusionstack.io/kusion/pkg/domain/request" + logutil "kusionstack.io/kusion/pkg/server/util/logging" + + "github.com/elliotchance/orderedmap/v2" + kpmdownloader "kcl-lang.io/kpm/pkg/downloader" + kpmpkg "kcl-lang.io/kpm/pkg/package" +) + +func (m *WorkspaceManager) GetWorkspaceConfigs(ctx context.Context, id uint) (*request.WorkspaceConfigs, error) { + workspaceEntity, err := m.workspaceRepo.Get(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrGettingNonExistingWorkspace + } + return nil, err + } + + // Get backend from workspace name. + wsBackend, err := m.getBackendFromWorkspaceName(ctx, workspaceEntity.Name) + if err != nil { + return nil, err + } + + // Get workspace storage from backend. + wsStorage, err := wsBackend.WorkspaceStorage() + if err != nil { + return nil, err + } + + // Get workspace configurations from storage. + ws, err := wsStorage.Get(workspaceEntity.Name) + if err != nil { + return nil, err + } + + return &request.WorkspaceConfigs{ + Workspace: ws, + }, nil +} + +func (m *WorkspaceManager) ValidateWorkspaceConfigs(ctx context.Context, configs request.WorkspaceConfigs) (*request.WorkspaceConfigs, error) { + // Validate the workspace configs to be updated. + if err := m.validateWorkspaceConfigs(ctx, configs.Workspace); err != nil { + return nil, err + } + + return &configs, nil +} + +func (m *WorkspaceManager) UpdateWorkspaceConfigs(ctx context.Context, id uint, configs request.WorkspaceConfigs) (*request.WorkspaceConfigs, error) { + workspaceEntity, err := m.workspaceRepo.Get(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrGettingNonExistingWorkspace + } + return nil, err + } + + // Get backend from workspace name. + wsBackend, err := m.getBackendFromWorkspaceName(ctx, workspaceEntity.Name) + if err != nil { + return nil, err + } + + // Get workspace storage from backend. + wsStorage, err := wsBackend.WorkspaceStorage() + if err != nil { + return nil, err + } + + // Validate the workspace configs to be updated. + if configs.Workspace.Name != "" && configs.Workspace.Name != workspaceEntity.Name { + return nil, fmt.Errorf("inconsistent workspace name, want: %s, got: %s", workspaceEntity.Name, configs.Workspace.Name) + } else { + configs.Workspace.Name = workspaceEntity.Name + } + + if err = m.validateWorkspaceConfigs(ctx, configs.Workspace); err != nil { + return nil, err + } + + // Update workspace configs in the storage. + if err = wsStorage.Update(configs.Workspace); err != nil { + return nil, err + } + + return &configs, nil +} + +func (m *WorkspaceManager) CreateKCLModDependencies(ctx context.Context, id uint) (string, error) { + workspaceEntity, err := m.workspaceRepo.Get(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", ErrGettingNonExistingWorkspace + } + return "", err + } + + // Get backend from workspace name. + wsBackend, err := m.getBackendFromWorkspaceName(ctx, workspaceEntity.Name) + if err != nil { + return "", err + } + + // Get workspace storage from backend. + wsStorage, err := wsBackend.WorkspaceStorage() + if err != nil { + return "", err + } + + // Get workspace configurations from storage. + ws, err := wsStorage.Get(workspaceEntity.Name) + if err != nil { + return "", err + } + + // Generate the dependencies in `kcl.mod`. + deps := &kpmpkg.Dependencies{ + Deps: orderedmap.NewOrderedMap[string, kpmpkg.Dependency](), + } + + // Traverse the modules in the workspace. + for modName, modConfig := range ws.Modules { + // Parse the source url of the module. + src, err := kpmdownloader.NewSourceFromStr(modConfig.Path) + if err != nil { + return "", err + } + + // Prepare the dependency object. + dep := kpmpkg.Dependency{ + Name: modName, + Version: modConfig.Version, + } + + if src.Git != nil { + dep.Source = kpmdownloader.Source{ + Git: &kpmdownloader.Git{ + Url: modConfig.Path, + Tag: modConfig.Version, + }, + } + } else if src.Oci != nil { + u, _ := url.Parse(modConfig.Path) + dep.Source = kpmdownloader.Source{ + Oci: &kpmdownloader.Oci{ + Reg: u.Host, + Repo: strings.TrimPrefix(u.Path, "/"), + Tag: modConfig.Version, + }, + } + } else if src.Local != nil { + dep.Source = kpmdownloader.Source{ + Local: src.Local, + } + } + + deps.Deps.Set(modName, dep) + } + + return deps.MarshalTOML(), nil +} + +func (m *WorkspaceManager) getBackendFromWorkspaceName(ctx context.Context, workspaceName string) (backend.Backend, error) { + logger := logutil.GetLogger(ctx) + logger.Info("Getting backend based on workspace name...") + + var remoteBackend backend.Backend + if workspaceName == constant.DefaultWorkspace { + // Get the default backend. + return m.getDefaultBackend() + } else { + // Get workspace entity by name. + workspaceEntity, err := m.workspaceRepo.GetByName(ctx, workspaceName) + if err != nil && err == gorm.ErrRecordNotFound { + return nil, ErrGettingNonExistingWorkspace + } else if err != nil { + return nil, err + } + + // Generate backend from the workspace entity. + remoteBackend, err = NewBackendFromEntity(*workspaceEntity.Backend) + if err != nil { + return nil, err + } + } + + return remoteBackend, nil +} + +func (m *WorkspaceManager) getDefaultBackend() (backend.Backend, error) { + defaultBackendEntity := m.defaultBackend + remoteBackend, err := NewBackendFromEntity(defaultBackendEntity) + if err != nil { + return nil, err + } + + return remoteBackend, nil +} + +func (m *WorkspaceManager) validateWorkspaceConfigs(ctx context.Context, workspaceConfigs *v1.Workspace) error { + logger := logutil.GetLogger(ctx) + logger.Info("Validating workspace configs...") + + var modulesNotFound, modulesPathNotMatched []string + for moduleName, moduleConfigs := range workspaceConfigs.Modules { + // Get module entity by name. + moduleEntity, err := m.moduleRepo.Get(ctx, moduleName) + if err != nil { + // The modules declared in the workspace should be registered. + if errors.Is(err, gorm.ErrRecordNotFound) { + modulesNotFound = append(modulesNotFound, moduleName) + } else { + return err + } + } else { + // The oci path of the modules should match the registered information. + if moduleConfigs.Path != "" && moduleConfigs.Path != moduleEntity.URL.String() { + modulesPathNotMatched = append(modulesPathNotMatched, moduleName) + } else if moduleConfigs.Path == "" { + // Set the oci path with the registered information. + moduleConfigs.Path = moduleEntity.URL.String() + } + } + } + + // Prepare and return the errors according to the results. + errModulesNotFound := fmt.Errorf(ErrMsgModulesNotRegistered, + plural(len(modulesNotFound)), modulesNotFound, verb(len(modulesNotFound))) + errModulesPathNotMatched := fmt.Errorf(ErrMsgModulesPathNotMatched, + plural(len(modulesPathNotMatched)), modulesPathNotMatched, verb(len(modulesPathNotMatched))) + + if len(modulesNotFound) > 0 && len(modulesPathNotMatched) > 0 { + return errors.Join(errModulesNotFound, errModulesPathNotMatched) + } else if len(modulesNotFound) > 0 { + return errModulesNotFound + } else if len(modulesPathNotMatched) > 0 { + return errModulesPathNotMatched + } + + return nil +} diff --git a/pkg/server/manager/workspace/types.go b/pkg/server/manager/workspace/types.go index 40fb0e4a..5806c2ac 100644 --- a/pkg/server/manager/workspace/types.go +++ b/pkg/server/manager/workspace/types.go @@ -12,21 +12,42 @@ var ( 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") + ErrMsgModulesNotRegistered = "the module%s %v %s not registered" + ErrMsgModulesPathNotMatched = "the oci path of the module%s %v %s not matched with the registered information" ) type WorkspaceManager struct { workspaceRepo repository.WorkspaceRepository backendRepo repository.BackendRepository + moduleRepo repository.ModuleRepository defaultBackend entity.Backend } func NewWorkspaceManager(workspaceRepo repository.WorkspaceRepository, backendRepo repository.BackendRepository, + moduleRepo repository.ModuleRepository, defaultBackend entity.Backend, ) *WorkspaceManager { return &WorkspaceManager{ workspaceRepo: workspaceRepo, backendRepo: backendRepo, + moduleRepo: moduleRepo, defaultBackend: defaultBackend, } } + +func plural(count int) string { + if count > 1 { + return "s" + } + + return "" +} + +func verb(count int) string { + if count > 1 { + return "are" + } + + return "is" +} diff --git a/pkg/server/manager/workspace/workspace_manager.go b/pkg/server/manager/workspace/workspace_manager.go index b8a600ba..95c61f7c 100644 --- a/pkg/server/manager/workspace/workspace_manager.go +++ b/pkg/server/manager/workspace/workspace_manager.go @@ -6,6 +6,7 @@ import ( "github.com/jinzhu/copier" "gorm.io/gorm" + v1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" "kusionstack.io/kusion/pkg/domain/entity" "kusionstack.io/kusion/pkg/domain/request" ) @@ -86,6 +87,23 @@ func (m *WorkspaceManager) CreateWorkspace(ctx context.Context, requestPayload r } createdEntity.Backend = backendEntity + // Generate backend from the backend entity. + remoteBackend, err := NewBackendFromEntity(*backendEntity) + if err != nil { + return nil, err + } + + // Get workspace storage from backend. + wsStorage, err := remoteBackend.WorkspaceStorage() + if err != nil { + return nil, err + } + + // Create an initiated workspace config. + if err = wsStorage.Create(&v1.Workspace{Name: createdEntity.Name}); err != nil { + return nil, err + } + // Create workspace with repository err = m.workspaceRepo.Create(ctx, &createdEntity) if err != nil { diff --git a/pkg/server/route/route.go b/pkg/server/route/route.go index dee250eb..2ccc45a1 100644 --- a/pkg/server/route/route.go +++ b/pkg/server/route/route.go @@ -138,7 +138,7 @@ func setupRestAPIV1( sourceManager := sourcemanager.NewSourceManager(sourceRepo) organizationManager := organizationmanager.NewOrganizationManager(organizationRepo) backendManager := backendmanager.NewBackendManager(backendRepo) - workspaceManager := workspacemanager.NewWorkspaceManager(workspaceRepo, backendRepo, config.DefaultBackend) + workspaceManager := workspacemanager.NewWorkspaceManager(workspaceRepo, backendRepo, moduleRepo, config.DefaultBackend) projectManager := projectmanager.NewProjectManager(projectRepo, organizationRepo, sourceRepo, config.DefaultSource) resourceManager := resourcemanager.NewResourceManager(resourceRepo) moduleManager := modulemanager.NewModuleManager(moduleRepo, workspaceRepo, backendRepo) @@ -250,6 +250,18 @@ func setupRestAPIV1( r.Get("/", workspaceHandler.GetWorkspace()) r.Put("/", workspaceHandler.UpdateWorkspace()) r.Delete("/", workspaceHandler.DeleteWorkspace()) + r.Route("/configs", func(r chi.Router) { + r.Get("/", workspaceHandler.GetWorkspaceConfigs()) + r.Put("/", workspaceHandler.UpdateWorkspaceConfigs()) + r.Route("/mod-deps", func(r chi.Router) { + r.Post("/", workspaceHandler.CreateWorkspaceModDeps()) + }) + }) + }) + r.Route("/configs", func(r chi.Router) { + r.Route("/validate", func(r chi.Router) { + r.Post("/", workspaceHandler.ValidateWorkspaceConfigs()) + }) }) r.Post("/", workspaceHandler.CreateWorkspace()) r.Get("/", workspaceHandler.ListWorkspaces())