Repository: sacloud/terraform-provider-sakuracloud (go)

Issue:
外部ツール経由でプラン変更された場合のID変更の追跡
### 概要

さくらのクラウドではプラン変更時にリソースのIDが変更される。

対象リソース: 
- サーバ
- ルータ
- ELB

Terraformからプラン変更を行なった場合、IDが変更されることへの対応が実装されているが、
外部ツール(AutoScalerなど)からプラン変更が行われた場合にリソースが追跡できなくなる。
これは、各リソースのReadでIDを元にさくらのクラウドAPIを用いてリソースの情報を参照しているため。
(IDで検索し404が返ってきた場合はリソースが削除されたとみなす)

追跡できなくなってもimportすることで復帰させることが出来るが運用的に煩雑。

このため、何らかのルールにしたがって変更前のIDをメタデータとしてリソースに保持しておき、IDでの検索が404になった場合にはメタデータを利用して検索するようにフォールバックする。

### 実装案

`@previous-id=123456789012`のようなタグで変更前のIDを表す。
Terraform側はまずIDでの検索を試し、404になった場合は同一ゾーンのリソースから`@previous-id=<現在保持しているID>`というタグを条件に検索する。

API呼び出し例: `GET /server?{"Filter":{"Tags.Name":"@previous-id=123456789012"}}`

Pull request:
Support @previous-id tags for server/internet/proxylb
closes #853 

Terraform外でプラン変更によりリソースIDが変更された際に`@previous-id`タグから変更前のIDを追跡する仕組みをサポートする。

対象リソース:

- サーバ
- ルータ
- エンハンスドロードバランサ

Note: エンハンスドロードバランサのLet's Encrypt設定についてはID変更後に再設定(Terraform上はリソースの再作成)が行われる。
このため頻繁にプラン変更&applyするとLet's Encryptのレートリミットに引っかかってしまうことがある点に注意が必要。

Patch:
diff --git a/go.mod b/go.mod
index e9cb5078..0f33e278 100644
--- a/go.mod
+++ b/go.mod
@@ -9,6 +9,6 @@ require (
 	github.com/mitchellh/go-homedir v1.1.0
 	github.com/sacloud/ftps v1.1.0
 	github.com/sacloud/iso9660wrap v0.0.0-20171031075302-eda21f77f6a8
-	github.com/sacloud/libsacloud/v2 v2.21.1
+	github.com/sacloud/libsacloud/v2 v2.22.0
 	github.com/stretchr/testify v1.7.0
 )
diff --git a/go.sum b/go.sum
index 6c079b86..7b44bdf2 100644
--- a/go.sum
+++ b/go.sum
@@ -313,8 +313,8 @@ github.com/sacloud/ftps v1.1.0 h1:cYv+b6qhrIT8msfx64XXRJzbv5S+Dqwb/rXa5Y641XA=
 github.com/sacloud/ftps v1.1.0/go.mod h1:h4awhOi3PEyhKLj1FpXjoVV5yVkmRUU+d5L95EwX2JU=
 github.com/sacloud/iso9660wrap v0.0.0-20171031075302-eda21f77f6a8 h1:5piK7EELHKRxGqBjgVEHsdfsFwEzF/Kds/bMWLS6gCw=
 github.com/sacloud/iso9660wrap v0.0.0-20171031075302-eda21f77f6a8/go.mod h1:1jHHCa624cG5rODkWmourombJaNmkY9/SBHWhLJcg+w=
-github.com/sacloud/libsacloud/v2 v2.21.1 h1:mDsEIrvOcm0l/pjZk0DnfKLOyHtTeIFpZ/ZbvTWOwQE=
-github.com/sacloud/libsacloud/v2 v2.21.1/go.mod h1:EPYsXh1SxP10pD6J7a0nK67gd03wAq413TSfCfl5sjc=
+github.com/sacloud/libsacloud/v2 v2.22.0 h1:kYHezSFHN9giqEw10aU2OJg3Bqt8Mtq9VGNaGDdLxOs=
+github.com/sacloud/libsacloud/v2 v2.22.0/go.mod h1:EPYsXh1SxP10pD6J7a0nK67gd03wAq413TSfCfl5sjc=
 github.com/sebdah/goldie v1.0.0/go.mod h1:jXP4hmWywNEwZzhMuv2ccnqTSFpuq8iyQhtQdkkZBH4=
 github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
 github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
diff --git a/sakuracloud/resource_sakuracloud_internet.go b/sakuracloud/resource_sakuracloud_internet.go
index d1c428ce..6cf82293 100644
--- a/sakuracloud/resource_sakuracloud_internet.go
+++ b/sakuracloud/resource_sakuracloud_internet.go
@@ -158,16 +158,15 @@ func resourceSakuraCloudInternetRead(ctx context.Context, d *schema.ResourceData
 		return diag.FromErr(err)
 	}
 
