Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New option: --append to allow importing to existing workspace #151

Merged
merged 5 commits into from
Jun 21, 2022
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,14 @@ E.g. to use the [`azurerm` backend](https://www.terraform.io/language/settings/b
aztfy --backend-type=azurerm --backend-config=resource_group_name=<resource group name> --backend-config=storage_account_name=<account name> --backend-config=container_name=<container name> --backend-config=key=terraform.tfstate <importing resource group name>
```

### Import Into Existing Local State

For local backend, `aztfy` will by default ensure the output directory is empty at the very begining. This is to avoid any conflicts happen for existing user files, including the terraform configuration, provider configuration, the state file, etc. As a result, `aztfy` generates a pretty new workspace for users.

One limitation of doing so is users can't import resources to existing state file via `aztfy`. To support this scenario, you can use the `--append` option. This option will make `aztfy` skip the empty guarantee for the output directory. If the output directory is empty, then it has no effect. Otherwise, it will ensure the provider setting (create a file for it if not exists). Then it proceeds the following steps.

This means if the output directory has an active Terraform workspace, i.e. there exists a state file, any resource imported by the `aztfy` will be imported into that state file. Especially, the file generated by `aztfy` in this case will be named differently than normal, where each file will has `.aztfy` suffix before the extension (e.g. `main.aztfy.tf`), to avoid potential file name conflicts. If you run `aztfy --append` multiple times, the generated config in `main.aztfy.tf` will be appended in each run.

## Demo

[![asciicast](https://asciinema.org/a/475516.svg)](https://asciinema.org/a/475516)
Expand Down
1 change: 1 addition & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ type Config struct {
OutputDir string
ResourceNamePattern string
Overwrite bool
Append bool
BatchMode bool
BackendType string
BackendConfig []string
Expand Down
205 changes: 161 additions & 44 deletions internal/meta/meta_impl.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
package meta

import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strings"
"time"

Expand Down Expand Up @@ -46,6 +48,10 @@ type MetaImpl struct {

backendType string
backendConfig []string

// Use a safer name which is less likely to conflicts with users' existing files.
// This is mainly used for the --append option.
useSafeFilename bool
}

func newMetaImpl(cfg config.Config) (Meta, error) {
Expand All @@ -71,39 +77,32 @@ func newMetaImpl(cfg config.Config) (Meta, error) {
return nil, err
}
}
stat, err := os.Stat(outdir)
if os.IsNotExist(err) {
return nil, fmt.Errorf("the output directory %q doesn't exist", outdir)
}
if !stat.IsDir() {
return nil, fmt.Errorf("the output path %q is not a directory", outdir)
}
dir, err := os.Open(outdir)
empty, err := dirIsEmpty(outdir)
if err != nil {
return nil, err
}
_, err = dir.Readdirnames(1)
dir.Close()
if err != io.EOF {
if cfg.Overwrite {
if err := removeEverythingUnder(outdir); err != nil {
return nil, err
}
} else {
if cfg.BatchMode {
return nil, fmt.Errorf("the output directory %q is not empty", outdir)
}

// Interactive mode
fmt.Printf("The output directory is not empty - overwrite (Y/N)? ")
var ans string
fmt.Scanf("%s", &ans)
if !strings.EqualFold(ans, "y") {
return nil, fmt.Errorf("the output directory %q is not empty", outdir)
} else {
if !empty {
if !cfg.Append {
if cfg.Overwrite {
if err := removeEverythingUnder(outdir); err != nil {
return nil, err
}
} else {
if cfg.BatchMode {
return nil, fmt.Errorf("the output directory %q is not empty", outdir)
}

// Interactive mode
fmt.Printf("The output directory is not empty - overwrite (Y/N)? ")
var ans string
fmt.Scanf("%s", &ans)
if !strings.EqualFold(ans, "y") {
return nil, fmt.Errorf("the output directory %q is not empty", outdir)
} else {
if err := removeEverythingUnder(outdir); err != nil {
return nil, err
}
}
}
}
}
Expand All @@ -126,6 +125,7 @@ func newMetaImpl(cfg config.Config) (Meta, error) {
resourceMapping: cfg.ResourceMapping,
backendType: cfg.BackendType,
backendConfig: cfg.BackendConfig,
useSafeFilename: cfg.Append,
}

if pos := strings.LastIndex(cfg.ResourceNamePattern, "*"); pos != -1 {
Expand Down Expand Up @@ -186,7 +186,6 @@ func (meta *MetaImpl) ListResource() (ImportList, error) {

var l ImportList

// No resource mapping specified, simply insert each listed id to the import list.
for i, id := range ids {
recommendations := RecommendationsForId(id)
item := ImportItem{
Expand Down Expand Up @@ -224,7 +223,7 @@ func (meta MetaImpl) Import(item *ImportItem) {

// Generate a temp Terraform config to include the empty template for each resource.
// This is required for the following importing.
cfgFile := filepath.Join(meta.outdir, "main.tf")
cfgFile := filepath.Join(meta.outdir, meta.filenameTmpCfg())
tpl := fmt.Sprintf(`resource "%s" "%s" {}`, item.TFAddr.Type, item.TFAddr.Name)
if err := os.WriteFile(cfgFile, []byte(tpl), 0644); err != nil {
item.ImportError = fmt.Errorf("generating resource template file: %w", err)
Expand Down Expand Up @@ -286,20 +285,61 @@ provider "azurerm" {
`, meta.backendType, azurerm.ProviderSchemaInfo.Version)
}

