Skip to content

Commit af11a0c

Browse files
Merge pull request #4 from ctrlplanedev/tfe-sync
fix: Tfe sync command
2 parents 4e10ea9 + 4450cb2 commit af11a0c

File tree

9 files changed

+469
-2
lines changed

9 files changed

+469
-2
lines changed

.github/workflows/release.yaml

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,74 @@ jobs:
3030
args: release --clean
3131
env:
3232
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
33+
34+
docker:
35+
runs-on: ubuntu-latest
36+
37+
if: github.repository_owner == 'ctrlplanedev'
38+
39+
permissions:
40+
contents: read
41+
id-token: write
42+
43+
strategy:
44+
matrix:
45+
platform: [linux/amd64]
46+
47+
steps:
48+
- name: Checkout
49+
uses: actions/checkout@v4
50+
51+
- name: Set up QEMU
52+
uses: docker/setup-qemu-action@v3
53+
54+
- name: Set up Docker Buildx
55+
uses: docker/setup-buildx-action@v3
56+
57+
- name: Check if Docker Hub secrets are available
58+
run: |
59+
if [ -z "${{ secrets.DOCKERHUB_USERNAME }}" ] || [ -z "${{ secrets.DOCKERHUB_TOKEN }}" ]; then
60+
echo "DOCKERHUB_LOGIN=false" >> $GITHUB_ENV
61+
else
62+
echo "DOCKERHUB_LOGIN=true" >> $GITHUB_ENV
63+
fi
64+
65+
- name: Login to Docker Hub
66+
uses: docker/login-action@v3
67+
if: env.DOCKERHUB_LOGIN == 'true'
68+
with:
69+
username: ${{ secrets.DOCKERHUB_USERNAME }}
70+
password: ${{ secrets.DOCKERHUB_TOKEN }}
71+
72+
- name: Extract metadata (tags, labels) for Docker
73+
id: meta
74+
uses: docker/metadata-action@v5
75+
with:
76+
images: ctrlplane/cli
77+
tags: |
78+
type=raw,value=latest
79+
type=ref,event=tag
80+
type=semver,pattern={{version}}
81+
type=semver,pattern={{major}}.{{minor}}
82+
83+
- name: Build Only
84+
uses: docker/build-push-action@v6
85+
if: env.DOCKERHUB_LOGIN != 'true'
86+
with:
87+
push: false
88+
file: docker/Dockerfile
89+
platforms: ${{ matrix.platform }}
90+
tags: ${{ steps.meta.outputs.tags }}
91+
build-args: |
92+
CLI_VERSION=${{ steps.meta.outputs.version }}
93+
94+
- name: Build and Push
95+
uses: docker/build-push-action@v6
96+
if: env.DOCKERHUB_LOGIN == 'true'
97+
with:
98+
push: true
99+
file: docker/Dockerfile
100+
platforms: ${{ matrix.platform }}
101+
tags: ${{ steps.meta.outputs.tags }}
102+
build-args: |
103+
CLI_VERSION=${{ steps.meta.outputs.version }}

cmd/ctrlc/root/root.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"github.com/ctrlplanedev/cli/cmd/ctrlc/root/agent"
88
"github.com/ctrlplanedev/cli/cmd/ctrlc/root/api"
99
"github.com/ctrlplanedev/cli/cmd/ctrlc/root/config"
10+
"github.com/ctrlplanedev/cli/cmd/ctrlc/root/sync"
1011
"github.com/spf13/cobra"
1112
)
1213

@@ -44,6 +45,7 @@ func NewRootCmd() *cobra.Command {
4445
cmd.AddCommand(agent.NewAgentCmd())
4546
cmd.AddCommand(api.NewAPICmd())
4647
cmd.AddCommand(config.NewConfigCmd())
48+
cmd.AddCommand(sync.NewSyncCmd())
4749

4850
return cmd
4951
}

cmd/ctrlc/root/sync/sync.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,22 @@ package sync
22

33
import (
44
"github.com/MakeNowJust/heredoc/v2"
5+
"github.com/ctrlplanedev/cli/cmd/ctrlc/root/sync/terraform"
56
"github.com/spf13/cobra"
67
)
78

