Skip to content

Commit 95031a4

Browse files
authored
Provide scaffold command (#29)
This implements the CLI command `tf-preview-gh scaffold` It will create these files: - `backend.tf` or edit any file that has a `terraform { backend { ... } }` block in it - `.github/workflows/tf-run.yaml` - workflow for running plans on PRs and applies on main - `.github/workflows/tf-preview.yaml` - workflow for running speculative plans from local machines
1 parent 30c894c commit 95031a4

File tree

23 files changed

+1538
-327
lines changed

23 files changed

+1538
-327
lines changed

.dockerignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ docs/**/*
44
docker-compose.yml
55
LICENSE
66
README.md
7+
testdata/

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,23 @@ sudo mv tf-preview-gh /usr/local/bin/tf-preview-gh
108108
sudo chmod +x /usr/local/bin/tf-preview-gh
109109
```
110110

111+
### Configure
112+
113+
In order to use it with your respository, you need to have some workflows in place.
114+
115+
The `tf-preview-gh scaffold` command sets up everything that's necessary. This includes the workflows to run plans for pull requests and applies for merges to main.
116+
117+
It should look like this:
118+
119+
```
120+
% tf-preview-gh scaffold
121+
Wrote backend config to: backend.tf
122+
Wrote workflow to: .github/workflows/tf-preview.yaml
123+
Wrote workflow to: .github/workflows/tf-run.yaml
124+
```
125+
126+
Next, commit the new files and get them on main before continuing.
127+
111128
### Usage
112129

113130
Run the CLI in the directory for which you want to run a remote plan.

cmd/tf-preview-gh/main.go

Lines changed: 9 additions & 288 deletions
Original file line numberDiff line numberDiff line change
@@ -1,309 +1,30 @@
11
package main
22

33
import (
4-
"bufio"
54
"context"
6-
"errors"
7-
"flag"
85
"fmt"
9-
"io"
10-
"net/http"
11-
"net/url"
126
"os"
13-
"os/exec"
147
"os/signal"
15-
"path/filepath"
16-
"slices"
17-
"strings"
188
"syscall"
19-
"time"
209

21-
"github.com/cenkalti/backoff"
22-
"github.com/go-git/go-git/v5"
23-
"github.com/google/go-github/v62/github"
24-
"github.com/google/uuid"
25-
"github.com/hashicorp/go-slug"
26-
"github.com/nimbolus/terraform-backend/pkg/tfcontext"
27-
giturls "github.com/whilp/git-urls"
10+
"github.com/nimbolus/terraform-backend/pkg/fs"
11+
"github.com/nimbolus/terraform-backend/pkg/scaffold"
12+
"github.com/nimbolus/terraform-backend/pkg/speculative"
2813
)
2914

30-
func serveWorkspace(ctx context.Context) (string, error) {
15+
func main() {
3116
cwd, err := os.Getwd()
3217
if err != nil {
33-
return "", err
34-
}
35-
36-
backend, err := tfcontext.FindBackend(cwd)
37-
if err != nil {
38-
return "", err
39-
}
40-
backendURL, err := url.Parse(backend.Address)
41-
if err != nil {
42-
return "", fmt.Errorf("failed to parse backend url: %s, %w", backend.Address, err)
43-
}
44-
if backend.Password == "" {
45-
backendPassword, ok := os.LookupEnv("TF_HTTP_PASSWORD")
46-
if !ok || backendPassword == "" {
47-
return "", errors.New("missing backend password")
48-
}
49-
backend.Password = backendPassword
50-
}
51-
52-
id := uuid.New()
53-
backendURL.Path = filepath.Join(backendURL.Path, "/share/", id.String())
54-
55-
pr, pw := io.Pipe()
56-
req, err := http.NewRequestWithContext(ctx, http.MethodPost, backendURL.String(), pr)
57-
if err != nil {
58-
return "", err
18+
panic(fmt.Errorf("failed to get working directory: %w", err))
5919
}
60-
req.Header.Set("Content-Type", "application/octet-stream")
61-
req.SetBasicAuth(backend.Username, backend.Password)
62-
63-
go func() {
64-
_, err := slug.Pack(cwd, pw, true)
65-
if err != nil {
66-
fmt.Printf("failed to pack workspace: %v\n", err)
67-
pw.CloseWithError(err)
68-
} else {
69-
pw.Close()
70-
}
71-
}()
72-
73-
go func() {
74-
resp, err := http.DefaultClient.Do(req)
75-
if err != nil {
76-
fmt.Printf("failed to stream workspace: %v\n", err)
77-
} else if resp.StatusCode/100 != 2 {
78-
fmt.Printf("invalid status code after streaming workspace: %d\n", resp.StatusCode)
79-
}
80-
fmt.Println("done streaming workspace")
81-
}()
82-
83-
return backendURL.String(), nil
84-
}
8520

86-
type countingReader struct {
87-
io.Reader
88-
readBytes int
89-
}
90-
91-
func (c *countingReader) Read(dst []byte) (int, error) {
92-
n, err := c.Reader.Read(dst)
93-
c.readBytes += n
94-
return n, err
95-
}
96-
97-
var ignoredGroupNames = []string{
98-
"Operating System",
99-
"Runner Image",
100-
"Runner Image Provisioner",
101-
"GITHUB_TOKEN Permissions",
102-
}
21+
rootCmd := speculative.NewCommand()
22+
rootCmd.AddCommand(scaffold.NewCommand(fs.ForOS(cwd), os.Stdin))
10323

104-
func streamLogs(logsURL *url.URL, skip int64) (int64, error) {
105-
logs, err := http.Get(logsURL.String())
106-
if err != nil {
107-
return 0, err
108-
}
109-
if logs.StatusCode != http.StatusOK {
110-
return 0, fmt.Errorf("invalid status for logs: %d", logs.StatusCode)
111-
}
112-
defer logs.Body.Close()
113-
114-
if _, err := io.Copy(io.Discard, io.LimitReader(logs.Body, skip)); err != nil {
115-
return 0, err
116-
}
117-
118-
r := &countingReader{Reader: logs.Body}
119-
scanner := bufio.NewScanner(r)
120-
groupDepth := 0
121-
for scanner.Scan() {
122-
line := scanner.Text()
123-
ts, rest, ok := strings.Cut(line, " ")
124-
if !ok {
125-
rest = ts
126-
}
127-
if groupName, ok := strings.CutPrefix(rest, "##[group]"); ok {
128-
groupDepth++
129-
if !slices.Contains(ignoredGroupNames, groupName) {
130-
fmt.Printf("\n# %s\n", groupName)
131-
}
132-
}
133-
if groupDepth == 0 {
134-
fmt.Println(rest)
135-
}
136-
if strings.HasPrefix(rest, "##[endgroup]") {
137-
groupDepth--
138-
}
139-
}
140-
if err := scanner.Err(); err != nil {
141-
return int64(r.readBytes), err
142-
}
143-
144-
return int64(r.readBytes), err
145-
}
146-
147-
var (
148-
owner string
149-
repo string
150-
workflowFilename string
151-
)
152-
153-
func gitRepoOrigin() (*url.URL, error) {
154-
cwd, err := os.Getwd()
155-
if err != nil {
156-
return nil, err
157-
}
158-
159-
repo, err := git.PlainOpen(cwd)
160-
if err != nil {
161-
return nil, err
162-
}
163-
164-
orig, err := repo.Remote("origin")
165-
if err != nil {
166-
return nil, err
167-
}
168-
if orig == nil {
169-
return nil, errors.New("origin remote not present")
170-
}
171-
172-
for _, u := range orig.Config().URLs {
173-
remoteURL, err := giturls.Parse(u)
174-
if err != nil {
175-
continue
176-
}
177-
if remoteURL.Hostname() == "github.com" {
178-
return remoteURL, nil
179-
}
180-
}
181-
return nil, errors.New("no suitable url found")
182-
}
183-
184-
func main() {
18524
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
18625
defer cancel()
18726

188-
flag.StringVar(&owner, "github-owner", "", "Repository owner")
189-
flag.StringVar(&repo, "github-repo", "", "Repository name")
190-
flag.StringVar(&workflowFilename, "workflow-file", "preview.yaml", "Name of the workflow file to run for previews")
191-
flag.Parse()
192-
193-
if owner == "" || repo == "" {
194-
if ghURL, err := gitRepoOrigin(); err == nil {
195-
parts := strings.Split(ghURL.Path, "/")
196-
if len(parts) >= 2 {
197-
owner = parts[0]
198-
repo = strings.TrimSuffix(parts[1], ".git")
199-
fmt.Printf("Using local repo info: %s/%s\n", owner, repo)
200-
}
201-
}
202-
}
203-
if owner == "" {
204-
panic("Missing flag: -github-owner")
205-
}
206-
if repo == "" {
207-
panic("Missing flag: -github-repo")
208-
}
209-
210-
serverURL, err := serveWorkspace(ctx)
211-
if err != nil {
212-
panic(err)
213-
}
214-
215-
// steal token from GH CLI
216-
cmd := exec.CommandContext(ctx, "gh", "auth", "token")
217-
out, err := cmd.Output()
218-
if err != nil {
219-
panic(err)
220-
}
221-
222-
token := strings.TrimSpace(string(out))
223-
gh := github.NewClient(nil).WithAuthToken(token)
224-
225-
startedAt := time.Now().UTC()
226-
227-
// start workflow
228-
_, err = gh.Actions.CreateWorkflowDispatchEventByFileName(ctx,
229-
owner, repo, workflowFilename,
230-
github.CreateWorkflowDispatchEventRequest{
231-
Ref: "main",
232-
Inputs: map[string]interface{}{
233-
"workspace_transfer_url": serverURL,
234-
},
235-
},
236-
)
237-
if err != nil {
238-
panic(err)
239-
}
240-
241-
fmt.Println("Waiting for run to start...")
242-
243-
// find workflow run
244-
var run *github.WorkflowRun
245-
err = backoff.Retry(func() error {
246-
workflows, _, err := gh.Actions.ListWorkflowRunsByFileName(
247-
ctx, owner, repo, workflowFilename,
248-
&github.ListWorkflowRunsOptions{
249-
Created: fmt.Sprintf(">=%s", startedAt.Format("2006-01-02T15:04")),
250-
},
251-
)
252-
if err != nil {
253-
return backoff.Permanent(err)
254-
}
255-
if len(workflows.WorkflowRuns) == 0 {
256-
return fmt.Errorf("no workflow runs found")
257-
}
258-
259-
run = workflows.WorkflowRuns[0]
260-
return nil
261-
}, backoff.NewExponentialBackOff())
262-
if err != nil {
263-
panic(err)
264-
}
265-
266-
var jobID int64
267-
err = backoff.Retry(func() error {
268-
jobs, _, err := gh.Actions.ListWorkflowJobs(ctx,
269-
owner, repo, *run.ID,
270-
&github.ListWorkflowJobsOptions{},
271-
)
272-
if err != nil {
273-
return backoff.Permanent(err)
274-
}
275-
if len(jobs.Jobs) == 0 {
276-
return fmt.Errorf("no jobs found")
277-
}
278-
279-
jobID = *jobs.Jobs[0].ID
280-
return nil
281-
}, backoff.NewExponentialBackOff())
282-
if err != nil {
283-
panic(err)
284-
}
285-
286-
logsURL, _, err := gh.Actions.GetWorkflowJobLogs(ctx, owner, repo, jobID, 2)
287-
if err != nil {
288-
panic(err)
289-
}
290-
291-
var readBytes int64
292-
for {
293-
n, err := streamLogs(logsURL, readBytes)
294-
if err != nil {
295-
panic(err)
296-
}
297-
readBytes += n
298-
299-
// check if job is done
300-
job, _, err := gh.Actions.GetWorkflowJobByID(ctx, owner, repo, jobID)
301-
if err != nil {
302-
panic(err)
303-
}
304-
if job.CompletedAt != nil {
305-
fmt.Println("Job complete.")
306-
break
307-
}
27+
if err := rootCmd.ExecuteContext(ctx); err != nil {
28+
os.Exit(1)
30829
}
30930
}

0 commit comments

Comments
 (0)