func (meta MetaImpl) filenameProviderSetting() string {
if meta.useSafeFilename {
return "provider.aztfy.tf"
}
return "provider.tf"
}

func (meta MetaImpl) filenameMainCfg() string {
if meta.useSafeFilename {
return "main.aztfy.tf"
}
return "main.tf"
}

func (meta MetaImpl) filenameTmpCfg() string {
return "tmp.aztfy.tf"
}

func (meta *MetaImpl) initProvider(ctx context.Context) error {
cfgFile := filepath.Join(meta.outdir, "provider.tf")
empty, err := dirIsEmpty(meta.outdir)
if err != nil {
return err
}

// Always use the latest provider version here, as this is a one shot tool, which should guarantees to work with the latest version.
if err := os.WriteFile(cfgFile, []byte(meta.providerConfig()), 0644); err != nil {
return fmt.Errorf("error creating provider config: %w", err)
// If the directory is empty, generate the full config as the output directory is empty.
// Otherwise:
// - If the output directory already exists the `azurerm` provider setting, then do nothing
// - Otherwise, just generate the `azurerm` provider setting (as it is only for local backend)
if empty {
cfgFile := filepath.Join(meta.outdir, meta.filenameProviderSetting())
if err := os.WriteFile(cfgFile, []byte(meta.providerConfig()), 0644); err != nil {
return fmt.Errorf("error creating provider config: %w", err)
}

var opts []tfexec.InitOption
for _, opt := range meta.backendConfig {
opts = append(opts, tfexec.BackendConfig(opt))
}
if err := meta.tf.Init(ctx, opts...); err != nil {
return fmt.Errorf("error running terraform init: %s", err)
}
return nil
}

var opts []tfexec.InitOption
for _, opt := range meta.backendConfig {
opts = append(opts, tfexec.BackendConfig(opt))
exists, err := dirContainsProviderSetting(meta.outdir)
if err != nil {
return err
}
if err := meta.tf.Init(ctx, opts...); err != nil {
return fmt.Errorf("error running terraform init: %s", err)
if !exists {
if err := appendToFile(meta.filenameProviderSetting(), `provider "azurerm" {
features {}
}
`); err != nil {
return fmt.Errorf("error creating provider config: %w", err)
}
}

return nil
Expand Down Expand Up @@ -418,17 +458,15 @@ func (meta MetaImpl) resolveDependency(configs ConfigInfos) (ConfigInfos, error)
}

func (meta MetaImpl) generateConfig(cfgs ConfigInfos) error {
cfgFile := filepath.Join(meta.outdir, "main.tf")
cfgFile := filepath.Join(meta.outdir, meta.filenameMainCfg())
buf := bytes.NewBuffer([]byte{})
for i, cfg := range cfgs {
for _, cfg := range cfgs {
if _, err := cfg.DumpHCL(buf); err != nil {
return err
}
if i != len(cfgs)-1 {
buf.Write([]byte("\n"))
}
buf.Write([]byte("\n"))
}
if err := os.WriteFile(cfgFile, buf.Bytes(), 0644); err != nil {
if err := appendToFile(cfgFile, buf.String()); err != nil {
return fmt.Errorf("generating main configuration file: %w", err)
}

Expand Down Expand Up @@ -465,3 +503,82 @@ func removeEverythingUnder(path string) error {
dir.Close()
return nil
}

func dirIsEmpty(path string) (bool, error) {
stat, err := os.Stat(path)
if os.IsNotExist(err) {
return false, fmt.Errorf("the path %q doesn't exist", path)
}
if !stat.IsDir() {
return false, fmt.Errorf("the path %q is not a directory", path)
}
dir, err := os.Open(path)
if err != nil {
return false, err
}
_, err = dir.Readdirnames(1)
if err != nil {
if err == io.EOF {
dir.Close()
return true, nil
}
return false, err
}
dir.Close()
return false, nil
}

func dirContainsProviderSetting(path string) (bool, error) {
stat, err := os.Stat(path)
if os.IsNotExist(err) {
return false, fmt.Errorf("the path %q doesn't exist", path)
}
if !stat.IsDir() {
return false, fmt.Errorf("the path %q is not a directory", path)
}
dir, err := os.Open(path)
if err != nil {
return false, err
}
defer dir.Close()

fnames, err := dir.Readdirnames(0)
if err != nil {
return false, err
}

// Ideally, we shall use hclgrep for a perfect match. But as the provider setting is simple enough, we do a text matching here.
p := regexp.MustCompile(`^\s*provider\s+"azurerm"\s*{\s*$`)
for _, fname := range fnames {
// fmt.Println(fname)
// fmt.Println(filepath.Ext(fname))
if filepath.Ext(fname) != ".tf" {
continue
}
f, err := os.Open(filepath.Join(path, fname))
if err != nil {
return false, fmt.Errorf("openning %s: %v", fname, err)
magodo marked this conversation as resolved.
Show resolved Hide resolved
}
scanner := bufio.NewScanner(f)
for scanner.Scan() {
if p.MatchString(scanner.Text()) {
return true, nil
magodo marked this conversation as resolved.
Show resolved Hide resolved
}
}
if err := scanner.Err(); err != nil {
return false, fmt.Errorf("reading file %s: %v", fname, err)
magodo marked this conversation as resolved.
Show resolved Hide resolved
}
f.Close()
}
return false, nil
}

func appendToFile(path, content string) error {
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return err
}
defer f.Close()
_, err = f.WriteString(content)
return err
}
Loading