diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 461714c..d1b50a9 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -54,6 +54,11 @@ jobs: docker-compose up -d localstack docker-compose run --rm dockerize -wait tcp://localstack:4566 -timeout 60s docker-compose exec -T localstack /etc/localstack/init/wait_s3_bucket_exists.sh + - name: start azurite + run: | + docker-compose up -d azurite + docker-compose run --rm dockerize -wait tcp://azurite:10000 -timeout 60s + docker-compose run --rm az az storage container create --name testcontainer - name: terraform --version run: docker-compose run --rm tfmigrate terraform --version - name: testacc @@ -78,6 +83,11 @@ jobs: docker-compose up -d localstack docker-compose run --rm dockerize -wait tcp://localstack:4566 -timeout 60s docker-compose exec -T localstack /etc/localstack/init/wait_s3_bucket_exists.sh + - name: start azurite + run: | + docker-compose up -d azurite + docker-compose run --rm dockerize -wait tcp://azurite:10000 -timeout 60s + docker-compose run --rm az az storage container create --name testcontainer - name: tofu --version run: docker-compose run --rm tfmigrate tofu --version - name: testacc diff --git a/README.md b/README.md index a28212b..47a46b1 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ A Terraform / OpenTofu state migration tool for GitOps. * [storage block (local)](#storage-block-local) * [storage block (s3)](#storage-block-s3) * [storage block (gcs)](#storage-block-gcs) + * [storage block (azurerm)](#storage-block-azurerm) * [Migration file](#migration-file) * [Environment Variables](#environment-variables-1) * [migration block](#migration-block) @@ -429,6 +430,7 @@ The storage block has one label, which is a type of storage. Valid types are as - `local`: Save a history file to local filesystem. - `s3`: Save a history file to AWS S3. - `gcs`: Save a history file to GCS (Google Cloud Storage). +- `azurerm`: Save a history file to Azure Blob storage. If your cloud provider has not been supported yet, as a workaround, you can use `local` storage and synchronize a history file to your cloud storage with a wrapper script. @@ -512,6 +514,33 @@ tfmigrate { If you want to connect to an emulator instead of GCS, set the `STORAGE_EMULATOR_HOST` environment variable as required by the [Go library for GCS](https://pkg.go.dev/cloud.google.com/go/storage). +#### storage block (azurerm) + +The `azurerm` storage has the following attributes: + +- `access_key` (optional): Access key for the blob storage container. If omitted, this value is read from the `ARM_ACCESS_KEY` environment variable. +- `storage_account_name` (required): Name of the storage account. +- `container_name` (required): Name of the storage container. +- `key` (required): Name of the migration history file. + +Note that the `azurerm` backend expects that the storage account and container already exist. The blob will be created if missing. + +An example of configuration file is as follows. + +```hcl +tfmigrate { + migration_dir = "./tfmigrate" + history { + storage "azurerm" { + access_key = "" + storage_account_name = "storage" + container_name = "tfmigrate-test" + key = "tfmigrate-history.json" + } + } +} +``` + ## Migration file You can write terraform state operations in HCL. The syntax of migration file is as follows: diff --git a/config/storage.go b/config/storage.go index c6cd0e5..3587d1a 100644 --- a/config/storage.go +++ b/config/storage.go @@ -6,6 +6,7 @@ import ( "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/gohcl" "github.com/minamijoyo/tfmigrate/storage" + "github.com/minamijoyo/tfmigrate/storage/azure" "github.com/minamijoyo/tfmigrate/storage/gcs" "github.com/minamijoyo/tfmigrate/storage/local" "github.com/minamijoyo/tfmigrate/storage/mock" @@ -35,6 +36,9 @@ func parseStorageBlock(b StorageBlock) (storage.Config, error) { case "local": return parseLocalStorageBlock(b) + case "azurerm": + return parseAzureStorageBlock(b) + case "s3": return parseS3StorageBlock(b) @@ -68,6 +72,17 @@ func parseLocalStorageBlock(b StorageBlock) (storage.Config, error) { return &config, nil } +// parseAzureStorageBlock parses a storage block for azure and returns a storage.Config. +func parseAzureStorageBlock(b StorageBlock) (storage.Config, error) { + var config azure.Config + diags := gohcl.DecodeBody(b.Remain, nil, &config) + if diags.HasErrors() { + return nil, diags + } + + return &config, nil +} + // parseS3StorageBlock parses a storage block for s3 and returns a storage.Config. func parseS3StorageBlock(b StorageBlock) (storage.Config, error) { var config s3.Config diff --git a/docker-compose.yml b/docker-compose.yml index 687de49..0cbf0f2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,9 +23,21 @@ services: OPENTOFU_VERSION: ${OPENTOFU_VERSION:-latest} TFMIGRATE_EXEC_PATH: depends_on: + - azurite - localstack - fake-gcs-server + az: + image: mcr.microsoft.com/azure-cli:2.60.0 + environment: + AZURE_STORAGE_CONNECTION_STRING: "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://azurite:10000/devstoreaccount1;" + + azurite: + image: mcr.microsoft.com/azure-storage/azurite:3.30.0 + command: ["azurite-blob", "--blobHost", "0.0.0.0", "--inMemoryPersistence"] + ports: + - "10000:10000" + localstack: image: localstack/localstack:2.0.2 ports: diff --git a/go.mod b/go.mod index b36ca3d..bc15c81 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,8 @@ go 1.22 require ( cloud.google.com/go/storage v1.25.0 + github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.5.1 + github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.3.1 github.com/aws/aws-sdk-go v1.43.22 github.com/davecgh/go-spew v1.1.1 github.com/google/go-cmp v0.5.8 @@ -20,32 +22,39 @@ require ( cloud.google.com/go v0.102.1 // indirect cloud.google.com/go/compute v1.7.0 // indirect cloud.google.com/go/iam v0.3.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.2 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2 // indirect + github.com/AzureAD/microsoft-authentication-library-for-go v1.2.1 // indirect github.com/agext/levenshtein v1.2.1 // indirect github.com/apparentlymart/go-textseg v1.0.0 // indirect github.com/apparentlymart/go-textseg/v12 v12.0.0 // indirect github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310 // indirect github.com/bgentry/speakeasy v0.1.0 // indirect github.com/fatih/color v1.7.0 // indirect + github.com/golang-jwt/jwt/v5 v5.2.0 // indirect github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e // indirect github.com/golang/protobuf v1.5.2 // indirect - github.com/google/uuid v1.3.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.1.0 // indirect github.com/googleapis/gax-go/v2 v2.4.0 // indirect github.com/hashicorp/errwrap v1.0.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.0 // indirect github.com/hashicorp/go-multierror v1.0.0 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect github.com/mattn/go-colorable v0.0.9 // indirect github.com/mattn/go-isatty v0.0.3 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/posener/complete v1.1.1 // indirect github.com/zclconf/go-cty v1.2.0 // indirect go.opencensus.io v0.23.0 // indirect - golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e // indirect + golang.org/x/crypto v0.18.0 // indirect + golang.org/x/net v0.20.0 // indirect golang.org/x/oauth2 v0.0.0-20220622183110-fd043fe589d2 // indirect - golang.org/x/sys v0.0.0-20220624220833-87e55d714810 // indirect - golang.org/x/text v0.3.7 // indirect + golang.org/x/sys v0.16.0 // indirect + golang.org/x/text v0.14.0 // indirect golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f // indirect google.golang.org/api v0.88.0 // indirect google.golang.org/appengine v1.6.7 // indirect diff --git a/go.sum b/go.sum index 934cdc1..d91ab72 100644 --- a/go.sum +++ b/go.sum @@ -60,6 +60,20 @@ cloud.google.com/go/storage v1.22.1/go.mod h1:S8N1cAStu7BOeFfE8KAQzmyyLkK8p/vmRq cloud.google.com/go/storage v1.25.0 h1:D2Dn0PslpK7Z3B2AvuUHyIC762bDbGJdlmQlCBR71os= cloud.google.com/go/storage v1.25.0/go.mod h1:Qys4JU+jeup3QnuKKAosWuxrD95C4MSqxfVDnSirDsI= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.1 h1:lGlwhPtrX6EVml1hO0ivjkUxsSyl4dsiw9qcA1k/3IQ= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.1/go.mod h1:RKUqNu35KJYcVG/fqTRqmuXJZYNhYkBrnC/hX7yGbTA= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.2 h1:c4k2FIYIh4xtwqrQwV0Ct1v5+ehlNXj5NI/MWVsiTkQ= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.2/go.mod h1:5FDJtLEO/GxwNgUxbwrY3LP0pEoThTQJtk2oysdXHxM= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.5.1 h1:sO0/P7g68FrryJzljemN+6GTssUXdANk6aJ7T1ZxnsQ= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.5.1/go.mod h1:h8hyGFDsU5HMivxiS2iYFZsgDbU9OnnJ163x5UGVKYo= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.1 h1:6oNBlSdi1QqM1PNW7FPA6xOGA5UNsXnkaYZz9vdPGhA= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.1/go.mod h1:s4kgfzA0covAXNicZHDMN58jExvcng2mC/DepXiF1EI= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2 h1:LqbJ/WzJUwBf8UiaSzgX7aMclParm9/5Vgp+TY51uBQ= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2/go.mod h1:yInRyqWXAuaPrgI7p70+lDDgh3mlBohis29jGMISnmc= +github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.3.1 h1:fXPMAmuh0gDuRDey0atC8cXBuKIlqCzCkL8sm1n9Ov0= +github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.3.1/go.mod h1:SUZc9YRRHfx2+FAQKNDGrssXehqLpxmwRv2mC/5ntj4= +github.com/AzureAD/microsoft-authentication-library-for-go v1.2.1 h1:DzHpqpoJVaCgOUdVHxE8QB52S6NiVdDQvGlny1qvPqA= +github.com/AzureAD/microsoft-authentication-library-for-go v1.2.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= @@ -116,6 +130,8 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2 github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= +github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -191,6 +207,10 @@ github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm4 github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= +github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/enterprise-certificate-proxy v0.0.0-20220520183353-fd19c99a87aa/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8= github.com/googleapis/enterprise-certificate-proxy v0.1.0 h1:zO8WHNx/MYiAKJ3d5spxZXZE6KHmIQGQcAzwUzV7qQw= github.com/googleapis/enterprise-certificate-proxy v0.1.0/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8= @@ -237,6 +257,8 @@ github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348 h1:MtvEpTB6LX3vkb4ax0b5D2DHbNAUsen0Gx5wZoq3lV4= github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-isatty v0.0.3 h1:ns/ykhmWi7G9O+8a448SecJU3nSMBXJfqQkl0upE1jI= @@ -249,6 +271,8 @@ github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzCv8LZP15IdmG+YdwD2luVPHITV96TkirNBM= github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -291,6 +315,10 @@ golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -370,6 +398,10 @@ golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e h1:TsQ7F31D3bUCLeqPT0u+yjp1guoArKaNKmCr22PYgTQ= golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= +golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= +golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -466,6 +498,11 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220610221304-9f5ed59c137d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220624220833-87e55d714810 h1:rHZQSjJdAI4Xf5Qzeh2bBc5YJIkPFVM6oDtMFYmgws0= golang.org/x/sys v0.0.0-20220624220833-87e55d714810/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -478,6 +515,8 @@ golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -727,6 +766,7 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/storage/azure/client.go b/storage/azure/client.go new file mode 100644 index 0000000..317d706 --- /dev/null +++ b/storage/azure/client.go @@ -0,0 +1,72 @@ +package azure + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob" +) + +type Client interface { + // Read an object from an Azure blob. + Read(ctx context.Context, container, blob string) ([]byte, error) + + // Write an object onto an Azure blob. + Write(ctx context.Context, container, blob string, p []byte) error +} + +type client struct { + BlobAPI *azblob.Client +} + +// newClient returns a new instance of Client. +func newClient(config *Config) (Client, error) { + // If the access key isn't defined in the configuration, try to read it from the environment. + if config.AccessKey == "" { + config.AccessKey = os.Getenv("ARM_ACCESS_KEY") + } + + cred, err := azblob.NewSharedKeyCredential(config.AccountName, config.AccessKey) + if err != nil { + return nil, err + } + + url := fmt.Sprintf("https://%s.blob.core.windows.net/", config.AccountName) + c, err := azblob.NewClientWithSharedKeyCredential(url, cred, nil) + + return &client{c}, err +} + +// Read an object from an Azure blob. +func (c *client) Read(ctx context.Context, container, blob string) ([]byte, error) { + resp, err := c.BlobAPI.DownloadStream(ctx, container, blob, nil) + if err != nil { + var responseError *azcore.ResponseError + if errors.As(err, &responseError) { + if responseError.StatusCode == 404 { + return []byte{}, nil + } + + return nil, err + } + } + + bs := bytes.Buffer{} + r := resp.NewRetryReader(ctx, &azblob.RetryReaderOptions{}) + defer r.Close() + + _, err = bs.ReadFrom(r) + + return bs.Bytes(), err +} + +// Write an object onto an Azure blob. +func (c *client) Write(ctx context.Context, container, blob string, p []byte) error { + _, err := c.BlobAPI.UploadBuffer(ctx, container, blob, p, nil) + + return err +} diff --git a/storage/azure/config.go b/storage/azure/config.go new file mode 100644 index 0000000..cf48ca7 --- /dev/null +++ b/storage/azure/config.go @@ -0,0 +1,18 @@ +package azure + +import "github.com/minamijoyo/tfmigrate/storage" + +type Config struct { + AccessKey string `hcl:"access_key,optional"` + AccountName string `hcl:"storage_account_name"` + ContainerName string `hcl:"container_name"` + Key string `hcl:"key"` +} + +// Config implements a storage.Config. +var _ storage.Config = (*Config)(nil) + +// NewStorage returns a new instance of storage.Storage. +func (c *Config) NewStorage() (storage.Storage, error) { + return NewStorage(c, nil) +} diff --git a/storage/azure/config_test.go b/storage/azure/config_test.go new file mode 100644 index 0000000..9e71bf5 --- /dev/null +++ b/storage/azure/config_test.go @@ -0,0 +1,46 @@ +package azure + +import "testing" + +func TestConfigNewStorage(t *testing.T) { + cases := []struct { + desc string + config *Config + ok bool + }{ + { + desc: "valid", + config: &Config{ + AccountName: "tfmigrate-test", + ContainerName: "tfmigrate", + Key: "history.json", + }, + ok: true, + }, + { + desc: "valid", + config: &Config{ + AccessKey: "ZHVtbXkK", // expected to be a base64-encoded string + AccountName: "tfmigrate-test", + ContainerName: "tfmigrate", + Key: "history.json", + }, + ok: true, + }, + } + + for _, tc := range cases { + t.Run(tc.desc, func(t *testing.T) { + got, err := tc.config.NewStorage() + if tc.ok && err != nil { + t.Fatalf("unexpected err: %s", err) + } + if !tc.ok && err == nil { + t.Fatalf("expected to return an error, but no error, got: %#v", got) + } + if tc.ok { + _ = got.(*Storage) + } + }) + } +} diff --git a/storage/azure/storage.go b/storage/azure/storage.go new file mode 100644 index 0000000..9fcfbad --- /dev/null +++ b/storage/azure/storage.go @@ -0,0 +1,46 @@ +package azure + +import ( + "context" + + "github.com/minamijoyo/tfmigrate/storage" +) + +// Storage is a storage.Storage implementation for Azure Blob Storage. +type Storage struct { + // config is a storage config for azure blob storage. + config *Config + // client is an instance of azclient interface to call API. + // It is intended to be replaced with a mock for testing. + client Client +} + +var _ storage.Storage = (*Storage)(nil) + +// NewStorage returns a new instance of Storage. +func NewStorage(config *Config, client Client) (*Storage, error) { + if client == nil { + var err error + client, err = newClient(config) + if err != nil { + return nil, err + } + } + + s := &Storage{ + config: config, + client: client, + } + + return s, nil +} + +// Write writes migration history data to storage. +func (s *Storage) Write(ctx context.Context, b []byte) error { + return s.client.Write(ctx, s.config.ContainerName, s.config.Key, b) +} + +// Read reads migration history data from storage. +func (s *Storage) Read(ctx context.Context) ([]byte, error) { + return s.client.Read(ctx, s.config.ContainerName, s.config.Key) +} diff --git a/storage/azure/storage_test.go b/storage/azure/storage_test.go new file mode 100644 index 0000000..2216795 --- /dev/null +++ b/storage/azure/storage_test.go @@ -0,0 +1,107 @@ +package azure + +import ( + "context" + "testing" +) + +// mockClient is a mock implementation for testing. +type mockClient struct { + dataToRead []byte + err error +} + +func (c *mockClient) Read(_ context.Context, _, _ string) ([]byte, error) { + return c.dataToRead, c.err +} + +func (c *mockClient) Write(_ context.Context, _, _ string, _ []byte) error { + return c.err +} + +func TestStorageWrite(t *testing.T) { + cases := []struct { + desc string + config *Config + client Client + contents []byte + ok bool + }{ + { + desc: "simple", + config: &Config{ + AccountName: "tfmigrate-test", + ContainerName: "tfmigrate", + Key: "history.json", + }, + client: &mockClient{ + err: nil, + }, + contents: []byte("foo"), + ok: true, + }, + } + + for _, tc := range cases { + t.Run(tc.desc, func(t *testing.T) { + s, err := NewStorage(tc.config, tc.client) + if err != nil { + t.Fatalf("failed to NewStorage: %s", err) + } + err = s.Write(context.Background(), tc.contents) + if tc.ok && err != nil { + t.Fatalf("unexpected err: %s", err) + } + if !tc.ok && err == nil { + t.Fatal("expected to return an error, but no error") + } + }) + } +} + +func TestStorageRead(t *testing.T) { + cases := []struct { + desc string + config *Config + client Client + contents []byte + ok bool + }{ + { + desc: "simple", + config: &Config{ + AccountName: "tfmigrate-test", + ContainerName: "tfmigrate", + Key: "history.json", + }, + client: &mockClient{ + dataToRead: []byte("foo"), + err: nil, + }, + contents: []byte("foo"), + ok: true, + }, + } + + for _, tc := range cases { + t.Run(tc.desc, func(t *testing.T) { + s, err := NewStorage(tc.config, tc.client) + if err != nil { + t.Fatalf("failed to NewStorage: %s", err) + } + got, err := s.Read(context.Background()) + if tc.ok && err != nil { + t.Fatalf("unexpected err: %s", err) + } + if !tc.ok && err == nil { + t.Fatal("expected to return an error, but no error") + } + + if tc.ok { + if string(got) != string(tc.contents) { + t.Errorf("got: %s, want: %s", string(got), string(tc.contents)) + } + } + }) + } +}