-	internetOp := sacloud.NewInternetOp(client)
-
-	internet, err := internetOp.Read(ctx, zone, sakuraCloudID(d.Id()))
+	internet, err := query.ReadRouter(ctx, client, zone, sakuraCloudID(d.Id()))
 	if err != nil {
-		if sacloud.IsNotFoundError(err) {
+		if sacloud.IsNoResultsError(err) {
 			d.SetId("")
 			return nil
 		}
 		return diag.Errorf("could not read SakuraCloud Internet[%s]: %s", d.Id(), err)
 	}
+	d.SetId(internet.ID.String())
 	return setInternetResourceData(ctx, d, client, internet)
 }
 
diff --git a/sakuracloud/resource_sakuracloud_proxylb.go b/sakuracloud/resource_sakuracloud_proxylb.go
index 6ebc4773..64985643 100644
--- a/sakuracloud/resource_sakuracloud_proxylb.go
+++ b/sakuracloud/resource_sakuracloud_proxylb.go
@@ -21,6 +21,7 @@ import (
 	"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
 	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
 	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation"
+	"github.com/sacloud/libsacloud/v2/helper/query"
 	"github.com/sacloud/libsacloud/v2/sacloud"
 	"github.com/sacloud/libsacloud/v2/sacloud/types"
 )
@@ -454,15 +455,15 @@ func resourceSakuraCloudProxyLBRead(ctx context.Context, d *schema.ResourceData,
 		return diag.FromErr(err)
 	}
 
-	proxyLBOp := sacloud.NewProxyLBOp(client)
-	proxyLB, err := proxyLBOp.Read(ctx, sakuraCloudID(d.Id()))
+	proxyLB, err := query.ReadProxyLB(ctx, client, sakuraCloudID(d.Id()))
 	if err != nil {
-		if sacloud.IsNotFoundError(err) {
+		if sacloud.IsNoResultsError(err) {
 			d.SetId("")
 			return nil
 		}
 		return diag.Errorf("could not read SakuraCloud ProxyLB[%s]: %s", d.Id(), err)
 	}
+	d.SetId(proxyLB.ID.String())
 
 	return setProxyLBResourceData(ctx, d, client, proxyLB)
 }
diff --git a/sakuracloud/resource_sakuracloud_server.go b/sakuracloud/resource_sakuracloud_server.go
index 509013dd..fc70ecb9 100644
--- a/sakuracloud/resource_sakuracloud_server.go
+++ b/sakuracloud/resource_sakuracloud_server.go
@@ -316,15 +316,15 @@ func resourceSakuraCloudServerRead(ctx context.Context, d *schema.ResourceData,
 		return diag.FromErr(err)
 	}
 
-	serverOp := sacloud.NewServerOp(client)
-	server, err := serverOp.Read(ctx, zone, sakuraCloudID(d.Id()))
+	server, err := query.ReadServer(ctx, client, zone, sakuraCloudID(d.Id()))
 	if err != nil {
-		if sacloud.IsNotFoundError(err) {
+		if sacloud.IsNoResultsError(err) {
 			d.SetId("")
 			return nil
 		}
 		return diag.Errorf("could not read SakuraCloud Server[%s]: %s", d.Id(), err)
 	}
+	d.SetId(server.ID.String())
 
 	return setServerResourceData(ctx, d, client, server)
 }
