diff --git a/README.md b/README.md index 4e1e258..71f91c0 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,19 @@ One limitation of doing so is users can't import resources to existing state fil 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. +### Config + +`aztfy` will create a configuration file at `$HOME/.aztfy/config.json`. This file is aim to be managed by command `aztfy config [subcommand]`, which includes following subcommands: + +- `get`: Get a config item +- `set`: Set a config item +- `show`: Show the full configuration + +Currently, following config items are supported: + +- `installation_id`: A UUID created on first run. If there is Azure CLI or Azure Powershell installed on the current machine, the UUID will be the same value among these tools. Otherwise, a new one will be created. This is used as an identifier in the telemetry trace. +- `telemetry_enabled`: Whether to enable telemetry? We use telemetry to identify issues and areas for improvement, in order to optimize this tool for better performance, reliability, and user experience. + ## How it Works `aztfy` leverage [`aztft`](https://github.com/magodo/aztft) to identify the Terraform resource type on its Azure resource ID. Then it runs `terraform import` under the hood to import each resource. Afterwards, it runs [`tfadd`](https://github.com/magodo/tfadd) to generate the Terraform template for each imported resource. diff --git a/go.mod b/go.mod index 8a52a4c..da1d4ce 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/charmbracelet/bubbles v0.14.0 github.com/charmbracelet/bubbletea v0.22.1 github.com/charmbracelet/lipgloss v0.5.0 + github.com/gofrs/uuid v3.3.0+incompatible github.com/hashicorp/go-hclog v1.3.1 github.com/hashicorp/go-version v1.6.0 github.com/hashicorp/hc-install v0.4.0 @@ -25,16 +26,19 @@ require ( github.com/magodo/tfadd v0.10.1-0.20230203080921-e92a7039ec75 github.com/magodo/tfmerge v0.0.0-20221214062955-f52e46d03402 github.com/magodo/workerpool v0.0.0-20230119025400-40192d2716ea + github.com/microsoft/ApplicationInsights-Go v0.4.4 github.com/mitchellh/go-wordwrap v1.0.0 github.com/muesli/reflow v0.3.0 github.com/pkg/profile v1.7.0 github.com/stretchr/testify v1.8.1 - github.com/tidwall/gjson v1.14.1 + github.com/tidwall/gjson v1.14.2 + github.com/tidwall/sjson v1.2.5 github.com/urfave/cli/v2 v2.24.1 github.com/zclconf/go-cty v1.11.0 ) require ( + code.cloudfoundry.org/clock v0.0.0-20180518195852-02e53af36e6c // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.1.2 // indirect github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/alertsmanagement/armalertsmanagement v0.6.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/apimanagement/armapimanagement v1.0.0 // indirect diff --git a/go.sum b/go.sum index 8e59894..de7718b 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +code.cloudfoundry.org/clock v0.0.0-20180518195852-02e53af36e6c h1:5eeuG0BHx1+DHeT3AP+ISKZ2ht1UjGhm581ljqYpVeQ= +code.cloudfoundry.org/clock v0.0.0-20180518195852-02e53af36e6c/go.mod h1:QD9Lzhd/ux6eNQVUDVRJX/RKTigpewimNYBi7ivZKY8= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.3.0 h1:VuHAcMq8pU1IWNT/m5yRaGqbK0BiQKHT8X4DTp9CHdI= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.3.0/go.mod h1:tZoQYdDZNOiIjdSn0dVWVfl0NEPGOJqVLzSrcFk4Is0= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.2.1 h1:T8quHYlUGyb/oqtSTwqlCr1ilJHrDv+ZtpSfo+hm1BU= @@ -121,6 +123,7 @@ github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYF github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g= github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= github.com/go-git/gcfg v1.5.0 h1:Q5ViNfGF8zFgyJWPqYwA7qGFoMTEiBmdlkcfRmpIMa4= github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E= @@ -131,9 +134,12 @@ github.com/go-git/go-git-fixtures/v4 v4.2.1/go.mod h1:K8zd3kDUAykwTdDCr+I0per6Y6 github.com/go-git/go-git/v5 v5.4.2 h1:BXyZu9t0VkbiHtqrsvdq39UDhGJTl1h55VW6CSC4aY4= github.com/go-git/go-git/v5 v5.4.2/go.mod h1:gQ1kArt6d+n+BGd+/B/I74HwRTLhth2+zti4ihgckDc= github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= +github.com/gofrs/uuid v3.3.0+incompatible h1:8K4tyRfvU1CYPgJsveYFQMhpFd/wXNM7iK6rR7UHz84= +github.com/gofrs/uuid v3.3.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/golang-jwt/jwt/v4 v4.4.2 h1:rcc4lwaZgFMCZ5jxF9ABolDcIHdBytAFgqFPbSJQAYs= github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -174,6 +180,7 @@ github.com/hashicorp/terraform-json v0.14.0 h1:sh9iZ1Y8IFJLx+xQiKHGud6/TSUCM0N8e github.com/hashicorp/terraform-json v0.14.0/go.mod h1:5A9HIWPkk4e5aeeXIBbkcOvaZbIYnAIkEyqP2pNSckM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/ianlancetaylor/demangle v0.0.0-20210905161508-09a460cdf81d/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w= github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU= github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= @@ -229,6 +236,8 @@ github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRC github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/microsoft/ApplicationInsights-Go v0.4.4 h1:G4+H9WNs6ygSCe6sUyxRc2U81TI5Es90b2t/MwX5KqY= +github.com/microsoft/ApplicationInsights-Go v0.4.4/go.mod h1:fKRUseBqkw6bDiXTs3ESTiU/4YTIHsQS4W3fP2ieF4U= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= @@ -247,6 +256,9 @@ github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 h1:QANkGiGr39l1EESqrE0gZw0/AJNYzIvoGLhIoVYtluI= github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/pkg/browser v0.0.0-20210115035449-ce105d075bb4 h1:Qj1ukM4GlMWXNdMBuXcXfz/Kw9s1qm0CLY32QxuSImI= github.com/pkg/browser v0.0.0-20210115035449-ce105d075bb4/go.mod h1:N6UoU20jOqggOuDwUaBQpluzLNDqif3kq9z2wpdYEfQ= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -279,12 +291,15 @@ github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1F github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/tidwall/gjson v1.14.1 h1:iymTbGkQBhveq21bEvAQ81I0LEBork8BFe1CUZXdyuo= -github.com/tidwall/gjson v1.14.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tedsuo/ifrit v0.0.0-20180802180643-bea94bb476cc/go.mod h1:eyZnKCc955uh98WQvzOm0dgAeLnf2O0Rz0LPoC5ze+0= +github.com/tidwall/gjson v1.14.2 h1:6BBkirS0rAHjumnjHF6qgy5d2YAJ1TLIaFE2lzfOLqo= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/urfave/cli/v2 v2.24.1 h1:/QYYr7g0EhwXEML8jO+8OYt5trPnLHS0p3mrgExJ5NU= github.com/urfave/cli/v2 v2.24.1/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc= github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= @@ -307,6 +322,7 @@ golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20220517005047-85d78b3ac167 h1:O8uGbHCqlTp2P6QJSLmCojM4mN6UemYv8K+dCnmHmu0= golang.org/x/crypto v0.0.0-20220517005047-85d78b3ac167/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/net v0.0.0-20180811021610-c39426892332/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= @@ -315,6 +331,7 @@ golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4 h1:HVyaeDAYux4pnY+D/SiwmLOR3 golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -353,8 +370,11 @@ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/internal/cfgfile/cfg.go b/internal/cfgfile/cfg.go new file mode 100644 index 0000000..e7c2455 --- /dev/null +++ b/internal/cfgfile/cfg.go @@ -0,0 +1,133 @@ +package cfgfile + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +const CfgDirName = ".aztfy" +const CfgFileName = "config.json" + +type Configuration struct { + InstallationId string `json:"installation_id"` + TelemetryEnabled bool `json:"telemetry_enabled"` +} + +func GetKey(key string) (interface{}, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("retrieving the user's HOME directory: %v", err) + } + path := filepath.Join(homeDir, CfgDirName, CfgFileName) + // #nosec G304 + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("opening config: %v", err) + } + // #nosec G307 + defer f.Close() + + b, err := io.ReadAll(f) + if err != nil { + return nil, fmt.Errorf("reading config: %v", err) + } + + result := gjson.Get(string(b), key) + if !result.Exists() { + return "", fmt.Errorf("invalid key") + } + return result.Value(), nil +} + +func SetKey(key, value string) error { + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("retrieving the user's HOME directory: %v", err) + } + path := filepath.Join(homeDir, CfgDirName, CfgFileName) + // #nosec G304 + b, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("reading config: %v", err) + } + + var cfg Configuration + if err := json.Unmarshal(b, &cfg); err != nil { + return fmt.Errorf("unmarshalling the config: %v", err) + } + newCfg, err := updateConfiguration(cfg, key, value) + if err != nil { + return err + } + b, err = json.Marshal(*newCfg) + if err != nil { + return fmt.Errorf("marshalling the updated config: %v", err) + } + // #nosec G304 + f, err := os.OpenFile(path, os.O_TRUNC|os.O_WRONLY, 0) + if err != nil { + return fmt.Errorf("open config for writing: %v", err) + } + + // #nosec G307 + defer f.Close() + if _, err := f.Write(b); err != nil { + return fmt.Errorf("writing config: %v", err) + } + return nil +} + +func GetConfig() (*Configuration, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("retrieving the user's HOME directory: %v", err) + } + path := filepath.Join(homeDir, CfgDirName, CfgFileName) + // #nosec G304 + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("opening config: %v", err) + } + // #nosec G307 + defer f.Close() + + b, err := io.ReadAll(f) + if err != nil { + return nil, fmt.Errorf("reading config: %v", err) + } + + var v Configuration + if err := json.Unmarshal(b, &v); err != nil { + return nil, err + } + return &v, nil +} + +func updateConfiguration(old Configuration, k, v string) (*Configuration, error) { + b, err := json.Marshal(old) + if err != nil { + return nil, fmt.Errorf("marshalling the old configuration: %v", err) + } + var vjson interface{} + if err := json.Unmarshal([]byte(v), &vjson); err != nil { + return nil, fmt.Errorf("unmarshalling the value: %v", err) + } + if !gjson.Get(string(b), k).Exists() { + return nil, fmt.Errorf("invalid key %q", k) + } + updated, err := sjson.Set(string(b), k, vjson) + if err != nil { + return nil, fmt.Errorf("setting the value: %v", err) + } + var cfg Configuration + if err := json.Unmarshal([]byte(updated), &cfg); err != nil { + return nil, fmt.Errorf("unmarshalling the new configuration: %v", err) + } + return &cfg, nil +} diff --git a/internal/cfgfile/cfg_test.go b/internal/cfgfile/cfg_test.go new file mode 100644 index 0000000..0f93d9b --- /dev/null +++ b/internal/cfgfile/cfg_test.go @@ -0,0 +1,50 @@ +package cfgfile + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUpdateConfiguration(t *testing.T) { + tests := []struct { + name string + ocfg Configuration + ncfg Configuration + k, v string + err string + }{ + { + name: "Invalid key", + ocfg: Configuration{}, + k: "nonexist", + v: "123", + err: `invalid key "nonexist"`, + }, + { + name: "Invalid value", + ocfg: Configuration{}, + k: "installation_id", + v: "123", + err: "unmarshalling the new configuration", + }, + { + name: "Valid update", + ocfg: Configuration{}, + k: "installation_id", + v: `"0000"`, + ncfg: Configuration{InstallationId: "0000"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ncfg, err := updateConfiguration(tt.ocfg, tt.k, tt.v) + if tt.err != "" { + require.ErrorContains(t, err, tt.err) + return + } + require.Equal(t, tt.ncfg, *ncfg) + }) + } +} diff --git a/internal/cfgfile/installation_id.go b/internal/cfgfile/installation_id.go new file mode 100644 index 0000000..6320acc --- /dev/null +++ b/internal/cfgfile/installation_id.go @@ -0,0 +1,59 @@ +package cfgfile + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +func GetInstallationIdFromCLI() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("retrieving user's HOME dir") + } + path := filepath.Join(home, ".azure", "azureProfile.json") + // #nosec G304 + b, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("reading %s: %v", path, err) + } + // Removing the preceding BOM (Byte Order Mark) + b = bytes.TrimPrefix(b, []byte("\xef\xbb\xbf")) + var f struct { + InstallationId string `json:"installationId"` + } + if err := json.Unmarshal(b, &f); err != nil { + return "", fmt.Errorf("unmarshalling the file: %v", err) + } + if f.InstallationId == "" { + return "", fmt.Errorf("no installation id found") + } + return f.InstallationId, nil +} + +func GetInstallationIdFromPWSH() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("retrieving user's HOME dir") + } + path := filepath.Join(home, ".azure", "AzureRmContextSettings.json") + // #nosec G304 + b, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("reading %s: %v", path, err) + } + var f struct { + Settings struct { + InstallationId string `json:"InstallationId"` + } `json:"Settings"` + } + if err := json.Unmarshal(b, &f); err != nil { + return "", fmt.Errorf("unmarshalling the file: %v", err) + } + if f.Settings.InstallationId == "" { + return "", fmt.Errorf("no installation id found") + } + return f.Settings.InstallationId, nil +} diff --git a/internal/meta/base_meta.go b/internal/meta/base_meta.go index fb04d1f..56f1c4a 100644 --- a/internal/meta/base_meta.go +++ b/internal/meta/base_meta.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "encoding/json" - "errors" "fmt" "os" "path/filepath" @@ -16,6 +15,7 @@ import ( "github.com/Azure/aztfy/internal/client" "github.com/Azure/aztfy/internal/resmap" "github.com/Azure/aztfy/internal/utils" + "github.com/Azure/aztfy/pkg/telemetry" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" @@ -97,6 +97,8 @@ type baseMeta struct { originBaseState []byte // The current base state, which is mutated during the importing baseState []byte + + tc telemetry.Client } func NewBaseMeta(cfg config.CommonConfig) (*baseMeta, error) { @@ -176,6 +178,12 @@ func NewBaseMeta(cfg config.CommonConfig) (*baseMeta, error) { if outputFileNames.MainFileName == "" { outputFileNames.MainFileName = "main.tf" } + + tc := cfg.TelemetryClient + if tc == nil { + tc = telemetry.NewNullClient() + } + meta := &baseMeta{ subscriptionId: cfg.SubscriptionId, azureSDKCred: cfg.AzureSDKCredential, @@ -193,6 +201,8 @@ func NewBaseMeta(cfg config.CommonConfig) (*baseMeta, error) { moduleAddr: moduleAddr, moduleDir: moduleDir, + + tc: tc, } return meta, nil @@ -203,6 +213,8 @@ func (meta baseMeta) Workspace() string { } func (meta *baseMeta) Init(ctx context.Context) error { + meta.tc.Trace(telemetry.Info, "Init Enter") + defer meta.tc.Trace(telemetry.Info, "Init Leave") // Create the import directories per parallelism var importBaseDirs []string var importModuleDirs []string @@ -266,6 +278,8 @@ func (meta *baseMeta) Init(ctx context.Context) error { } func (meta baseMeta) DeInit(_ context.Context) error { + meta.tc.Trace(telemetry.Info, "DeInit Enter") + defer meta.tc.Trace(telemetry.Info, "DeInit Leave") // Clean up the temporary workspaces for parallel import for _, dir := range meta.importBaseDirs { // #nosec G104 @@ -280,6 +294,8 @@ func (meta *baseMeta) CleanTFState(ctx context.Context, addr string) { } func (meta *baseMeta) ParallelImport(ctx context.Context, items []*ImportItem) error { + meta.tc.Trace(telemetry.Info, "ParallelImport Enter") + defer meta.tc.Trace(telemetry.Info, "ParallelImport Leave") itemsCh := make(chan *ImportItem, len(items)) for _, item := range items { itemsCh <- item @@ -296,7 +312,7 @@ func (meta *baseMeta) ParallelImport(ctx context.Context, items []*ImportItem) e stateFile := filepath.Join(meta.importBaseDirs[idx], "terraform.tfstate") // Don't merge state file if this import dir doesn't contain state file, which can because either this import dir imported nothing, or it encountered import error - if _, err := os.Stat(stateFile); errors.Is(err, os.ErrNotExist) { + if _, err := os.Stat(stateFile); os.IsNotExist(err) { return nil } // Ensure the state file is removed after this round import, preparing for the next round. @@ -374,6 +390,8 @@ func (meta *baseMeta) ParallelImport(ctx context.Context, items []*ImportItem) e } func (meta baseMeta) PushState(ctx context.Context) error { + meta.tc.Trace(telemetry.Info, "PushState Enter") + defer meta.tc.Trace(telemetry.Info, "PushState Leave") // Don't push state if there is no state to push. This might happen when all the resources failed to import with "--continue". if len(meta.baseState) == 0 { return nil @@ -413,6 +431,8 @@ func (meta baseMeta) PushState(ctx context.Context) error { } func (meta baseMeta) GenerateCfg(ctx context.Context, l ImportList) error { + meta.tc.Trace(telemetry.Info, "GenerateCfg Enter") + defer meta.tc.Trace(telemetry.Info, "GenerateCfg Leave") return meta.generateCfg(ctx, l, meta.lifecycleAddon, meta.addDependency) } @@ -697,11 +717,17 @@ func (meta *baseMeta) importItem(ctx context.Context, item *ImportItem, importId if meta.moduleAddr != "" { addr = meta.moduleAddr + "." + addr } + log.Printf("[INFO] Importing %s as %s", item.TFResourceId, addr) + // The actual resource type names in telemetry is redacted + meta.tc.Trace(telemetry.Info, fmt.Sprintf("Importing %s as %s", item.AzureResourceID.TypeString(), addr)) + err := tf.Import(ctx, addr, item.TFResourceId) if err != nil { - err = fmt.Errorf("importing %s: %w", item.TFAddr, err) - log.Printf("[ERROR] %v", err) + log.Printf("[ERROR] Importing %s: %v", item.TFAddr, err) + meta.tc.Trace(telemetry.Error, fmt.Sprintf("Importing %s: %v", item.AzureResourceID.TypeString(), err)) + } else { + meta.tc.Trace(telemetry.Info, fmt.Sprintf("Importing %s as %s successfully", item.AzureResourceID.TypeString(), addr)) } item.ImportError = err item.Imported = err == nil diff --git a/main.go b/main.go index c6b3880..a4d6a45 100644 --- a/main.go +++ b/main.go @@ -3,17 +3,22 @@ package main import ( "bytes" "context" + "encoding/json" "fmt" "io" golog "log" "os" "os/exec" + "path/filepath" "sort" "strconv" "strings" + "github.com/Azure/aztfy/internal/cfgfile" internalconfig "github.com/Azure/aztfy/internal/config" "github.com/Azure/aztfy/internal/meta" + "github.com/Azure/aztfy/pkg/telemetry" + "github.com/gofrs/uuid" "github.com/pkg/profile" "github.com/Azure/aztfy/pkg/config" @@ -82,7 +87,70 @@ func main() { flagResType string ) - beforeFunc := func(ctx *cli.Context) error { + prepareConfigFile := func(ctx *cli.Context) error { + // Prepare the config directory at $HOME/.aztfy + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("retrieving the user's HOME directory: %v", err) + } + configDir := filepath.Join(homeDir, cfgfile.CfgDirName) + if err := os.MkdirAll(configDir, 0750); err != nil { + return fmt.Errorf("creating the config directory at %s: %v", configDir, err) + } + configFile := filepath.Join(configDir, cfgfile.CfgFileName) + + _, err = os.Stat(configFile) + if err == nil { + return nil + } + if !os.IsNotExist(err) { + return nil + } + + // Generate a configuration file if not exist. + + // Get the installation id from following sources in order: + // 1. The Azure CLI's configuration file + // 2. The Azure PWSH's configuration file + // 3. Generate one + id, err := func() (string, error) { + if id, err := cfgfile.GetInstallationIdFromCLI(); err == nil { + return id, nil + } + log.Printf("[DEBUG] Installation ID not found from Azure CLI: %v", err) + + if id, err := cfgfile.GetInstallationIdFromPWSH(); err == nil { + return id, nil + } + log.Printf("[DEBUG] Installation ID not found from Azure PWSH: %v", err) + + uuid, err := uuid.NewV4() + if err != nil { + return "", fmt.Errorf("generating installation id: %w", err) + } + return uuid.String(), nil + }() + + if err != nil { + return err + } + + cfg := cfgfile.Configuration{ + InstallationId: id, + TelemetryEnabled: true, + } + b, err := json.Marshal(cfg) + if err != nil { + return fmt.Errorf("marshalling the configuration file: %v", err) + } + // #nosec G306 + if err := os.WriteFile(configFile, b, 0644); err != nil { + return fmt.Errorf("writing the configuration file: %v", err) + } + return nil + } + + commandBeforeFunc := func(ctx *cli.Context) error { // Common flags check if flagAppend { if flagBackendType != "local" { @@ -369,14 +437,72 @@ The output directory is not empty. Please choose one of actions below: Version: getVersion(), Usage: "Bring existing Azure resources under Terraform's management", UsageText: "aztfy [command] [option]", + Before: prepareConfigFile, Commands: []*cli.Command{ + { + Name: "config", + Usage: `aztfy configuration command`, + UsageText: "aztfy config [subcommand]", + Subcommands: []*cli.Command{ + { + Name: "set", + Usage: `Set a configuration item for aztfy`, + UsageText: "aztfy config set key value", + Action: func(c *cli.Context) error { + if c.NArg() != 2 { + return fmt.Errorf("Please specify a configuration key and value") + } + + key := c.Args().Get(0) + value := c.Args().Get(1) + + return cfgfile.SetKey(key, value) + }, + }, + { + Name: "get", + Usage: `Get a configuration item for aztfy`, + UsageText: "aztfy config get key", + Action: func(c *cli.Context) error { + if c.NArg() != 1 { + return fmt.Errorf("Please specify a configuration key") + } + + key := c.Args().Get(0) + v, err := cfgfile.GetKey(key) + if err != nil { + return err + } + fmt.Println(v) + return nil + }, + }, + { + Name: "show", + Usage: `Show the full configuration for aztfy`, + UsageText: "aztfy config show", + Action: func(c *cli.Context) error { + cfg, err := cfgfile.GetConfig() + if err != nil { + return err + } + b, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + fmt.Println(string(b)) + return nil + }, + }, + }, + }, { Name: "resource", Aliases: []string{"res"}, Usage: "Terrafying a single resource", UsageText: "aztfy resource [option] ", Flags: resourceFlags, - Before: beforeFunc, + Before: commandBeforeFunc, Action: func(c *cli.Context) error { if c.NArg() == 0 { return fmt.Errorf("No resource id specified") @@ -411,6 +537,7 @@ The output directory is not empty. Please choose one of actions below: Parallelism: flagParallelism, HCLOnly: flagHCLOnly, ModulePath: flagModulePath, + TelemetryClient: initTelemetryClient(), }, ResourceId: resId, TFResourceName: flagResName, @@ -430,7 +557,7 @@ The output directory is not empty. Please choose one of actions below: Usage: "Terrafying a resource group and the nested resources resides within it", UsageText: "aztfy resource-group [option] ", Flags: resourceGroupFlags, - Before: beforeFunc, + Before: commandBeforeFunc, Action: func(c *cli.Context) error { if c.NArg() == 0 { return fmt.Errorf("No resource group specified") @@ -461,6 +588,7 @@ The output directory is not empty. Please choose one of actions below: Parallelism: flagParallelism, HCLOnly: flagHCLOnly, ModulePath: flagModulePath, + TelemetryClient: initTelemetryClient(), }, ResourceGroupName: rg, ResourceNamePattern: flagPattern, @@ -479,7 +607,7 @@ The output directory is not empty. Please choose one of actions below: Usage: "Terrafying a customized scope of resources determined by an Azure Resource Graph where predicate", UsageText: "aztfy query [option] ", Flags: queryFlags, - Before: beforeFunc, + Before: commandBeforeFunc, Action: func(c *cli.Context) error { if c.NArg() == 0 { return fmt.Errorf("No query specified") @@ -510,6 +638,7 @@ The output directory is not empty. Please choose one of actions below: Parallelism: flagParallelism, HCLOnly: flagHCLOnly, ModulePath: flagModulePath, + TelemetryClient: initTelemetryClient(), }, ARGPredicate: predicate, ResourceNamePattern: flagPattern, @@ -529,7 +658,7 @@ The output directory is not empty. Please choose one of actions below: Usage: "Terrafying a customized scope of resources determined by the resource mapping file", UsageText: "aztfy mapping-file [option] ", Flags: mappingFileFlags, - Before: beforeFunc, + Before: commandBeforeFunc, Action: func(c *cli.Context) error { if c.NArg() == 0 { return fmt.Errorf("No resource mapping file specified") @@ -560,6 +689,7 @@ The output directory is not empty. Please choose one of actions below: Parallelism: flagParallelism, HCLOnly: flagHCLOnly, ModulePath: flagModulePath, + TelemetryClient: initTelemetryClient(), }, MappingFile: mapFile, } @@ -599,9 +729,14 @@ func logLevel(level string) (hclog.Level, error) { } } -func initLog(path string, level hclog.Level) error { +func initLog(path string, flagLevel string) error { golog.SetOutput(io.Discard) + level, err := logLevel(flagLevel) + if err != nil { + return err + } + if path != "" { // #nosec G304 f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0600) @@ -632,6 +767,31 @@ func initLog(path string, level hclog.Level) error { return nil } +func initTelemetryClient() telemetry.Client { + cfg, err := cfgfile.GetConfig() + if err != nil { + return telemetry.NewNullClient() + } + enabled, id := cfg.TelemetryEnabled, cfg.InstallationId + if !enabled { + return telemetry.NewNullClient() + } + if id == "" { + uuid, err := uuid.NewV4() + if err == nil { + id = uuid.String() + } else { + id = "undefined" + } + } + + sessionId := "undefined" + if uuid, err := uuid.NewV4(); err == nil { + sessionId = uuid.String() + } + return telemetry.NewAppInsight(id, sessionId) +} + // buildAzureSDKCredAndClientOpt builds the Azure SDK credential and client option from multiple sources (i.e. environment variables, MSI, Azure CLI). func buildAzureSDKCredAndClientOpt() (azcore.TokenCredential, *arm.ClientOptions, error) { env := "public" @@ -721,25 +881,26 @@ func realMain(ctx context.Context, cfg config.Config, batch, mockMeta, plainUI, } // Initialize log - logLevel, err := logLevel(flagLogLevel) - if err != nil { - result = err - return - } - if err := initLog(flagLogPath, logLevel); err != nil { + if err := initLog(flagLogPath, flagLogLevel); err != nil { result = err return } + tc := cfg.TelemetryClient + defer func() { if result == nil { log.Printf("[INFO] aztfy ends") + tc.Trace(telemetry.Info, "aztfy ends") } else { log.Printf("[ERROR] aztfy ends with error: %v", result) + tc.Trace(telemetry.Error, fmt.Sprintf("aztfy ends with error: %v", result)) } + tc.Close() }() log.Printf("[INFO] aztfy starts with config: %#v", cfg) + tc.Trace(telemetry.Info, "aztfy starts") // Run in non-interactive mode if batch { diff --git a/pkg/config/config.go b/pkg/config/config.go index 7de133c..0a75acc 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -1,6 +1,7 @@ package config import ( + "github.com/Azure/aztfy/pkg/telemetry" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" ) @@ -49,6 +50,8 @@ type CommonConfig struct { // HCLOnly is a strange field, which is only used internally by aztfy to indicate whether to remove other files other than TF config at the end. // External Go modules shoudl just ignore it. HCLOnly bool + // TelemetryClient is a client to send telemetry + TelemetryClient telemetry.Client } type Config struct { diff --git a/pkg/telemetry/telemetry.go b/pkg/telemetry/telemetry.go new file mode 100644 index 0000000..ef76f27 --- /dev/null +++ b/pkg/telemetry/telemetry.go @@ -0,0 +1,68 @@ +package telemetry + +import ( + "encoding/json" + + "github.com/microsoft/ApplicationInsights-Go/appinsights" + "github.com/microsoft/ApplicationInsights-Go/appinsights/contracts" +) + +type Level int + +const ( + Verbose Level = iota + Info + Warn + Error + Critical +) + +type Client interface { + Trace(level Level, msg string) + Close() +} + +type NullClient struct{} + +func NewNullClient() Client { + return NullClient{} +} + +func (NullClient) Trace(Level, string) {} +func (NullClient) Close() {} + +type AppInsightClient struct { + appinsights.TelemetryClient + installId string + sessionId string +} + +func NewAppInsight(installId string, sessionid string) Client { + // The instrument key of a MS managed application insights + const instrumentKey = "1bfe1d29-b42e-49b5-9d51-77514f85b37b" + return AppInsightClient{ + TelemetryClient: appinsights.NewTelemetryClient(instrumentKey), + installId: installId, + sessionId: sessionid, + } +} + +type ApplicationInsightMessage struct { + InstallationId string `json:"installation_id"` + SessionId string `json:"session_id"` + Payload string `json:"payload"` +} + +func (c AppInsightClient) Trace(level Level, payload string) { + msg := ApplicationInsightMessage{ + InstallationId: c.installId, + SessionId: c.sessionId, + Payload: payload, + } + b, _ := json.Marshal(msg) + c.TrackTrace(string(b), contracts.SeverityLevel(level)) +} + +func (c AppInsightClient) Close() { + <-c.Channel().Close() +}