8-
func NewRootCmd() *cobra.Command {
9+
func NewSyncCmd() *cobra.Command {
910
cmd := &cobra.Command{
1011
Use: "sync <integration>",
1112
Short: "Sync resources into Ctrlplane",
1213
Example: heredoc.Doc(`
1314
$ ctrlc sync aws-eks
1415
$ ctrlc sync google-gke
16+
$ ctrlc sync terraform
1517
`),
1618
}
1719

20+
cmd.AddCommand(terraform.NewSyncTerraformCmd())
21+
1822
return cmd
1923
}
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
package terraform
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"net/url"
8+
"time"
9+
10+
"strconv"
11+
12+
"github.com/avast/retry-go"
13+
"github.com/charmbracelet/log"
14+
"github.com/hashicorp/go-tfe"
15+
)
16+
17+
const (
18+
Kind = "Workspace"
19+
Version = "terraform/v1"
20+
)
21+
22+
type WorkspaceResource struct {
23+
Config map[string]interface{}
24+
Identifier string
25+
Kind string
26+
Metadata map[string]string
27+
Name string
28+
Version string
29+
}
30+
31+
func getLinksMetadata(workspace *tfe.Workspace, baseURL url.URL) *string {
32+
if workspace.Organization == nil {
33+
return nil
34+
}
35+
links := map[string]string{
36+
"Terraform Workspace": fmt.Sprintf("%s/app/%s/workspaces/%s", baseURL.String(), workspace.Organization.Name, workspace.Name),
37+
}
38+
linksJSON, err := json.Marshal(links)
39+
if err != nil {
40+
log.Error("Failed to marshal links", "error", err)
41+
return nil
42+
}
43+
linksString := string(linksJSON)
44+
return &linksString
45+
}
46+
47+
func getWorkspaceVariables(workspace *tfe.Workspace) map[string]string {
48+
variables := make(map[string]string)
49+
for _, variable := range workspace.Variables {
50+
if variable != nil && variable.Category == tfe.CategoryTerraform && !variable.Sensitive {
51+
key := fmt.Sprintf("terraform-cloud/variables/%s", variable.Key)
52+
variables[key] = variable.Value
53+
}
54+
}
55+
return variables
56+
}
57+
58+
func getWorkspaceVcsRepo(workspace *tfe.Workspace) map[string]string {
59+
vcsRepo := make(map[string]string)
60+
if workspace.VCSRepo != nil {
61+
vcsRepo["terraform-cloud/vcs-repo/identifier"] = workspace.VCSRepo.Identifier
62+
vcsRepo["terraform-cloud/vcs-repo/branch"] = workspace.VCSRepo.Branch
63+
vcsRepo["terraform-cloud/vcs-repo/repository-http-url"] = workspace.VCSRepo.RepositoryHTTPURL
64+
}
65+
return vcsRepo
66+
}
67+
68+
func getWorkspaceTags(workspace *tfe.Workspace) map[string]string {
69+
tags := make(map[string]string)
70+
for _, tag := range workspace.Tags {
71+
if tag != nil {
72+
key := fmt.Sprintf("terraform-cloud/tag/%s", tag.Name)
73+
tags[key] = "true"
74+
}
75+
}
76+
return tags
77+
}
78+
79+
func convertWorkspaceToResource(workspace *tfe.Workspace, baseURL url.URL) (WorkspaceResource, error) {
80+
if workspace == nil {
81+
return WorkspaceResource{}, fmt.Errorf("workspace is nil")
82+
}
83+
version := Version
84+
kind := Kind
85+
name := workspace.Name
86+
identifier := workspace.ID
87+
config := map[string]interface{}{
88+
"workspaceId": workspace.ID,
89+
}
90+
metadata := map[string]string{
91+
"ctrlplane/external-id": workspace.ID,
92+
"terraform-cloud/workspace-name": workspace.Name,
93+
"terraform-cloud/workspace-auto-apply": strconv.FormatBool(workspace.AutoApply),
94+
"terraform/version": workspace.TerraformVersion,
95+
}
96+
97+
if workspace.Organization != nil {
98+
metadata["terraform-cloud/organization"] = workspace.Organization.Name
99+
}
100+
101+
linksMetadata := getLinksMetadata(workspace, baseURL)
102+
if linksMetadata != nil {
103+
metadata["ctrlplane/links"] = *linksMetadata
104+
}
105+
106+
moreValues := []map[string]string{
107+
getWorkspaceVariables(workspace),
108+
getWorkspaceTags(workspace),
109+
getWorkspaceVcsRepo(workspace),
110+
}
111+
112+
for _, moreValue := range moreValues {
113+
for key, value := range moreValue {
114+
metadata[key] = value
115+
}
116+
}
117+
118+
return WorkspaceResource{
119+
Version: version,
120+
Kind: kind,
121+
Name: name,
122+
Identifier: identifier,
123+
Config: config,
124+
Metadata: metadata,
125+
}, nil
126+
}
127+
128+
func listWorkspacesWithRetry(ctx context.Context, client *tfe.Client, organization string, pageNum, pageSize int) (*tfe.WorkspaceList, error) {
129+
var workspaces *tfe.WorkspaceList
130+
err := retry.Do(
131+
func() error {
132+
var err error
133+
workspaces, err = client.Workspaces.List(ctx, organization, &tfe.WorkspaceListOptions{
134+
ListOptions: tfe.ListOptions{
135+
PageNumber: pageNum,
136+
PageSize: pageSize,
137+
},
138+
})
139+
return err
140+
},
141+
retry.Attempts(5),
142+
retry.Delay(time.Second),
143+
retry.MaxDelay(5*time.Second),
144+
)
145+
return workspaces, err
146+
}
147+
148+
func listAllWorkspaces(ctx context.Context, client *tfe.Client, organization string) ([]*tfe.Workspace, error) {
149+
var allWorkspaces []*tfe.Workspace
150+
pageNum := 1
151+
pageSize := 100
152+
153+
for {
154+
workspaces, err := listWorkspacesWithRetry(ctx, client, organization, pageNum, pageSize)
155+
if err != nil {
156+
return nil, fmt.Errorf("failed to list workspaces: %w", err)
157+
}
158+
159+
allWorkspaces = append(allWorkspaces, workspaces.Items...)
160+
if len(workspaces.Items) < pageSize {
161+
break
162+
}
163+
pageNum++
164+
}
165+
166+
return allWorkspaces, nil
167+
}
168+
169+
func getWorkspacesInOrg(ctx context.Context, client *tfe.Client, organization string) ([]WorkspaceResource, error) {
170+
workspaces, err := listAllWorkspaces(ctx, client, organization)
171+
if err != nil {
172+
return nil, err
173+
}
174+
175+
workspaceResources := []WorkspaceResource{}
176+
for _, workspace := range workspaces {
177+
workspaceResource, err := convertWorkspaceToResource(workspace, client.BaseURL())
178+
if err != nil {
179+
log.Error("Failed to convert workspace to resource", "error", err, "workspace", workspace.Name)
180+
continue
181+
}
182+
workspaceResources = append(workspaceResources, workspaceResource)
183+
}
184+
return workspaceResources, nil
185+
}

0 commit comments

Comments
 (0)