diff --git a/sakuracloud/structure.go b/sakuracloud/structure.go
index 5a821bb8..a582d629 100644
--- a/sakuracloud/structure.go
+++ b/sakuracloud/structure.go
@@ -23,10 +23,12 @@ import (
 	"io"
 	"os"
 	"strconv"
+	"strings"
 
 	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
 	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation"
 	"github.com/mitchellh/go-homedir"
+	"github.com/sacloud/libsacloud/v2/helper/plans"
 	"github.com/sacloud/libsacloud/v2/sacloud/search"
 	"github.com/sacloud/libsacloud/v2/sacloud/search/keys"
 	"github.com/sacloud/libsacloud/v2/sacloud/types"
@@ -207,7 +209,13 @@ func expandTags(d resourceValueGettable) types.Tags {
 }
 
 func flattenTags(tags types.Tags) *schema.Set {
-	return stringListToSet(tags)
+	filtered := types.Tags{}
+	for _, t := range tags {
+		if !strings.HasPrefix(t, plans.PreviousIDTagName) {
+			filtered = append(filtered, t)
+		}
+	}
+	return stringListToSet(filtered)
 }
 
 func expandSubjectAltNames(d resourceValueGettable) []string {
diff --git a/website/docs/guides/tags.md b/website/docs/guides/tags.md
new file mode 100644
index 00000000..94ccf206
--- /dev/null
+++ b/website/docs/guides/tags.md
@@ -0,0 +1,37 @@
+---
+layout: "sakuracloud"
+page_title: "Guide: Handling of Resource Tags with SAKURA Cloud Provider"
+subcategory: "Guides"
+description: |-
+  Handling of Resource Tags with SAKURA Cloud Provider.
+---
+
+# Handling of Resource Tags with SAKURA Cloud Provider
+
+You can manage resource tags with SAKURA Cloud provider as follows:
+
+```tf
+resource sakuracloud_server "example" {
+  name = "example"
+  tags = ["tag1", "tag2"]
+}
+```
+
+However, some tags cannot be managed from Terraform.
+
+- Database Appliance
+  - `@MariaDB-*`
+  - `@postgres-*`
+- Server / EnhancedLoadBalancer / Router  
+  - `@previous-id*`
+  
+### `@MariaDB-*` and `@postgres-*`
+
+Tags such as `@MariaDB-*` and `@postgres-*` are automatically assigned when Database Appliance is created, but this provider will ignores them.
+Also, it is not supported to specify tags with these prefixes in the tf file.
+
+### `@previous-id`
+
+`@previous-id` tag is assigned when resource plan is changed with using libsacloud, but this provider will ignore it when storing tags to state.
+This tag is used as a search condition for fallback when a 404 error occurs when fetching by resource ID.
+Also, it is not supported to specify tags with these prefixes in the tf file.
diff --git a/website/sakuracloud.erb b/website/sakuracloud.erb
index 54be09f9..b85f0483 100644
--- a/website/sakuracloud.erb
+++ b/website/sakuracloud.erb
@@ -17,6 +17,9 @@
             <li>
               <a href="/docs/providers/sakuracloud/guides/object_storage.html">How to Manage Object Storage in SAKURA Cloud with Terraform</a>
             </li>
+            <li>
+              <a href="/docs/providers/sakuracloud/guides/tags.html">Handling of Resource Tags with SAKURA Cloud Provider</a>
+            </li>
           </ul>
         </li>
 


Test patch:
diff --git a/sakuracloud/resource_sakuracloud_server_test.go b/sakuracloud/resource_sakuracloud_server_test.go
index eaa5be7a..54f84c7a 100644
--- a/sakuracloud/resource_sakuracloud_server_test.go
+++ b/sakuracloud/resource_sakuracloud_server_test.go
@@ -22,6 +22,8 @@ import (
 
 	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource"
 	"github.com/hashicorp/terraform-plugin-sdk/v2/terraform"
+	"github.com/sacloud/libsacloud/v2/helper/plans"
+	"github.com/sacloud/libsacloud/v2/helper/power"
 	"github.com/sacloud/libsacloud/v2/sacloud"
 	"github.com/sacloud/libsacloud/v2/sacloud/types"
 )
@@ -155,6 +157,65 @@ func TestAccSakuraCloudServer_planChange(t *testing.T) {
 	})
 }
 
+func TestAccSakuraCloudServer_planChangeByOutsideOfTerraform(t *testing.T) {
+	resourceName := "sakuracloud_server.foobar"
+	rand := randomName()
+	password := randomPassword()
+
+	var step1, step2, step3 sacloud.Server
+	resource.ParallelTest(t, resource.TestCase{
+		PreCheck:          func() { testAccPreCheck(t) },
+		ProviderFactories: testAccProviderFactories,
+		CheckDestroy:      testCheckSakuraCloudServerDestroy,
+		Steps: []resource.TestStep{
+			{
+				Config:             buildConfigWithArgs(testAccSakuraCloudServer_standardPlan, rand, password),
+				ExpectNonEmptyPlan: true, // Terraform外での変更を擬似的にStepの中で行うためtrueにしておく
+				Check: resource.ComposeTestCheckFunc(
+					testCheckSakuraCloudServerExists(resourceName, &step1),
+					resource.TestCheckResourceAttr(resourceName, "name", rand),
+					resource.TestCheckResourceAttr(resourceName, "core", "2"),
+					resource.TestCheckResourceAttr(resourceName, "memory", "4"),
+					resource.TestCheckResourceAttr(resourceName, "commitment", "standard"),
+					func(*terraform.State) error {
+						client := testAccProvider.Meta().(*APIClient)
+						ctx := context.Background()
+						// shutdown
+						if err := power.ShutdownServer(ctx, sacloud.NewServerOp(client), step1.Zone.Name, step1.ID, true); err != nil {
+							return err
+						}
+
+						updated, err := plans.ChangeServerPlan(
+							context.Background(), client, step1.Zone.Name, step1.ID,
+							1, 1, types.Commitments.Standard, types.PlanGenerations.Default)
+						if err != nil {
+							return err
+						}
+						step2 = *updated
+						return nil
+					},
+				),
+			},
+			{
+				Config: buildConfigWithArgs(testAccSakuraCloudServer_standardPlan, rand, password),
+				Check: resource.ComposeTestCheckFunc(
+					testCheckSakuraCloudServerExists(resourceName, &step3),
+					resource.TestCheckResourceAttr(resourceName, "name", rand),
+					resource.TestCheckResourceAttr(resourceName, "core", "2"),
+					resource.TestCheckResourceAttr(resourceName, "memory", "4"),
+					resource.TestCheckResourceAttr(resourceName, "commitment", "standard"),
+					func(*terraform.State) error {
+						if step1.ID == step2.ID || step1.ID == step3.ID || step2.ID == step3.ID {
+							return fmt.Errorf("server plan changed, but id is not updated")
+						}
+						return nil
+					},
+				),
+			},
+		},
+	})
+}
+
 func TestAccSakuraCloudServer_withoutShutdown(t *testing.T) {
 	resourceName := "sakuracloud_server.foobar"
 	rand := randomName()