diff --git a/cmd/tf-preview-gh/main.go b/cmd/tf-preview-gh/main.go index 7c005a9..d89bd3d 100644 --- a/cmd/tf-preview-gh/main.go +++ b/cmd/tf-preview-gh/main.go @@ -6,11 +6,13 @@ import ( "os/signal" "syscall" + "github.com/nimbolus/terraform-backend/pkg/scaffold" "github.com/nimbolus/terraform-backend/pkg/speculative" ) func main() { rootCmd := speculative.NewCommand() + rootCmd.AddCommand(scaffold.NewCommand()) ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer cancel() diff --git a/go.mod b/go.mod index 1e8af5c..286aa9e 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( github.com/spf13/viper v1.18.2 github.com/stretchr/testify v1.9.0 github.com/whilp/git-urls v1.0.0 + github.com/zclconf/go-cty v1.13.1 go.uber.org/multierr v1.11.0 ) @@ -57,6 +58,7 @@ require ( github.com/go-test/deep v1.1.0 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.3 // indirect + github.com/google/go-cmp v0.6.0 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/s2a-go v0.1.7 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect @@ -112,7 +114,6 @@ require ( github.com/tmccombs/hcl2json v0.5.0 // indirect github.com/ulikunitz/xz v0.5.11 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect - github.com/zclconf/go-cty v1.13.1 // indirect go.opencensus.io v0.24.0 // indirect golang.org/x/crypto v0.21.0 // indirect golang.org/x/exp v0.0.0-20240119083558-1b970713d09a // indirect diff --git a/pkg/scaffold/scaffold.go b/pkg/scaffold/scaffold.go new file mode 100644 index 0000000..d0fbce3 --- /dev/null +++ b/pkg/scaffold/scaffold.go @@ -0,0 +1,175 @@ +package scaffold + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "net/url" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclwrite" + "github.com/nimbolus/terraform-backend/pkg/git" + "github.com/nimbolus/terraform-backend/pkg/tfcontext" + "github.com/spf13/cobra" + "github.com/zclconf/go-cty/cty" +) + +var ( + backendAddress string +) + +func NewCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "scaffold", + Short: "scaffold the necessary config to use the GitHub Actions Terraform workflow", + RunE: func(cmd *cobra.Command, args []string) error { + return run(cmd.Context()) + }, + } + + cmd.Flags().StringVar(&backendAddress, "backend-url", "https://ffddorf-terraform-backend.fly.dev/", "URL to use as the backend address") + + return cmd +} + +func run(ctx context.Context) error { + cwd, err := os.Getwd() + if err != nil { + return err + } + + if err := writeBackendConfig(cwd); err != nil { + return err + } + + // todo: create github actions workflows + + return nil +} + +func prompt(text string) (string, error) { + stdout := os.Stderr + fmt.Fprint(stdout, text) + + rdr := bufio.NewReader(os.Stdin) + answer, err := rdr.ReadBytes('\n') + if err != nil { + return "", err + } + return string(answer[:len(answer)-1]), nil +} + +func writeBackendConfig(dir string) (reterr error) { + var file *hclwrite.File + var outFile io.WriteCloser + var backendBlock *hclwrite.Block + + _, filename, err := tfcontext.FindBackendBlock(dir) + if err == nil { + relPath, _ := filepath.Rel(dir, filename) + answer, err := prompt(fmt.Sprintf("There is an existing backend config at %s. Do you want to replace it? [y/N] ", relPath)) + if err != nil { + return + } + if !strings.EqualFold(answer, "y") { + return errors.New("aborting") + } + + b, err := os.ReadFile(filename) + if err != nil { + return err + } + + var diags hcl.Diagnostics + file, diags = hclwrite.ParseConfig(b, filename, hcl.Pos{}) + if len(diags) > 0 { + return errors.Join(diags) + } + var tfBlock *hclwrite.Block + for _, block := range file.Body().Blocks() { + if block.Type() != "terraform" { + continue + } + tfBlock = block + for _, innerBlock := range block.Body().Blocks() { + if innerBlock.Type() == "backend" { + backendBlock = innerBlock + } + } + } + if backendBlock == nil { + return errors.New("backend block not found anymore") + } + if backendBlock.Labels()[0] != "http" { + tfBlock.Body().RemoveBlock(backendBlock) + backendBlock = tfBlock.Body().AppendNewBlock("backend", nil) + } + + outFile, err = os.Create(filename) + if err != nil { + return err + } + defer func() { + if reterr != nil { + // restore original content + _, _ = outFile.Write(b) + } + _ = outFile.Close() + }() + } else { + file = hclwrite.NewEmptyFile() + tfBlock := file.Body().AppendNewBlock("terraform", nil) + backendBlock = tfBlock.Body().AppendNewBlock("backend", nil) + filename = filepath.Join(dir, "backend.tf") + outFile, err = os.Create(filename) + if err != nil { + return err + } + defer outFile.Close() + } + + origin, err := git.RepoOrigin() + if err != nil { + return err + } + segments := strings.Split(origin.Path, "/") + if len(segments) < 2 { + return fmt.Errorf("invalid repo path: %s", origin.Path) + } + repo := segments[1] + + backendURL, err := url.Parse(backendAddress) + if err != nil { + return err + } + backendURL.Path = filepath.Join(backendURL.Path, "state", repo, "default") + address := backendURL.String() + + backendBlock.SetLabels([]string{"http"}) + backendBody := backendBlock.Body() + backendAttributes := []string{"address", "lock_address", "unlock_address", "username"} + for name := range backendBody.Attributes() { + if slices.Contains(backendAttributes, name) { + continue + } + backendBody.RemoveAttribute(name) + } + backendBody.SetAttributeValue("address", cty.StringVal(address)) + backendBody.SetAttributeValue("lock_address", cty.StringVal(address)) + backendBody.SetAttributeValue("unlock_address", cty.StringVal(address)) + backendBody.SetAttributeValue("username", cty.StringVal("github_pat")) + + if _, err := file.WriteTo(outFile); err != nil { + return err + } + + relPath, _ := filepath.Rel(dir, filename) + fmt.Printf("Wrote backend config to: %s\n", relPath) + return nil +} diff --git a/pkg/tfcontext/parse.go b/pkg/tfcontext/parse.go index eea48fb..17a271c 100644 --- a/pkg/tfcontext/parse.go +++ b/pkg/tfcontext/parse.go @@ -80,19 +80,19 @@ func readAttribute(attrs hcl.Attributes, name string) (string, error) { return val.AsString(), nil } -func FindBackend(dir string) (*BackendConfig, error) { +func FindBackendBlock(dir string) (*hcl.Block, string, error) { parser := hclparse.NewParser() tfFiles, err := files(dir) if err != nil { - return nil, err + return nil, "", err } var file *hcl.File for _, filename := range tfFiles { b, err := os.ReadFile(filename) if err != nil { - return nil, err + return nil, "", err } file, _ = parser.ParseHCL(b, filename) @@ -108,35 +108,43 @@ func FindBackend(dir string) (*BackendConfig, error) { content, _, _ := block.Body.PartialContent(terraformBlockSchema) for _, innerBlock := range content.Blocks { - if innerBlock.Type != "backend" { - continue - } - if innerBlock.Labels[0] != "http" { - continue - } - - content, _, _ := innerBlock.Body.PartialContent(backendSchema) - address, err := readAttribute(content.Attributes, "address") - if err != nil { - return nil, err - } - username, err := readAttribute(content.Attributes, "username") - if err != nil { - return nil, err - } - password, err := readAttribute(content.Attributes, "password") - if err != nil { - return nil, err + if innerBlock.Type == "backend" { + return innerBlock, filename, nil } - - return &BackendConfig{ - Address: address, - Username: username, - Password: password, - }, nil } } } - return nil, errors.New("backend config not found") + return nil, "", errors.New("backend block not found") +} + +func FindBackend(dir string) (*BackendConfig, error) { + backend, _, err := FindBackendBlock(dir) + if err != nil { + return nil, err + } + + if backend.Labels[0] != "http" { + return nil, errors.New("not using http backend") + } + + content, _, _ := backend.Body.PartialContent(backendSchema) + address, err := readAttribute(content.Attributes, "address") + if err != nil { + return nil, err + } + username, err := readAttribute(content.Attributes, "username") + if err != nil { + return nil, err + } + password, err := readAttribute(content.Attributes, "password") + if err != nil { + return nil, err + } + + return &BackendConfig{ + Address: address, + Username: username, + Password: password, + }, nil }