Skip to content

Commit

Permalink
Provide scaffold command (#29)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
mraerino committed Jun 30, 2024
1 parent 30c894c commit 95031a4
Show file tree
Hide file tree
Showing 23 changed files with 1,538 additions and 327 deletions.
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ docs/**/*
docker-compose.yml
LICENSE
README.md
testdata/
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,23 @@ sudo mv tf-preview-gh /usr/local/bin/tf-preview-gh
sudo chmod +x /usr/local/bin/tf-preview-gh
```

### Configure

In order to use it with your respository, you need to have some workflows in place.

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.

It should look like this:

```
% tf-preview-gh scaffold
Wrote backend config to: backend.tf
Wrote workflow to: .github/workflows/tf-preview.yaml
Wrote workflow to: .github/workflows/tf-run.yaml
```

Next, commit the new files and get them on main before continuing.

### Usage

Run the CLI in the directory for which you want to run a remote plan.
Expand Down
297 changes: 9 additions & 288 deletions cmd/tf-preview-gh/main.go
Original file line number Diff line number Diff line change
@@ -1,309 +1,30 @@
package main

import (
"bufio"
"context"
"errors"
"flag"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"os/signal"
"path/filepath"
"slices"
"strings"
"syscall"
"time"

"github.com/cenkalti/backoff"
"github.com/go-git/go-git/v5"
"github.com/google/go-github/v62/github"
"github.com/google/uuid"
"github.com/hashicorp/go-slug"
"github.com/nimbolus/terraform-backend/pkg/tfcontext"
giturls "github.com/whilp/git-urls"
"github.com/nimbolus/terraform-backend/pkg/fs"
"github.com/nimbolus/terraform-backend/pkg/scaffold"
"github.com/nimbolus/terraform-backend/pkg/speculative"
)

func serveWorkspace(ctx context.Context) (string, error) {
func main() {
cwd, err := os.Getwd()
if err != nil {
return "", err
}

backend, err := tfcontext.FindBackend(cwd)
if err != nil {
return "", err
}
backendURL, err := url.Parse(backend.Address)
if err != nil {
return "", fmt.Errorf("failed to parse backend url: %s, %w", backend.Address, err)
}
if backend.Password == "" {
backendPassword, ok := os.LookupEnv("TF_HTTP_PASSWORD")
if !ok || backendPassword == "" {
return "", errors.New("missing backend password")
}
backend.Password = backendPassword
}

id := uuid.New()
backendURL.Path = filepath.Join(backendURL.Path, "/share/", id.String())

pr, pw := io.Pipe()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, backendURL.String(), pr)
if err != nil {
return "", err
panic(fmt.Errorf("failed to get working directory: %w", err))
}
req.Header.Set("Content-Type", "application/octet-stream")
req.SetBasicAuth(backend.Username, backend.Password)

go func() {
_, err := slug.Pack(cwd, pw, true)
if err != nil {
fmt.Printf("failed to pack workspace: %v\n", err)
pw.CloseWithError(err)
} else {
pw.Close()
}
}()

go func() {
resp, err := http.DefaultClient.Do(req)
if err != nil {
fmt.Printf("failed to stream workspace: %v\n", err)
} else if resp.StatusCode/100 != 2 {
fmt.Printf("invalid status code after streaming workspace: %d\n", resp.StatusCode)
}
fmt.Println("done streaming workspace")
}()

return backendURL.String(), nil
}

type countingReader struct {
io.Reader
readBytes int
}

func (c *countingReader) Read(dst []byte) (int, error) {
n, err := c.Reader.Read(dst)
c.readBytes += n
return n, err
}

var ignoredGroupNames = []string{
"Operating System",
"Runner Image",
"Runner Image Provisioner",
"GITHUB_TOKEN Permissions",
}
rootCmd := speculative.NewCommand()
rootCmd.AddCommand(scaffold.NewCommand(fs.ForOS(cwd), os.Stdin))

func streamLogs(logsURL *url.URL, skip int64) (int64, error) {
logs, err := http.Get(logsURL.String())
if err != nil {
return 0, err
}
if logs.StatusCode != http.StatusOK {
return 0, fmt.Errorf("invalid status for logs: %d", logs.StatusCode)
}
defer logs.Body.Close()

if _, err := io.Copy(io.Discard, io.LimitReader(logs.Body, skip)); err != nil {
return 0, err
}

r := &countingReader{Reader: logs.Body}
scanner := bufio.NewScanner(r)
groupDepth := 0
for scanner.Scan() {
line := scanner.Text()
ts, rest, ok := strings.Cut(line, " ")
if !ok {
rest = ts
}
if groupName, ok := strings.CutPrefix(rest, "##[group]"); ok {
groupDepth++
if !slices.Contains(ignoredGroupNames, groupName) {
fmt.Printf("\n# %s\n", groupName)
}
}
if groupDepth == 0 {
fmt.Println(rest)
}
if strings.HasPrefix(rest, "##[endgroup]") {
groupDepth--
}
}
if err := scanner.Err(); err != nil {
return int64(r.readBytes), err
}

return int64(r.readBytes), err
}

var (
owner string
repo string
workflowFilename string
)

func gitRepoOrigin() (*url.URL, error) {
cwd, err := os.Getwd()
if err != nil {
return nil, err
}

repo, err := git.PlainOpen(cwd)
if err != nil {
return nil, err
}

orig, err := repo.Remote("origin")
if err != nil {
return nil, err
}
if orig == nil {
return nil, errors.New("origin remote not present")
}

for _, u := range orig.Config().URLs {
remoteURL, err := giturls.Parse(u)
if err != nil {
continue
}
if remoteURL.Hostname() == "github.com" {
return remoteURL, nil
}
}
return nil, errors.New("no suitable url found")
}

func main() {
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()

flag.StringVar(&owner, "github-owner", "", "Repository owner")
flag.StringVar(&repo, "github-repo", "", "Repository name")
flag.StringVar(&workflowFilename, "workflow-file", "preview.yaml", "Name of the workflow file to run for previews")
flag.Parse()

if owner == "" || repo == "" {
if ghURL, err := gitRepoOrigin(); err == nil {
parts := strings.Split(ghURL.Path, "/")
if len(parts) >= 2 {
owner = parts[0]
repo = strings.TrimSuffix(parts[1], ".git")
fmt.Printf("Using local repo info: %s/%s\n", owner, repo)
}
}
}
if owner == "" {
panic("Missing flag: -github-owner")
}
if repo == "" {
panic("Missing flag: -github-repo")
}

serverURL, err := serveWorkspace(ctx)
if err != nil {
panic(err)
}

// steal token from GH CLI
cmd := exec.CommandContext(ctx, "gh", "auth", "token")
out, err := cmd.Output()
if err != nil {
panic(err)
}

token := strings.TrimSpace(string(out))
gh := github.NewClient(nil).WithAuthToken(token)

startedAt := time.Now().UTC()

// start workflow
_, err = gh.Actions.CreateWorkflowDispatchEventByFileName(ctx,
owner, repo, workflowFilename,
github.CreateWorkflowDispatchEventRequest{
Ref: "main",
Inputs: map[string]interface{}{
"workspace_transfer_url": serverURL,
},
},
)
if err != nil {
panic(err)
}

fmt.Println("Waiting for run to start...")

// find workflow run
var run *github.WorkflowRun
err = backoff.Retry(func() error {
workflows, _, err := gh.Actions.ListWorkflowRunsByFileName(
ctx, owner, repo, workflowFilename,
&github.ListWorkflowRunsOptions{
Created: fmt.Sprintf(">=%s", startedAt.Format("2006-01-02T15:04")),
},
)
if err != nil {
return backoff.Permanent(err)
}
if len(workflows.WorkflowRuns) == 0 {
return fmt.Errorf("no workflow runs found")
}

run = workflows.WorkflowRuns[0]
return nil
}, backoff.NewExponentialBackOff())
if err != nil {
panic(err)
}

var jobID int64
err = backoff.Retry(func() error {
jobs, _, err := gh.Actions.ListWorkflowJobs(ctx,
owner, repo, *run.ID,
&github.ListWorkflowJobsOptions{},
)
if err != nil {
return backoff.Permanent(err)
}
if len(jobs.Jobs) == 0 {
return fmt.Errorf("no jobs found")
}

jobID = *jobs.Jobs[0].ID
return nil
}, backoff.NewExponentialBackOff())
if err != nil {
panic(err)
}

logsURL, _, err := gh.Actions.GetWorkflowJobLogs(ctx, owner, repo, jobID, 2)
if err != nil {
panic(err)
}

var readBytes int64
for {
n, err := streamLogs(logsURL, readBytes)
if err != nil {
panic(err)
}
readBytes += n

// check if job is done
job, _, err := gh.Actions.GetWorkflowJobByID(ctx, owner, repo, jobID)
if err != nil {
panic(err)
}
if job.CompletedAt != nil {
fmt.Println("Job complete.")
break
}
if err := rootCmd.ExecuteContext(ctx); err != nil {
os.Exit(1)
}
}
Loading

0 comments on commit 95031a4

Please sign in to comment.