From 35b161cc2a8937fe818fbe4a2fcc3f849fcc14d9 Mon Sep 17 00:00:00 2001 From: Yann Stepienik Date: Tue, 17 Mar 2026 18:25:47 +0000 Subject: [PATCH] [release] v0.22.0-unstable05 --- .circleci/config.yml | 14 + .gitignore | 9 +- api-docs/openapi.json | 102 ++- api-docs/swagger.json | 86 +- api-docs/swagger.yaml | 54 ++ changelog.md | 3 +- go-sdk/client.gen.go | 811 +++++++++++------- src/backups/API.go | 78 +- src/backups/restic.go | 3 +- src/configapi/openid.go | 32 +- src/configapi/routes.go | 17 + src/constellation/api_devices_create.go | 2 +- src/httpServer.go | 2 + terraform-provider-cosmos/.gitignore | 8 + terraform-provider-cosmos/.goreleaser.yml | 40 + terraform-provider-cosmos/GNUmakefile | 19 + terraform-provider-cosmos/README.md | 139 +++ terraform-provider-cosmos/go.mod | 49 ++ terraform-provider-cosmos/go.sum | 119 +++ .../internal/client/client.go | 325 +++++++ .../internal/datasources/containers.go | 157 ++++ .../internal/datasources/status.go | 152 ++++ .../internal/provider/provider.go | 126 +++ .../internal/resources/alert.go | 465 ++++++++++ .../internal/resources/api_token.go | 311 +++++++ .../internal/resources/backup.go | 268 ++++++ .../internal/resources/constellation.go | 177 ++++ .../resources/constellation_device.go | 458 ++++++++++ .../internal/resources/constellation_dns.go | 196 +++++ .../internal/resources/cron_job.go | 230 +++++ .../internal/resources/docker_network.go | 297 +++++++ .../internal/resources/docker_service.go | 311 +++++++ .../internal/resources/docker_volume.go | 207 +++++ .../internal/resources/openid_client.go | 222 +++++ .../internal/resources/route.go | 763 ++++++++++++++++ .../internal/resources/snapraid.go | 355 ++++++++ .../internal/resources/storage_mount.go | 289 +++++++ .../internal/resources/user.go | 250 ++++++ terraform-provider-cosmos/main.go | 28 + 39 files changed, 6879 insertions(+), 295 deletions(-) create mode 100644 terraform-provider-cosmos/.gitignore create mode 100644 terraform-provider-cosmos/.goreleaser.yml create mode 100644 terraform-provider-cosmos/GNUmakefile create mode 100644 terraform-provider-cosmos/README.md create mode 100644 terraform-provider-cosmos/go.mod create mode 100644 terraform-provider-cosmos/go.sum create mode 100644 terraform-provider-cosmos/internal/client/client.go create mode 100644 terraform-provider-cosmos/internal/datasources/containers.go create mode 100644 terraform-provider-cosmos/internal/datasources/status.go create mode 100644 terraform-provider-cosmos/internal/provider/provider.go create mode 100644 terraform-provider-cosmos/internal/resources/alert.go create mode 100644 terraform-provider-cosmos/internal/resources/api_token.go create mode 100644 terraform-provider-cosmos/internal/resources/backup.go create mode 100644 terraform-provider-cosmos/internal/resources/constellation.go create mode 100644 terraform-provider-cosmos/internal/resources/constellation_device.go create mode 100644 terraform-provider-cosmos/internal/resources/constellation_dns.go create mode 100644 terraform-provider-cosmos/internal/resources/cron_job.go create mode 100644 terraform-provider-cosmos/internal/resources/docker_network.go create mode 100644 terraform-provider-cosmos/internal/resources/docker_service.go create mode 100644 terraform-provider-cosmos/internal/resources/docker_volume.go create mode 100644 terraform-provider-cosmos/internal/resources/openid_client.go create mode 100644 terraform-provider-cosmos/internal/resources/route.go create mode 100644 terraform-provider-cosmos/internal/resources/snapraid.go create mode 100644 terraform-provider-cosmos/internal/resources/storage_mount.go create mode 100644 terraform-provider-cosmos/internal/resources/user.go create mode 100644 terraform-provider-cosmos/main.go diff --git a/.circleci/config.yml b/.circleci/config.yml index 9f116b2c..9ca03f44 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -209,6 +209,20 @@ jobs: git tag "go-sdk/v${VERSION}" 2>/dev/null || true git push origin "go-sdk/v${VERSION}" 2>/dev/null || true + - run: + name: Build and attach Terraform provider to release + command: | + VERSION=$(jq -r .version package.json) + cd terraform-provider-cosmos + for PAIR in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do + OS="${PAIR%/*}" + ARCH="${PAIR#*/}" + GOOS=$OS GOARCH=$ARCH go build -ldflags "-s -w -X main.version=${VERSION}" -o "terraform-provider-cosmos_${OS}_${ARCH}" + zip "terraform-provider-cosmos_${VERSION}_${OS}_${ARCH}.zip" "terraform-provider-cosmos_${OS}_${ARCH}" + rm "terraform-provider-cosmos_${OS}_${ARCH}" + done + gh release upload "v${VERSION}" terraform-provider-cosmos_${VERSION}_*.zip --clobber + workflows: version: 2 build-all: diff --git a/.gitignore b/.gitignore index 7f0d0b7b..579674f7 100644 --- a/.gitignore +++ b/.gitignore @@ -30,4 +30,11 @@ test-backup /backups ca.pem sdk/dist -sdk/node_modules \ No newline at end of file +sdk/node_modules + +.terraform +terraform.tfstate +terraform.tfstate.* +.terraform.lock.hcl +*.log +main.tf diff --git a/api-docs/openapi.json b/api-docs/openapi.json index cdab68b5..418da390 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -804,6 +804,99 @@ } } }, + "/api/backups-config": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns a map of all backup configurations from the server config, keyed by backup name.", + "tags": [ + "Backups" + ], + "summary": "List all backup configurations", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/utils.APIResponse" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/utils.HTTPErrorResult" + } + } + } + } + } + } + }, + "/api/backups-config/{name}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns a single backup configuration from the server config.", + "tags": [ + "Backups" + ], + "summary": "Get a single backup configuration by name", + "parameters": [ + { + "description": "Backup name", + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/utils.APIResponse" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/utils.HTTPErrorResult" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/utils.HTTPErrorResult" + } + } + } + } + } + } + }, "/api/backups-repository": { "get": { "security": [ @@ -9582,6 +9675,12 @@ "name": { "type": "string" }, + "permissions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/utils.Permission" + } + }, "readOnly": { "type": "boolean" }, @@ -9735,7 +9834,8 @@ "type": "object", "required": [ "deviceName", - "ip" + "ip", + "nickname" ], "properties": { "PublicHostname": { diff --git a/api-docs/swagger.json b/api-docs/swagger.json index 5e6f1db3..63dd91f6 100644 --- a/api-docs/swagger.json +++ b/api-docs/swagger.json @@ -682,6 +682,83 @@ } } }, + "/api/backups-config": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns a map of all backup configurations from the server config, keyed by backup name.", + "produces": [ + "application/json" + ], + "tags": [ + "Backups" + ], + "summary": "List all backup configurations", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/utils.APIResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/utils.HTTPErrorResult" + } + } + } + } + }, + "/api/backups-config/{name}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns a single backup configuration from the server config.", + "produces": [ + "application/json" + ], + "tags": [ + "Backups" + ], + "summary": "Get a single backup configuration by name", + "parameters": [ + { + "type": "string", + "description": "Backup name", + "name": "name", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/utils.APIResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/utils.HTTPErrorResult" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/utils.HTTPErrorResult" + } + } + } + } + }, "/api/backups-repository": { "get": { "security": [ @@ -7839,6 +7916,12 @@ "name": { "type": "string" }, + "permissions": { + "type": "array", + "items": { + "$ref": "#/definitions/utils.Permission" + } + }, "readOnly": { "type": "boolean" }, @@ -7992,7 +8075,8 @@ "type": "object", "required": [ "deviceName", - "ip" + "ip", + "nickname" ], "properties": { "PublicHostname": { diff --git a/api-docs/swagger.yaml b/api-docs/swagger.yaml index 78ef97d6..bae3c369 100644 --- a/api-docs/swagger.yaml +++ b/api-docs/swagger.yaml @@ -10,6 +10,10 @@ definitions: type: array name: type: string + permissions: + items: + $ref: '#/definitions/utils.Permission' + type: array readOnly: type: boolean restrictToConstellation: @@ -145,6 +149,7 @@ definitions: required: - deviceName - ip + - nickname type: object constellation.DeviceEditRequestJSON: properties: @@ -2349,6 +2354,55 @@ paths: summary: Create a new backup configuration tags: - Backups + /api/backups-config: + get: + description: Returns a map of all backup configurations from the server config, + keyed by backup name. + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/utils.APIResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/utils.HTTPErrorResult' + security: + - BearerAuth: [] + summary: List all backup configurations + tags: + - Backups + /api/backups-config/{name}: + get: + description: Returns a single backup configuration from the server config. + parameters: + - description: Backup name + in: path + name: name + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/utils.APIResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/utils.HTTPErrorResult' + "404": + description: Not Found + schema: + $ref: '#/definitions/utils.HTTPErrorResult' + security: + - BearerAuth: [] + summary: Get a single backup configuration by name + tags: + - Backups /api/backups-repository: get: produces: diff --git a/changelog.md b/changelog.md index e492c469..6d1c3d89 100644 --- a/changelog.md +++ b/changelog.md @@ -1,6 +1,6 @@ ## Version 0.22 - Adding feature to create API key to directly query the Cosmos API - - Added GO/TS SDK to call the API from any project (AI generated) + - Added GO/TS/Terraform SDK to call the API from any project (AI generated) - Refactor the entire permission system for future work on roles and fine-grained permissions (partially AI assisted) - You can now create multiple admin users - Fixed issue with post-install on compose import @@ -10,6 +10,7 @@ - Fix issue with Constellation sometime not connecting right away on new network - Sync more settings accross servers in Constellations (Rclone and api token/permissions configs) - Fix issue with /logout not clearing cookies normally + - Fix issue with deleting backups ## Version 0.21.7 - Fix issue with Constellation routing HTTP tunnels to the wrong protocol diff --git a/go-sdk/client.gen.go b/go-sdk/client.gen.go index 9edcf104..79144dfd 100644 --- a/go-sdk/client.gen.go +++ b/go-sdk/client.gen.go @@ -219,11 +219,12 @@ func (e GetApiSnapraidNameActionParamsAction) Valid() bool { // ConfigapiCreateAPITokenRequest defines model for configapi.CreateAPITokenRequest. type ConfigapiCreateAPITokenRequest struct { - Description *string `json:"description,omitempty"` - IpWhitelist *[]string `json:"ipWhitelist,omitempty"` - Name string `json:"name"` - ReadOnly *bool `json:"readOnly,omitempty"` - RestrictToConstellation *bool `json:"restrictToConstellation,omitempty"` + Description *string `json:"description,omitempty"` + IpWhitelist *[]string `json:"ipWhitelist,omitempty"` + Name string `json:"name"` + Permissions *[]UtilsPermission `json:"permissions,omitempty"` + ReadOnly *bool `json:"readOnly,omitempty"` + RestrictToConstellation *bool `json:"restrictToConstellation,omitempty"` } // ConfigapiDNSConfigRequest defines model for configapi.DNSConfigRequest. @@ -295,7 +296,7 @@ type ConstellationDeviceCreateRequestJSON struct { IsRelay *bool `json:"isRelay,omitempty"` // Nickname for devices only - Nickname *string `json:"nickname,omitempty"` + Nickname string `json:"nickname"` Port *string `json:"port,omitempty"` PublicKey *string `json:"publicKey,omitempty"` } @@ -1768,6 +1769,12 @@ type ClientInterface interface { PostApiBackups(ctx context.Context, body PostApiBackupsJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // GetApiBackupsConfig request + GetApiBackupsConfig(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetApiBackupsConfigName request + GetApiBackupsConfigName(ctx context.Context, name string, reqEditors ...RequestEditorFn) (*http.Response, error) + // GetApiBackupsRepository request GetApiBackupsRepository(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -2545,6 +2552,30 @@ func (c *Client) PostApiBackups(ctx context.Context, body PostApiBackupsJSONRequ return c.Client.Do(req) } +func (c *Client) GetApiBackupsConfig(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetApiBackupsConfigRequest(c.Server) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetApiBackupsConfigName(ctx context.Context, name string, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetApiBackupsConfigNameRequest(c.Server, name) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) GetApiBackupsRepository(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewGetApiBackupsRepositoryRequest(c.Server) if err != nil { @@ -5318,6 +5349,67 @@ func NewPostApiBackupsRequestWithBody(server string, contentType string, body io return req, nil } +// NewGetApiBackupsConfigRequest generates requests for GetApiBackupsConfig +func NewGetApiBackupsConfigRequest(server string) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/backups-config") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewGetApiBackupsConfigNameRequest generates requests for GetApiBackupsConfigName +func NewGetApiBackupsConfigNameRequest(server string, name string) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "name", name, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/backups-config/%s", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + // NewGetApiBackupsRepositoryRequest generates requests for GetApiBackupsRepository func NewGetApiBackupsRepositoryRequest(server string) (*http.Request, error) { var err error @@ -10807,6 +10899,12 @@ type ClientWithResponsesInterface interface { PostApiBackupsWithResponse(ctx context.Context, body PostApiBackupsJSONRequestBody, reqEditors ...RequestEditorFn) (*PostApiBackupsResponse, error) + // GetApiBackupsConfigWithResponse request + GetApiBackupsConfigWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetApiBackupsConfigResponse, error) + + // GetApiBackupsConfigNameWithResponse request + GetApiBackupsConfigNameWithResponse(ctx context.Context, name string, reqEditors ...RequestEditorFn) (*GetApiBackupsConfigNameResponse, error) + // GetApiBackupsRepositoryWithResponse request GetApiBackupsRepositoryWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetApiBackupsRepositoryResponse, error) @@ -11631,6 +11729,53 @@ func (r PostApiBackupsResponse) StatusCode() int { return 0 } +type GetApiBackupsConfigResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *UtilsAPIResponse + JSON403 *UtilsHTTPErrorResult +} + +// Status returns HTTPResponse.Status +func (r GetApiBackupsConfigResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetApiBackupsConfigResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type GetApiBackupsConfigNameResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *UtilsAPIResponse + JSON403 *UtilsHTTPErrorResult + JSON404 *UtilsHTTPErrorResult +} + +// Status returns HTTPResponse.Status +func (r GetApiBackupsConfigNameResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetApiBackupsConfigNameResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type GetApiBackupsRepositoryResponse struct { Body []byte HTTPResponse *http.Response @@ -15270,6 +15415,24 @@ func (c *ClientWithResponses) PostApiBackupsWithResponse(ctx context.Context, bo return ParsePostApiBackupsResponse(rsp) } +// GetApiBackupsConfigWithResponse request returning *GetApiBackupsConfigResponse +func (c *ClientWithResponses) GetApiBackupsConfigWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetApiBackupsConfigResponse, error) { + rsp, err := c.GetApiBackupsConfig(ctx, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetApiBackupsConfigResponse(rsp) +} + +// GetApiBackupsConfigNameWithResponse request returning *GetApiBackupsConfigNameResponse +func (c *ClientWithResponses) GetApiBackupsConfigNameWithResponse(ctx context.Context, name string, reqEditors ...RequestEditorFn) (*GetApiBackupsConfigNameResponse, error) { + rsp, err := c.GetApiBackupsConfigName(ctx, name, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetApiBackupsConfigNameResponse(rsp) +} + // GetApiBackupsRepositoryWithResponse request returning *GetApiBackupsRepositoryResponse func (c *ClientWithResponses) GetApiBackupsRepositoryWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetApiBackupsRepositoryResponse, error) { rsp, err := c.GetApiBackupsRepository(ctx, reqEditors...) @@ -17500,6 +17663,79 @@ func ParsePostApiBackupsResponse(rsp *http.Response) (*PostApiBackupsResponse, e return response, nil } +// ParseGetApiBackupsConfigResponse parses an HTTP response from a GetApiBackupsConfigWithResponse call +func ParseGetApiBackupsConfigResponse(rsp *http.Response) (*GetApiBackupsConfigResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetApiBackupsConfigResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest UtilsAPIResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest UtilsHTTPErrorResult + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + } + + return response, nil +} + +// ParseGetApiBackupsConfigNameResponse parses an HTTP response from a GetApiBackupsConfigNameWithResponse call +func ParseGetApiBackupsConfigNameResponse(rsp *http.Response) (*GetApiBackupsConfigNameResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetApiBackupsConfigNameResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest UtilsAPIResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest UtilsHTTPErrorResult + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest UtilsHTTPErrorResult + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + } + + return response, nil +} + // ParseGetApiBackupsRepositoryResponse parses an HTTP response from a GetApiBackupsRepositoryWithResponse call func ParseGetApiBackupsRepositoryResponse(rsp *http.Response) (*GetApiBackupsRepositoryResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -23523,287 +23759,288 @@ func ParsePostRcloneVfsStatsResponse(rsp *http.Response) (*PostRcloneVfsStatsRes // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", - "AP//FbMekod3AgA=", + "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", + "Y9MefP309f8FAAD///hIZQT7fAIA", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/src/backups/API.go b/src/backups/API.go index 94530b61..320e2cc8 100644 --- a/src/backups/API.go +++ b/src/backups/API.go @@ -10,6 +10,82 @@ import ( "github.com/azukaar/cosmos-server/src/utils" ) +// ListBackupConfigsRoute godoc +// @Summary List all backup configurations +// @Description Returns a map of all backup configurations from the server config, keyed by backup name. +// @Tags Backups +// @Produce json +// @Security BearerAuth +// @Success 200 {object} utils.APIResponse +// @Failure 403 {object} utils.HTTPErrorResult +// @Router /api/backups-config [get] +func ListBackupConfigsRoute(w http.ResponseWriter, req *http.Request) { + if utils.CheckPermissions(w, req, utils.PERM_RESOURCES) != nil { + return + } + + if req.Method == "GET" { + config := utils.GetMainConfig() + backups := config.Backup.Backups + if backups == nil { + backups = make(map[string]utils.SingleBackupConfig) + } + + // Strip passwords from the response + sanitized := make(map[string]utils.SingleBackupConfig) + for k, v := range backups { + v.Password = "" + sanitized[k] = v + } + + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "OK", + "data": sanitized, + }) + } else { + utils.HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001") + } +} + +// GetBackupConfigRoute godoc +// @Summary Get a single backup configuration by name +// @Description Returns a single backup configuration from the server config. +// @Tags Backups +// @Produce json +// @Param name path string true "Backup name" +// @Security BearerAuth +// @Success 200 {object} utils.APIResponse +// @Failure 403 {object} utils.HTTPErrorResult +// @Failure 404 {object} utils.HTTPErrorResult +// @Router /api/backups-config/{name} [get] +func GetBackupConfigRoute(w http.ResponseWriter, req *http.Request) { + if utils.CheckPermissions(w, req, utils.PERM_RESOURCES) != nil { + return + } + + if req.Method == "GET" { + vars := mux.Vars(req) + name := vars["name"] + + config := utils.GetMainConfig() + backup, exists := config.Backup.Backups[name] + if !exists { + utils.HTTPError(w, "Backup not found", http.StatusNotFound, "BCK004") + return + } + + // Strip password from the response + backup.Password = "" + + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "OK", + "data": backup, + }) + } else { + utils.HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001") + } +} + // AddBackupRoute godoc // @Summary Create a new backup configuration // @Description Creates a backup config and initializes the repository if needed. Requires premium licence and non-container mode. @@ -245,7 +321,7 @@ func RemoveBackupRoute(w http.ResponseWriter, req *http.Request) { output, err := ListSnapshots(backup.Repository, backup.Password) if err == nil { var outputJSON []map[string]interface{} - if err := json.Unmarshal([]byte(output), &outputJSON); err == nil { + if err := json.Unmarshal([]byte(SplitJSONObjects(output)), &outputJSON); err == nil { if len(outputJSON) == 0 { err = DeleteRepository(backup.Repository) if err != nil { diff --git a/src/backups/restic.go b/src/backups/restic.go index 45b4778b..858b9d04 100644 --- a/src/backups/restic.go +++ b/src/backups/restic.go @@ -640,8 +640,9 @@ func DeleteByTag(repository string, password string, tag string) error { } // Parse the JSON output to get snapshot IDs + // Use SplitJSONObjects to extract JSON from potentially noisy PTY output var snapshots []map[string]interface{} - if err := json.Unmarshal([]byte(output), &snapshots); err != nil { + if err := json.Unmarshal([]byte(SplitJSONObjects(output)), &snapshots); err != nil { return fmt.Errorf("[Restic] failed to parse snapshots: %w", err) } diff --git a/src/configapi/openid.go b/src/configapi/openid.go index 6039923d..6a2932d5 100644 --- a/src/configapi/openid.go +++ b/src/configapi/openid.go @@ -6,6 +6,7 @@ import ( "github.com/azukaar/cosmos-server/src/utils" "github.com/gorilla/mux" + "golang.org/x/crypto/bcrypt" ) func OpenIDRoute(w http.ResponseWriter, req *http.Request) { @@ -49,6 +50,11 @@ func listOpenIDClients(w http.ResponseWriter, req *http.Request) { config := utils.ReadConfigFromFile() + // delete secrets before returning + for i := range config.OpenIDClients { + config.OpenIDClients[i].Secret = "" + } + json.NewEncoder(w).Encode(map[string]interface{}{ "status": "OK", "data": config.OpenIDClients, @@ -77,6 +83,9 @@ func getOpenIDClient(w http.ResponseWriter, req *http.Request) { for _, client := range config.OpenIDClients { if client.ID == id { + // delete secret + client.Secret = "" + json.NewEncoder(w).Encode(map[string]interface{}{ "status": "OK", "data": client, @@ -133,9 +142,21 @@ func createOpenIDClient(w http.ResponseWriter, req *http.Request) { } } + // Generate a secret server-side, store the bcrypt hash, return the plain secret once + plainSecret := utils.GenerateRandomString(32) + hashedSecret, err := bcrypt.GenerateFromPassword([]byte(plainSecret), bcrypt.DefaultCost) + if err != nil { + utils.Error("CreateOpenIDClient: Failed to hash secret", err) + utils.HTTPError(w, "Failed to generate secret", http.StatusInternalServerError, "OID008") + return + } + newClient.Secret = string(hashedSecret) + config.OpenIDClients = append(config.OpenIDClients, newClient) utils.SetBaseMainConfig(config) + utils.Log("OpenID client created: " + newClient.ID) + utils.TriggerEvent( "cosmos.settings", "OpenID client created: "+newClient.ID, @@ -144,9 +165,14 @@ func createOpenIDClient(w http.ResponseWriter, req *http.Request) { map[string]interface{}{ }) + // Return the plain secret once — it won't be retrievable after this json.NewEncoder(w).Encode(map[string]interface{}{ "status": "OK", - "data": newClient, + "data": map[string]interface{}{ + "id": newClient.ID, + "redirect": newClient.Redirect, + "secret": plainSecret, + }, }) } @@ -203,6 +229,8 @@ func updateOpenIDClient(w http.ResponseWriter, req *http.Request) { config.OpenIDClients = clients utils.SetBaseMainConfig(config) + utils.Log("OpenID client updated: " + id) + utils.TriggerEvent( "cosmos.settings", "OpenID client updated: "+id, @@ -258,6 +286,8 @@ func deleteOpenIDClient(w http.ResponseWriter, req *http.Request) { clients = append(clients[:clientIndex], clients[clientIndex+1:]...) config.OpenIDClients = clients utils.SetBaseMainConfig(config) + + utils.Log("OpenID client deleted: " + id) utils.TriggerEvent( "cosmos.settings", diff --git a/src/configapi/routes.go b/src/configapi/routes.go index d468cb97..de5e243f 100644 --- a/src/configapi/routes.go +++ b/src/configapi/routes.go @@ -120,6 +120,11 @@ func createRoute(w http.ResponseWriter, req *http.Request) { return } + if !newRoute.UseHost && !newRoute.UsePathPrefix { + utils.HTTPError(w, "Route must have at least one of UseHost or UsePathPrefix enabled, otherwise it will catch all requests", http.StatusBadRequest, "RT008") + return + } + utils.ConfigLock.Lock() defer utils.ConfigLock.Unlock() @@ -136,6 +141,8 @@ func createRoute(w http.ResponseWriter, req *http.Request) { config.HTTPConfig.ProxyConfig.Routes = append([]utils.ProxyRouteConfig{newRoute}, routes...) utils.SetBaseMainConfig(config) + utils.Log("Route created: " + newRoute.Name) + utils.TriggerEvent( "cosmos.routes", "Route created: "+newRoute.Name, @@ -203,10 +210,17 @@ func updateRoute(w http.ResponseWriter, req *http.Request) { return } + if !updatedRoute.UseHost && !updatedRoute.UsePathPrefix { + utils.HTTPError(w, "Route must have at least one of UseHost or UsePathPrefix enabled, otherwise it will catch all requests", http.StatusBadRequest, "RT008") + return + } + routes[routeIndex] = updatedRoute config.HTTPConfig.ProxyConfig.Routes = routes utils.SetBaseMainConfig(config) + utils.Log("Route updated: " + name) + utils.TriggerEvent( "cosmos.routes", "Route updated: "+name, @@ -267,6 +281,9 @@ func deleteRoute(w http.ResponseWriter, req *http.Request) { config.HTTPConfig.ProxyConfig.Routes = routes utils.SetBaseMainConfig(config) + + utils.Log("Route deleted: " + name) + utils.TriggerEvent( "cosmos.routes", "Route deleted: "+name, diff --git a/src/constellation/api_devices_create.go b/src/constellation/api_devices_create.go index 70c06f1d..91f75ccc 100644 --- a/src/constellation/api_devices_create.go +++ b/src/constellation/api_devices_create.go @@ -16,7 +16,7 @@ type DeviceCreateRequestJSON struct { PublicKey string `json:"publicKey,omitempty"` // for devices only - Nickname string `json:"nickname,omitempty" validate:"max=32,alphanum"` + Nickname string `json:"nickname" validate:"required,max=32,alphanum"` Invisible bool `json:"invisible,omitempty"` // for lighthouse only diff --git a/src/httpServer.go b/src/httpServer.go index 38d8816f..bc8670c0 100644 --- a/src/httpServer.go +++ b/src/httpServer.go @@ -729,6 +729,8 @@ func InitServer() *mux.Router { srapiAdmin.HandleFunc("/api/list-dir", storage.ListDirectoryRoute) srapiAdmin.HandleFunc("/api/new-dir", storage.CreateFolderRoute) + srapiAdmin.HandleFunc("/api/backups-config", backups.ListBackupConfigsRoute) + srapiAdmin.HandleFunc("/api/backups-config/{name}", backups.GetBackupConfigRoute) srapiAdmin.HandleFunc("/api/backups-repository", backups.ListRepos) srapiAdmin.HandleFunc("/api/backups-repository/{name}/snapshots", backups.ListSnapshotsRouteFromRepo) srapiAdmin.HandleFunc("/api/backups/{name}/snapshots", backups.ListSnapshotsRoute) diff --git a/terraform-provider-cosmos/.gitignore b/terraform-provider-cosmos/.gitignore new file mode 100644 index 00000000..fc6bc663 --- /dev/null +++ b/terraform-provider-cosmos/.gitignore @@ -0,0 +1,8 @@ +terraform-provider-cosmos +*.exe +dist/ +.terraform/ +*.tfstate +*.tfstate.backup +*.tfplan +crash.log diff --git a/terraform-provider-cosmos/.goreleaser.yml b/terraform-provider-cosmos/.goreleaser.yml new file mode 100644 index 00000000..264afccf --- /dev/null +++ b/terraform-provider-cosmos/.goreleaser.yml @@ -0,0 +1,40 @@ +version: 2 + +builds: + - env: + - CGO_ENABLED=0 + mod_timestamp: "{{ .CommitTimestamp }}" + flags: + - -trimpath + ldflags: + - "-s -w -X main.version={{ .Version }}" + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + binary: "{{ .ProjectName }}_v{{ .Version }}" + +archives: + - format: zip + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + +checksum: + name_template: "{{ .ProjectName }}_{{ .Version }}_SHA256SUMS" + algorithm: sha256 + +signs: + - artifacts: checksum + args: + - "--batch" + - "--local-user" + - "{{ .Env.GPG_FINGERPRINT }}" + - "--output" + - "${signature}" + - "--detach-sign" + - "${artifact}" + +release: + draft: true diff --git a/terraform-provider-cosmos/GNUmakefile b/terraform-provider-cosmos/GNUmakefile new file mode 100644 index 00000000..1d292f45 --- /dev/null +++ b/terraform-provider-cosmos/GNUmakefile @@ -0,0 +1,19 @@ +default: build + +build: + go build -o terraform-provider-cosmos + +install: build + mkdir -p ~/.terraform.d/plugins/cosmos-cloud.io/azukaar/cosmos/0.1.0/linux_amd64 + cp terraform-provider-cosmos ~/.terraform.d/plugins/cosmos-cloud.io/azukaar/cosmos/0.1.0/linux_amd64/ + +test: + go test ./... -v + +testacc: + TF_ACC=1 go test ./... -v -timeout 120m + +fmt: + go fmt ./... + +.PHONY: build install test testacc fmt diff --git a/terraform-provider-cosmos/README.md b/terraform-provider-cosmos/README.md new file mode 100644 index 00000000..22ce5149 --- /dev/null +++ b/terraform-provider-cosmos/README.md @@ -0,0 +1,139 @@ +# Terraform Provider for Cosmos Server + +Manage [Cosmos Server](https://github.com/azukaar/cosmos-server) resources with Terraform. + +## Requirements + +- Terraform >= 1.0 +- Go >= 1.22 (for development) +- A running Cosmos Server instance with an API token + +## Installation + +```hcl +terraform { + required_providers { + cosmos = { + source = "cosmos-cloud.io/azukaar/cosmos" + } + } +} + +provider "cosmos" { + base_url = "https://cosmos.example.com" # or COSMOS_BASE_URL env + token = var.cosmos_token # or COSMOS_TOKEN env + insecure = true # or COSMOS_INSECURE env +} +``` + +`terraform init` will automatically download the provider for your platform. + +### Manual installation + +Alternatively, download the binary from [GitHub Releases](https://github.com/azukaar/cosmos-server/releases) and place it in: + +``` +~/.terraform.d/plugins/cosmos-cloud.io/azukaar/cosmos//_/ +``` + +## Provider Configuration + +| Attribute | Type | Required | Description | +|-----------|--------|----------|-------------| +| `base_url` | string | yes | Cosmos Server URL (e.g. `https://cosmos.example.com`) | +| `token` | string | yes | API token (sensitive) | +| `insecure` | bool | no | Skip TLS verification. Default: `false` | + +All attributes can be set via environment variables: `COSMOS_BASE_URL`, `COSMOS_TOKEN`, `COSMOS_INSECURE`. + +## Resources + +| Resource | Description | +|----------|-------------| +| `cosmos_route` | Proxy route with smart shield, auth, and filtering | +| `cosmos_docker_service` | Docker container (JSON blob config, recreate on change) | +| `cosmos_docker_volume` | Docker volume | +| `cosmos_docker_network` | Docker network | +| `cosmos_api_token` | API token (token value returned once on create) | +| `cosmos_user` | User account | +| `cosmos_backup` | Backup configuration (password managed server-side) | +| `cosmos_alert` | Alert with conditions and actions | +| `cosmos_cron_job` | Scheduled cron job | +| `cosmos_openid_client` | OpenID Connect client (secret generated server-side, returned once) | +| `cosmos_constellation` | Initialize Constellation VPN (destroy resets the VPN) | +| `cosmos_constellation_device` | VPN device (config YAML returned once on create) | +| `cosmos_constellation_dns` | Constellation DNS entry | +| `cosmos_snapraid` | SnapRAID array configuration | +| `cosmos_storage_mount` | Storage mount (all fields require replacement) | + +## Data Sources + +| Data Source | Description | +|-------------|-------------| +| `cosmos_status` | Server status (hostname, version, docker/constellation status) | +| `cosmos_containers` | List of running Docker containers | + +## Examples + +See the [examples/](examples/) directory for complete, working configurations: + +- **[web-app](examples/web-app/)** — Token, volume, container, route with smart shield, backup, and alert +- **[vpn](examples/vpn/)** — Constellation VPN init, device, and DNS + +## Development + +### Build and install locally + +```bash +cd terraform-provider-cosmos +make build # compile +make install # copy to ~/.terraform.d/plugins/ +``` + +After installing, in your Terraform project: + +```bash +rm .terraform.lock.hcl && terraform init +``` + +### Dev overrides (faster iteration) + +Add to `~/.terraformrc`: + +```hcl +provider_installation { + dev_overrides { + "cosmos-cloud.io/azukaar/cosmos" = "/path/to/terraform-provider-cosmos" + } + direct {} +} +``` + +Then just `make build` and run `terraform plan` directly — no `terraform init` needed. + +### Project structure + +``` +main.go # Entry point +internal/ + provider/provider.go # Provider config + client/client.go # SDK wrapper, response parsing, retry logic + resources/ # All resource implementations + datasources/ # Data source implementations +examples/ # Usage examples +``` + +### Adding a new resource + +1. Create `internal/resources/my_resource.go` +2. Implement `resource.Resource` and `resource.ResourceWithImportState` +3. Register it in `internal/provider/provider.go` -> `Resources()` +4. Run `make build` to verify + +### Common patterns + +- **Response parsing**: `client.ParseResponse[T]()` for typed, `client.CheckResponse()` for status-only, `client.CheckStreamingResponse()` for streaming endpoints +- **404 handling**: `client.IsNotFound(err)` — Read methods should call `resp.State.RemoveResource(ctx)` on 404 +- **Create-time secrets**: `Computed + Sensitive` with `UseStateForUnknown` — capture on create, preserve on read +- **Optional with server defaults**: `Optional + Computed + Default` to avoid phantom diffs +- **Nested objects**: Use `types.Object` (not Go struct pointers) for `SingleNestedAttribute` diff --git a/terraform-provider-cosmos/go.mod b/terraform-provider-cosmos/go.mod new file mode 100644 index 00000000..815b889a --- /dev/null +++ b/terraform-provider-cosmos/go.mod @@ -0,0 +1,49 @@ +module github.com/azukaar/terraform-provider-cosmos + +go 1.22.5 + +require ( + github.com/azukaar/cosmos-server/go-sdk v0.22.0-unstable04 + github.com/hashicorp/terraform-plugin-framework v1.13.0 +) + +require ( + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect + github.com/fatih/color v1.13.0 // indirect + github.com/getkin/kin-openapi v0.134.0 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/go-hclog v1.5.0 // indirect + github.com/hashicorp/go-plugin v1.6.2 // indirect + github.com/hashicorp/go-uuid v1.0.3 // indirect + github.com/hashicorp/terraform-plugin-go v0.25.0 // indirect + github.com/hashicorp/terraform-plugin-log v0.9.0 // indirect + github.com/hashicorp/terraform-registry-address v0.2.3 // indirect + github.com/hashicorp/terraform-svchost v0.1.1 // indirect + github.com/hashicorp/yamux v0.1.1 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/go-testing-interface v1.14.1 // indirect + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/oapi-codegen/runtime v1.2.0 // indirect + github.com/oasdiff/yaml v0.0.0-20260313112342-a3ea61cb4d4c // indirect + github.com/oasdiff/yaml3 v0.0.0-20260224194419-61cd415a242b // indirect + github.com/oklog/run v1.0.0 // indirect + github.com/perimeterx/marshmallow v1.1.5 // indirect + github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + github.com/woodsbury/decimal128 v1.3.0 // indirect + golang.org/x/net v0.28.0 // indirect + golang.org/x/sys v0.29.0 // indirect + golang.org/x/text v0.21.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 // indirect + google.golang.org/grpc v1.67.1 // indirect + google.golang.org/protobuf v1.35.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/azukaar/cosmos-server/go-sdk => ../go-sdk diff --git a/terraform-provider-cosmos/go.sum b/terraform-provider-cosmos/go.sum new file mode 100644 index 00000000..ebcced18 --- /dev/null +++ b/terraform-provider-cosmos/go.sum @@ -0,0 +1,119 @@ +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= +github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA= +github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/getkin/kin-openapi v0.134.0 h1:/L5+1+kfe6dXh8Ot/wqiTgUkjOIEJiC0bbYVziHB8rU= +github.com/getkin/kin-openapi v0.134.0/go.mod h1:wK6ZLG/VgoETO9pcLJ/VmAtIcl/DNlMayNTb716EUxE= +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/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-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= +github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/go-hclog v1.5.0 h1:bI2ocEMgcVlz55Oj1xZNBsVi900c7II+fWDyV9o+13c= +github.com/hashicorp/go-hclog v1.5.0/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-plugin v1.6.2 h1:zdGAEd0V1lCaU0u+MxWQhtSDQmahpkwOun8U8EiRVog= +github.com/hashicorp/go-plugin v1.6.2/go.mod h1:CkgLQ5CZqNmdL9U9JzM532t8ZiYQ35+pj3b1FD37R0Q= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/terraform-plugin-framework v1.13.0 h1:8OTG4+oZUfKgnfTdPTJwZ532Bh2BobF4H+yBiYJ/scw= +github.com/hashicorp/terraform-plugin-framework v1.13.0/go.mod h1:j64rwMGpgM3NYXTKuxrCnyubQb/4VKldEKlcG8cvmjU= +github.com/hashicorp/terraform-plugin-go v0.25.0 h1:oi13cx7xXA6QciMcpcFi/rwA974rdTxjqEhXJjbAyks= +github.com/hashicorp/terraform-plugin-go v0.25.0/go.mod h1:+SYagMYadJP86Kvn+TGeV+ofr/R3g4/If0O5sO96MVw= +github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= +github.com/hashicorp/terraform-plugin-log v0.9.0/go.mod h1:rKL8egZQ/eXSyDqzLUuwUYLVdlYeamldAHSxjUFADow= +github.com/hashicorp/terraform-registry-address v0.2.3 h1:2TAiKJ1A3MAkZlH1YI/aTVcLZRu7JseiXNRHbOAyoTI= +github.com/hashicorp/terraform-registry-address v0.2.3/go.mod h1:lFHA76T8jfQteVfT7caREqguFrW3c4MFSPhZB7HHgUM= +github.com/hashicorp/terraform-svchost v0.1.1 h1:EZZimZ1GxdqFRinZ1tpJwVxxt49xc/S52uzrw4x0jKQ= +github.com/hashicorp/terraform-svchost v0.1.1/go.mod h1:mNsjQfZyf/Jhz35v6/0LWcv26+X7JPS+buii2c9/ctc= +github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= +github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= +github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c= +github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= +github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= +github.com/oapi-codegen/runtime v1.2.0 h1:RvKc1CVS1QeKSNzO97FBQbSMZyQ8s6rZd+LpmzwHMP4= +github.com/oapi-codegen/runtime v1.2.0/go.mod h1:Y7ZhmmlE8ikZOmuHRRndiIm7nf3xcVv+YMweKgG1DT0= +github.com/oasdiff/yaml v0.0.0-20260313112342-a3ea61cb4d4c h1:7ACFcSaQsrWtrH4WHHfUqE1C+f8r2uv8KGaW0jTNjus= +github.com/oasdiff/yaml v0.0.0-20260313112342-a3ea61cb4d4c/go.mod h1:JKox4Gszkxt57kj27u7rvi7IFoIULvCZHUsBTUmQM/s= +github.com/oasdiff/yaml3 v0.0.0-20260224194419-61cd415a242b h1:vivRhVUAa9t1q0Db4ZmezBP8pWQWnXHFokZj0AOea2g= +github.com/oasdiff/yaml3 v0.0.0-20260224194419-61cd415a242b/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= +github.com/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw= +github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= +github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= +github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/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/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +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/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0= +github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds= +golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= +golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 h1:e7S5W7MGGLaSu8j3YjdezkZ+m1/Nm0uRVRMEMGk26Xs= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= +google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E= +google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= +google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= +google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/terraform-provider-cosmos/internal/client/client.go b/terraform-provider-cosmos/internal/client/client.go new file mode 100644 index 00000000..42e55df5 --- /dev/null +++ b/terraform-provider-cosmos/internal/client/client.go @@ -0,0 +1,325 @@ +package client + +import ( + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + cosmossdk "github.com/azukaar/cosmos-server/go-sdk" +) + +// CosmosClient wraps the generated SDK client with convenience helpers. +type CosmosClient struct { + Raw *cosmossdk.Client + BaseURL string +} + +// apiResponse is the standard Cosmos API envelope. +type apiResponse struct { + Status string `json:"status"` + Data json.RawMessage `json:"data,omitempty"` + Message string `json:"message,omitempty"` + Code string `json:"code,omitempty"` +} + +// retryTransport wraps an http.RoundTripper and retries on connection errors. +type retryTransport struct { + base http.RoundTripper + maxRetries int + delay time.Duration +} + +func (t *retryTransport) RoundTrip(req *http.Request) (*http.Response, error) { + var resp *http.Response + var err error + for i := 0; i <= t.maxRetries; i++ { + resp, err = t.base.RoundTrip(req) + if err == nil { + return resp, nil + } + // Only retry on connection errors (server restarting, etc.) + if !isConnectionError(err) { + return resp, err + } + if i < t.maxRetries { + time.Sleep(t.delay) + } + } + return resp, err +} + +func isConnectionError(err error) bool { + if err == nil { + return false + } + s := err.Error() + return strings.Contains(s, "connection refused") || + strings.Contains(s, "connection reset") || + strings.Contains(s, "EOF") || + strings.Contains(s, "no such host") +} + +// NewCosmosClient creates a new SDK client configured with auth and TLS settings. +func NewCosmosClient(baseURL, token string, insecure bool) (*CosmosClient, error) { + var base http.RoundTripper = http.DefaultTransport + if insecure { + base = &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + } + + httpClient := &http.Client{ + Transport: &retryTransport{ + base: base, + maxRetries: 3, + delay: 1 * time.Second, + }, + } + + addToken := func(ctx context.Context, req *http.Request) error { + req.Header.Set("Authorization", "Bearer "+token) + return nil + } + + sdkClient, err := cosmossdk.NewClient( + baseURL+"/cosmos", + cosmossdk.WithHTTPClient(httpClient), + cosmossdk.WithRequestEditorFn(addToken), + ) + if err != nil { + return nil, fmt.Errorf("creating cosmos SDK client: %w", err) + } + + return &CosmosClient{ + Raw: sdkClient, + BaseURL: baseURL, + }, nil +} + +// NotFoundError is returned when the API returns HTTP 404. +type NotFoundError struct { + Message string +} + +func (e *NotFoundError) Error() string { + return e.Message +} + +// IsNotFound returns true if the error is a 404 response. +func IsNotFound(err error) bool { + _, ok := err.(*NotFoundError) + return ok +} + +// ParseResponse reads an http.Response body, checks the status envelope, and +// unmarshals the data field into T. +func ParseResponse[T any](resp *http.Response) (*T, error) { + if resp == nil { + return nil, fmt.Errorf("nil response") + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response body: %w", err) + } + + if resp.StatusCode == 404 { + return nil, &NotFoundError{Message: string(body)} + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("API returned HTTP %d: %s", resp.StatusCode, string(body)) + } + + var envelope apiResponse + if err := json.Unmarshal(body, &envelope); err != nil { + return nil, fmt.Errorf("parsing response envelope: %w (body: %s)", err, string(body)) + } + + if envelope.Status != "OK" && envelope.Status != "ok" { + msg := envelope.Message + if msg == "" { + msg = string(body) + } + return nil, fmt.Errorf("API error (status=%s): %s", envelope.Status, msg) + } + + if len(envelope.Data) == 0 || string(envelope.Data) == "null" { + return nil, nil + } + + var result T + if err := json.Unmarshal(envelope.Data, &result); err != nil { + return nil, fmt.Errorf("parsing response data: %w (raw: %s)", err, string(envelope.Data)) + } + + return &result, nil +} + +// CheckResponse reads an http.Response and verifies the status envelope without +// trying to parse data. +func CheckResponse(resp *http.Response) error { + if resp == nil { + return fmt.Errorf("nil response") + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("reading response body: %w", err) + } + + if resp.StatusCode == 404 { + return &NotFoundError{Message: string(body)} + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("API returned HTTP %d: %s", resp.StatusCode, string(body)) + } + + var envelope apiResponse + if err := json.Unmarshal(body, &envelope); err != nil { + return fmt.Errorf("parsing response envelope: %w (body: %s)", err, string(body)) + } + + if envelope.Status != "OK" && envelope.Status != "ok" { + msg := envelope.Message + if msg == "" { + msg = string(body) + } + return fmt.Errorf("API error (status=%s): %s", envelope.Status, msg) + } + + return nil +} + +// ParseRawResponse reads an http.Response and returns the raw data field from +// the response envelope. +func ParseRawResponse(resp *http.Response) (json.RawMessage, error) { + if resp == nil { + return nil, fmt.Errorf("nil response") + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response body: %w", err) + } + + if resp.StatusCode == 404 { + return nil, &NotFoundError{Message: string(body)} + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("API returned HTTP %d: %s", resp.StatusCode, string(body)) + } + + var envelope apiResponse + if err := json.Unmarshal(body, &envelope); err != nil { + return nil, fmt.Errorf("parsing response envelope: %w (body: %s)", err, string(body)) + } + + if envelope.Status != "OK" && envelope.Status != "ok" { + msg := envelope.Message + if msg == "" { + msg = string(body) + } + return nil, fmt.Errorf("API error (status=%s): %s", envelope.Status, msg) + } + + return envelope.Data, nil +} + +// CheckStreamingResponse handles API endpoints that stream progress text +// (e.g. docker pull logs, service creation) instead of returning a standard +// JSON envelope. It reads the full body and checks for success/failure markers. +func CheckStreamingResponse(resp *http.Response) error { + if resp == nil { + return fmt.Errorf("nil response") + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("reading response body: %w", err) + } + + bodyStr := string(body) + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("API returned HTTP %d: %s", resp.StatusCode, bodyStr) + } + + // Check for failure indicators in the streamed output. + if strings.Contains(bodyStr, "[OPERATION FAILED]") || strings.Contains(bodyStr, "[FAIL]") { + return fmt.Errorf("operation failed: %s", bodyStr) + } + + return nil +} + +// Helper functions for converting between Terraform and SDK types. + +func StringPtr(s string) *string { + if s == "" { + return nil + } + return &s +} + +func StringPtrVal(s *string) string { + if s == nil { + return "" + } + return *s +} + +func BoolPtr(b bool) *bool { + return &b +} + +func BoolPtrVal(b *bool) bool { + if b == nil { + return false + } + return *b +} + +func IntPtr(i int) *int { + return &i +} + +func IntPtrVal(i *int) int { + if i == nil { + return 0 + } + return *i +} + +func Int64Ptr(i int64) *int64 { + return &i +} + +func Int64PtrVal(i *int64) int64 { + if i == nil { + return 0 + } + return *i +} + +func Float32Ptr(f float32) *float32 { + return &f +} + +func Float32PtrVal(f *float32) float32 { + if f == nil { + return 0 + } + return *f +} diff --git a/terraform-provider-cosmos/internal/datasources/containers.go b/terraform-provider-cosmos/internal/datasources/containers.go new file mode 100644 index 00000000..727e2b78 --- /dev/null +++ b/terraform-provider-cosmos/internal/datasources/containers.go @@ -0,0 +1,157 @@ +package datasources + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/azukaar/terraform-provider-cosmos/internal/client" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var _ datasource.DataSource = &containersDataSource{} + +func NewContainersDataSource() datasource.DataSource { + return &containersDataSource{} +} + +type containersDataSource struct { + client *client.CosmosClient +} + +// ---------- Terraform model ---------- + +type containersDataSourceModel struct { + Containers []containerModel `tfsdk:"containers"` +} + +type containerModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Image types.String `tfsdk:"image"` + State types.String `tfsdk:"state"` +} + +// ---------- datasource.DataSource ---------- + +func (d *containersDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_containers" +} + +func (d *containersDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Lists all Docker containers managed by Cosmos Server.", + Attributes: map[string]schema.Attribute{ + "containers": schema.ListNestedAttribute{ + Description: "List of containers.", + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The container ID.", + Computed: true, + }, + "name": schema.StringAttribute{ + Description: "The container name.", + Computed: true, + }, + "image": schema.StringAttribute{ + Description: "The container image.", + Computed: true, + }, + "state": schema.StringAttribute{ + Description: "The current state of the container (e.g. running, stopped).", + Computed: true, + }, + }, + }, + }, + }, + } +} + +func (d *containersDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + c, ok := req.ProviderData.(*client.CosmosClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected DataSource Configure Type", + fmt.Sprintf("Expected *client.CosmosClient, got: %T", req.ProviderData), + ) + return + } + d.client = c +} + +func (d *containersDataSource) Read(ctx context.Context, _ datasource.ReadRequest, resp *datasource.ReadResponse) { + httpResp, err := d.client.Raw.GetApiServapps(ctx, nil) + if err != nil { + resp.Diagnostics.AddError("Error reading containers", err.Error()) + return + } + + rawData, err := client.ParseRawResponse(httpResp) + if err != nil { + resp.Diagnostics.AddError("Error reading containers", err.Error()) + return + } + + var model containersDataSourceModel + + if rawData == nil || string(rawData) == "null" { + model.Containers = []containerModel{} + resp.Diagnostics.Append(resp.State.Set(ctx, &model)...) + return + } + + var containers []map[string]interface{} + if err := json.Unmarshal(rawData, &containers); err != nil { + resp.Diagnostics.AddError("Error parsing containers response", err.Error()) + return + } + + model.Containers = make([]containerModel, 0, len(containers)) + for _, c := range containers { + cm := containerModel{ + ID: types.StringValue(containerFieldString(c, "Id", "id", "ID")), + Name: types.StringValue(containerFieldString(c, "Name", "name", "Names")), + Image: types.StringValue(containerFieldString(c, "Image", "image")), + State: types.StringValue(containerFieldString(c, "State", "state", "Status")), + } + model.Containers = append(model.Containers, cm) + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &model)...) +} + +// ---------- helpers ---------- + +// containerFieldString extracts a string field from a container map, trying multiple key variants. +func containerFieldString(m map[string]interface{}, keys ...string) string { + for _, key := range keys { + if v, ok := m[key]; ok { + switch val := v.(type) { + case string: + return val + case []interface{}: + // Docker sometimes returns Names as an array like ["/container_name"]. + if len(val) > 0 { + if s, ok := val[0].(string); ok { + // Strip leading slash if present. + if len(s) > 0 && s[0] == '/' { + return s[1:] + } + return s + } + } + default: + return fmt.Sprintf("%v", val) + } + } + } + return "" +} diff --git a/terraform-provider-cosmos/internal/datasources/status.go b/terraform-provider-cosmos/internal/datasources/status.go new file mode 100644 index 00000000..979ab8ab --- /dev/null +++ b/terraform-provider-cosmos/internal/datasources/status.go @@ -0,0 +1,152 @@ +package datasources + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/azukaar/terraform-provider-cosmos/internal/client" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var _ datasource.DataSource = &statusDataSource{} + +func NewStatusDataSource() datasource.DataSource { + return &statusDataSource{} +} + +type statusDataSource struct { + client *client.CosmosClient +} + +// ---------- Terraform model ---------- + +type statusDataSourceModel struct { + Hostname types.String `tfsdk:"hostname"` + Version types.String `tfsdk:"version"` + NeedsUpdate types.Bool `tfsdk:"needs_update"` + DockerStatus types.String `tfsdk:"docker_status"` + ConstellationStatus types.String `tfsdk:"constellation_status"` +} + +// ---------- datasource.DataSource ---------- + +func (d *statusDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_status" +} + +func (d *statusDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Reads the current status of the Cosmos Server instance.", + Attributes: map[string]schema.Attribute{ + "hostname": schema.StringAttribute{ + Description: "The hostname of the Cosmos Server.", + Computed: true, + }, + "version": schema.StringAttribute{ + Description: "The current version of Cosmos Server.", + Computed: true, + }, + "needs_update": schema.BoolAttribute{ + Description: "Whether an update is available for Cosmos Server.", + Computed: true, + }, + "docker_status": schema.StringAttribute{ + Description: "The status of the Docker daemon.", + Computed: true, + }, + "constellation_status": schema.StringAttribute{ + Description: "The status of the Constellation VPN.", + Computed: true, + }, + }, + } +} + +func (d *statusDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + c, ok := req.ProviderData.(*client.CosmosClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected DataSource Configure Type", + fmt.Sprintf("Expected *client.CosmosClient, got: %T", req.ProviderData), + ) + return + } + d.client = c +} + +func (d *statusDataSource) Read(ctx context.Context, _ datasource.ReadRequest, resp *datasource.ReadResponse) { + httpResp, err := d.client.Raw.GetApiStatus(ctx) + if err != nil { + resp.Diagnostics.AddError("Error reading Cosmos status", err.Error()) + return + } + + rawData, err := client.ParseRawResponse(httpResp) + if err != nil { + resp.Diagnostics.AddError("Error reading Cosmos status", err.Error()) + return + } + + // Parse the status response as a generic map since the structure may vary. + var status map[string]interface{} + if rawData != nil { + if err := json.Unmarshal(rawData, &status); err != nil { + resp.Diagnostics.AddError("Error parsing Cosmos status response", err.Error()) + return + } + } + + var model statusDataSourceModel + + model.Hostname = types.StringValue(extractString(status, "hostname")) + model.Version = types.StringValue(extractString(status, "version")) + model.NeedsUpdate = types.BoolValue(extractBool(status, "needsUpdate")) + model.DockerStatus = types.StringValue(extractString(status, "dockerStatus")) + model.ConstellationStatus = types.StringValue(extractString(status, "constellationStatus")) + + resp.Diagnostics.Append(resp.State.Set(ctx, &model)...) +} + +// ---------- helpers ---------- + +// extractString safely extracts a string value from a map, returning "" if not found. +func extractString(m map[string]interface{}, key string) string { + if m == nil { + return "" + } + if v, ok := m[key]; ok { + switch val := v.(type) { + case string: + return val + case float64: + return fmt.Sprintf("%v", val) + default: + // Try JSON marshalling for complex types. + b, err := json.Marshal(val) + if err != nil { + return fmt.Sprintf("%v", val) + } + return string(b) + } + } + return "" +} + +// extractBool safely extracts a boolean value from a map, returning false if not found. +func extractBool(m map[string]interface{}, key string) bool { + if m == nil { + return false + } + if v, ok := m[key]; ok { + if b, ok := v.(bool); ok { + return b + } + } + return false +} diff --git a/terraform-provider-cosmos/internal/provider/provider.go b/terraform-provider-cosmos/internal/provider/provider.go new file mode 100644 index 00000000..b1893725 --- /dev/null +++ b/terraform-provider-cosmos/internal/provider/provider.go @@ -0,0 +1,126 @@ +package provider + +import ( + "context" + "os" + + "github.com/azukaar/terraform-provider-cosmos/internal/client" + "github.com/azukaar/terraform-provider-cosmos/internal/datasources" + "github.com/azukaar/terraform-provider-cosmos/internal/resources" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/provider/schema" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var _ provider.Provider = &CosmosProvider{} + +type CosmosProvider struct { + version string +} + +type CosmosProviderModel struct { + BaseURL types.String `tfsdk:"base_url"` + Token types.String `tfsdk:"token"` + Insecure types.Bool `tfsdk:"insecure"` +} + +func New(version string) func() provider.Provider { + return func() provider.Provider { + return &CosmosProvider{version: version} + } +} + +func (p *CosmosProvider) Metadata(_ context.Context, _ provider.MetadataRequest, resp *provider.MetadataResponse) { + resp.TypeName = "cosmos" + resp.Version = p.version +} + +func (p *CosmosProvider) Schema(_ context.Context, _ provider.SchemaRequest, resp *provider.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Terraform provider for managing Cosmos Server resources.", + Attributes: map[string]schema.Attribute{ + "base_url": schema.StringAttribute{ + Description: "The base URL of the Cosmos Server (e.g. https://cosmos.example.com). Can also be set with COSMOS_BASE_URL env var.", + Optional: true, + }, + "token": schema.StringAttribute{ + Description: "API token for authenticating with Cosmos Server. Can also be set with COSMOS_TOKEN env var.", + Optional: true, + Sensitive: true, + }, + "insecure": schema.BoolAttribute{ + Description: "Skip TLS certificate verification. Can also be set with COSMOS_INSECURE env var. Default: false.", + Optional: true, + }, + }, + } +} + +func (p *CosmosProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) { + var config CosmosProviderModel + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + if resp.Diagnostics.HasError() { + return + } + + baseURL := os.Getenv("COSMOS_BASE_URL") + if !config.BaseURL.IsNull() { + baseURL = config.BaseURL.ValueString() + } + if baseURL == "" { + resp.Diagnostics.AddError("Missing base_url", "base_url must be set in the provider configuration or via COSMOS_BASE_URL environment variable.") + return + } + + token := os.Getenv("COSMOS_TOKEN") + if !config.Token.IsNull() { + token = config.Token.ValueString() + } + if token == "" { + resp.Diagnostics.AddError("Missing token", "token must be set in the provider configuration or via COSMOS_TOKEN environment variable.") + return + } + + insecure := os.Getenv("COSMOS_INSECURE") == "true" + if !config.Insecure.IsNull() { + insecure = config.Insecure.ValueBool() + } + + cosmosClient, err := client.NewCosmosClient(baseURL, token, insecure) + if err != nil { + resp.Diagnostics.AddError("Failed to create Cosmos client", err.Error()) + return + } + + resp.DataSourceData = cosmosClient + resp.ResourceData = cosmosClient +} + +func (p *CosmosProvider) Resources(_ context.Context) []func() resource.Resource { + return []func() resource.Resource{ + resources.NewRouteResource, + resources.NewUserResource, + resources.NewAPITokenResource, + resources.NewOpenIDClientResource, + resources.NewConstellationResource, + resources.NewConstellationDNSResource, + resources.NewCronJobResource, + resources.NewAlertResource, + resources.NewConstellationDeviceResource, + resources.NewBackupResource, + resources.NewDockerNetworkResource, + resources.NewDockerVolumeResource, + resources.NewDockerServiceResource, + resources.NewSnapRAIDResource, + resources.NewStorageMountResource, + } +} + +func (p *CosmosProvider) DataSources(_ context.Context) []func() datasource.DataSource { + return []func() datasource.DataSource{ + datasources.NewStatusDataSource, + datasources.NewContainersDataSource, + } +} diff --git a/terraform-provider-cosmos/internal/resources/alert.go b/terraform-provider-cosmos/internal/resources/alert.go new file mode 100644 index 00000000..dd55eb2f --- /dev/null +++ b/terraform-provider-cosmos/internal/resources/alert.go @@ -0,0 +1,465 @@ +package resources + +import ( + "context" + "fmt" + + cosmossdk "github.com/azukaar/cosmos-server/go-sdk" + "github.com/azukaar/terraform-provider-cosmos/internal/client" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" +) + +var ( + _ resource.Resource = &alertResource{} + _ resource.ResourceWithImportState = &alertResource{} +) + +// NewAlertResource returns a new alert resource instance. +func NewAlertResource() resource.Resource { + return &alertResource{} +} + +type alertResource struct { + client *client.CosmosClient +} + +// ---------- Terraform model types ---------- + +type alertResourceModel struct { + Name types.String `tfsdk:"name"` + Enabled types.Bool `tfsdk:"enabled"` + TrackingMetric types.String `tfsdk:"tracking_metric"` + Period types.String `tfsdk:"period"` + Severity types.String `tfsdk:"severity"` + Condition types.Object `tfsdk:"condition"` + Actions types.List `tfsdk:"actions"` + LastTriggered types.String `tfsdk:"last_triggered"` + Throttled types.Bool `tfsdk:"throttled"` +} + +type alertConditionModel struct { + Operator types.String `tfsdk:"operator"` + Value types.Int64 `tfsdk:"value"` + Percent types.Bool `tfsdk:"percent"` +} + +type alertActionModel struct { + Type types.String `tfsdk:"type"` + Target types.String `tfsdk:"target"` +} + +// Attribute type maps used when constructing framework object/list values. +var alertConditionAttrTypes = map[string]attr.Type{ + "operator": types.StringType, + "value": types.Int64Type, + "percent": types.BoolType, +} + +var alertActionAttrTypes = map[string]attr.Type{ + "type": types.StringType, + "target": types.StringType, +} + +// ---------- Resource interface ---------- + +func (r *alertResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_alert" +} + +func (r *alertResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Manages a Cosmos Server monitoring alert.", + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Description: "The unique name of the alert.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "enabled": schema.BoolAttribute{ + Description: "Whether the alert is enabled.", + Optional: true, + }, + "tracking_metric": schema.StringAttribute{ + Description: "The metric to track for this alert.", + Optional: true, + }, + "period": schema.StringAttribute{ + Description: "The time period for alert evaluation.", + Optional: true, + }, + "severity": schema.StringAttribute{ + Description: "The severity level of the alert.", + Optional: true, + }, + "condition": schema.SingleNestedAttribute{ + Description: "The condition that triggers the alert.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "operator": schema.StringAttribute{ + Description: "The comparison operator (e.g. >, <, ==).", + Optional: true, + }, + "value": schema.Int64Attribute{ + Description: "The threshold value.", + Optional: true, + }, + "percent": schema.BoolAttribute{ + Description: "Whether the value is a percentage.", + Optional: true, + }, + }, + }, + "actions": schema.ListNestedAttribute{ + Description: "Actions to perform when the alert triggers.", + Optional: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "type": schema.StringAttribute{ + Description: "The action type.", + Optional: true, + }, + "target": schema.StringAttribute{ + Description: "The action target.", + Optional: true, + }, + }, + }, + }, + "last_triggered": schema.StringAttribute{ + Description: "The last time the alert was triggered.", + Computed: true, + }, + "throttled": schema.BoolAttribute{ + Description: "Whether the alert is currently throttled.", + Computed: true, + }, + }, + } +} + +func (r *alertResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + c, ok := req.ProviderData.(*client.CosmosClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *client.CosmosClient, got: %T", req.ProviderData), + ) + return + } + r.client = c +} + +// ---------- CRUD ---------- + +func (r *alertResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan alertResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + alert := r.modelToSDK(ctx, &plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + httpResp, err := r.client.Raw.PostApiAlerts(ctx, alert) + if err != nil { + resp.Diagnostics.AddError("Error creating alert", err.Error()) + return + } + if err := client.CheckResponse(httpResp); err != nil { + resp.Diagnostics.AddError("Error creating alert", err.Error()) + return + } + + // Read back to populate computed fields. + if !r.readAlert(ctx, plan.Name.ValueString(), &plan, &resp.Diagnostics) { + resp.Diagnostics.AddError("Error reading alert", "Alert not found after creation") + return + } + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *alertResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state alertResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + name := state.Name.ValueString() + httpResp, err := r.client.Raw.GetApiAlertsName(ctx, name, cosmossdk.UtilsAlert{Name: name}) + if err != nil { + resp.Diagnostics.AddError("Error reading alert", err.Error()) + return + } + + alert, err := client.ParseResponse[cosmossdk.UtilsAlert](httpResp) + if err != nil { + if client.IsNotFound(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("Error parsing alert response", err.Error()) + return + } + if alert == nil { + resp.State.RemoveResource(ctx) + return + } + + r.sdkToModel(alert, &state) + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *alertResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan alertResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + alert := r.modelToSDK(ctx, &plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + name := plan.Name.ValueString() + httpResp, err := r.client.Raw.PutApiAlertsName(ctx, name, alert) + if err != nil { + resp.Diagnostics.AddError("Error updating alert", err.Error()) + return + } + if err := client.CheckResponse(httpResp); err != nil { + resp.Diagnostics.AddError("Error updating alert", err.Error()) + return + } + + if !r.readAlert(ctx, name, &plan, &resp.Diagnostics) { + resp.Diagnostics.AddError("Error reading alert", "Alert not found after update") + return + } + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *alertResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state alertResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + name := state.Name.ValueString() + httpResp, err := r.client.Raw.DeleteApiAlertsName(ctx, name, cosmossdk.UtilsAlert{Name: name}) + if err != nil { + resp.Diagnostics.AddError("Error deleting alert", err.Error()) + return + } + if err := client.CheckResponse(httpResp); err != nil { + resp.Diagnostics.AddError("Error deleting alert", err.Error()) + return + } +} + +func (r *alertResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("name"), req, resp) +} + +// ---------- helpers ---------- + +// readAlert fetches the alert from the API and updates the Terraform model. +// It returns false if the alert was not found (404), in which case no diagnostic +// error is added — the caller should remove the resource from state. +func (r *alertResource) readAlert(ctx context.Context, name string, model *alertResourceModel, diags *diag.Diagnostics) bool { + httpResp, err := r.client.Raw.GetApiAlertsName(ctx, name, cosmossdk.UtilsAlert{Name: name}) + if err != nil { + diags.AddError("Error reading alert", err.Error()) + return true + } + + alert, err := client.ParseResponse[cosmossdk.UtilsAlert](httpResp) + if err != nil { + if client.IsNotFound(err) { + return false + } + diags.AddError("Error parsing alert response", err.Error()) + return true + } + if alert == nil { + return false + } + + r.sdkToModel(alert, model) + return true +} + +// modelToSDK converts the Terraform resource model into an SDK UtilsAlert. +func (r *alertResource) modelToSDK(ctx context.Context, model *alertResourceModel, diags *diag.Diagnostics) cosmossdk.UtilsAlert { + alert := cosmossdk.UtilsAlert{ + Name: model.Name.ValueString(), + } + + if !model.Enabled.IsNull() && !model.Enabled.IsUnknown() { + alert.Enabled = client.BoolPtr(model.Enabled.ValueBool()) + } + if !model.TrackingMetric.IsNull() && !model.TrackingMetric.IsUnknown() { + alert.TrackingMetric = client.StringPtr(model.TrackingMetric.ValueString()) + } + if !model.Period.IsNull() && !model.Period.IsUnknown() { + alert.Period = client.StringPtr(model.Period.ValueString()) + } + if !model.Severity.IsNull() && !model.Severity.IsUnknown() { + alert.Severity = client.StringPtr(model.Severity.ValueString()) + } + + // Condition + if !model.Condition.IsNull() && !model.Condition.IsUnknown() { + var condModel alertConditionModel + d := model.Condition.As(ctx, &condModel, basetypes.ObjectAsOptions{}) + diags.Append(d...) + if d.HasError() { + return alert + } + cond := cosmossdk.UtilsAlertCondition{} + if !condModel.Operator.IsNull() && !condModel.Operator.IsUnknown() { + cond.Operator = client.StringPtr(condModel.Operator.ValueString()) + } + if !condModel.Value.IsNull() && !condModel.Value.IsUnknown() { + v := int(condModel.Value.ValueInt64()) + cond.Value = client.IntPtr(v) + } + if !condModel.Percent.IsNull() && !condModel.Percent.IsUnknown() { + cond.Percent = client.BoolPtr(condModel.Percent.ValueBool()) + } + alert.Condition = &cond + } + + // Actions + if !model.Actions.IsNull() && !model.Actions.IsUnknown() { + var actionModels []alertActionModel + d := model.Actions.ElementsAs(ctx, &actionModels, false) + diags.Append(d...) + if d.HasError() { + return alert + } + actions := make([]cosmossdk.UtilsAlertAction, len(actionModels)) + for i, am := range actionModels { + a := cosmossdk.UtilsAlertAction{} + if !am.Type.IsNull() && !am.Type.IsUnknown() { + a.Type = client.StringPtr(am.Type.ValueString()) + } + if !am.Target.IsNull() && !am.Target.IsUnknown() { + a.Target = client.StringPtr(am.Target.ValueString()) + } + actions[i] = a + } + alert.Actions = &actions + } + + return alert +} + +// sdkToModel populates the Terraform resource model from an SDK UtilsAlert. +func (r *alertResource) sdkToModel(alert *cosmossdk.UtilsAlert, model *alertResourceModel) { + model.Name = types.StringValue(alert.Name) + + if alert.Enabled != nil { + model.Enabled = types.BoolValue(*alert.Enabled) + } + if alert.TrackingMetric != nil { + model.TrackingMetric = types.StringValue(*alert.TrackingMetric) + } + if alert.Period != nil { + model.Period = types.StringValue(*alert.Period) + } + if alert.Severity != nil { + model.Severity = types.StringValue(*alert.Severity) + } + + // Computed fields always get a value. + if alert.LastTriggered != nil { + model.LastTriggered = types.StringValue(*alert.LastTriggered) + } else { + model.LastTriggered = types.StringValue("") + } + if alert.Throttled != nil { + model.Throttled = types.BoolValue(*alert.Throttled) + } else { + model.Throttled = types.BoolValue(false) + } + + // Condition + if alert.Condition != nil { + c := alert.Condition + condObj, _ := types.ObjectValue(alertConditionAttrTypes, map[string]attr.Value{ + "operator": alertStringOrNull(c.Operator), + "value": alertIntOrNull(c.Value), + "percent": alertBoolOrNull(c.Percent), + }) + model.Condition = condObj + } else { + model.Condition = types.ObjectNull(alertConditionAttrTypes) + } + + // Actions + if alert.Actions != nil && len(*alert.Actions) > 0 { + actionValues := make([]attr.Value, len(*alert.Actions)) + for i, a := range *alert.Actions { + obj, _ := types.ObjectValue(alertActionAttrTypes, map[string]attr.Value{ + "type": alertStringOrNull(a.Type), + "target": alertStringOrNull(a.Target), + }) + actionValues[i] = obj + } + actionList, _ := types.ListValue(types.ObjectType{AttrTypes: alertActionAttrTypes}, actionValues) + model.Actions = actionList + } else { + model.Actions = types.ListNull(types.ObjectType{AttrTypes: alertActionAttrTypes}) + } +} + +// Small helpers to convert SDK pointers into framework attr.Value. + +func alertStringOrNull(s *string) attr.Value { + if s == nil || *s == "" { + return types.StringNull() + } + return types.StringValue(*s) +} + +func alertIntOrNull(i *int) attr.Value { + if i == nil { + return types.Int64Null() + } + return types.Int64Value(int64(*i)) +} + +func alertBoolOrNull(b *bool) attr.Value { + if b == nil || !*b { + return types.BoolNull() + } + return types.BoolValue(*b) +} diff --git a/terraform-provider-cosmos/internal/resources/api_token.go b/terraform-provider-cosmos/internal/resources/api_token.go new file mode 100644 index 00000000..ce8d5f35 --- /dev/null +++ b/terraform-provider-cosmos/internal/resources/api_token.go @@ -0,0 +1,311 @@ +package resources + +import ( + "context" + "encoding/json" + "fmt" + + cosmossdk "github.com/azukaar/cosmos-server/go-sdk" + "github.com/azukaar/terraform-provider-cosmos/internal/client" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// Ensure interface compliance. +var ( + _ resource.Resource = &apiTokenResource{} + _ resource.ResourceWithImportState = &apiTokenResource{} +) + +// NewAPITokenResource returns a new resource.Resource for API tokens. +func NewAPITokenResource() resource.Resource { + return &apiTokenResource{} +} + +type apiTokenResource struct { + client *client.CosmosClient +} + +// apiTokenModel maps the resource schema to a Go struct. +type apiTokenModel struct { + Name types.String `tfsdk:"name"` + Description types.String `tfsdk:"description"` + ReadOnly types.Bool `tfsdk:"read_only"` + IpWhitelist types.List `tfsdk:"ip_whitelist"` + RestrictToConstellation types.Bool `tfsdk:"restrict_to_constellation"` + Token types.String `tfsdk:"token"` +} + +func (r *apiTokenResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_api_token" +} + +func (r *apiTokenResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Manages a Cosmos Server API token.", + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Description: "The unique name of the API token. Changing this forces re-creation.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "description": schema.StringAttribute{ + Description: "A human-readable description of the API token.", + Optional: true, + }, + "read_only": schema.BoolAttribute{ + Description: "Whether the token is read-only. Can only be set at creation time.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + PlanModifiers: []planmodifier.Bool{ + // read_only is only sent on create; after that it is immutable. + }, + }, + "ip_whitelist": schema.ListAttribute{ + Description: "List of IP addresses or CIDRs allowed to use this token.", + Optional: true, + ElementType: types.StringType, + }, + "restrict_to_constellation": schema.BoolAttribute{ + Description: "Whether this token is restricted to the Constellation network.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + }, + "token": schema.StringAttribute{ + Description: "The raw API token string. Only available after creation; it is not returned by subsequent reads. On import this value will be empty.", + Computed: true, + Sensitive: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + }, + } +} + +func (r *apiTokenResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + c, ok := req.ProviderData.(*client.CosmosClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *client.CosmosClient, got: %T", req.ProviderData), + ) + return + } + r.client = c +} + +func (r *apiTokenResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan apiTokenModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + // Build the SDK request. + createReq := cosmossdk.ConfigapiCreateAPITokenRequest{ + Name: plan.Name.ValueString(), + } + + if !plan.Description.IsNull() && !plan.Description.IsUnknown() { + createReq.Description = client.StringPtr(plan.Description.ValueString()) + } + if !plan.ReadOnly.IsNull() && !plan.ReadOnly.IsUnknown() { + createReq.ReadOnly = client.BoolPtr(plan.ReadOnly.ValueBool()) + } + if !plan.RestrictToConstellation.IsNull() && !plan.RestrictToConstellation.IsUnknown() { + createReq.RestrictToConstellation = client.BoolPtr(plan.RestrictToConstellation.ValueBool()) + } + if !plan.IpWhitelist.IsNull() && !plan.IpWhitelist.IsUnknown() { + var ips []string + resp.Diagnostics.Append(plan.IpWhitelist.ElementsAs(ctx, &ips, false)...) + if resp.Diagnostics.HasError() { + return + } + createReq.IpWhitelist = &ips + } + + // Call the API. + httpResp, err := r.client.Raw.PostApiApiTokens(ctx, createReq) + if err != nil { + resp.Diagnostics.AddError("Error creating API token", err.Error()) + return + } + + // The create response data is {"token":"..."} — use ParseRawResponse so we + // can extract the token value which is only returned on create. + rawData, err := client.ParseRawResponse(httpResp) + if err != nil { + resp.Diagnostics.AddError("Error parsing create API token response", err.Error()) + return + } + + var createResult struct { + Token string `json:"token"` + } + if err := json.Unmarshal(rawData, &createResult); err != nil { + resp.Diagnostics.AddError("Error unmarshalling create API token response", err.Error()) + return + } + + plan.Token = types.StringValue(createResult.Token) + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *apiTokenResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state apiTokenModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + name := state.Name.ValueString() + + httpResp, err := r.client.Raw.GetApiApiTokens(ctx) + if err != nil { + resp.Diagnostics.AddError("Error reading API tokens", err.Error()) + return + } + + rawData, err := client.ParseRawResponse(httpResp) + if err != nil { + if client.IsNotFound(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("Error parsing API tokens response", err.Error()) + return + } + + // The API returns a map keyed by token name. + var tokensMap map[string]cosmossdk.UtilsAPITokenConfig + if err := json.Unmarshal(rawData, &tokensMap); err != nil { + resp.Diagnostics.AddError("Error parsing API tokens response", err.Error()) + return + } + + tokenData, ok := tokensMap[name] + if !ok { + resp.State.RemoveResource(ctx) + return + } + + found := &tokenData + + // Map API response back to state. + state.Name = types.StringValue(client.StringPtrVal(found.Name)) + state.Description = types.StringValue(client.StringPtrVal(found.Description)) + + if found.RestrictToConstellation != nil { + state.RestrictToConstellation = types.BoolValue(*found.RestrictToConstellation) + } else { + state.RestrictToConstellation = types.BoolValue(false) + } + + if found.IpWhitelist != nil { + listVal, diags := types.ListValueFrom(ctx, types.StringType, *found.IpWhitelist) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + state.IpWhitelist = listVal + } else { + state.IpWhitelist = types.ListNull(types.StringType) + } + + // The token field is never returned by Read. Preserve whatever is already + // in state (set during Create). On import it will be empty/unknown. + // state.Token is left unchanged from what was in req.State. + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *apiTokenResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan apiTokenModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + // Preserve the token from current state since it is not part of the update. + var state apiTokenModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + name := plan.Name.ValueString() + + updateReq := cosmossdk.ConfigapiUpdateAPITokenRequest{} + + if !plan.Description.IsNull() && !plan.Description.IsUnknown() { + updateReq.Description = client.StringPtr(plan.Description.ValueString()) + } + if !plan.RestrictToConstellation.IsNull() && !plan.RestrictToConstellation.IsUnknown() { + updateReq.RestrictToConstellation = client.BoolPtr(plan.RestrictToConstellation.ValueBool()) + } + if !plan.IpWhitelist.IsNull() && !plan.IpWhitelist.IsUnknown() { + var ips []string + resp.Diagnostics.Append(plan.IpWhitelist.ElementsAs(ctx, &ips, false)...) + if resp.Diagnostics.HasError() { + return + } + updateReq.IpWhitelist = &ips + } + + httpResp, err := r.client.Raw.PutApiApiTokensName(ctx, name, updateReq) + if err != nil { + resp.Diagnostics.AddError("Error updating API token", err.Error()) + return + } + if err := client.CheckResponse(httpResp); err != nil { + resp.Diagnostics.AddError("Error updating API token", err.Error()) + return + } + + // Preserve the token value from state — it is never returned after create. + plan.Token = state.Token + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *apiTokenResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state apiTokenModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + name := state.Name.ValueString() + + httpResp, err := r.client.Raw.DeleteApiApiTokens(ctx, cosmossdk.ConfigapiDeleteAPITokenRequest{ + Name: name, + }) + if err != nil { + resp.Diagnostics.AddError("Error deleting API token", err.Error()) + return + } + if err := client.CheckResponse(httpResp); err != nil { + resp.Diagnostics.AddError("Error deleting API token", err.Error()) + return + } +} + +func (r *apiTokenResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + // Import by name. Note: the token value will be empty after import because + // it is only returned during creation. + resource.ImportStatePassthroughID(ctx, path.Root("name"), req, resp) +} diff --git a/terraform-provider-cosmos/internal/resources/backup.go b/terraform-provider-cosmos/internal/resources/backup.go new file mode 100644 index 00000000..7c94a0e9 --- /dev/null +++ b/terraform-provider-cosmos/internal/resources/backup.go @@ -0,0 +1,268 @@ +package resources + +import ( + "context" + "fmt" + + cosmossdk "github.com/azukaar/cosmos-server/go-sdk" + "github.com/azukaar/terraform-provider-cosmos/internal/client" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// Ensure interface compliance. +var ( + _ resource.Resource = &backupResource{} + _ resource.ResourceWithImportState = &backupResource{} +) + +// NewBackupResource returns a new resource.Resource for backup configurations. +func NewBackupResource() resource.Resource { + return &backupResource{} +} + +type backupResource struct { + client *client.CosmosClient +} + +type backupResourceModel struct { + Name types.String `tfsdk:"name"` + Repository types.String `tfsdk:"repository"` + Source types.String `tfsdk:"source"` + Crontab types.String `tfsdk:"crontab"` + CrontabForget types.String `tfsdk:"crontab_forget"` + RetentionPolicy types.String `tfsdk:"retention_policy"` + AutoStopContainers types.Bool `tfsdk:"auto_stop_containers"` +} + +func (r *backupResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_backup" +} + +func (r *backupResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Manages a Cosmos Server backup configuration.", + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Description: "The unique name of the backup configuration (used as the resource ID). Changing this forces re-creation.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "repository": schema.StringAttribute{ + Description: "The repository path for storing backups.", + Required: true, + }, + "source": schema.StringAttribute{ + Description: "The source path to back up.", + Optional: true, + }, + "crontab": schema.StringAttribute{ + Description: "Cron schedule for running backups.", + Optional: true, + }, + "crontab_forget": schema.StringAttribute{ + Description: "Cron schedule for running backup forget/prune.", + Optional: true, + Computed: true, + }, + "retention_policy": schema.StringAttribute{ + Description: "Retention policy for backup snapshots.", + Optional: true, + Computed: true, + }, + "auto_stop_containers": schema.BoolAttribute{ + Description: "Whether to automatically stop containers before backing up.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + }, + }, + } +} + +func (r *backupResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + c, ok := req.ProviderData.(*client.CosmosClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *client.CosmosClient, got: %T", req.ProviderData), + ) + return + } + r.client = c +} + +func (r *backupResource) backupConfigFromModel(model *backupResourceModel) cosmossdk.UtilsSingleBackupConfig { + cfg := cosmossdk.UtilsSingleBackupConfig{ + Name: model.Name.ValueString(), + Repository: model.Repository.ValueString(), + } + + if !model.Source.IsNull() && !model.Source.IsUnknown() { + cfg.Source = client.StringPtr(model.Source.ValueString()) + } + if !model.Crontab.IsNull() && !model.Crontab.IsUnknown() { + cfg.Crontab = client.StringPtr(model.Crontab.ValueString()) + } + if !model.CrontabForget.IsNull() && !model.CrontabForget.IsUnknown() { + cfg.CrontabForget = client.StringPtr(model.CrontabForget.ValueString()) + } + if !model.RetentionPolicy.IsNull() && !model.RetentionPolicy.IsUnknown() { + cfg.RetentionPolicy = client.StringPtr(model.RetentionPolicy.ValueString()) + } + if !model.AutoStopContainers.IsNull() && !model.AutoStopContainers.IsUnknown() { + cfg.AutoStopContainers = client.BoolPtr(model.AutoStopContainers.ValueBool()) + } + + return cfg +} + +func (r *backupResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan backupResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + cfg := r.backupConfigFromModel(&plan) + + httpResp, err := r.client.Raw.PostApiBackups(ctx, cfg) + if err != nil { + resp.Diagnostics.AddError("Error creating backup", err.Error()) + return + } + if err := client.CheckResponse(httpResp); err != nil { + resp.Diagnostics.AddError("Error creating backup", err.Error()) + return + } + + // Resolve any unknown Computed fields to null so Terraform doesn't error. + // We don't read back server-set defaults to avoid phantom diffs on next plan. + if plan.CrontabForget.IsUnknown() { + plan.CrontabForget = types.StringNull() + } + if plan.RetentionPolicy.IsUnknown() { + plan.RetentionPolicy = types.StringNull() + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *backupResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state backupResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + name := state.Name.ValueString() + + httpResp, err := r.client.Raw.GetApiBackupsConfigName(ctx, name) + if err != nil { + resp.Diagnostics.AddError("Error reading backup", err.Error()) + return + } + + backupPtr, err := client.ParseResponse[cosmossdk.UtilsSingleBackupConfig](httpResp) + if err != nil { + if client.IsNotFound(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("Error reading backup", err.Error()) + return + } + if backupPtr == nil { + resp.State.RemoveResource(ctx) + return + } + + backup := *backupPtr + + state.Name = types.StringValue(backup.Name) + state.Repository = types.StringValue(backup.Repository) + + if backup.Source != nil { + state.Source = types.StringValue(*backup.Source) + } else { + state.Source = types.StringNull() + } + + if backup.Crontab != nil { + state.Crontab = types.StringValue(*backup.Crontab) + } else { + state.Crontab = types.StringNull() + } + + // Only update crontab_forget and retention_policy from API if the user set them. + // The server may populate defaults that would cause phantom diffs. + if !state.CrontabForget.IsNull() && backup.CrontabForget != nil { + state.CrontabForget = types.StringValue(*backup.CrontabForget) + } + + if !state.RetentionPolicy.IsNull() && backup.RetentionPolicy != nil { + state.RetentionPolicy = types.StringValue(*backup.RetentionPolicy) + } + + if backup.AutoStopContainers != nil { + state.AutoStopContainers = types.BoolValue(*backup.AutoStopContainers) + } else { + state.AutoStopContainers = types.BoolNull() + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *backupResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan backupResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + cfg := r.backupConfigFromModel(&plan) + + httpResp, err := r.client.Raw.PostApiBackupsEdit(ctx, cfg) + if err != nil { + resp.Diagnostics.AddError("Error updating backup", err.Error()) + return + } + if err := client.CheckResponse(httpResp); err != nil { + resp.Diagnostics.AddError("Error updating backup", err.Error()) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *backupResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state backupResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + httpResp, err := r.client.Raw.DeleteApiBackupsName(ctx, state.Name.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Error deleting backup", err.Error()) + return + } + if err := client.CheckResponse(httpResp); err != nil { + resp.Diagnostics.AddError("Error deleting backup", err.Error()) + return + } +} + +func (r *backupResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("name"), req, resp) +} diff --git a/terraform-provider-cosmos/internal/resources/constellation.go b/terraform-provider-cosmos/internal/resources/constellation.go new file mode 100644 index 00000000..e3ae3d96 --- /dev/null +++ b/terraform-provider-cosmos/internal/resources/constellation.go @@ -0,0 +1,177 @@ +package resources + +import ( + "context" + "fmt" + + "github.com/azukaar/terraform-provider-cosmos/internal/client" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var ( + _ resource.Resource = &constellationResource{} + _ resource.ResourceWithImportState = &constellationResource{} +) + +func NewConstellationResource() resource.Resource { + return &constellationResource{} +} + +type constellationResource struct { + client *client.CosmosClient +} + +type constellationModel struct { + DeviceName types.String `tfsdk:"device_name"` + Hostname types.String `tfsdk:"hostname"` + IPRange types.String `tfsdk:"ip_range"` + IsLighthouse types.Bool `tfsdk:"is_lighthouse"` + Enabled types.Bool `tfsdk:"enabled"` +} + +func (r *constellationResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_constellation" +} + +func (r *constellationResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Initializes a Cosmos Constellation VPN network. This is a one-time setup that generates CA certificates and creates the lighthouse device. Changing any field triggers a full reset and re-initialization.", + Attributes: map[string]schema.Attribute{ + "device_name": schema.StringAttribute{ + Description: "Name for the lighthouse device on this server.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "hostname": schema.StringAttribute{ + Description: "Public hostname for the Constellation VPN (e.g. vpn.example.com).", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "ip_range": schema.StringAttribute{ + Description: "IP range for the VPN network in CIDR notation.", + Optional: true, + Computed: true, + Default: stringdefault.StaticString("192.168.201.0/24"), + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "is_lighthouse": schema.BoolAttribute{ + Description: "Whether this device acts as a lighthouse (should be true for the initial server).", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(true), + }, + "enabled": schema.BoolAttribute{ + Description: "Whether the Constellation VPN is currently enabled.", + Computed: true, + }, + }, + } +} + +func (r *constellationResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + c, ok := req.ProviderData.(*client.CosmosClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *client.CosmosClient, got %T", req.ProviderData), + ) + return + } + r.client = c +} + +func (r *constellationResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan constellationModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + body := map[string]interface{}{ + "deviceName": plan.DeviceName.ValueString(), + "hostname": plan.Hostname.ValueString(), + "ipRange": plan.IPRange.ValueString(), + "isLighthouse": plan.IsLighthouse.ValueBool(), + } + + httpResp, err := r.client.Raw.PostApiConstellationCreate(ctx, body) + if err != nil { + resp.Diagnostics.AddError("Error creating constellation", err.Error()) + return + } + if err := client.CheckResponse(httpResp); err != nil { + resp.Diagnostics.AddError("Error creating constellation", err.Error()) + return + } + + plan.Enabled = types.BoolValue(true) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *constellationResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state constellationModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + // Check if constellation is still active by calling the config endpoint. + httpResp, err := r.client.Raw.GetApiConstellationConfig(ctx) + if err != nil { + // If we can't reach the API, preserve state. + return + } + + _, err = client.ParseRawResponse(httpResp) + if err != nil { + if client.IsNotFound(err) { + // Constellation was reset/disabled externally — remove from state. + resp.State.RemoveResource(ctx) + return + } + // Constellation was reset/disabled externally — remove from state. + resp.State.RemoveResource(ctx) + return + } + + // Constellation is active. User-set fields are preserved from state. + state.Enabled = types.BoolValue(true) + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *constellationResource) Update(_ context.Context, _ resource.UpdateRequest, resp *resource.UpdateResponse) { + // All fields are RequiresReplace, so this should never be called. + resp.Diagnostics.AddError("Update not supported", "All constellation fields require replacement. This is a provider bug.") +} + +func (r *constellationResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + httpResp, err := r.client.Raw.GetApiConstellationReset(ctx) + if err != nil { + resp.Diagnostics.AddError("Error resetting constellation", err.Error()) + return + } + if err := client.CheckResponse(httpResp); err != nil { + resp.Diagnostics.AddError("Error resetting constellation", err.Error()) + return + } +} + +func (r *constellationResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("device_name"), req, resp) +} diff --git a/terraform-provider-cosmos/internal/resources/constellation_device.go b/terraform-provider-cosmos/internal/resources/constellation_device.go new file mode 100644 index 00000000..c820158e --- /dev/null +++ b/terraform-provider-cosmos/internal/resources/constellation_device.go @@ -0,0 +1,458 @@ +package resources + +import ( + "context" + "encoding/json" + "fmt" + + cosmossdk "github.com/azukaar/cosmos-server/go-sdk" + "github.com/azukaar/terraform-provider-cosmos/internal/client" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var ( + _ resource.Resource = &constellationDeviceResource{} + _ resource.ResourceWithImportState = &constellationDeviceResource{} +) + +// NewConstellationDeviceResource returns a new constellation device resource instance. +func NewConstellationDeviceResource() resource.Resource { + return &constellationDeviceResource{} +} + +type constellationDeviceResource struct { + client *client.CosmosClient +} + +// ---------- Terraform model ---------- + +type constellationDeviceResourceModel struct { + DeviceName types.String `tfsdk:"device_name"` + IP types.String `tfsdk:"ip"` + Nickname types.String `tfsdk:"nickname"` + IsLighthouse types.Bool `tfsdk:"is_lighthouse"` + IsRelay types.Bool `tfsdk:"is_relay"` + IsExitNode types.Bool `tfsdk:"is_exit_node"` + IsLoadBalancer types.Bool `tfsdk:"is_load_balancer"` + PublicHostname types.String `tfsdk:"public_hostname"` + Invisible types.Bool `tfsdk:"invisible"` + CosmosNode types.Int64 `tfsdk:"cosmos_node"` + Config types.String `tfsdk:"config"` +} + +type deviceJSON struct { + DeviceName string `json:"deviceName"` + IP string `json:"ip"` + Nickname string `json:"nickname"` + IsLighthouse *bool `json:"isLighthouse,omitempty"` + IsRelay *bool `json:"isRelay,omitempty"` + IsExitNode *bool `json:"isExitNode,omitempty"` + IsLoadBalancer *bool `json:"isLoadBalancer,omitempty"` + PublicHostname string `json:"PublicHostname,omitempty"` + Invisible *bool `json:"invisible,omitempty"` + CosmosNode *int `json:"cosmosNode,omitempty"` + Port string `json:"port,omitempty"` +} + +// ---------- Resource interface ---------- + +func (r *constellationDeviceResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_constellation_device" +} + +func (r *constellationDeviceResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Manages a Cosmos Server constellation VPN device.", + Attributes: map[string]schema.Attribute{ + "device_name": schema.StringAttribute{ + Description: "The unique name of the device.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "ip": schema.StringAttribute{ + Description: "The IP address assigned to the device in the constellation network.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "is_lighthouse": schema.BoolAttribute{ + Description: "Whether this device acts as a lighthouse.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + }, + "is_relay": schema.BoolAttribute{ + Description: "Whether this device acts as a relay.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + }, + "is_exit_node": schema.BoolAttribute{ + Description: "Whether this device acts as an exit node.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + }, + "is_load_balancer": schema.BoolAttribute{ + Description: "Whether this device acts as a load balancer.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + }, + "public_hostname": schema.StringAttribute{ + Description: "The public hostname of the device.", + Optional: true, + Computed: true, + }, + "nickname": schema.StringAttribute{ + Description: "The device nickname. Defaults to device_name if not set.", + Optional: true, + Computed: true, + }, + "invisible": schema.BoolAttribute{ + Description: "Whether the device is hidden from the device list.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + }, + "cosmos_node": schema.Int64Attribute{ + Description: "Cosmos node type (0 = not a cosmos node, 1 = agent, 2 = full).", + Optional: true, + Computed: true, + Default: int64default.StaticInt64(0), + }, + "config": schema.StringAttribute{ + Description: "The full Nebula YAML configuration for this device. Only available after creation — not returned by subsequent reads. On import this value will be empty.", + Computed: true, + Sensitive: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + }, + } +} + +func (r *constellationDeviceResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + c, ok := req.ProviderData.(*client.CosmosClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *client.CosmosClient, got: %T", req.ProviderData), + ) + return + } + r.client = c +} + +// ---------- CRUD ---------- + +func (r *constellationDeviceResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan constellationDeviceResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + deviceName := plan.DeviceName.ValueString() + nickname := deviceName + if !plan.Nickname.IsNull() && !plan.Nickname.IsUnknown() { + nickname = plan.Nickname.ValueString() + } + + body := cosmossdk.ConstellationDeviceCreateRequestJSON{ + DeviceName: deviceName, + Ip: plan.IP.ValueString(), + Nickname: nickname, + } + if !plan.IsLighthouse.IsNull() && !plan.IsLighthouse.IsUnknown() { + body.IsLighthouse = client.BoolPtr(plan.IsLighthouse.ValueBool()) + } + if !plan.IsRelay.IsNull() && !plan.IsRelay.IsUnknown() { + body.IsRelay = client.BoolPtr(plan.IsRelay.ValueBool()) + } + if !plan.IsExitNode.IsNull() && !plan.IsExitNode.IsUnknown() { + body.IsExitNode = client.BoolPtr(plan.IsExitNode.ValueBool()) + } + if !plan.IsLoadBalancer.IsNull() && !plan.IsLoadBalancer.IsUnknown() { + body.IsLoadBalancer = client.BoolPtr(plan.IsLoadBalancer.ValueBool()) + } + if !plan.PublicHostname.IsNull() && !plan.PublicHostname.IsUnknown() { + body.PublicHostname = client.StringPtr(plan.PublicHostname.ValueString()) + } + if !plan.Invisible.IsNull() && !plan.Invisible.IsUnknown() { + body.Invisible = client.BoolPtr(plan.Invisible.ValueBool()) + } + if !plan.CosmosNode.IsNull() && !plan.CosmosNode.IsUnknown() { + v := int(plan.CosmosNode.ValueInt64()) + body.CosmosNode = &v + } + + httpResp, err := r.client.Raw.PostApiConstellationDevices(ctx, body) + if err != nil { + resp.Diagnostics.AddError("Error creating constellation device", err.Error()) + return + } + + // Capture the Config from the create response — it's only returned once. + rawData, err := client.ParseRawResponse(httpResp) + if err != nil { + resp.Diagnostics.AddError("Error creating constellation device", err.Error()) + return + } + if rawData != nil { + var createResult struct { + Config string `json:"Config"` + } + if err := json.Unmarshal(rawData, &createResult); err == nil && createResult.Config != "" { + plan.Config = types.StringValue(createResult.Config) + } + } + + // Read back to populate computed fields. + if !r.readDevice(ctx, plan.DeviceName.ValueString(), &plan, &resp.Diagnostics) { + resp.Diagnostics.AddError("Error reading constellation device", "Device not found after creation") + return + } + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *constellationDeviceResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state constellationDeviceResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + deviceName := state.DeviceName.ValueString() + + httpResp, err := r.client.Raw.GetApiConstellationDevices(ctx) + if err != nil { + resp.Diagnostics.AddError("Error reading constellation devices", err.Error()) + return + } + + rawData, err := client.ParseRawResponse(httpResp) + if err != nil { + if client.IsNotFound(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("Error parsing constellation devices response", err.Error()) + return + } + + if rawData == nil { + resp.State.RemoveResource(ctx) + return + } + + device, err := findDevice(rawData, deviceName) + if err != nil { + resp.Diagnostics.AddError("Error locating constellation device", err.Error()) + return + } + if device == nil { + resp.State.RemoveResource(ctx) + return + } + + populateDeviceModel(device, &state) + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *constellationDeviceResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan constellationDeviceResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + body := cosmossdk.ConstellationDeviceEditRequestJSON{} + if !plan.IsLighthouse.IsNull() && !plan.IsLighthouse.IsUnknown() { + body.IsLighthouse = client.BoolPtr(plan.IsLighthouse.ValueBool()) + } + if !plan.IsRelay.IsNull() && !plan.IsRelay.IsUnknown() { + body.IsRelay = client.BoolPtr(plan.IsRelay.ValueBool()) + } + if !plan.IsExitNode.IsNull() && !plan.IsExitNode.IsUnknown() { + body.IsExitNode = client.BoolPtr(plan.IsExitNode.ValueBool()) + } + if !plan.IsLoadBalancer.IsNull() && !plan.IsLoadBalancer.IsUnknown() { + body.IsLoadBalancer = client.BoolPtr(plan.IsLoadBalancer.ValueBool()) + } + + httpResp, err := r.client.Raw.PostApiConstellationEditDevice(ctx, body) + if err != nil { + resp.Diagnostics.AddError("Error updating constellation device", err.Error()) + return + } + if err := client.CheckResponse(httpResp); err != nil { + resp.Diagnostics.AddError("Error updating constellation device", err.Error()) + return + } + + // Read back to refresh state. + if !r.readDevice(ctx, plan.DeviceName.ValueString(), &plan, &resp.Diagnostics) { + resp.Diagnostics.AddError("Error reading constellation device", "Device not found after update") + return + } + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *constellationDeviceResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state constellationDeviceResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + name := state.DeviceName.ValueString() + httpResp, err := r.client.Raw.PostApiConstellationBlock(ctx, cosmossdk.ConstellationDeviceBlockRequestJSON{ + DeviceName: name, + Nickname: name, + Block: client.BoolPtr(true), + }) + if err != nil { + resp.Diagnostics.AddError("Error deleting (blocking) constellation device", err.Error()) + return + } + if err := client.CheckResponse(httpResp); err != nil { + resp.Diagnostics.AddError("Error deleting (blocking) constellation device", err.Error()) + return + } +} + +func (r *constellationDeviceResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("device_name"), req, resp) +} + +// ---------- helpers ---------- + +// readDevice fetches the device list from the API, finds the device by name, +// and populates the Terraform model. The response data structure may be a map +// keyed by device name or a list — we try both. +// It returns false if the device was not found (404), in which case no diagnostic +// error is added — the caller should remove the resource from state. +func (r *constellationDeviceResource) readDevice(ctx context.Context, deviceName string, model *constellationDeviceResourceModel, diags *diag.Diagnostics) bool { + httpResp, err := r.client.Raw.GetApiConstellationDevices(ctx) + if err != nil { + diags.AddError("Error reading constellation devices", err.Error()) + return true + } + + rawData, err := client.ParseRawResponse(httpResp) + if err != nil { + if client.IsNotFound(err) { + return false + } + diags.AddError("Error parsing constellation devices response", err.Error()) + return true + } + if rawData == nil { + return false + } + + device, err := findDevice(rawData, deviceName) + if err != nil { + diags.AddError("Error locating constellation device", err.Error()) + return true + } + if device == nil { + return false + } + + populateDeviceModel(device, model) + return true +} + +// populateDeviceModel maps the raw device JSON fields to the Terraform model. +func populateDeviceModel(device *deviceJSON, model *constellationDeviceResourceModel) { + model.DeviceName = types.StringValue(device.DeviceName) + model.IP = types.StringValue(device.IP) + + if device.Nickname != "" { + model.Nickname = types.StringValue(device.Nickname) + } else { + model.Nickname = types.StringValue("") + } + + model.IsLighthouse = types.BoolValue(client.BoolPtrVal(device.IsLighthouse)) + model.IsRelay = types.BoolValue(client.BoolPtrVal(device.IsRelay)) + model.IsExitNode = types.BoolValue(client.BoolPtrVal(device.IsExitNode)) + model.IsLoadBalancer = types.BoolValue(client.BoolPtrVal(device.IsLoadBalancer)) + model.PublicHostname = types.StringValue(device.PublicHostname) + model.Invisible = types.BoolValue(client.BoolPtrVal(device.Invisible)) + if device.CosmosNode != nil { + model.CosmosNode = types.Int64Value(int64(*device.CosmosNode)) + } else { + model.CosmosNode = types.Int64Value(0) + } +} + +// findDevice tries to locate a device by name in the raw JSON data. The API +// may return either a map[string]Device (keyed by device name) or a []Device. +func findDevice(rawData json.RawMessage, deviceName string) (*deviceJSON, error) { + // Try map keyed by device name first. + var deviceMap map[string]json.RawMessage + if err := json.Unmarshal(rawData, &deviceMap); err == nil { + // Look for an exact key match. + if raw, ok := deviceMap[deviceName]; ok { + var d deviceJSON + if err := json.Unmarshal(raw, &d); err != nil { + return nil, fmt.Errorf("unmarshalling device from map: %w", err) + } + return &d, nil + } + // The map may contain devices where the key differs from the + // deviceName field. Iterate values and check the field. + for _, raw := range deviceMap { + var d deviceJSON + if err := json.Unmarshal(raw, &d); err != nil { + continue + } + if d.DeviceName == deviceName { + return &d, nil + } + } + } + + // Try as a list. + var deviceList []json.RawMessage + if err := json.Unmarshal(rawData, &deviceList); err == nil { + for _, raw := range deviceList { + var d deviceJSON + if err := json.Unmarshal(raw, &d); err != nil { + continue + } + if d.DeviceName == deviceName { + return &d, nil + } + } + } + + return nil, nil +} diff --git a/terraform-provider-cosmos/internal/resources/constellation_dns.go b/terraform-provider-cosmos/internal/resources/constellation_dns.go new file mode 100644 index 00000000..e76b025c --- /dev/null +++ b/terraform-provider-cosmos/internal/resources/constellation_dns.go @@ -0,0 +1,196 @@ +package resources + +import ( + "context" + "fmt" + + cosmossdk "github.com/azukaar/cosmos-server/go-sdk" + "github.com/azukaar/terraform-provider-cosmos/internal/client" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var ( + _ resource.Resource = &constellationDNSResource{} + _ resource.ResourceWithImportState = &constellationDNSResource{} +) + +type constellationDNSResource struct { + client *client.CosmosClient +} + +type constellationDNSResourceModel struct { + Key types.String `tfsdk:"key"` + Type types.String `tfsdk:"type"` + Value types.String `tfsdk:"value"` +} + +func NewConstellationDNSResource() resource.Resource { + return &constellationDNSResource{} +} + +func (r *constellationDNSResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_constellation_dns" +} + +func (r *constellationDNSResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Manages a Cosmos Constellation DNS entry.", + Attributes: map[string]schema.Attribute{ + "key": schema.StringAttribute{ + Description: "The DNS entry key (used as the resource ID).", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "type": schema.StringAttribute{ + Description: "The DNS record type (e.g. \"A\", \"CNAME\").", + Required: true, + }, + "value": schema.StringAttribute{ + Description: "The DNS record value.", + Required: true, + }, + }, + } +} + +func (r *constellationDNSResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + cosmosClient, ok := req.ProviderData.(*client.CosmosClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *client.CosmosClient, got: %T", req.ProviderData), + ) + return + } + + r.client = cosmosClient +} + +func (r *constellationDNSResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan constellationDNSResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + entry := cosmossdk.UtilsConstellationDNSEntry{ + Key: plan.Key.ValueString(), + Type: client.StringPtr(plan.Type.ValueString()), + Value: client.StringPtr(plan.Value.ValueString()), + } + + httpResp, err := r.client.Raw.PostApiConstellationDns(ctx, entry) + if err != nil { + resp.Diagnostics.AddError("Error creating constellation DNS entry", err.Error()) + return + } + + if err := client.CheckResponse(httpResp); err != nil { + resp.Diagnostics.AddError("Error creating constellation DNS entry", err.Error()) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *constellationDNSResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state constellationDNSResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + key := state.Key.ValueString() + + httpResp, err := r.client.Raw.GetApiConstellationDnsKey(ctx, key) + if err != nil { + resp.Diagnostics.AddError("Error reading constellation DNS entry", err.Error()) + return + } + + entry, err := client.ParseResponse[cosmossdk.UtilsConstellationDNSEntry](httpResp) + if err != nil { + if client.IsNotFound(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("Error reading constellation DNS entry", err.Error()) + return + } + + if entry == nil { + resp.State.RemoveResource(ctx) + return + } + + state.Key = types.StringValue(entry.Key) + state.Type = types.StringValue(client.StringPtrVal(entry.Type)) + state.Value = types.StringValue(client.StringPtrVal(entry.Value)) + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *constellationDNSResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan constellationDNSResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + key := plan.Key.ValueString() + + entry := cosmossdk.UtilsConstellationDNSEntry{ + Key: key, + Type: client.StringPtr(plan.Type.ValueString()), + Value: client.StringPtr(plan.Value.ValueString()), + } + + httpResp, err := r.client.Raw.PutApiConstellationDnsKey(ctx, key, entry) + if err != nil { + resp.Diagnostics.AddError("Error updating constellation DNS entry", err.Error()) + return + } + + if err := client.CheckResponse(httpResp); err != nil { + resp.Diagnostics.AddError("Error updating constellation DNS entry", err.Error()) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *constellationDNSResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state constellationDNSResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + key := state.Key.ValueString() + + httpResp, err := r.client.Raw.DeleteApiConstellationDnsKey(ctx, key) + if err != nil { + resp.Diagnostics.AddError("Error deleting constellation DNS entry", err.Error()) + return + } + + if err := client.CheckResponse(httpResp); err != nil { + resp.Diagnostics.AddError("Error deleting constellation DNS entry", err.Error()) + return + } +} + +func (r *constellationDNSResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("key"), req, resp) +} diff --git a/terraform-provider-cosmos/internal/resources/cron_job.go b/terraform-provider-cosmos/internal/resources/cron_job.go new file mode 100644 index 00000000..cce7a538 --- /dev/null +++ b/terraform-provider-cosmos/internal/resources/cron_job.go @@ -0,0 +1,230 @@ +package resources + +import ( + "context" + "fmt" + + cosmossdk "github.com/azukaar/cosmos-server/go-sdk" + "github.com/azukaar/terraform-provider-cosmos/internal/client" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var ( + _ resource.Resource = &cronJobResource{} + _ resource.ResourceWithImportState = &cronJobResource{} +) + +type cronJobResource struct { + client *client.CosmosClient +} + +type cronJobResourceModel struct { + Name types.String `tfsdk:"name"` + Crontab types.String `tfsdk:"crontab"` + Command types.String `tfsdk:"command"` + Container types.String `tfsdk:"container"` + Enabled types.Bool `tfsdk:"enabled"` +} + +func NewCronJobResource() resource.Resource { + return &cronJobResource{} +} + +func (r *cronJobResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_cron_job" +} + +func (r *cronJobResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Manages a Cosmos cron job.", + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Description: "The cron job name (used as the resource ID).", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "crontab": schema.StringAttribute{ + Description: "The cron schedule expression (e.g. \"0 * * * *\").", + Required: true, + }, + "command": schema.StringAttribute{ + Description: "The command to execute.", + Optional: true, + }, + "container": schema.StringAttribute{ + Description: "The container to run the command in.", + Optional: true, + }, + "enabled": schema.BoolAttribute{ + Description: "Whether the cron job is enabled. Default: true.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(true), + }, + }, + } +} + +func (r *cronJobResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + cosmosClient, ok := req.ProviderData.(*client.CosmosClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *client.CosmosClient, got: %T", req.ProviderData), + ) + return + } + + r.client = cosmosClient +} + +func (r *cronJobResource) cronConfigFromModel(model *cronJobResourceModel) cosmossdk.UtilsCRONConfig { + cfg := cosmossdk.UtilsCRONConfig{ + Name: model.Name.ValueString(), + Crontab: client.StringPtr(model.Crontab.ValueString()), + Enabled: client.BoolPtr(model.Enabled.ValueBool()), + } + + if !model.Command.IsNull() && !model.Command.IsUnknown() { + cfg.Command = client.StringPtr(model.Command.ValueString()) + } + + if !model.Container.IsNull() && !model.Container.IsUnknown() { + cfg.Container = client.StringPtr(model.Container.ValueString()) + } + + return cfg +} + +func (r *cronJobResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan cronJobResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + cfg := r.cronConfigFromModel(&plan) + + httpResp, err := r.client.Raw.PostApiCron(ctx, cfg) + if err != nil { + resp.Diagnostics.AddError("Error creating cron job", err.Error()) + return + } + + if err := client.CheckResponse(httpResp); err != nil { + resp.Diagnostics.AddError("Error creating cron job", err.Error()) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *cronJobResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state cronJobResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + name := state.Name.ValueString() + + httpResp, err := r.client.Raw.GetApiCronName(ctx, name, cosmossdk.UtilsCRONConfig{Name: name}) + if err != nil { + resp.Diagnostics.AddError("Error reading cron job", err.Error()) + return + } + + cronCfg, err := client.ParseResponse[cosmossdk.UtilsCRONConfig](httpResp) + if err != nil { + if client.IsNotFound(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("Error reading cron job", err.Error()) + return + } + + if cronCfg == nil { + resp.State.RemoveResource(ctx) + return + } + + state.Name = types.StringValue(cronCfg.Name) + state.Crontab = types.StringValue(client.StringPtrVal(cronCfg.Crontab)) + state.Enabled = types.BoolValue(client.BoolPtrVal(cronCfg.Enabled)) + + if cronCfg.Command != nil { + state.Command = types.StringValue(client.StringPtrVal(cronCfg.Command)) + } else { + state.Command = types.StringNull() + } + + if cronCfg.Container != nil { + state.Container = types.StringValue(client.StringPtrVal(cronCfg.Container)) + } else { + state.Container = types.StringNull() + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *cronJobResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan cronJobResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + name := plan.Name.ValueString() + cfg := r.cronConfigFromModel(&plan) + + httpResp, err := r.client.Raw.PutApiCronName(ctx, name, cfg) + if err != nil { + resp.Diagnostics.AddError("Error updating cron job", err.Error()) + return + } + + if err := client.CheckResponse(httpResp); err != nil { + resp.Diagnostics.AddError("Error updating cron job", err.Error()) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *cronJobResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state cronJobResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + name := state.Name.ValueString() + + httpResp, err := r.client.Raw.DeleteApiCronName(ctx, name, cosmossdk.UtilsCRONConfig{Name: name}) + if err != nil { + resp.Diagnostics.AddError("Error deleting cron job", err.Error()) + return + } + + if err := client.CheckResponse(httpResp); err != nil { + resp.Diagnostics.AddError("Error deleting cron job", err.Error()) + return + } +} + +func (r *cronJobResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("name"), req, resp) +} diff --git a/terraform-provider-cosmos/internal/resources/docker_network.go b/terraform-provider-cosmos/internal/resources/docker_network.go new file mode 100644 index 00000000..cf7460f5 --- /dev/null +++ b/terraform-provider-cosmos/internal/resources/docker_network.go @@ -0,0 +1,297 @@ +package resources + +import ( + "context" + "encoding/json" + "fmt" + + cosmossdk "github.com/azukaar/cosmos-server/go-sdk" + "github.com/azukaar/terraform-provider-cosmos/internal/client" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// Ensure interface compliance. +var ( + _ resource.Resource = &dockerNetworkResource{} + _ resource.ResourceWithImportState = &dockerNetworkResource{} +) + +// NewDockerNetworkResource returns a new resource.Resource for Docker networks. +func NewDockerNetworkResource() resource.Resource { + return &dockerNetworkResource{} +} + +type dockerNetworkResource struct { + client *client.CosmosClient +} + +type dockerNetworkResourceModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Driver types.String `tfsdk:"driver"` + Subnet types.String `tfsdk:"subnet"` + AttachCosmos types.Bool `tfsdk:"attach_cosmos"` +} + +func (r *dockerNetworkResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_docker_network" +} + +func (r *dockerNetworkResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Manages a Docker network through Cosmos Server.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The Docker network ID assigned on creation.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "name": schema.StringAttribute{ + Description: "The name of the Docker network. Changing this forces re-creation.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "driver": schema.StringAttribute{ + Description: "The network driver to use (e.g. bridge, overlay). Changing this forces re-creation.", + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "subnet": schema.StringAttribute{ + Description: "The subnet in CIDR format for the network. Changing this forces re-creation.", + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "attach_cosmos": schema.BoolAttribute{ + Description: "Whether to attach the Cosmos container to this network. Changing this forces re-creation.", + Optional: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.RequiresReplace(), + }, + }, + }, + } +} + +func (r *dockerNetworkResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + c, ok := req.ProviderData.(*client.CosmosClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *client.CosmosClient, got: %T", req.ProviderData), + ) + return + } + r.client = c +} + +func (r *dockerNetworkResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan dockerNetworkResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + payload := cosmossdk.DockerCreateNetworkPayload{ + Name: plan.Name.ValueString(), + } + if !plan.Driver.IsNull() && !plan.Driver.IsUnknown() { + payload.Driver = client.StringPtr(plan.Driver.ValueString()) + } + if !plan.Subnet.IsNull() && !plan.Subnet.IsUnknown() { + payload.Subnet = client.StringPtr(plan.Subnet.ValueString()) + } + if !plan.AttachCosmos.IsNull() && !plan.AttachCosmos.IsUnknown() { + payload.AttachCosmos = client.BoolPtr(plan.AttachCosmos.ValueBool()) + } + + httpResp, err := r.client.Raw.PostApiNetworks(ctx, payload) + if err != nil { + resp.Diagnostics.AddError("Error creating Docker network", err.Error()) + return + } + + rawData, err := client.ParseRawResponse(httpResp) + if err != nil { + resp.Diagnostics.AddError("Error creating Docker network", err.Error()) + return + } + + // The response data may contain the network ID in various formats. + // Try to extract it as a JSON object with an "Id" field first, + // then fall back to a plain string. + var networkID string + var idObj struct { + ID string `json:"Id"` + } + if err := json.Unmarshal(rawData, &idObj); err == nil && idObj.ID != "" { + networkID = idObj.ID + } else { + // Try as a plain string. + if err := json.Unmarshal(rawData, &networkID); err != nil || networkID == "" { + // Fall back: read the network list to find by name. + foundID, findErr := r.findNetworkIDByName(ctx, plan.Name.ValueString()) + if findErr != nil { + resp.Diagnostics.AddError("Error determining Docker network ID after creation", findErr.Error()) + return + } + networkID = foundID + } + } + + plan.ID = types.StringValue(networkID) + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *dockerNetworkResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state dockerNetworkResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + networkID := state.ID.ValueString() + networkName := state.Name.ValueString() + + httpResp, err := r.client.Raw.GetApiNetworks(ctx) + if err != nil { + resp.Diagnostics.AddError("Error reading Docker networks", err.Error()) + return + } + + rawData, err := client.ParseRawResponse(httpResp) + if err != nil { + if client.IsNotFound(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("Error reading Docker networks", err.Error()) + return + } + + var networks []networkInfo + if err := json.Unmarshal(rawData, &networks); err != nil { + resp.Diagnostics.AddError("Error parsing Docker networks response", err.Error()) + return + } + + var found *networkInfo + for i := range networks { + if networks[i].ID == networkID { + found = &networks[i] + break + } + } + // Fall back to matching by name if ID was not found. + if found == nil { + for i := range networks { + if networks[i].Name == networkName { + found = &networks[i] + break + } + } + } + + if found == nil { + // Network no longer exists; remove from state. + resp.State.RemoveResource(ctx) + return + } + + state.ID = types.StringValue(found.ID) + state.Name = types.StringValue(found.Name) + + if found.Driver != "" { + state.Driver = types.StringValue(found.Driver) + } else if !state.Driver.IsNull() { + state.Driver = types.StringNull() + } + + // Subnet and AttachCosmos are creation-time only and are not returned + // by the list endpoint, so we preserve the existing state values. + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *dockerNetworkResource) Update(_ context.Context, _ resource.UpdateRequest, resp *resource.UpdateResponse) { + // All attributes require replacement; no in-place update is supported. + resp.Diagnostics.AddError( + "Update not supported", + "All attributes of cosmos_docker_network require replacement. This method should never be called.", + ) +} + +func (r *dockerNetworkResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state dockerNetworkResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + httpResp, err := r.client.Raw.DeleteApiNetworkNetworkID(ctx, state.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Error deleting Docker network", err.Error()) + return + } + if err := client.CheckResponse(httpResp); err != nil { + resp.Diagnostics.AddError("Error deleting Docker network", err.Error()) + return + } +} + +func (r *dockerNetworkResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} + +// ---------- helpers ---------- + +// networkInfo represents the minimal fields returned by the network list API. +type networkInfo struct { + ID string `json:"Id"` + Name string `json:"Name"` + Driver string `json:"Driver"` +} + +// findNetworkIDByName queries the network list and returns the ID for the +// given network name. +func (r *dockerNetworkResource) findNetworkIDByName(ctx context.Context, name string) (string, error) { + httpResp, err := r.client.Raw.GetApiNetworks(ctx) + if err != nil { + return "", fmt.Errorf("listing networks: %w", err) + } + + rawData, err := client.ParseRawResponse(httpResp) + if err != nil { + return "", fmt.Errorf("parsing network list: %w", err) + } + + var networks []networkInfo + if err := json.Unmarshal(rawData, &networks); err != nil { + return "", fmt.Errorf("unmarshalling network list: %w", err) + } + + for _, n := range networks { + if n.Name == name { + return n.ID, nil + } + } + + return "", fmt.Errorf("network %q not found after creation", name) +} diff --git a/terraform-provider-cosmos/internal/resources/docker_service.go b/terraform-provider-cosmos/internal/resources/docker_service.go new file mode 100644 index 00000000..80217b55 --- /dev/null +++ b/terraform-provider-cosmos/internal/resources/docker_service.go @@ -0,0 +1,311 @@ +package resources + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + + cosmossdk "github.com/azukaar/cosmos-server/go-sdk" + "github.com/azukaar/terraform-provider-cosmos/internal/client" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var ( + _ resource.Resource = &dockerServiceResource{} + _ resource.ResourceWithImportState = &dockerServiceResource{} +) + +func NewDockerServiceResource() resource.Resource { + return &dockerServiceResource{} +} + +type dockerServiceResource struct { + client *client.CosmosClient +} + +// ---------- Terraform model ---------- + +type dockerServiceModel struct { + Name types.String `tfsdk:"name"` + Image types.String `tfsdk:"image"` + ServiceJSON types.String `tfsdk:"service_json"` +} + +// ---------- resource.Resource ---------- + +func (r *dockerServiceResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_docker_service" +} + +func (r *dockerServiceResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Manages a Docker service (container) in Cosmos Server.", + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Description: "The container name. Used as the resource ID.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "image": schema.StringAttribute{ + Description: "The Docker image to use for the container.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "service_json": schema.StringAttribute{ + Description: "Optional JSON blob representing the full container configuration (ports, volumes, environment, etc.). Changes trigger container recreation.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + }, + } +} + +func (r *dockerServiceResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + c, ok := req.ProviderData.(*client.CosmosClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *client.CosmosClient, got: %T", req.ProviderData), + ) + return + } + r.client = c +} + +// ---------- CRUD ---------- + +func (r *dockerServiceResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan dockerServiceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + containerName := plan.Name.ValueString() + image := plan.Image.ValueString() + + // Build the container definition. Start from service_json if provided, + // otherwise construct a minimal container config. + var container cosmossdk.DockerContainerCreateRequestContainer + if !plan.ServiceJSON.IsNull() && !plan.ServiceJSON.IsUnknown() && plan.ServiceJSON.ValueString() != "" { + if err := json.Unmarshal([]byte(plan.ServiceJSON.ValueString()), &container); err != nil { + resp.Diagnostics.AddError("Error parsing service_json", err.Error()) + return + } + } + + // Always set image and container_name from the explicit attributes. + container.Image = image + container.ContainerName = client.StringPtr(containerName) + + services := map[string]cosmossdk.DockerContainerCreateRequestContainer{ + containerName: container, + } + + createReq := cosmossdk.DockerDockerServiceCreateRequest{ + Services: &services, + } + + httpResp, err := r.client.Raw.PostApiDockerService(ctx, createReq) + if err != nil { + resp.Diagnostics.AddError("Error creating docker service", err.Error()) + return + } + // This endpoint streams progress text (pull logs, status messages) before + // the final result. We read the full body and check for success markers + // rather than trying to parse it as a standard JSON envelope. + if err := client.CheckStreamingResponse(httpResp); err != nil { + resp.Diagnostics.AddError("Error creating docker service", err.Error()) + return + } + + // Read back the created container to populate computed fields. + if !r.readContainer(ctx, containerName, &plan, &resp.Diagnostics) { + resp.Diagnostics.AddError("Error reading docker service", "Container not found after creation") + return + } + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *dockerServiceResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state dockerServiceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + found := r.readContainer(ctx, state.Name.ValueString(), &state, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + if !found { + resp.State.RemoveResource(ctx) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *dockerServiceResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan dockerServiceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + containerName := plan.Name.ValueString() + image := plan.Image.ValueString() + + // Build the update body from service_json or a minimal config. + var updateBody map[string]interface{} + if !plan.ServiceJSON.IsNull() && !plan.ServiceJSON.IsUnknown() && plan.ServiceJSON.ValueString() != "" { + if err := json.Unmarshal([]byte(plan.ServiceJSON.ValueString()), &updateBody); err != nil { + resp.Diagnostics.AddError("Error parsing service_json for update", err.Error()) + return + } + } else { + updateBody = make(map[string]interface{}) + } + + // Always ensure the image is set. + updateBody["image"] = image + updateBody["container_name"] = containerName + + jsonBytes, err := json.Marshal(updateBody) + if err != nil { + resp.Diagnostics.AddError("Error marshalling update body", err.Error()) + return + } + + httpResp, err := r.client.Raw.PostApiServappsContainerIdUpdateWithBody( + ctx, containerName, "application/json", bytes.NewReader(jsonBytes), + ) + if err != nil { + resp.Diagnostics.AddError("Error updating docker service", err.Error()) + return + } + if err := client.CheckStreamingResponse(httpResp); err != nil { + resp.Diagnostics.AddError("Error updating docker service", err.Error()) + return + } + + // Read back the updated container. + if !r.readContainer(ctx, containerName, &plan, &resp.Diagnostics) { + resp.Diagnostics.AddError("Error reading docker service", "Container not found after update") + return + } + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *dockerServiceResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state dockerServiceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + containerName := state.Name.ValueString() + + // Stop the container first — Docker won't remove a running container. + stopAction := cosmossdk.GetApiServappsContainerIdManageActionParamsAction("stop") + httpResp, err := r.client.Raw.GetApiServappsContainerIdManageAction(ctx, containerName, stopAction) + if err != nil { + resp.Diagnostics.AddError("Error stopping docker service", err.Error()) + return + } + if err := client.CheckStreamingResponse(httpResp); err != nil { + resp.Diagnostics.AddError("Error stopping docker service", err.Error()) + return + } + + // Now remove it. + removeAction := cosmossdk.GetApiServappsContainerIdManageActionParamsAction("remove") + httpResp, err = r.client.Raw.GetApiServappsContainerIdManageAction(ctx, containerName, removeAction) + if err != nil { + resp.Diagnostics.AddError("Error deleting docker service", err.Error()) + return + } + if err := client.CheckStreamingResponse(httpResp); err != nil { + resp.Diagnostics.AddError("Error deleting docker service", err.Error()) + return + } +} + +func (r *dockerServiceResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("name"), req, resp) +} + +// ---------- helpers ---------- + +// readContainer fetches a container by name and populates the model. +// It returns false if the container was not found (404), in which case no +// diagnostic error is added — the caller should remove the resource from state. +func (r *dockerServiceResource) readContainer(ctx context.Context, containerName string, model *dockerServiceModel, diags interface { + AddError(string, string) + HasError() bool +}) bool { + httpResp, err := r.client.Raw.GetApiServappsContainerId(ctx, containerName) + if err != nil { + diags.AddError("Error reading docker service", err.Error()) + return true + } + + rawData, err := client.ParseRawResponse(httpResp) + if err != nil { + if client.IsNotFound(err) { + return false + } + diags.AddError("Error reading docker service", err.Error()) + return true + } + if rawData == nil { + return false + } + + // Parse as a generic map to extract fields and store the full JSON. + var containerData map[string]interface{} + if err := json.Unmarshal(rawData, &containerData); err != nil { + diags.AddError("Error parsing docker service response", err.Error()) + return true + } + + model.Name = types.StringValue(containerName) + + // Extract image from the Config block if available (docker inspect format), + // otherwise fall back to top-level "image" field. + if cfg, ok := containerData["Config"].(map[string]interface{}); ok { + if img, ok := cfg["Image"].(string); ok { + model.Image = types.StringValue(img) + } + } else if img, ok := containerData["image"].(string); ok { + model.Image = types.StringValue(img) + } + + // Don't overwrite service_json — it's a user-provided config input, not + // server state. Preserve whatever the user originally set. + return true +} diff --git a/terraform-provider-cosmos/internal/resources/docker_volume.go b/terraform-provider-cosmos/internal/resources/docker_volume.go new file mode 100644 index 00000000..c56caad6 --- /dev/null +++ b/terraform-provider-cosmos/internal/resources/docker_volume.go @@ -0,0 +1,207 @@ +package resources + +import ( + "context" + "encoding/json" + "fmt" + + cosmossdk "github.com/azukaar/cosmos-server/go-sdk" + "github.com/azukaar/terraform-provider-cosmos/internal/client" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// Ensure interface compliance. +var ( + _ resource.Resource = &dockerVolumeResource{} + _ resource.ResourceWithImportState = &dockerVolumeResource{} +) + +// NewDockerVolumeResource returns a new resource.Resource for Docker volumes. +func NewDockerVolumeResource() resource.Resource { + return &dockerVolumeResource{} +} + +type dockerVolumeResource struct { + client *client.CosmosClient +} + +type dockerVolumeResourceModel struct { + Name types.String `tfsdk:"name"` + Driver types.String `tfsdk:"driver"` +} + +func (r *dockerVolumeResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_docker_volume" +} + +func (r *dockerVolumeResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Manages a Docker volume through Cosmos Server.", + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Description: "The name of the Docker volume (used as the resource ID). Changing this forces re-creation.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "driver": schema.StringAttribute{ + Description: "The volume driver to use. Default: local. Changing this forces re-creation.", + Optional: true, + Computed: true, + Default: stringdefault.StaticString("local"), + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + }, + } +} + +func (r *dockerVolumeResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + c, ok := req.ProviderData.(*client.CosmosClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *client.CosmosClient, got: %T", req.ProviderData), + ) + return + } + r.client = c +} + +func (r *dockerVolumeResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan dockerVolumeResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + payload := cosmossdk.DockerVolumeCreateRequest{ + Name: plan.Name.ValueString(), + } + if !plan.Driver.IsNull() && !plan.Driver.IsUnknown() { + payload.Driver = client.StringPtr(plan.Driver.ValueString()) + } + + httpResp, err := r.client.Raw.PostApiVolumes(ctx, payload) + if err != nil { + resp.Diagnostics.AddError("Error creating Docker volume", err.Error()) + return + } + if err := client.CheckResponse(httpResp); err != nil { + resp.Diagnostics.AddError("Error creating Docker volume", err.Error()) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *dockerVolumeResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state dockerVolumeResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + name := state.Name.ValueString() + + httpResp, err := r.client.Raw.GetApiVolumes(ctx) + if err != nil { + resp.Diagnostics.AddError("Error reading Docker volumes", err.Error()) + return + } + + rawData, err := client.ParseRawResponse(httpResp) + if err != nil { + if client.IsNotFound(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("Error reading Docker volumes", err.Error()) + return + } + + // The API returns {"Volumes": [...], "Warnings": ...} + var volumeList struct { + Volumes []volumeInfo `json:"Volumes"` + } + if err := json.Unmarshal(rawData, &volumeList); err != nil { + resp.Diagnostics.AddError("Error parsing Docker volumes response", err.Error()) + return + } + + var found *volumeInfo + for i := range volumeList.Volumes { + if volumeList.Volumes[i].Name == name { + found = &volumeList.Volumes[i] + break + } + } + + if found == nil { + // Volume no longer exists; remove from state. + resp.State.RemoveResource(ctx) + return + } + + state.Name = types.StringValue(found.Name) + + // Only update driver from API if the user originally set it. + // Otherwise leave it as null to avoid spurious replacements + // (e.g. API returns "local" but user didn't specify driver). + if !state.Driver.IsNull() { + if found.Driver != "" { + state.Driver = types.StringValue(found.Driver) + } + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *dockerVolumeResource) Update(_ context.Context, _ resource.UpdateRequest, resp *resource.UpdateResponse) { + // All attributes require replacement; no in-place update is supported. + resp.Diagnostics.AddError( + "Update not supported", + "All attributes of cosmos_docker_volume require replacement. This method should never be called.", + ) +} + +func (r *dockerVolumeResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state dockerVolumeResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + httpResp, err := r.client.Raw.DeleteApiVolumeVolumeName(ctx, state.Name.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Error deleting Docker volume", err.Error()) + return + } + if err := client.CheckResponse(httpResp); err != nil { + resp.Diagnostics.AddError("Error deleting Docker volume", err.Error()) + return + } +} + +func (r *dockerVolumeResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("name"), req, resp) +} + +// ---------- helpers ---------- + +// volumeInfo represents the minimal fields returned by the volume list API. +type volumeInfo struct { + Name string `json:"Name"` + Driver string `json:"Driver"` +} diff --git a/terraform-provider-cosmos/internal/resources/openid_client.go b/terraform-provider-cosmos/internal/resources/openid_client.go new file mode 100644 index 00000000..fc225dc7 --- /dev/null +++ b/terraform-provider-cosmos/internal/resources/openid_client.go @@ -0,0 +1,222 @@ +package resources + +import ( + "context" + "fmt" + + cosmossdk "github.com/azukaar/cosmos-server/go-sdk" + "github.com/azukaar/terraform-provider-cosmos/internal/client" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// Ensure interface compliance. +var ( + _ resource.Resource = &openIDClientResource{} + _ resource.ResourceWithImportState = &openIDClientResource{} +) + +// NewOpenIDClientResource returns a new resource.Resource for OpenID clients. +func NewOpenIDClientResource() resource.Resource { + return &openIDClientResource{} +} + +type openIDClientResource struct { + client *client.CosmosClient +} + +// openIDClientModel maps the resource schema to a Go struct. +type openIDClientModel struct { + ID types.String `tfsdk:"id"` + Secret types.String `tfsdk:"secret"` + Redirect types.String `tfsdk:"redirect"` +} + +func (r *openIDClientResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_openid_client" +} + +func (r *openIDClientResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Manages a Cosmos Server OpenID Connect client.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The unique identifier of the OpenID client. Changing this forces re-creation.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "secret": schema.StringAttribute{ + Description: "The client secret for this OpenID client. Returned on creation only — subsequent reads will not return it.", + Optional: true, + Computed: true, + Sensitive: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "redirect": schema.StringAttribute{ + Description: "The redirect URI for this OpenID client.", + Optional: true, + }, + }, + } +} + +func (r *openIDClientResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + c, ok := req.ProviderData.(*client.CosmosClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *client.CosmosClient, got: %T", req.ProviderData), + ) + return + } + r.client = c +} + +func (r *openIDClientResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan openIDClientModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + createReq := cosmossdk.UtilsOpenIDClient{ + Id: plan.ID.ValueString(), + } + + if !plan.Secret.IsNull() && !plan.Secret.IsUnknown() { + createReq.Secret = client.StringPtr(plan.Secret.ValueString()) + } + if !plan.Redirect.IsNull() && !plan.Redirect.IsUnknown() { + createReq.Redirect = client.StringPtr(plan.Redirect.ValueString()) + } + + httpResp, err := r.client.Raw.PostApiOpenid(ctx, createReq) + if err != nil { + resp.Diagnostics.AddError("Error creating OpenID client", err.Error()) + return + } + + // Capture the secret from the create response — the GET endpoint strips it. + result, err := client.ParseResponse[cosmossdk.UtilsOpenIDClient](httpResp) + if err != nil { + resp.Diagnostics.AddError("Error creating OpenID client", err.Error()) + return + } + if result != nil && result.Secret != nil && *result.Secret != "" { + plan.Secret = types.StringValue(*result.Secret) + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *openIDClientResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state openIDClientModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + id := state.ID.ValueString() + + httpResp, err := r.client.Raw.GetApiOpenidId(ctx, id) + if err != nil { + resp.Diagnostics.AddError("Error reading OpenID client", err.Error()) + return + } + + result, err := client.ParseResponse[cosmossdk.UtilsOpenIDClient](httpResp) + if err != nil { + if client.IsNotFound(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("Error parsing OpenID client response", err.Error()) + return + } + + if result == nil { + resp.State.RemoveResource(ctx) + return + } + + state.ID = types.StringValue(result.Id) + + // Don't read secret back from API — preserve whatever is in state. + // The secret should ideally not be returned by the server at all. + + if result.Redirect != nil { + state.Redirect = types.StringValue(*result.Redirect) + } else { + state.Redirect = types.StringNull() + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *openIDClientResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan openIDClientModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + id := plan.ID.ValueString() + + updateReq := cosmossdk.UtilsOpenIDClient{ + Id: id, + } + + if !plan.Secret.IsNull() && !plan.Secret.IsUnknown() { + updateReq.Secret = client.StringPtr(plan.Secret.ValueString()) + } + if !plan.Redirect.IsNull() && !plan.Redirect.IsUnknown() { + updateReq.Redirect = client.StringPtr(plan.Redirect.ValueString()) + } + + httpResp, err := r.client.Raw.PutApiOpenidId(ctx, id, updateReq) + if err != nil { + resp.Diagnostics.AddError("Error updating OpenID client", err.Error()) + return + } + if err := client.CheckResponse(httpResp); err != nil { + resp.Diagnostics.AddError("Error updating OpenID client", err.Error()) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *openIDClientResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state openIDClientModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + id := state.ID.ValueString() + + httpResp, err := r.client.Raw.DeleteApiOpenidId(ctx, id) + if err != nil { + resp.Diagnostics.AddError("Error deleting OpenID client", err.Error()) + return + } + if err := client.CheckResponse(httpResp); err != nil { + resp.Diagnostics.AddError("Error deleting OpenID client", err.Error()) + return + } +} + +func (r *openIDClientResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} diff --git a/terraform-provider-cosmos/internal/resources/route.go b/terraform-provider-cosmos/internal/resources/route.go new file mode 100644 index 00000000..4978bffa --- /dev/null +++ b/terraform-provider-cosmos/internal/resources/route.go @@ -0,0 +1,763 @@ +package resources + +import ( + "context" + "fmt" + + cosmossdk "github.com/azukaar/cosmos-server/go-sdk" + "github.com/azukaar/terraform-provider-cosmos/internal/client" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" +) + +var ( + _ resource.Resource = &routeResource{} + _ resource.ResourceWithImportState = &routeResource{} +) + +func NewRouteResource() resource.Resource { + return &routeResource{} +} + +type routeResource struct { + client *client.CosmosClient +} + +// ---------- Terraform model ---------- + +type routeSmartShieldModel struct { + Enabled types.Bool `tfsdk:"enabled"` + MaxGlobalSimultaneous types.Int64 `tfsdk:"max_global_simultaneous"` + PerUserByteLimit types.Int64 `tfsdk:"per_user_byte_limit"` + PerUserRequestLimit types.Int64 `tfsdk:"per_user_request_limit"` + PerUserSimultaneous types.Int64 `tfsdk:"per_user_simultaneous"` + PerUserTimeBudget types.Float32 `tfsdk:"per_user_time_budget"` + PolicyStrictness types.Int64 `tfsdk:"policy_strictness"` + PrivilegedGroups types.Int64 `tfsdk:"privileged_groups"` +} + +var smartShieldAttrTypes = map[string]attr.Type{ + "enabled": types.BoolType, + "max_global_simultaneous": types.Int64Type, + "per_user_byte_limit": types.Int64Type, + "per_user_request_limit": types.Int64Type, + "per_user_simultaneous": types.Int64Type, + "per_user_time_budget": types.Float32Type, + "policy_strictness": types.Int64Type, + "privileged_groups": types.Int64Type, +} + +type routeAdditionalFilterModel struct { + Name types.String `tfsdk:"name"` + Type types.String `tfsdk:"type"` + Value types.String `tfsdk:"value"` +} + +var additionalFilterAttrTypes = map[string]attr.Type{ + "name": types.StringType, + "type": types.StringType, + "value": types.StringType, +} + +type routeModel struct { + Name types.String `tfsdk:"name"` + Target types.String `tfsdk:"target"` + Host types.String `tfsdk:"host"` + Mode types.String `tfsdk:"mode"` + Description types.String `tfsdk:"description"` + AuthEnabled types.Bool `tfsdk:"auth_enabled"` + AdminOnly types.Bool `tfsdk:"admin_only"` + Disabled types.Bool `tfsdk:"disabled"` + HideFromDashboard types.Bool `tfsdk:"hide_from_dashboard"` + RestrictToConstellation types.Bool `tfsdk:"restrict_to_constellation"` + BlockCommonBots types.Bool `tfsdk:"block_common_bots"` + BlockAPIAbuse types.Bool `tfsdk:"block_api_abuse"` + DisableHeaderHardening types.Bool `tfsdk:"disable_header_hardening"` + SpoofHostname types.Bool `tfsdk:"spoof_hostname"` + AcceptInsecureHTTPSTarget types.Bool `tfsdk:"accept_insecure_https_target"` + SkipURLClean types.Bool `tfsdk:"skip_url_clean"` + StripPathPrefix types.Bool `tfsdk:"strip_path_prefix"` + Corsorigin types.String `tfsdk:"cors_origin"` + Icon types.String `tfsdk:"icon"` + MaxBandwith types.Int64 `tfsdk:"max_bandwidth"` + OverwriteHostHeader types.String `tfsdk:"overwrite_host_header"` + PathPrefix types.String `tfsdk:"path_prefix"` + ThrottlePerMinute types.Int64 `tfsdk:"throttle_per_minute"` + Timeout types.Int64 `tfsdk:"timeout"` + Tunnel types.String `tfsdk:"tunnel"` + TunneledHost types.String `tfsdk:"tunneled_host"` + LBMode types.String `tfsdk:"lb_mode"` + LBStickyMode types.Bool `tfsdk:"lb_sticky_mode"` + UseHost types.Bool `tfsdk:"use_host"` + UseH2C types.Bool `tfsdk:"use_h2c"` + UsePathPrefix types.Bool `tfsdk:"use_path_prefix"` + ExtraHeaders types.Map `tfsdk:"extra_headers"` + WhitelistInboundIPs types.List `tfsdk:"whitelist_inbound_ips"` + AdditionalFilters types.List `tfsdk:"additional_filters"` + SmartShield types.Object `tfsdk:"smart_shield"` +} + +// ---------- resource.Resource ---------- + +func (r *routeResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_route" +} + +func (r *routeResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Manages a Cosmos Server proxy route.", + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Description: "Unique name of the route. Used as the resource ID.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "target": schema.StringAttribute{ + Description: "Target URL the route proxies to.", + Required: true, + }, + "host": schema.StringAttribute{ + Description: "Hostname the route listens on.", + Optional: true, + Computed: true, + }, + "mode": schema.StringAttribute{ + Description: "Proxy mode (e.g. PROXY, SPA, STATIC, REDIRECT, SERVAPP).", + Optional: true, + Computed: true, + }, + "description": schema.StringAttribute{ + Description: "Human-readable description of the route.", + Optional: true, + Computed: true, + }, + "auth_enabled": schema.BoolAttribute{ + Description: "Whether authentication is required.", + Optional: true, + Computed: true, + }, + "admin_only": schema.BoolAttribute{ + Description: "Restrict access to admin users.", + Optional: true, + Computed: true, + }, + "disabled": schema.BoolAttribute{ + Description: "Whether the route is disabled.", + Optional: true, + Computed: true, + }, + "hide_from_dashboard": schema.BoolAttribute{ + Description: "Hide the route from the Cosmos dashboard.", + Optional: true, + Computed: true, + }, + "restrict_to_constellation": schema.BoolAttribute{ + Description: "Only allow access from the Constellation VPN.", + Optional: true, + Computed: true, + }, + "block_common_bots": schema.BoolAttribute{ + Description: "Block common bots and crawlers.", + Optional: true, + Computed: true, + }, + "block_api_abuse": schema.BoolAttribute{ + Description: "Block API abuse patterns.", + Optional: true, + Computed: true, + }, + "disable_header_hardening": schema.BoolAttribute{ + Description: "Disable security header hardening.", + Optional: true, + Computed: true, + }, + "spoof_hostname": schema.BoolAttribute{ + Description: "Spoof the hostname in proxied requests.", + Optional: true, + Computed: true, + }, + "accept_insecure_https_target": schema.BoolAttribute{ + Description: "Accept self-signed certificates on the target.", + Optional: true, + Computed: true, + }, + "skip_url_clean": schema.BoolAttribute{ + Description: "Skip URL cleaning/normalization.", + Optional: true, + Computed: true, + }, + "strip_path_prefix": schema.BoolAttribute{ + Description: "Strip the path prefix before forwarding.", + Optional: true, + Computed: true, + }, + "cors_origin": schema.StringAttribute{ + Description: "Allowed CORS origin.", + Optional: true, + Computed: true, + }, + "icon": schema.StringAttribute{ + Description: "Icon identifier for the dashboard.", + Optional: true, + Computed: true, + }, + "max_bandwidth": schema.Int64Attribute{ + Description: "Maximum bandwidth in bytes per second (0 = unlimited).", + Optional: true, + Computed: true, + }, + "overwrite_host_header": schema.StringAttribute{ + Description: "Override the Host header sent to the target.", + Optional: true, + Computed: true, + }, + "path_prefix": schema.StringAttribute{ + Description: "Path prefix to match for this route.", + Optional: true, + Computed: true, + }, + "throttle_per_minute": schema.Int64Attribute{ + Description: "Maximum requests per minute (0 = unlimited).", + Optional: true, + Computed: true, + }, + "timeout": schema.Int64Attribute{ + Description: "Request timeout in nanoseconds.", + Optional: true, + Computed: true, + }, + "tunnel": schema.StringAttribute{ + Description: "Tunnel identifier for Constellation tunneling.", + Optional: true, + Computed: true, + }, + "tunneled_host": schema.StringAttribute{ + Description: "Hostname to use when tunneling through Constellation.", + Optional: true, + Computed: true, + }, + "lb_mode": schema.StringAttribute{ + Description: "Load balancer mode.", + Optional: true, + Computed: true, + }, + "lb_sticky_mode": schema.BoolAttribute{ + Description: "Enable sticky sessions for load balancing.", + Optional: true, + Computed: true, + }, + "use_host": schema.BoolAttribute{ + Description: "Whether the route matches by hostname.", + Optional: true, + Computed: true, + }, + "use_h2c": schema.BoolAttribute{ + Description: "Use HTTP/2 cleartext (h2c) for the upstream connection.", + Optional: true, + Computed: true, + }, + "use_path_prefix": schema.BoolAttribute{ + Description: "Whether the route matches by path prefix.", + Optional: true, + Computed: true, + }, + "extra_headers": schema.MapAttribute{ + Description: "Additional HTTP headers to add to proxied requests.", + Optional: true, + Computed: true, + ElementType: types.StringType, + }, + "whitelist_inbound_ips": schema.ListAttribute{ + Description: "List of IP addresses or CIDRs allowed to access this route.", + Optional: true, + Computed: true, + ElementType: types.StringType, + }, + "additional_filters": schema.ListNestedAttribute{ + Description: "Additional request filters.", + Optional: true, + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Description: "Filter name.", + Optional: true, + }, + "type": schema.StringAttribute{ + Description: "Filter type.", + Optional: true, + }, + "value": schema.StringAttribute{ + Description: "Filter value.", + Optional: true, + }, + }, + }, + }, + "smart_shield": schema.SingleNestedAttribute{ + Description: "SmartShield DDoS protection policy.", + Optional: true, + Computed: true, + Attributes: map[string]schema.Attribute{ + "enabled": schema.BoolAttribute{ + Description: "Enable SmartShield protection.", + Optional: true, + Computed: true, + }, + "max_global_simultaneous": schema.Int64Attribute{ + Description: "Maximum global simultaneous connections.", + Optional: true, + Computed: true, + }, + "per_user_byte_limit": schema.Int64Attribute{ + Description: "Per-user byte limit.", + Optional: true, + Computed: true, + }, + "per_user_request_limit": schema.Int64Attribute{ + Description: "Per-user request limit.", + Optional: true, + Computed: true, + }, + "per_user_simultaneous": schema.Int64Attribute{ + Description: "Per-user simultaneous connections limit.", + Optional: true, + Computed: true, + }, + "per_user_time_budget": schema.Float32Attribute{ + Description: "Per-user time budget in seconds.", + Optional: true, + Computed: true, + }, + "policy_strictness": schema.Int64Attribute{ + Description: "Policy strictness level.", + Optional: true, + Computed: true, + }, + "privileged_groups": schema.Int64Attribute{ + Description: "Privileged groups bitmask.", + Optional: true, + Computed: true, + }, + }, + }, + }, + } +} + +func (r *routeResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + c, ok := req.ProviderData.(*client.CosmosClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *client.CosmosClient, got %T", req.ProviderData), + ) + return + } + r.client = c +} + +// ---------- CRUD ---------- + +func (r *routeResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan routeModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + apiReq := routeModelToSDK(ctx, plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + httpResp, err := r.client.Raw.PostApiRoutes(ctx, apiReq) + if err != nil { + resp.Diagnostics.AddError("Error creating route", err.Error()) + return + } + if err := client.CheckResponse(httpResp); err != nil { + resp.Diagnostics.AddError("Error creating route", err.Error()) + return + } + + if !readRouteIntoModel(ctx, r.client, plan.Name.ValueString(), &plan, &resp.Diagnostics) { + resp.Diagnostics.AddError("Error reading route", "Route not found after creation") + return + } + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *routeResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state routeModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + found := readRouteIntoModel(ctx, r.client, state.Name.ValueString(), &state, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + if !found { + resp.State.RemoveResource(ctx) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *routeResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan routeModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + var state routeModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + apiReq := routeModelToSDK(ctx, plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + httpResp, err := r.client.Raw.PutApiRoutesName(ctx, state.Name.ValueString(), apiReq) + if err != nil { + resp.Diagnostics.AddError("Error updating route", err.Error()) + return + } + if err := client.CheckResponse(httpResp); err != nil { + resp.Diagnostics.AddError("Error updating route", err.Error()) + return + } + + if !readRouteIntoModel(ctx, r.client, plan.Name.ValueString(), &plan, &resp.Diagnostics) { + resp.Diagnostics.AddError("Error reading route", "Route not found after update") + return + } + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *routeResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state routeModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + httpResp, err := r.client.Raw.DeleteApiRoutesName(ctx, state.Name.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Error deleting route", err.Error()) + return + } + if err := client.CheckResponse(httpResp); err != nil { + resp.Diagnostics.AddError("Error deleting route", err.Error()) + return + } +} + +func (r *routeResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("name"), req, resp) +} + +// ---------- helpers ---------- + +// readRouteIntoModel fetches a route by name from the API and populates the model. +// It returns false if the route was not found (404), in which case no diagnostic +// error is added — the caller should remove the resource from state. +func readRouteIntoModel(ctx context.Context, c *client.CosmosClient, name string, model *routeModel, diags *diag.Diagnostics) bool { + httpResp, err := c.Raw.GetApiRoutesName(ctx, name) + if err != nil { + diags.AddError("Error reading route", err.Error()) + return true + } + route, err := client.ParseResponse[cosmossdk.UtilsProxyRouteConfig](httpResp) + if err != nil { + if client.IsNotFound(err) { + return false + } + diags.AddError("Error reading route", err.Error()) + return true + } + if route == nil { + return false + } + + sdkToRouteModel(ctx, route, model, diags) + return true +} + +func routeModelToSDK(ctx context.Context, m routeModel, diags *diag.Diagnostics) cosmossdk.UtilsProxyRouteConfig { + cfg := cosmossdk.UtilsProxyRouteConfig{ + Name: m.Name.ValueString(), + Target: m.Target.ValueString(), + } + + if !m.Host.IsNull() && !m.Host.IsUnknown() { + cfg.Host = client.StringPtr(m.Host.ValueString()) + } + if !m.Mode.IsNull() && !m.Mode.IsUnknown() { + cfg.Mode = client.StringPtr(m.Mode.ValueString()) + } + if !m.Description.IsNull() && !m.Description.IsUnknown() { + cfg.Description = client.StringPtr(m.Description.ValueString()) + } + if !m.AuthEnabled.IsNull() && !m.AuthEnabled.IsUnknown() { + cfg.AuthEnabled = client.BoolPtr(m.AuthEnabled.ValueBool()) + } + if !m.AdminOnly.IsNull() && !m.AdminOnly.IsUnknown() { + cfg.AdminOnly = client.BoolPtr(m.AdminOnly.ValueBool()) + } + if !m.Disabled.IsNull() && !m.Disabled.IsUnknown() { + cfg.Disabled = client.BoolPtr(m.Disabled.ValueBool()) + } + if !m.HideFromDashboard.IsNull() && !m.HideFromDashboard.IsUnknown() { + cfg.HideFromDashboard = client.BoolPtr(m.HideFromDashboard.ValueBool()) + } + if !m.RestrictToConstellation.IsNull() && !m.RestrictToConstellation.IsUnknown() { + cfg.RestrictToConstellation = client.BoolPtr(m.RestrictToConstellation.ValueBool()) + } + if !m.BlockCommonBots.IsNull() && !m.BlockCommonBots.IsUnknown() { + cfg.BlockCommonBots = client.BoolPtr(m.BlockCommonBots.ValueBool()) + } + if !m.BlockAPIAbuse.IsNull() && !m.BlockAPIAbuse.IsUnknown() { + cfg.BlockAPIAbuse = client.BoolPtr(m.BlockAPIAbuse.ValueBool()) + } + if !m.DisableHeaderHardening.IsNull() && !m.DisableHeaderHardening.IsUnknown() { + cfg.DisableHeaderHardening = client.BoolPtr(m.DisableHeaderHardening.ValueBool()) + } + if !m.SpoofHostname.IsNull() && !m.SpoofHostname.IsUnknown() { + cfg.SpoofHostname = client.BoolPtr(m.SpoofHostname.ValueBool()) + } + if !m.AcceptInsecureHTTPSTarget.IsNull() && !m.AcceptInsecureHTTPSTarget.IsUnknown() { + cfg.AcceptInsecureHTTPSTarget = client.BoolPtr(m.AcceptInsecureHTTPSTarget.ValueBool()) + } + if !m.SkipURLClean.IsNull() && !m.SkipURLClean.IsUnknown() { + cfg.SkipURLClean = client.BoolPtr(m.SkipURLClean.ValueBool()) + } + if !m.StripPathPrefix.IsNull() && !m.StripPathPrefix.IsUnknown() { + cfg.StripPathPrefix = client.BoolPtr(m.StripPathPrefix.ValueBool()) + } + if !m.Corsorigin.IsNull() && !m.Corsorigin.IsUnknown() { + cfg.Corsorigin = client.StringPtr(m.Corsorigin.ValueString()) + } + if !m.Icon.IsNull() && !m.Icon.IsUnknown() { + cfg.Icon = client.StringPtr(m.Icon.ValueString()) + } + if !m.MaxBandwith.IsNull() && !m.MaxBandwith.IsUnknown() { + cfg.MaxBandwith = client.IntPtr(int(m.MaxBandwith.ValueInt64())) + } + if !m.OverwriteHostHeader.IsNull() && !m.OverwriteHostHeader.IsUnknown() { + cfg.OverwriteHostHeader = client.StringPtr(m.OverwriteHostHeader.ValueString()) + } + if !m.PathPrefix.IsNull() && !m.PathPrefix.IsUnknown() { + cfg.PathPrefix = client.StringPtr(m.PathPrefix.ValueString()) + } + if !m.ThrottlePerMinute.IsNull() && !m.ThrottlePerMinute.IsUnknown() { + cfg.ThrottlePerMinute = client.IntPtr(int(m.ThrottlePerMinute.ValueInt64())) + } + if !m.Timeout.IsNull() && !m.Timeout.IsUnknown() { + t := cosmossdk.TimeDuration(m.Timeout.ValueInt64()) + cfg.Timeout = &t + } + if !m.Tunnel.IsNull() && !m.Tunnel.IsUnknown() { + cfg.Tunnel = client.StringPtr(m.Tunnel.ValueString()) + } + if !m.TunneledHost.IsNull() && !m.TunneledHost.IsUnknown() { + cfg.TunneledHost = client.StringPtr(m.TunneledHost.ValueString()) + } + if !m.LBMode.IsNull() && !m.LBMode.IsUnknown() { + cfg.LBMode = client.StringPtr(m.LBMode.ValueString()) + } + if !m.LBStickyMode.IsNull() && !m.LBStickyMode.IsUnknown() { + cfg.LBStickyMode = client.BoolPtr(m.LBStickyMode.ValueBool()) + } + if !m.UseHost.IsNull() && !m.UseHost.IsUnknown() { + cfg.UseHost = client.BoolPtr(m.UseHost.ValueBool()) + } + if !m.UseH2C.IsNull() && !m.UseH2C.IsUnknown() { + cfg.UseH2C = client.BoolPtr(m.UseH2C.ValueBool()) + } + if !m.UsePathPrefix.IsNull() && !m.UsePathPrefix.IsUnknown() { + cfg.UsePathPrefix = client.BoolPtr(m.UsePathPrefix.ValueBool()) + } + if !m.ExtraHeaders.IsNull() && !m.ExtraHeaders.IsUnknown() { + headers := make(map[string]string) + diags.Append(m.ExtraHeaders.ElementsAs(ctx, &headers, false)...) + cfg.ExtraHeaders = &headers + } + if !m.WhitelistInboundIPs.IsNull() && !m.WhitelistInboundIPs.IsUnknown() { + var ips []string + diags.Append(m.WhitelistInboundIPs.ElementsAs(ctx, &ips, false)...) + cfg.WhitelistInboundIPs = &ips + } + if !m.AdditionalFilters.IsNull() && !m.AdditionalFilters.IsUnknown() { + var filterModels []routeAdditionalFilterModel + diags.Append(m.AdditionalFilters.ElementsAs(ctx, &filterModels, false)...) + filters := make([]cosmossdk.UtilsAddionalFiltersConfig, len(filterModels)) + for i, fm := range filterModels { + if !fm.Name.IsNull() { + filters[i].Name = client.StringPtr(fm.Name.ValueString()) + } + if !fm.Type.IsNull() { + filters[i].Type = client.StringPtr(fm.Type.ValueString()) + } + if !fm.Value.IsNull() { + filters[i].Value = client.StringPtr(fm.Value.ValueString()) + } + } + cfg.AddionalFilters = &filters + } + + if !m.SmartShield.IsNull() && !m.SmartShield.IsUnknown() { + var ssModel routeSmartShieldModel + d := m.SmartShield.As(ctx, &ssModel, basetypes.ObjectAsOptions{}) + diags.Append(d...) + if !d.HasError() { + ss := &cosmossdk.UtilsSmartShieldPolicy{} + if !ssModel.Enabled.IsNull() && !ssModel.Enabled.IsUnknown() { + ss.Enabled = client.BoolPtr(ssModel.Enabled.ValueBool()) + } + if !ssModel.MaxGlobalSimultaneous.IsNull() && !ssModel.MaxGlobalSimultaneous.IsUnknown() { + ss.MaxGlobalSimultaneous = client.IntPtr(int(ssModel.MaxGlobalSimultaneous.ValueInt64())) + } + if !ssModel.PerUserByteLimit.IsNull() && !ssModel.PerUserByteLimit.IsUnknown() { + ss.PerUserByteLimit = client.IntPtr(int(ssModel.PerUserByteLimit.ValueInt64())) + } + if !ssModel.PerUserRequestLimit.IsNull() && !ssModel.PerUserRequestLimit.IsUnknown() { + ss.PerUserRequestLimit = client.IntPtr(int(ssModel.PerUserRequestLimit.ValueInt64())) + } + if !ssModel.PerUserSimultaneous.IsNull() && !ssModel.PerUserSimultaneous.IsUnknown() { + ss.PerUserSimultaneous = client.IntPtr(int(ssModel.PerUserSimultaneous.ValueInt64())) + } + if !ssModel.PerUserTimeBudget.IsNull() && !ssModel.PerUserTimeBudget.IsUnknown() { + ss.PerUserTimeBudget = client.Float32Ptr(ssModel.PerUserTimeBudget.ValueFloat32()) + } + if !ssModel.PolicyStrictness.IsNull() && !ssModel.PolicyStrictness.IsUnknown() { + ss.PolicyStrictness = client.IntPtr(int(ssModel.PolicyStrictness.ValueInt64())) + } + if !ssModel.PrivilegedGroups.IsNull() && !ssModel.PrivilegedGroups.IsUnknown() { + ss.PrivilegedGroups = client.IntPtr(int(ssModel.PrivilegedGroups.ValueInt64())) + } + cfg.SmartShield = ss + } + } + + return cfg +} + +func sdkToRouteModel(ctx context.Context, route *cosmossdk.UtilsProxyRouteConfig, m *routeModel, diags *diag.Diagnostics) { + m.Name = types.StringValue(route.Name) + m.Target = types.StringValue(route.Target) + m.Host = types.StringValue(client.StringPtrVal(route.Host)) + m.Mode = types.StringValue(client.StringPtrVal(route.Mode)) + m.Description = types.StringValue(client.StringPtrVal(route.Description)) + m.AuthEnabled = types.BoolValue(client.BoolPtrVal(route.AuthEnabled)) + m.AdminOnly = types.BoolValue(client.BoolPtrVal(route.AdminOnly)) + m.Disabled = types.BoolValue(client.BoolPtrVal(route.Disabled)) + m.HideFromDashboard = types.BoolValue(client.BoolPtrVal(route.HideFromDashboard)) + m.RestrictToConstellation = types.BoolValue(client.BoolPtrVal(route.RestrictToConstellation)) + m.BlockCommonBots = types.BoolValue(client.BoolPtrVal(route.BlockCommonBots)) + m.BlockAPIAbuse = types.BoolValue(client.BoolPtrVal(route.BlockAPIAbuse)) + m.DisableHeaderHardening = types.BoolValue(client.BoolPtrVal(route.DisableHeaderHardening)) + m.SpoofHostname = types.BoolValue(client.BoolPtrVal(route.SpoofHostname)) + m.AcceptInsecureHTTPSTarget = types.BoolValue(client.BoolPtrVal(route.AcceptInsecureHTTPSTarget)) + m.SkipURLClean = types.BoolValue(client.BoolPtrVal(route.SkipURLClean)) + m.StripPathPrefix = types.BoolValue(client.BoolPtrVal(route.StripPathPrefix)) + m.Corsorigin = types.StringValue(client.StringPtrVal(route.Corsorigin)) + m.Icon = types.StringValue(client.StringPtrVal(route.Icon)) + m.MaxBandwith = types.Int64Value(int64(client.IntPtrVal(route.MaxBandwith))) + m.OverwriteHostHeader = types.StringValue(client.StringPtrVal(route.OverwriteHostHeader)) + m.PathPrefix = types.StringValue(client.StringPtrVal(route.PathPrefix)) + m.ThrottlePerMinute = types.Int64Value(int64(client.IntPtrVal(route.ThrottlePerMinute))) + if route.Timeout != nil { + m.Timeout = types.Int64Value(int64(*route.Timeout)) + } else { + m.Timeout = types.Int64Value(0) + } + m.Tunnel = types.StringValue(client.StringPtrVal(route.Tunnel)) + m.TunneledHost = types.StringValue(client.StringPtrVal(route.TunneledHost)) + m.LBMode = types.StringValue(client.StringPtrVal(route.LBMode)) + m.LBStickyMode = types.BoolValue(client.BoolPtrVal(route.LBStickyMode)) + m.UseHost = types.BoolValue(client.BoolPtrVal(route.UseHost)) + m.UseH2C = types.BoolValue(client.BoolPtrVal(route.UseH2C)) + m.UsePathPrefix = types.BoolValue(client.BoolPtrVal(route.UsePathPrefix)) + + // ExtraHeaders + if route.ExtraHeaders != nil && len(*route.ExtraHeaders) > 0 { + mapVal, d := types.MapValueFrom(ctx, types.StringType, *route.ExtraHeaders) + diags.Append(d...) + m.ExtraHeaders = mapVal + } else { + m.ExtraHeaders = types.MapNull(types.StringType) + } + + // WhitelistInboundIPs + if route.WhitelistInboundIPs != nil && len(*route.WhitelistInboundIPs) > 0 { + listVal, d := types.ListValueFrom(ctx, types.StringType, *route.WhitelistInboundIPs) + diags.Append(d...) + m.WhitelistInboundIPs = listVal + } else { + m.WhitelistInboundIPs = types.ListNull(types.StringType) + } + + // AdditionalFilters + if route.AddionalFilters != nil && len(*route.AddionalFilters) > 0 { + filterModels := make([]routeAdditionalFilterModel, len(*route.AddionalFilters)) + for i, f := range *route.AddionalFilters { + filterModels[i] = routeAdditionalFilterModel{ + Name: types.StringValue(client.StringPtrVal(f.Name)), + Type: types.StringValue(client.StringPtrVal(f.Type)), + Value: types.StringValue(client.StringPtrVal(f.Value)), + } + } + listVal, d := types.ListValueFrom(ctx, types.ObjectType{AttrTypes: additionalFilterAttrTypes}, filterModels) + diags.Append(d...) + m.AdditionalFilters = listVal + } else { + m.AdditionalFilters = types.ListNull(types.ObjectType{AttrTypes: additionalFilterAttrTypes}) + } + + // SmartShield + if route.SmartShield != nil { + ssModel := routeSmartShieldModel{ + Enabled: types.BoolValue(client.BoolPtrVal(route.SmartShield.Enabled)), + MaxGlobalSimultaneous: types.Int64Value(int64(client.IntPtrVal(route.SmartShield.MaxGlobalSimultaneous))), + PerUserByteLimit: types.Int64Value(int64(client.IntPtrVal(route.SmartShield.PerUserByteLimit))), + PerUserRequestLimit: types.Int64Value(int64(client.IntPtrVal(route.SmartShield.PerUserRequestLimit))), + PerUserSimultaneous: types.Int64Value(int64(client.IntPtrVal(route.SmartShield.PerUserSimultaneous))), + PerUserTimeBudget: types.Float32Value(client.Float32PtrVal(route.SmartShield.PerUserTimeBudget)), + PolicyStrictness: types.Int64Value(int64(client.IntPtrVal(route.SmartShield.PolicyStrictness))), + PrivilegedGroups: types.Int64Value(int64(client.IntPtrVal(route.SmartShield.PrivilegedGroups))), + } + objVal, d := types.ObjectValueFrom(ctx, smartShieldAttrTypes, ssModel) + diags.Append(d...) + m.SmartShield = objVal + } else { + m.SmartShield = types.ObjectNull(smartShieldAttrTypes) + } +} diff --git a/terraform-provider-cosmos/internal/resources/snapraid.go b/terraform-provider-cosmos/internal/resources/snapraid.go new file mode 100644 index 00000000..4aa07c11 --- /dev/null +++ b/terraform-provider-cosmos/internal/resources/snapraid.go @@ -0,0 +1,355 @@ +package resources + +import ( + "context" + "encoding/json" + "fmt" + + cosmossdk "github.com/azukaar/cosmos-server/go-sdk" + "github.com/azukaar/terraform-provider-cosmos/internal/client" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var ( + _ resource.Resource = &snapRAIDResource{} + _ resource.ResourceWithImportState = &snapRAIDResource{} +) + +func NewSnapRAIDResource() resource.Resource { + return &snapRAIDResource{} +} + +type snapRAIDResource struct { + client *client.CosmosClient +} + +// ---------- Terraform model ---------- + +type snapRAIDModel struct { + Name types.String `tfsdk:"name"` + Enabled types.Bool `tfsdk:"enabled"` + Data types.Map `tfsdk:"data"` + Parity types.List `tfsdk:"parity"` + SyncCrontab types.String `tfsdk:"sync_crontab"` + ScrubCrontab types.String `tfsdk:"scrub_crontab"` + CheckOnFix types.Bool `tfsdk:"check_on_fix"` +} + +// ---------- resource.Resource ---------- + +func (r *snapRAIDResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_snapraid" +} + +func (r *snapRAIDResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Manages a SnapRAID configuration in Cosmos Server.", + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Description: "The unique name of the SnapRAID configuration. Used as the resource ID.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "enabled": schema.BoolAttribute{ + Description: "Whether the SnapRAID configuration is enabled.", + Optional: true, + }, + "data": schema.MapAttribute{ + Description: "Map of data disk names to their paths.", + Optional: true, + ElementType: types.StringType, + }, + "parity": schema.ListAttribute{ + Description: "List of parity file paths.", + Optional: true, + ElementType: types.StringType, + }, + "sync_crontab": schema.StringAttribute{ + Description: "Cron schedule for SnapRAID sync operations.", + Optional: true, + }, + "scrub_crontab": schema.StringAttribute{ + Description: "Cron schedule for SnapRAID scrub operations.", + Optional: true, + }, + "check_on_fix": schema.BoolAttribute{ + Description: "Whether to run a check before fixing.", + Optional: true, + }, + }, + } +} + +func (r *snapRAIDResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + c, ok := req.ProviderData.(*client.CosmosClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *client.CosmosClient, got: %T", req.ProviderData), + ) + return + } + r.client = c +} + +// ---------- CRUD ---------- + +func (r *snapRAIDResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan snapRAIDModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + payload := r.modelToSDK(ctx, &plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + httpResp, err := r.client.Raw.PostApiSnapraid(ctx, payload) + if err != nil { + resp.Diagnostics.AddError("Error creating SnapRAID config", err.Error()) + return + } + if err := client.CheckResponse(httpResp); err != nil { + resp.Diagnostics.AddError("Error creating SnapRAID config", err.Error()) + return + } + + // Read back to populate any server-set defaults. + if !r.readSnapRAID(ctx, plan.Name.ValueString(), &plan, &resp.Diagnostics) { + resp.Diagnostics.AddError("Error reading SnapRAID config", "SnapRAID config not found after creation") + return + } + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *snapRAIDResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state snapRAIDModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + found := r.readSnapRAID(ctx, state.Name.ValueString(), &state, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + if !found { + resp.State.RemoveResource(ctx) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *snapRAIDResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan snapRAIDModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + name := plan.Name.ValueString() + payload := r.modelToSDK(ctx, &plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + httpResp, err := r.client.Raw.PostApiSnapraidName(ctx, name, payload) + if err != nil { + resp.Diagnostics.AddError("Error updating SnapRAID config", err.Error()) + return + } + if err := client.CheckResponse(httpResp); err != nil { + resp.Diagnostics.AddError("Error updating SnapRAID config", err.Error()) + return + } + + // Read back to capture any server-side changes. + if !r.readSnapRAID(ctx, name, &plan, &resp.Diagnostics) { + resp.Diagnostics.AddError("Error reading SnapRAID config", "SnapRAID config not found after update") + return + } + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *snapRAIDResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state snapRAIDModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + name := state.Name.ValueString() + body := cosmossdk.UtilsSnapRAIDConfig{ + Name: client.StringPtr(name), + } + + httpResp, err := r.client.Raw.DeleteApiSnapraidName(ctx, name, body) + if err != nil { + resp.Diagnostics.AddError("Error deleting SnapRAID config", err.Error()) + return + } + if err := client.CheckResponse(httpResp); err != nil { + resp.Diagnostics.AddError("Error deleting SnapRAID config", err.Error()) + return + } +} + +func (r *snapRAIDResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("name"), req, resp) +} + +// ---------- helpers ---------- + +// readSnapRAID fetches the SnapRAID config by name and populates the model. +// It returns false if the config was not found (404), in which case no diagnostic +// error is added — the caller should remove the resource from state. +func (r *snapRAIDResource) readSnapRAID(ctx context.Context, name string, model *snapRAIDModel, diags interface { + AddError(string, string) + HasError() bool +}) bool { + body := cosmossdk.UtilsSnapRAIDConfig{ + Name: client.StringPtr(name), + } + + httpResp, err := r.client.Raw.GetApiSnapraid(ctx, body) + if err != nil { + diags.AddError("Error reading SnapRAID config", err.Error()) + return true + } + + rawData, err := client.ParseRawResponse(httpResp) + if err != nil { + if client.IsNotFound(err) { + return false + } + diags.AddError("Error reading SnapRAID config", err.Error()) + return true + } + if rawData == nil { + return false + } + + var config cosmossdk.UtilsSnapRAIDConfig + if err := json.Unmarshal(rawData, &config); err != nil { + diags.AddError("Error parsing SnapRAID config response", err.Error()) + return true + } + + r.sdkToModel(&config, model) + return true +} + +// modelToSDK converts the Terraform model to an SDK UtilsSnapRAIDConfig. +func (r *snapRAIDResource) modelToSDK(ctx context.Context, model *snapRAIDModel, diags *diag.Diagnostics) cosmossdk.UtilsSnapRAIDConfig { + config := cosmossdk.UtilsSnapRAIDConfig{ + Name: client.StringPtr(model.Name.ValueString()), + } + + if !model.Enabled.IsNull() && !model.Enabled.IsUnknown() { + config.Enabled = client.BoolPtr(model.Enabled.ValueBool()) + } + if !model.CheckOnFix.IsNull() && !model.CheckOnFix.IsUnknown() { + config.CheckOnFix = client.BoolPtr(model.CheckOnFix.ValueBool()) + } + if !model.SyncCrontab.IsNull() && !model.SyncCrontab.IsUnknown() { + config.SyncCrontab = client.StringPtr(model.SyncCrontab.ValueString()) + } + if !model.ScrubCrontab.IsNull() && !model.ScrubCrontab.IsUnknown() { + config.ScrubCrontab = client.StringPtr(model.ScrubCrontab.ValueString()) + } + + if !model.Data.IsNull() && !model.Data.IsUnknown() { + dataMap := make(map[string]string) + for k, v := range model.Data.Elements() { + if sv, ok := v.(types.String); ok { + dataMap[k] = sv.ValueString() + } + } + config.Data = &dataMap + } + + if !model.Parity.IsNull() && !model.Parity.IsUnknown() { + var parityList []string + for _, v := range model.Parity.Elements() { + if sv, ok := v.(types.String); ok { + parityList = append(parityList, sv.ValueString()) + } + } + config.Parity = &parityList + } + + return config +} + +// sdkToModel converts an SDK UtilsSnapRAIDConfig to the Terraform model. +func (r *snapRAIDResource) sdkToModel(config *cosmossdk.UtilsSnapRAIDConfig, model *snapRAIDModel) { + model.Name = types.StringValue(client.StringPtrVal(config.Name)) + + if config.Enabled != nil { + model.Enabled = types.BoolValue(*config.Enabled) + } else { + model.Enabled = types.BoolNull() + } + + if config.CheckOnFix != nil { + model.CheckOnFix = types.BoolValue(*config.CheckOnFix) + } else { + model.CheckOnFix = types.BoolNull() + } + + if config.SyncCrontab != nil { + model.SyncCrontab = types.StringValue(*config.SyncCrontab) + } else { + model.SyncCrontab = types.StringNull() + } + + if config.ScrubCrontab != nil { + model.ScrubCrontab = types.StringValue(*config.ScrubCrontab) + } else { + model.ScrubCrontab = types.StringNull() + } + + if config.Data != nil { + mapVal, diags := types.MapValueFrom(context.Background(), types.StringType, *config.Data) + if !diags.HasError() { + model.Data = mapVal + } else { + model.Data = types.MapNull(types.StringType) + } + } else { + model.Data = types.MapNull(types.StringType) + } + + if config.Parity != nil { + listVal, diags := types.ListValueFrom(context.Background(), types.StringType, *config.Parity) + if !diags.HasError() { + model.Parity = listVal + } else { + model.Parity = types.ListNull(types.StringType) + } + } else { + model.Parity = types.ListNull(types.StringType) + } +} diff --git a/terraform-provider-cosmos/internal/resources/storage_mount.go b/terraform-provider-cosmos/internal/resources/storage_mount.go new file mode 100644 index 00000000..a7e7e40a --- /dev/null +++ b/terraform-provider-cosmos/internal/resources/storage_mount.go @@ -0,0 +1,289 @@ +package resources + +import ( + "context" + "encoding/json" + "fmt" + + cosmossdk "github.com/azukaar/cosmos-server/go-sdk" + "github.com/azukaar/terraform-provider-cosmos/internal/client" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var ( + _ resource.Resource = &storageMountResource{} + _ resource.ResourceWithImportState = &storageMountResource{} +) + +func NewStorageMountResource() resource.Resource { + return &storageMountResource{} +} + +type storageMountResource struct { + client *client.CosmosClient +} + +// ---------- Terraform model ---------- + +type storageMountModel struct { + Path types.String `tfsdk:"path"` + MountPoint types.String `tfsdk:"mount_point"` + Permanent types.Bool `tfsdk:"permanent"` + NetDisk types.Bool `tfsdk:"net_disk"` + Chown types.String `tfsdk:"chown"` +} + +// ---------- resource.Resource ---------- + +func (r *storageMountResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_storage_mount" +} + +func (r *storageMountResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Manages a storage mount in Cosmos Server.", + Attributes: map[string]schema.Attribute{ + "path": schema.StringAttribute{ + Description: "The source path to mount.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "mount_point": schema.StringAttribute{ + Description: "The mount point destination. Used as the resource ID.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "permanent": schema.BoolAttribute{ + Description: "Whether the mount should persist across reboots.", + Optional: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.RequiresReplace(), + }, + }, + "net_disk": schema.BoolAttribute{ + Description: "Whether this is a network disk mount.", + Optional: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.RequiresReplace(), + }, + }, + "chown": schema.StringAttribute{ + Description: "Change ownership of the mount point (e.g. \"1000:1000\").", + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + }, + } +} + +func (r *storageMountResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + c, ok := req.ProviderData.(*client.CosmosClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *client.CosmosClient, got: %T", req.ProviderData), + ) + return + } + r.client = c +} + +// ---------- CRUD ---------- + +func (r *storageMountResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan storageMountModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + payload := r.modelToSDK(&plan) + + httpResp, err := r.client.Raw.PostApiMount(ctx, payload) + if err != nil { + resp.Diagnostics.AddError("Error creating storage mount", err.Error()) + return + } + if err := client.CheckResponse(httpResp); err != nil { + resp.Diagnostics.AddError("Error creating storage mount", err.Error()) + return + } + + // Read back to verify mount was created and populate state. + r.readMount(ctx, plan.MountPoint.ValueString(), &plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *storageMountResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state storageMountModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + mountPoint := state.MountPoint.ValueString() + found := r.readMount(ctx, mountPoint, &state, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + if !found { + // Mount no longer exists; remove from state. + resp.State.RemoveResource(ctx) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *storageMountResource) Update(_ context.Context, _ resource.UpdateRequest, resp *resource.UpdateResponse) { + // All attributes have RequiresReplace, so Update should never be called. + resp.Diagnostics.AddError( + "Update not supported", + "All storage mount attributes require replacement. This is an internal error.", + ) +} + +func (r *storageMountResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state storageMountModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + payload := cosmossdk.StorageMountRequest{ + Path: state.Path.ValueString(), + MountPoint: state.MountPoint.ValueString(), + } + + httpResp, err := r.client.Raw.PostApiUnmount(ctx, payload) + if err != nil { + resp.Diagnostics.AddError("Error deleting storage mount", err.Error()) + return + } + if err := client.CheckResponse(httpResp); err != nil { + resp.Diagnostics.AddError("Error deleting storage mount", err.Error()) + return + } +} + +func (r *storageMountResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("mount_point"), req, resp) +} + +// ---------- helpers ---------- + +// readMount fetches all mounts and finds the one matching the given mount_point. +// Returns true if the mount was found, false otherwise. +func (r *storageMountResource) readMount(ctx context.Context, mountPoint string, model *storageMountModel, diags interface { + AddError(string, string) + HasError() bool +}) bool { + httpResp, err := r.client.Raw.GetApiMounts(ctx) + if err != nil { + diags.AddError("Error reading storage mounts", err.Error()) + return false + } + + rawData, err := client.ParseRawResponse(httpResp) + if err != nil { + if client.IsNotFound(err) { + return false + } + diags.AddError("Error reading storage mounts", err.Error()) + return false + } + if rawData == nil { + return false + } + + // The response may be an array of mount objects or a map. Try array first. + var mounts []map[string]interface{} + if err := json.Unmarshal(rawData, &mounts); err != nil { + // Try as a map keyed by mount point. + var mountMap map[string]map[string]interface{} + if err2 := json.Unmarshal(rawData, &mountMap); err2 != nil { + diags.AddError("Error parsing storage mounts response", fmt.Sprintf("array: %s, map: %s", err.Error(), err2.Error())) + return false + } + // Convert map to slice for uniform handling. + for mp, m := range mountMap { + m["mountPoint"] = mp + mounts = append(mounts, m) + } + } + + for _, m := range mounts { + mp, _ := m["mountPoint"].(string) + if mp == "" { + mp, _ = m["mount_point"].(string) + } + if mp != mountPoint { + continue + } + + // Found the mount. Populate path if available. + if p, ok := m["path"].(string); ok { + model.Path = types.StringValue(p) + } + model.MountPoint = types.StringValue(mountPoint) + + if perm, ok := m["permanent"].(bool); ok { + model.Permanent = types.BoolValue(perm) + } else if !model.Permanent.IsNull() { + // Preserve existing state value if not returned. + } + + if nd, ok := m["netDisk"].(bool); ok { + model.NetDisk = types.BoolValue(nd) + } else if ndAlt, ok := m["net_disk"].(bool); ok { + model.NetDisk = types.BoolValue(ndAlt) + } + + if ch, ok := m["chown"].(string); ok && ch != "" { + model.Chown = types.StringValue(ch) + } + + return true + } + + return false +} + +// modelToSDK converts the Terraform model to an SDK StorageMountRequest. +func (r *storageMountResource) modelToSDK(model *storageMountModel) cosmossdk.StorageMountRequest { + req := cosmossdk.StorageMountRequest{ + Path: model.Path.ValueString(), + MountPoint: model.MountPoint.ValueString(), + } + + if !model.Permanent.IsNull() && !model.Permanent.IsUnknown() { + req.Permanent = client.BoolPtr(model.Permanent.ValueBool()) + } + if !model.NetDisk.IsNull() && !model.NetDisk.IsUnknown() { + req.NetDisk = client.BoolPtr(model.NetDisk.ValueBool()) + } + if !model.Chown.IsNull() && !model.Chown.IsUnknown() { + req.Chown = client.StringPtr(model.Chown.ValueString()) + } + + return req +} diff --git a/terraform-provider-cosmos/internal/resources/user.go b/terraform-provider-cosmos/internal/resources/user.go new file mode 100644 index 00000000..9e16c8c7 --- /dev/null +++ b/terraform-provider-cosmos/internal/resources/user.go @@ -0,0 +1,250 @@ +package resources + +import ( + "context" + "fmt" + + cosmossdk "github.com/azukaar/cosmos-server/go-sdk" + "github.com/azukaar/terraform-provider-cosmos/internal/client" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var ( + _ resource.Resource = &userResource{} + _ resource.ResourceWithImportState = &userResource{} +) + +func NewUserResource() resource.Resource { + return &userResource{} +} + +type userResource struct { + client *client.CosmosClient +} + +// ---------- Terraform model ---------- + +type userModel struct { + Nickname types.String `tfsdk:"nickname"` + Email types.String `tfsdk:"email"` + Role types.Int64 `tfsdk:"role"` + Password types.String `tfsdk:"password"` + CreatedAt types.String `tfsdk:"created_at"` + LastLogin types.String `tfsdk:"last_login"` +} + +// ---------- resource.Resource ---------- + +func (r *userResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_user" +} + +func (r *userResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Manages a Cosmos Server user account.", + Attributes: map[string]schema.Attribute{ + "nickname": schema.StringAttribute{ + Description: "Unique nickname of the user. Used as the resource ID.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "email": schema.StringAttribute{ + Description: "Email address of the user.", + Optional: true, + Computed: true, + }, + "role": schema.Int64Attribute{ + Description: "Role of the user (e.g. 1 = admin, 2 = user).", + Optional: true, + Computed: true, + }, + "password": schema.StringAttribute{ + Description: "Password for the user. Only used during creation; never read back from the API.", + Optional: true, + Sensitive: true, + }, + "created_at": schema.StringAttribute{ + Description: "Timestamp when the user was created.", + Computed: true, + }, + "last_login": schema.StringAttribute{ + Description: "Timestamp of the user's last login.", + Computed: true, + }, + }, + } +} + +func (r *userResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + c, ok := req.ProviderData.(*client.CosmosClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *client.CosmosClient, got %T", req.ProviderData), + ) + return + } + r.client = c +} + +// ---------- CRUD ---------- + +func (r *userResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan userModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + createReq := cosmossdk.UserCreateRequestJSON{ + Nickname: plan.Nickname.ValueString(), + } + if !plan.Email.IsNull() && !plan.Email.IsUnknown() { + createReq.Email = client.StringPtr(plan.Email.ValueString()) + } + if !plan.Role.IsNull() && !plan.Role.IsUnknown() { + createReq.Role = client.IntPtr(int(plan.Role.ValueInt64())) + } + + httpResp, err := r.client.Raw.PostApiUsers(ctx, createReq) + if err != nil { + resp.Diagnostics.AddError("Error creating user", err.Error()) + return + } + if err := client.CheckResponse(httpResp); err != nil { + resp.Diagnostics.AddError("Error creating user", err.Error()) + return + } + + // Read back the created user to populate computed fields. + if !readUserIntoModel(ctx, r.client, plan.Nickname.ValueString(), &plan, &resp.Diagnostics) { + resp.Diagnostics.AddError("Error reading user", "User not found after creation") + return + } + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *userResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state userModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + found := readUserIntoModel(ctx, r.client, state.Nickname.ValueString(), &state, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + if !found { + resp.State.RemoveResource(ctx) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *userResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan userModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + editReq := cosmossdk.UserEditRequestJSON{} + if !plan.Email.IsNull() && !plan.Email.IsUnknown() { + editReq.Email = client.StringPtr(plan.Email.ValueString()) + } + if !plan.Role.IsNull() && !plan.Role.IsUnknown() { + editReq.Role = client.IntPtr(int(plan.Role.ValueInt64())) + } + + httpResp, err := r.client.Raw.PatchApiUsersNickname(ctx, plan.Nickname.ValueString(), editReq) + if err != nil { + resp.Diagnostics.AddError("Error updating user", err.Error()) + return + } + if err := client.CheckResponse(httpResp); err != nil { + resp.Diagnostics.AddError("Error updating user", err.Error()) + return + } + + // Read back to capture any server-side changes. + if !readUserIntoModel(ctx, r.client, plan.Nickname.ValueString(), &plan, &resp.Diagnostics) { + resp.Diagnostics.AddError("Error reading user", "User not found after update") + return + } + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *userResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state userModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + httpResp, err := r.client.Raw.DeleteApiUsersNickname(ctx, state.Nickname.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Error deleting user", err.Error()) + return + } + if err := client.CheckResponse(httpResp); err != nil { + resp.Diagnostics.AddError("Error deleting user", err.Error()) + return + } +} + +func (r *userResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("nickname"), req, resp) +} + +// ---------- helpers ---------- + +// readUserIntoModel fetches a user by nickname from the API and populates the model. +// It returns false if the user was not found (404), in which case no diagnostic +// error is added — the caller should remove the resource from state. +func readUserIntoModel(ctx context.Context, c *client.CosmosClient, nickname string, model *userModel, diags *diag.Diagnostics) bool { + httpResp, err := c.Raw.GetApiUsersNickname(ctx, nickname) + if err != nil { + diags.AddError("Error reading user", err.Error()) + return true + } + user, err := client.ParseResponse[cosmossdk.UtilsUser](httpResp) + if err != nil { + if client.IsNotFound(err) { + return false + } + diags.AddError("Error reading user", err.Error()) + return true + } + if user == nil { + return false + } + + model.Nickname = types.StringValue(user.Nickname) + model.Email = types.StringValue(client.StringPtrVal(user.Email)) + model.Role = types.Int64Value(int64(user.Role)) + model.CreatedAt = types.StringValue(client.StringPtrVal(user.CreatedAt)) + model.LastLogin = types.StringValue(client.StringPtrVal(user.LastLogin)) + // Password is write-only; never populated from API responses. + return true +} diff --git a/terraform-provider-cosmos/main.go b/terraform-provider-cosmos/main.go new file mode 100644 index 00000000..5d1ed55c --- /dev/null +++ b/terraform-provider-cosmos/main.go @@ -0,0 +1,28 @@ +package main + +import ( + "context" + "flag" + "log" + + "github.com/azukaar/terraform-provider-cosmos/internal/provider" + "github.com/hashicorp/terraform-plugin-framework/providerserver" +) + +var version = "dev" + +func main() { + var debug bool + flag.BoolVar(&debug, "debug", false, "set to true to run the provider with support for debuggers like delve") + flag.Parse() + + opts := providerserver.ServeOpts{ + Address: "cosmos-cloud.io/azukaar/cosmos", + Debug: debug, + } + + err := providerserver.Serve(context.Background(), provider.New(version), opts) + if err != nil { + log.Fatal(err.Error()) + } +}