Skip to content

Commit 9ceb888

Browse files
authored
Implementation of project resource (#15)
1 parent 97e0dfa commit 9ceb888

File tree

9 files changed

+503
-4
lines changed

9 files changed

+503
-4
lines changed

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,10 @@ go.work.sum
2424
# env file
2525
.env
2626
.aider*
27+
28+
# IDE specific files
29+
.idea/
30+
31+
#Example
32+
*.tfstate
33+
*.tfvars
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# ZenML Project Example
2+
3+
This example demonstrates how to create a ZenML project using the Terraform provider.
4+
5+
## Authentication Options
6+
7+
You have two options for authentication:
8+
9+
### Option 1: API Key (Recommended)
10+
Use a service account API key. This is recommended for long-term automation.
11+
12+
```bash
13+
# Set environment variables
14+
export ZENML_SERVER_URL="https://your-zenml-server.com"
15+
export ZENML_API_KEY="your-api-key"
16+
```
17+
18+
Or use the tfvars file:
19+
```hcl
20+
zenml_server_url = "https://your-zenml-server.com"
21+
zenml_api_key = "your-api-key"
22+
```
23+
24+
### Option 2: API Token
25+
Use a JWT token directly. Note that tokens expire and are not recommended for automation.
26+
27+
```bash
28+
# Set environment variables
29+
export ZENML_SERVER_URL="https://your-zenml-server.com"
30+
export ZENML_API_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
31+
```
32+
33+
Or use the tfvars file:
34+
```hcl
35+
zenml_server_url = "https://your-zenml-server.com"
36+
zenml_api_token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
37+
```
38+
39+
## Usage
40+
41+
1. Copy the example tfvars file:
42+
```bash
43+
cp terraform.tfvars.example terraform.tfvars
44+
```
45+
46+
2. Edit `terraform.tfvars` with your actual values:
47+
- Update `zenml_server_url` with your ZenML server URL
48+
- Add either `zenml_api_key` OR `zenml_api_token` (not both)
49+
- Customize the project settings
50+
51+
3. Run Terraform:
52+
```bash
53+
terraform init
54+
terraform plan
55+
terraform apply
56+
```
57+
58+
## Troubleshooting
59+
60+
- **401 Authentication Error**:
61+
- If using JWT token, make sure it's not expired and you're using `zenml_api_token`
62+
- If using API key, make sure it's valid and you're using `zenml_api_key`
63+
- Don't use both `api_key` and `api_token` at the same time
64+
65+
- **JWT Token Format Error**:
66+
- Make sure JWT tokens start with `eyJ` and are complete
67+
- Use `zenml_api_token` for JWT tokens, not `zenml_api_key`
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# ZenML Server Configuration
2+
zenml_server_url = "https://your-zenml-server.com"
3+
4+
# Authentication - Use EITHER api_key OR api_token, not both
5+
# Option 1: Use API Key (recommended for service accounts)
6+
zenml_api_key = "your-api-key-here"
7+
8+
# Option 2: Use API Token (JWT token - starts with eyJ)
9+
# zenml_api_token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
10+
11+
# Project Configuration
12+
project_name = "my-terraform-project"
13+
project_display_name = "My Terraform Project"
14+
project_description = "A project managed by Terraform"
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
terraform {
2+
required_providers {
3+
zenml = {
4+
source = "zenml/zenml"
5+
}
6+
}
7+
}
8+
9+
provider "zenml" {
10+
server_url = var.zenml_server_url
11+
api_key = var.zenml_api_key
12+
api_token = var.zenml_api_token
13+
}
14+
15+
resource "zenml_project" "test_project" {
16+
name = var.project_name
17+
display_name = var.project_display_name
18+
description = var.project_description
19+
}
20+
21+
output "project_id" {
22+
value = zenml_project.test_project.id
23+
}
24+
25+
output "project_created" {
26+
value = zenml_project.test_project.created
27+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
variable "zenml_server_url" {
2+
description = "The URL of your ZenML server"
3+
type = string
4+
default = "https://your-zenml-server.com"
5+
}
6+
7+
variable "zenml_api_key" {
8+
description = "Your ZenML API key (for service accounts)"
9+
type = string
10+
sensitive = true
11+
default = null
12+
}
13+
14+
variable "zenml_api_token" {
15+
description = "Your ZenML API token (JWT token)"
16+
type = string
17+
sensitive = true
18+
default = null
19+
}
20+
21+
variable "project_name" {
22+
description = "The name of the project to create"
23+
type = string
24+
default = "my-terraform-project"
25+
}
26+
27+
variable "project_display_name" {
28+
description = "The display name of the project"
29+
type = string
30+
default = "My Terraform Project"
31+
}
32+
33+
variable "project_description" {
34+
description = "The description of the project"
35+
type = string
36+
default = "A project managed by Terraform"
37+
}

internal/provider/client.go

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,3 +553,136 @@ func (c *Client) GetCurrentUser(ctx context.Context) (*UserResponse, error) {
553553
}
554554
return &result, nil
555555
}
556+
557+
// Project operations
558+
func (c *Client) CreateProject(ctx context.Context, project ProjectRequest) (*ProjectResponse, error) {
559+
endpoint := "/api/v1/projects"
560+
resp, _, err := c.doRequest(ctx, "POST", endpoint, project)
561+
if err != nil {
562+
return nil, err
563+
}
564+
defer resp.Body.Close()
565+
566+
var result ProjectResponse
567+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
568+
return nil, fmt.Errorf("error decoding project response: %v", err)
569+
}
570+
return &result, nil
571+
}
572+
573+
func (c *Client) GetProject(ctx context.Context, nameOrID string) (*ProjectResponse, error) {
574+
endpoint := fmt.Sprintf("/api/v1/projects/%s", nameOrID)
575+
resp, status, err := c.doRequest(ctx, "GET", endpoint, nil)
576+
if err != nil {
577+
if status == 404 {
578+
return nil, fmt.Errorf("404")
579+
}
580+
return nil, err
581+
}
582+
defer resp.Body.Close()
583+
584+
var result ProjectResponse
585+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
586+
return nil, fmt.Errorf("error decoding project response: %v", err)
587+
}
588+
return &result, nil
589+
}
590+
591+
func (c *Client) UpdateProject(ctx context.Context, nameOrID string, project ProjectUpdate) (*ProjectResponse, error) {
592+
endpoint := fmt.Sprintf("/api/v1/projects/%s", nameOrID)
593+
resp, _, err := c.doRequest(ctx, "PUT", endpoint, project)
594+
if err != nil {
595+
return nil, err
596+
}
597+
defer resp.Body.Close()
598+
599+
var result ProjectResponse
600+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
601+
return nil, fmt.Errorf("error decoding project response: %v", err)
602+
}
603+
return &result, nil
604+
}
605+
606+
func (c *Client) DeleteProject(ctx context.Context, nameOrID string) error {
607+
endpoint := fmt.Sprintf("/api/v1/projects/%s", nameOrID)
608+
resp, status, err := c.doRequest(ctx, "DELETE", endpoint, nil)
609+
if err != nil {
610+
if status == 404 {
611+
return fmt.Errorf("404")
612+
}
613+
return err
614+
}
615+
defer resp.Body.Close()
616+
return nil
617+
}
618+
619+
func (c *Client) ListProjects(ctx context.Context, params ProjectFilterParams) (*PageProjectResponse, error) {
620+
endpoint := "/api/v1/projects"
621+
622+
// Build query parameters
623+
query := make(url.Values)
624+
if params.Hydrate != nil {
625+
query.Set("hydrate", fmt.Sprintf("%t", *params.Hydrate))
626+
}
627+
if params.SortBy != nil {
628+
query.Set("sort_by", *params.SortBy)
629+
}
630+
if params.LogicalOperator != nil {
631+
query.Set("logical_operator", string(*params.LogicalOperator))
632+
}
633+
if params.Page != nil {
634+
query.Set("page", fmt.Sprintf("%d", *params.Page))
635+
}
636+
if params.Size != nil {
637+
query.Set("size", fmt.Sprintf("%d", *params.Size))
638+
}
639+
if params.ID != nil {
640+
query.Set("id", *params.ID)
641+
}
642+
if params.Created != nil {
643+
query.Set("created", *params.Created)
644+
}
645+
if params.Updated != nil {
646+
query.Set("updated", *params.Updated)
647+
}
648+
if params.Name != nil {
649+
query.Set("name", *params.Name)
650+
}
651+
if params.DisplayName != nil {
652+
query.Set("display_name", *params.DisplayName)
653+
}
654+
655+
if len(query) > 0 {
656+
endpoint += "?" + query.Encode()
657+
}
658+
659+
resp, _, err := c.doRequest(ctx, "GET", endpoint, nil)
660+
if err != nil {
661+
return nil, err
662+
}
663+
defer resp.Body.Close()
664+
665+
var result PageProjectResponse
666+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
667+
return nil, fmt.Errorf("error decoding projects response: %v", err)
668+
}
669+
return &result, nil
670+
}
671+
672+
func (c *Client) GetProjectStatistics(ctx context.Context, nameOrID string) (*ProjectStatistics, error) {
673+
endpoint := fmt.Sprintf("/api/v1/projects/%s/statistics", nameOrID)
674+
resp, status, err := c.doRequest(ctx, "GET", endpoint, nil)
675+
if err != nil {
676+
if status == 404 {
677+
return nil, fmt.Errorf("404")
678+
}
679+
return nil, err
680+
}
681+
defer resp.Body.Close()
682+
683+
var result ProjectStatistics
684+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
685+
return nil, fmt.Errorf("error decoding project statistics response: %v", err)
686+
}
687+
return &result, nil
688+
}

