Initial commit

This commit is contained in:
mitch 2022-02-23 13:15:12 -05:00
commit 8b24af46e1
14 changed files with 907 additions and 0 deletions

82
.gitignore vendored Normal file
View File

@ -0,0 +1,82 @@
# File created using '.gitignore Generator' for Visual Studio Code: https://bit.ly/vscode-gig
# Created by https://www.gitignore.io/api/macos,visualstudiocode,terraform,windows
# Edit at https://www.gitignore.io/?templates=macos,visualstudiocode,terraform,windows
### macOS ###
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
# Crash log files
crash.log
# Include override files you do wish to add to version control using negated pattern
#
# !example_override.tf
### VisualStudioCode ###
.vscode/*
#!.vscode/settings.json
#!.vscode/tasks.json
#!.vscode/launch.json
#!.vscode/extensions.json
### VisualStudioCode Patch ###
# Ignore all local history of files
.history
### Windows ###
# Windows thumbnail cache files
Thumbs.db
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
# End of https://www.gitignore.io/api/macos,visualstudiocode,terraform,windows
# Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option)
.vscode/*
.idea

27
Makefile Normal file
View File

@ -0,0 +1,27 @@
####################################################
# Build
####################################################
build:
go build -o versionedTerraform ./cmd
####################################################
# Clean
####################################################
clean:
rm -f ~/.local/bin/versionedTerraform
####################################################
# Install
####################################################
install:
mv versionedTerraform ~/.local/bin/
####################################################
# help feature
####################################################
help:
@echo ''
@echo 'Usage: make [TARGET]'
@echo 'Targets:'
@echo ' build go build -o versionedTerraform ./cmd'
@echo ' clean removes installed versionedTerraform file'
@echo ' install installs versionedTerraform to local user bin folder'
@echo ' all Nothing to do.'
@echo ''

22
README.md Normal file
View File

@ -0,0 +1,22 @@
#Versioned Terraform
A wrapper for terraform to detect the expected version of terraform,
download, and execute that version
## Requirements
- go compiler (only tested on go1.17)
## Install
`make build install` for installation to local user<br>
`make build` will create an executable file for you to place where you'd like
## Commands
```
All arguments are passed through to terraform
```
## sample usage
`versionedTerraform version` will display the terraform version executed in a folder
## Known Issues
Currently, does not support semantic versioning between values<br>
i.e. `required_version = "~> 0.14", "< 0.14.3"`

73
SemVersion.go Normal file
View File

@ -0,0 +1,73 @@
package versionedTerraform
import (
"strconv"
"strings"
)
const (
//todo include others if needed
//todo add comparison i.e. >= 0.11.10, < 0.12.0
latestRelease = ">="
latestPatch = "~>"
)
type SemVersion struct {
version string
majorVersion int
minorVersion int
patchVersion int
}
type SemVersionInterface interface {
setMajorVersion()
setMinorVersion()
setPatchVersion()
}
func NewSemVersion(v string) *SemVersion {
s := new(SemVersion)
s.version = removeSpacesVersion(v)
s.setMajorVersion()
s.setMinorVersion()
s.setPatchVersion()
return s
}
func (s *SemVersion) setMajorVersion() {
version := s.version
majorVersionString := strings.Split(version, ".")[0]
s.majorVersion, _ = strconv.Atoi(majorVersionString)
}
func (s *SemVersion) setMinorVersion() {
version := s.version
minorVersionString := strings.Split(version, ".")[1]
s.minorVersion, _ = strconv.Atoi(minorVersionString)
}
func (s *SemVersion) setPatchVersion() {
version := s.version
patchStringSlice := strings.Split(version, ".")
if len(patchStringSlice) < 3 {
s.patchVersion = 0
return
}
s.patchVersion, _ = strconv.Atoi(patchStringSlice[2])
}
func (s *SemVersion) ToString() string {
return s.version
}
func (s *SemVersion) VersionInSlice(sSem []SemVersion) bool {
for _, ver := range sSem {
if ver.ToString() == s.ToString() {
return true
}
}
return false
}

7
cmd/examples/example.tf Normal file
View File

@ -0,0 +1,7 @@
terraform {
required_version = "~> 0.14"
}
output "hello" {
value = "world"
}

76
cmd/main.go Normal file
View File

@ -0,0 +1,76 @@
package main
import (
"flag"
"fmt"
"os"
"os/exec"
"versionedTerraform"
)
const (
configFileLocation = "config"
shortConfigDirString = "/.versionedTerraform"
pwd = "."
terraformPrefix = "/terraform_"
)
func main() {
homeDir, _ := os.UserHomeDir()
configDirString := homeDir + shortConfigDirString
configDir := os.DirFS(configDirString)
workingDir := os.DirFS(pwd)
var versionsFromConfig []versionedTerraform.SemVersion
flag.Parse()
args := flag.Args()
needsUpdate, err := versionedTerraform.NeedToUpdateAvailableVersions(configDir, configFileLocation)
if needsUpdate {
fileHandle, _ := os.OpenFile(configDirString+"/"+configFileLocation, os.O_RDWR, 0666)
defer fileHandle.Close()
versionedTerraform.UpdateConfig(*fileHandle)
}
versionsFromConfig, err = versionedTerraform.LoadVersionsFromConfig(configDir, configFileLocation)
if err != nil {
fmt.Printf("Unable to read config: %v", err)
os.Exit(1)
}
installedVersions, err := versionedTerraform.LoadInstalledVersions(configDir)
if err != nil {
fmt.Printf("Unable to verify installed verisons: %v", err)
os.Exit(1)
}
var vSlice []string
for _, v := range versionsFromConfig {
vSlice = append(vSlice, v.ToString())
}
ver, err := versionedTerraform.GetVersionFromFile(workingDir, vSlice)
if err != nil {
fmt.Printf("Unable to retrieve terraform version from files: %v", err)
}
if !ver.Version.VersionInSlice(installedVersions) {
fmt.Printf("Installing terraform version %s\n\n", ver.Version.ToString())
err = ver.InstallTerraformVersion()
if err != nil {
fmt.Printf("Unable to install terraform version: %v", err)
}
}
terraformFile := configDirString + terraformPrefix + ver.VersionToString()
argsForTerraform := append([]string{""}, args...)
cmd := exec.Cmd{
Path: terraformFile,
Args: argsForTerraform,
Env: os.Environ(),
Dir: pwd,
Stdin: os.Stdin,
Stdout: os.Stdout,
Stderr: os.Stderr,
}
cmd.Run()
}

111
configManagement.go Normal file
View File

@ -0,0 +1,111 @@
package versionedTerraform
import (
"bufio"
"fmt"
"io/fs"
"os"
"regexp"
"strconv"
"strings"
"time"
)
type configStruct struct {
LastUpdate int64
AvailableVersions []string
}
func NeedToUpdateAvailableVersions(fileSystem fs.FS, availableVersions string) (bool, error) {
//todo this is used a lot abstract it?
fileHandle, err := fileSystem.Open(availableVersions)
oneDayAgo := time.Now().AddDate(0, 0, -1).Unix()
if err != nil {
return false, err
}
defer fileHandle.Close()
fileScanner := bufio.NewScanner(fileHandle)
fileScanner.Split(bufio.ScanLines)
for fileScanner.Scan() {
_line := fileScanner.Text()
if strings.Contains(_line, "LastUpdate: ") {
lastUpdateTimeString := strings.SplitAfter(_line, "LastUpdate: ")[1]
lastUpdateTimeString = strings.TrimSpace(lastUpdateTimeString)
lastUpdateTime, err := strconv.ParseInt(lastUpdateTimeString, 10, 64)
if err != nil {
return false, err
}
if lastUpdateTime <= oneDayAgo {
return true, nil
}
}
}
return false, nil
}
func LoadVersionsFromConfig(fileSystem fs.FS, configFile string) ([]SemVersion, error) {
fileHandle, err := fileSystem.Open(configFile)
removeOpenBracket := regexp.MustCompile("\\[")
removeCloseBracket := regexp.MustCompile("]")
if err != nil {
return nil, err
}
defer fileHandle.Close()
fileScanner := bufio.NewScanner(fileHandle)
fileScanner.Split(bufio.ScanLines)
for fileScanner.Scan() {
_line := fileScanner.Text()
if strings.Contains(_line, "AvailableVersions: ") {
var versionList []SemVersion
_line = strings.SplitAfter(_line, "AvailableVersions: ")[1]
_line = removeOpenBracket.ReplaceAllString(_line, "")
_line = removeCloseBracket.ReplaceAllString(_line, "")
versions := strings.Split(_line, " ")
for _, version := range versions {
versionList = append(versionList, *NewSemVersion(version))
}
return versionList, nil
}
}
return nil, nil
}
func LoadInstalledVersions(fileSystem fs.FS) ([]SemVersion, error) {
dir, err := fs.ReadDir(fileSystem, ".")
var installedTerraformVersions []SemVersion
terraformRegex := regexp.MustCompile(terraformPrefix)
if err != nil {
return nil, err
}
for _, f := range dir {
terraformFileName := f.Name()
if strings.Contains(terraformFileName, terraformPrefix) {
terraformVersionString := terraformRegex.ReplaceAllString(terraformFileName, "")
installedTerraformVersions = append(installedTerraformVersions, *NewSemVersion(terraformVersionString))
}
}
return installedTerraformVersions, nil
}
func UpdateConfig(File os.File) error {
configValues := new(configStruct)
configValues.AvailableVersions, _ = GetVersionList()
timeNow := time.Now()
configValues.LastUpdate = timeNow.Unix()
File.Truncate(0)
File.Seek(0, 0)
lineToByte := []byte(fmt.Sprintf("LastUpdate: %d\n", configValues.LastUpdate))
File.Write(lineToByte)
lineToByte = []byte(fmt.Sprintf("AvailableVersions: %+v\n", configValues.AvailableVersions))
File.Write(lineToByte)
return nil
}

112
configManagement_test.go Normal file
View File

@ -0,0 +1,112 @@
package versionedTerraform
import (
"fmt"
"reflect"
"sort"
"testing"
"testing/fstest"
"time"
)
func TestUpdateAvailableVersions(t *testing.T) {
timeNow := time.Now()
currentTime := timeNow.Unix()
twoDaysAgoTime := timeNow.AddDate(0, 0, -2).Unix()
successUpdate := fmt.Sprintf("LastUpdate: %d", currentTime)
needsUpdate := fmt.Sprintf("LastUpdate: %d", twoDaysAgoTime)
fs := fstest.MapFS{
"successConfig.conf": {Data: []byte(successUpdate)},
"failConfig.conf": {Data: []byte(needsUpdate)},
}
t.Run("Test success last update time", func(t *testing.T) {
want := true
got, err := NeedToUpdateAvailableVersions(fs, "successConfig.conf")
if err != nil {
t.Fatal(err)
}
if got != want {
t.Errorf("updateAvailableVersions had incorrect output expected %v got %v", got, want)
}
})
t.Run("Test failed last update time", func(t *testing.T) {
want := false
got, err := NeedToUpdateAvailableVersions(fs, "failConfig.conf")
if err != nil {
t.Fatal(err)
}
if got != want {
t.Errorf("updateAvailableVersions had incorrect output expected %v got %v", got, want)
}
})
}
func TestAvailableVersions(t *testing.T) {
availableVersionList := fmt.Sprintf("AvailableVersions: %+v", testVersionList())
var want []SemVersion
for _, version := range testVersionList() {
want = append(want, *NewSemVersion(version))
}
fs := fstest.MapFS{
"successConfig.conf": {Data: []byte(availableVersionList)},
}
t.Run("Test success last update time", func(t *testing.T) {
got, err := LoadVersionsFromConfig(fs, "successConfig.conf")
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, want) {
t.Errorf("LoadInstalledVersions had incorrect output expected %+v\n got %+v", want, got)
}
})
}
func TestInstalledVersions(t *testing.T) {
var want []SemVersion
testVersionList := testVersionList()
sort.Strings(testVersionList)
for _, version := range testVersionList {
want = append(want, *NewSemVersion(version))
}
fs := fstest.MapFS{
"terraform_0.12.31": {Data: []byte("")},
"terraform_0.12.30": {Data: []byte("")},
"terraform_0.11.10": {Data: []byte("")},
"terraform_0.11.15": {Data: []byte("")},
"terraform_1.0.1": {Data: []byte("")},
"terraform_1.0.12": {Data: []byte("")},
"terraform_1.1.1": {Data: []byte("")},
"terraform_1.1.2": {Data: []byte("")},
"terraform_1.1.3": {Data: []byte("")},
"terraform_1.1.4": {Data: []byte("")},
"terraform_1.1.5": {Data: []byte("")},
"terraform_1.1.6": {Data: []byte("")},
"terraform_1.1.7": {Data: []byte("")},
"terraform_1.1.8": {Data: []byte("")},
"terraform_1.1.9": {Data: []byte("")},
"terraform_1.1.10": {Data: []byte("")},
"terraform_1.1.11": {Data: []byte("")},
}
t.Run("Test installed versions", func(t *testing.T) {
got, err := LoadInstalledVersions(fs)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, want) {
t.Errorf("LoadInstalledVersions had incorrect output expected\n %+v\n got %+v", want, got)
}
})
}

56
fileHandler.go Normal file
View File

@ -0,0 +1,56 @@
package versionedTerraform
import (
"bufio"
"io/fs"
"regexp"
"strings"
)
//todo this should be (Version) GetVers...
func GetVersionFromFile(fileSystem fs.FS, versionList []string) (*Version, error) {
var versionFinal Version
dir, err := fs.ReadDir(fileSystem, ".")
if err != nil {
return &versionFinal, err
}
for _, f := range dir {
version, isFinished, err := parseVersionFromFile(fileSystem, f.Name(), versionList)
if err != nil {
return &versionFinal, err
}
if isFinished {
versionFinal = *version
break
}
}
return &versionFinal, nil
}
//todo same here
func parseVersionFromFile(f fs.FS, fileName string, versionList []string) (*Version, bool, error) {
fileHandle, err := f.Open(fileName)
regex := regexp.MustCompile("required_version\\s+?=")
isComment := "^\\s?#"
removeQuotes := regexp.MustCompile("\"")
if err != nil {
return &Version{}, false, err
}
defer fileHandle.Close()
fileScanner := bufio.NewScanner(fileHandle)
fileScanner.Split(bufio.ScanLines)
for fileScanner.Scan() {
_line := fileScanner.Text()
isComment, _ := regexp.MatchString(isComment, _line)
if strings.Contains(_line, "required_version") && !isComment {
_line = regex.ReplaceAllString(_line, "")
_line = removeQuotes.ReplaceAllString(_line, "")
return NewVersion(_line, versionList), true, nil
}
}
return &Version{}, false, nil
}

49
fileHandler_test.go Normal file
View File

@ -0,0 +1,49 @@
package versionedTerraform
import (
"testing"
"testing/fstest"
)
func TestFileHandler(t *testing.T) {
const (
firstFile = `
resource "aws_mq_broker" "sample" {
depends_on = [aws_security_group.mq]
broker_name = var.name
engine_type = "ActiveMQ"
engine_version = var.mqEngineVersion
host_instance_type = var.hostInstanceType
security_groups = [aws_security_groups.mq.id]
apply_immediately = "true"
deployment_mode = "ACTIVE_STANDBY_MULTI_AZ"
auto_minor_version_upgrade = "true"
subnet_ids = ["10.0.0.0/24", "10.0.1.0/24"]
}
`
secondFile = `
terraform {
required_version = "~> 0.12.4"
}
`
)
want := NewVersion("0.12.31", testVersionList())
fs := fstest.MapFS{
"main.tf": {Data: []byte(firstFile)},
"versions.tf": {Data: []byte(secondFile)},
}
version, err := GetVersionFromFile(fs, testVersionList())
if err != nil {
t.Fatal(err)
}
got := *version
if got.Version != want.Version {
t.Errorf("Expected %v, got %v", want.Version, got.Version)
}
}

5
go.mod Normal file
View File

@ -0,0 +1,5 @@
module versionedTerraform
go 1.17
require github.com/blang/semver/v4 v4.0.0

2
go.sum Normal file
View File

@ -0,0 +1,2 @@
github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=
github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=

194
versionedTerraform.go Normal file
View File

@ -0,0 +1,194 @@
package versionedTerraform
import (
"archive/zip"
"bufio"
"bytes"
"errors"
"io"
"io/ioutil"
"net/http"
"os"
"strings"
)
func (*Version) latestMajorVersion() {
}
func (*Version) latestMinorVersion() {
}
func (*Version) latestPatchVersion() {
}
type Version struct {
Version SemVersion
availableVersions []SemVersion
installedVersions []SemVersion
}
const (
hashicorpUrl = "https://releases.hashicorp.com/terraform/"
terraformPrefix = "terraform_"
fileSuffix = "_linux_amd64.zip"
versionedTerraformFolder = "/.versionedTerraform"
)
//getLatestMajorRelease() returns the latest major release from Version
func (v *Version) getLatestMajorRelease() {
//todo clean up
for _, release := range v.availableVersions {
if release.majorVersion == v.Version.majorVersion &&
release.minorVersion == v.Version.minorVersion &&
release.patchVersion >= v.Version.patchVersion {
v.Version = release
}
}
}
//getLatestRelease returns the latest release from Version
func (v *Version) getLatestRelease() {
//todo clean up
for _, release := range v.availableVersions {
if release.majorVersion > v.Version.majorVersion {
v.Version = release
}
if release.majorVersion >= v.Version.majorVersion &&
release.minorVersion > v.Version.minorVersion {
v.Version = release
}
if release.majorVersion >= v.Version.majorVersion &&
release.minorVersion >= v.Version.minorVersion &&
release.patchVersion >= v.Version.patchVersion {
v.Version = release
}
}
}
//InstallTerraformVersion installs the defined terraform Version in the application
//configuration directory
func (v *Version) InstallTerraformVersion() error {
homeDir, _ := os.UserHomeDir()
resp, err := http.Get(hashicorpUrl +
v.Version.ToString() +
"/" + terraformPrefix +
v.Version.ToString() +
fileSuffix)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return err
}
zipReader, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
if err != nil {
return err
}
versionedFileName := homeDir + versionedTerraformFolder + "/" + terraformPrefix + v.Version.ToString()
versionedFile, err := os.OpenFile(versionedFileName, os.O_WRONLY, 0755)
if os.IsNotExist(err) {
//_, err = os.Create(versionedFileName)
//if err != nil {
// return err
//}
versionedFile, err = os.OpenFile(versionedFileName, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0755)
if err != nil {
return err
}
}
if err != nil {
return err
}
defer versionedFile.Close()
for _, zipFIle := range zipReader.File {
zr, err := zipFIle.Open()
if err != nil {
return err
}
unzippedFileBytes, _ := ioutil.ReadAll(zr)
_, err = versionedFile.Write(unzippedFileBytes)
if err != nil {
return err
}
zr.Close()
}
return nil
}
//NewVersion creates a new Version using sem versioning for determining the
//latest release
func NewVersion(_version string, _vList []string) *Version {
v := new(Version)
v.Version = *NewSemVersion(_version)
for _, release := range _vList {
v.availableVersions = append(v.availableVersions, *NewSemVersion(release))
}
switch {
case strings.Contains(v.Version.ToString(), latestRelease):
release := strings.Split(v.Version.ToString(), latestRelease)[1]
v.Version = *NewSemVersion(release)
v.getLatestRelease()
case strings.Contains(v.Version.ToString(), latestPatch):
release := strings.Split(v.Version.ToString(), latestPatch)[1]
v.Version = *NewSemVersion(release)
v.getLatestMajorRelease()
}
return v
}
//GetVersionList returns a list of available versions from hashicorp's release page
func GetVersionList() ([]string, error) {
var versionList []string
resp, err := http.Get(hashicorpUrl)
if err != nil {
return versionList, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return versionList, errors.New("invalid response code")
}
body, err := io.ReadAll(resp.Body)
//todo maybe change this like GetVersionFromFile and consolidate
bodyText := string(body)
scanner := bufio.NewScanner(strings.NewReader(bodyText))
for scanner.Scan() {
_line := scanner.Text()
if strings.Contains(_line, "a href") {
_lineSplice := strings.Split(_line, terraformPrefix)
if len(_lineSplice) == 2 {
_line = _lineSplice[1]
_line = strings.Split(_line, "</a")[0]
versionList = append(versionList, _line)
}
}
}
return versionList, nil
}
//removeSpacesVersion removes spaces from Version string for parsing
func removeSpacesVersion(v string) string {
splitV := strings.Split(v, " ")
var returnString string
for _, version := range splitV {
version := strings.TrimSpace(version)
returnString += version
}
return strings.TrimSpace(returnString)
}
//VersionToString returns string of a Version
func (v *Version) VersionToString() string {
return v.Version.ToString()
}

View File

@ -0,0 +1,91 @@
package versionedTerraform
import (
"testing"
)
func testVersionList() []string {
return []string{
"1.1.11",
"1.1.10",
"1.1.9",
"1.1.8",
"1.1.7",
"1.1.6",
"1.1.5",
"1.1.4",
"1.1.3",
"1.1.2",
"1.1.1",
"1.0.12",
"1.0.1",
"0.12.31",
"0.12.30",
"0.11.15",
"0.11.10",
}
}
func TestGetVersion(t *testing.T) {
cases := []struct {
available []string
version, expected string
}{
{testVersionList(), "0.12.31", "0.12.31"},
{testVersionList(), "0.12.30", "0.12.30"},
{testVersionList(), "~> 0.12.30", "0.12.31"},
{testVersionList(), "~>0.12.30", "0.12.31"},
{testVersionList(), "~>0.12.4", "0.12.31"},
{testVersionList(), ">= 0.11.15", "1.1.11"},
{testVersionList(), ">= 0.12.0", "1.1.11"},
{testVersionList(), "~> 0.12", "0.12.31"},
}
for _, c := range cases {
t.Run("test Version check with various conditions: "+c.version, func(t *testing.T) {
//t.Parallel()
got := NewVersion(c.version, c.available)
if got.Version.version != c.expected {
t.Errorf("got %q, want %q", got.Version.version, c.expected)
}
})
}
}
func TestRemoveSpacesVersion(t *testing.T) {
cases := []struct {
tesValue, want string
}{
{"test", "test"},
{"test ", "test"},
{" test", "test"},
{" test ", "test"},
{" test test ", "testtest"},
}
for _, c := range cases {
t.Run("test remove space in various conditions: "+c.tesValue, func(t *testing.T) {
t.Parallel()
got := removeSpacesVersion(c.tesValue)
if got != c.want {
t.Errorf("got %q, want %q", got, c.want)
}
})
}
}
func TestGetVersionList(t *testing.T) {
//todo write test for this
//response, _ := getVersionList()
//for _, Version := range response {
// t.Errorf("%v", Version)
//}
//t.Errorf("%v", response)
}
func TestInstallTerraformVersion(t *testing.T) {
//todo write test for this
//Version := NewVersion("0.12.31", testVersionList())
//response := Version.InstallTerraformVersion()
//t.Errorf("%v", response)
}