[release] v0.22.0-unstable05

This commit is contained in:
Yann Stepienik
2026-03-17 18:25:47 +00:00
parent 2816fed4cc
commit 35b161cc2a
39 changed files with 6879 additions and 295 deletions
+14
View File
@@ -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:
+8 -1
View File
@@ -30,4 +30,11 @@ test-backup
/backups
ca.pem
sdk/dist
sdk/node_modules
sdk/node_modules
.terraform
terraform.tfstate
terraform.tfstate.*
.terraform.lock.hcl
*.log
main.tf
+101 -1
View File
@@ -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": {
+85 -1
View File
@@ -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": {
+54
View File
@@ -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:
+2 -1
View File
@@ -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
+524 -287
View File
@@ -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
+77 -1
View File
@@ -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 {
+2 -1
View File
@@ -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)
}
+31 -1
View File
@@ -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",
+17
View File
@@ -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,
+1 -1
View File
@@ -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
+2
View File
@@ -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)
+8
View File
@@ -0,0 +1,8 @@
terraform-provider-cosmos
*.exe
dist/
.terraform/
*.tfstate
*.tfstate.backup
*.tfplan
crash.log
+40
View File
@@ -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
+19
View File
@@ -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
+139
View File
@@ -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/<VERSION>/<OS>_<ARCH>/
```
## 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`
+49
View File
@@ -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
+119
View File
@@ -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=
@@ -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
}
@@ -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 ""
}
@@ -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
}
@@ -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,
}
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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
}
@@ -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"`
}
@@ -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)
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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
}
@@ -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
}
+28
View File
@@ -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())
}
}