internal/provider/models.go

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -244,12 +244,71 @@ type UserMetadata struct {
244244
OverviewTourDone bool `json:"overview_tour_done"`
245245
}
246246

247-
// ProjectResponse represents a project response from the API
248-
type ProjectResponse struct {
249-
ID string `json:"id"`
247+
// ProjectRequest represents a request to create a new project
248+
type ProjectRequest struct {
250249
Name string `json:"name"`
251-
DisplayName string `json:"display_name"`
250+
DisplayName string `json:"display_name,omitempty"`
252251
Description string `json:"description,omitempty"`
252+
}
253+
254+
// ProjectResponse represents a project response from the API
255+
type ProjectResponse struct {
256+
ID string `json:"id"`
257+
Name string `json:"name"`
258+
Body *ProjectResponseBody `json:"body,omitempty"`
259+
Metadata *ProjectResponseMetadata `json:"metadata,omitempty"`
260+
}
261+
262+
type ProjectResponseBody struct {
253263
Created string `json:"created"`
254264
Updated string `json:"updated"`
265+
DisplayName string `json:"display_name"`
266+
}
267+
268+
type ProjectResponseMetadata struct {
269+
Description string `json:"description,omitempty"`
270+
}
271+
272+
// ProjectUpdate represents an update to an existing project
273+
type ProjectUpdate struct {
274+
Name *string `json:"name,omitempty"`
275+
DisplayName *string `json:"display_name,omitempty"`
276+
Description *string `json:"description,omitempty"`
277+
}
278+
279+
// PageProjectResponse represents a paginated response of projects
280+
type PageProjectResponse struct {
281+
Index int `json:"index"`
282+
MaxSize int `json:"max_size"`
283+
TotalPages int `json:"total_pages"`
284+
Total int `json:"total"`
285+
Items []ProjectResponse `json:"items"`
286+
}
287+
288+
// ProjectStatistics represents project statistics
289+
type ProjectStatistics struct {
290+
Pipelines int `json:"pipelines"`
291+
Runs int `json:"runs"`
292+
}
293+
294+
// LogicalOperator represents logical operators for filtering
295+
type LogicalOperator string
296+
297+
const (
298+
LogicalOperatorAnd LogicalOperator = "and"
299+
LogicalOperatorOr LogicalOperator = "or"
300+
)
301+
302+
// ProjectFilterParams represents parameters for filtering projects
303+
type ProjectFilterParams struct {
304+
Hydrate *bool `json:"hydrate,omitempty"`
305+
SortBy *string `json:"sort_by,omitempty"`
306+
LogicalOperator *LogicalOperator `json:"logical_operator,omitempty"`
307+
Page *int `json:"page,omitempty"`
308+
Size *int `json:"size,omitempty"`
309+
ID *string `json:"id,omitempty"`
310+
Created *string `json:"created,omitempty"`
311+
Updated *string `json:"updated,omitempty"`
312+
Name *string `json:"name,omitempty"`
313+
DisplayName *string `json:"display_name,omitempty"`
255314
}

internal/provider/provider.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ func Provider() *schema.Provider {
4040
"zenml_stack": resourceStack(),
4141
"zenml_stack_component": resourceStackComponent(),
4242
"zenml_service_connector": resourceServiceConnector(),
43+
"zenml_project": resourceProject(),
4344
},
4445
DataSourcesMap: map[string]*schema.Resource{
4546
"zenml_server": dataSourceServer(),

0 commit comments

Comments
 (0)