diff --git a/jsonencode.go b/jsonencode.go index 3bcf780..d8fea4c 100644 --- a/jsonencode.go +++ b/jsonencode.go @@ -1,3 +1,138 @@ package tfplanparse -// TODO implement +import ( + "fmt" + "strings" +) + +type JSONEncodeAttributeChange struct { + Name string + AttributeChanges []attributeChange + UpdateType UpdateType +} + +var _ attributeChange = &JSONEncodeAttributeChange{} + +// IsJSONEncodeAttributeChangeLine returns true if the line is a valid attribute change +// This requires the line to start with "+", "-" or "~", delimited with a space, and the value to start with "jsonencode(". +func IsJSONEncodeAttributeChangeLine(line string) bool { + line = strings.TrimSpace(line) + attribute := strings.SplitN(line, ATTRIBUTE_DEFINITON_DELIMITER, 2) + if len(attribute) != 2 { + return false + } + + validPrefix := strings.HasPrefix(line, "+") || strings.HasPrefix(line, "-") || strings.HasPrefix(line, "~") + isJSONEncode := strings.HasPrefix(attribute[1], "jsonencode(") + return validPrefix && isJSONEncode && !IsResourceChangeLine(line) +} + +// IsJSONEncodeAttributeTerminator returns true if the line is ")" +// TODO: verify this +func IsJSONEncodeAttributeTerminator(line string) bool { + return strings.TrimSuffix(strings.TrimSpace(line), " -> null") == ")" +} + +// NewJSONEncodeAttributeChangeFromLine initializes a JSONEncodeAttributeChange from a line containing a JSONEncode change +// It expects a line that passes the IsJSONEncodeAttributeChangeLine check +func NewJSONEncodeAttributeChangeFromLine(line string) (*JSONEncodeAttributeChange, error) { + line = strings.TrimSpace(line) + if !IsJSONEncodeAttributeChangeLine(line) { + return nil, fmt.Errorf("%s is not a valid line to initialize a JSONEncodeAttributeChange", line) + } + attribute := strings.SplitN(removeChangeTypeCharacters(line), ATTRIBUTE_DEFINITON_DELIMITER, 2) + + if strings.HasPrefix(line, "+") { + // add + return &JSONEncodeAttributeChange{ + Name: dequote(strings.TrimSpace(attribute[0])), + UpdateType: NewResource, + }, nil + } else if strings.HasPrefix(line, "-") { + // destroy + return &JSONEncodeAttributeChange{ + Name: dequote(strings.TrimSpace(attribute[0])), + UpdateType: DestroyResource, + }, nil + } else if strings.HasPrefix(line, "~") { + // replace + updateType := UpdateInPlaceResource + if strings.HasSuffix(attribute[1], " # forces replacement") { + updateType = ForceReplaceResource + } + + return &JSONEncodeAttributeChange{ + Name: dequote(strings.TrimSpace(attribute[0])), + UpdateType: updateType, + }, nil + } else { + return nil, fmt.Errorf("unrecognized line pattern") + } +} + +// GetName returns the name of the attribute +func (j *JSONEncodeAttributeChange) GetName() string { + return j.Name +} + +// GetUpdateType returns the UpdateType of the attribute +func (j *JSONEncodeAttributeChange) GetUpdateType() UpdateType { + return j.UpdateType +} + +// IsSensitive returns true if the attribute contains a sensitive value +func (j *JSONEncodeAttributeChange) IsSensitive() bool { + for _, ac := range j.AttributeChanges { + if ac.IsSensitive() { + return true + } + } + return false +} + +// IsComputed returns true if the attribute contains a computed value +func (j *JSONEncodeAttributeChange) IsComputed() bool { + for _, ac := range j.AttributeChanges { + if ac.IsComputed() { + return true + } + } + return false +} + +// IsNoOp returns true if the attribute has not changed +func (j *JSONEncodeAttributeChange) IsNoOp() bool { + return j.UpdateType == NoOpResource +} + +func (j *JSONEncodeAttributeChange) GetBefore(opts ...GetBeforeAfterOptions) interface{} { + result := map[string]interface{}{} + +attrs: + for _, a := range j.AttributeChanges { + for _, opt := range opts { + if opt(a) { + continue attrs + } + } + result[a.GetName()] = a.GetBefore(opts...) + } + + return result +} + +func (j *JSONEncodeAttributeChange) GetAfter(opts ...GetBeforeAfterOptions) interface{} { + result := map[string]interface{}{} + +attrs: + for _, a := range j.AttributeChanges { + for _, opt := range opts { + if opt(a) { + continue attrs + } + } + result[a.GetName()] = a.GetAfter(opts...) + } + + return result +} diff --git a/parse.go b/parse.go index 61cfacf..9dcb975 100644 --- a/parse.go +++ b/parse.go @@ -89,6 +89,12 @@ func parseResource(s *bufio.Scanner) (*ResourceChange, error) { return nil, err } rc.AttributeChanges = append(rc.AttributeChanges, aa) + case IsJSONEncodeAttributeChangeLine(text): + ja, err := parseJSONEncodeAttribute(s) + if err != nil { + return nil, err + } + rc.AttributeChanges = append(rc.AttributeChanges, ja) case IsHeredocAttributeChangeLine(text): ha, err := parseHeredocAttribute(s) if err != nil { @@ -136,6 +142,12 @@ func parseMapAttribute(s *bufio.Scanner) (*MapAttributeChange, error) { return nil, err } result.AttributeChanges = append(result.AttributeChanges, aa) + case IsJSONEncodeAttributeChangeLine(text): + ja, err := parseJSONEncodeAttribute(s) + if err != nil { + return nil, err + } + result.AttributeChanges = append(result.AttributeChanges, ja) case IsHeredocAttributeChangeLine(text): ha, err := parseHeredocAttribute(s) if err != nil { @@ -183,6 +195,12 @@ func parseArrayAttribute(s *bufio.Scanner) (*ArrayAttributeChange, error) { return nil, err } result.AttributeChanges = append(result.AttributeChanges, ma) + case IsJSONEncodeAttributeChangeLine(text): + ja, err := parseJSONEncodeAttribute(s) + if err != nil { + return nil, err + } + result.AttributeChanges = append(result.AttributeChanges, ja) case IsHeredocAttributeChangeLine(text): ha, err := parseHeredocAttribute(s) if err != nil { @@ -201,6 +219,53 @@ func parseArrayAttribute(s *bufio.Scanner) (*ArrayAttributeChange, error) { return nil, fmt.Errorf("unexpected end of input while parsing array attribute") } +func parseJSONEncodeAttribute(s *bufio.Scanner) (*JSONEncodeAttributeChange, error) { + normalized := formatInput(s.Bytes()) + result, err := NewJSONEncodeAttributeChangeFromLine(normalized) + if err != nil { + return nil, err + } + // TODO: check if oneline check needed + + for s.Scan() { + text := formatInput(s.Bytes()) + switch { + case IsJSONEncodeAttributeTerminator(text): + return result, nil + case IsResourceCommentLine(text), strings.Contains(text, CHANGES_END_STRING): + return nil, fmt.Errorf("unexpected line while parsing jsonencode attribute: %s", text) + case IsMapAttributeChangeLine(text): + ma, err := parseMapAttribute(s) + if err != nil { + return nil, err + } + result.AttributeChanges = append(result.AttributeChanges, ma) + case IsArrayAttributeChangeLine(text): + aa, err := parseArrayAttribute(s) + if err != nil { + return nil, err + } + result.AttributeChanges = append(result.AttributeChanges, aa) + case IsHeredocAttributeChangeLine(text): + // TODO: check if this is even allowed by terraform + ha, err := parseHeredocAttribute(s) + if err != nil { + return nil, err + } + result.AttributeChanges = append(result.AttributeChanges, ha) + case IsAttributeChangeLine(text): + // TODO: check if this is even allowed by terraform + ac, err := NewAttributeChangeFromLine(text) + if err != nil { + return nil, err + } + result.AttributeChanges = append(result.AttributeChanges, ac) + } + } + + return nil, fmt.Errorf("unexpected end of input while parsing jsonencode attribute") +} + func parseHeredocAttribute(s *bufio.Scanner) (*HeredocAttributeChange, error) { normalized := formatInput(s.Bytes()) result, err := NewHeredocAttributeChangeFromLine(normalized) diff --git a/parse_test.go b/parse_test.go index 1194538..0fa1a2b 100644 --- a/parse_test.go +++ b/parse_test.go @@ -563,6 +563,254 @@ func TestParse(t *testing.T) { }, }, }, + "jsonencode": { + file: "test/jsonencode.stdout", + expected: []*ResourceChange{ + &ResourceChange{ + Address: "module.mymodule.kubernetes_role_binding.user_is_view", + ModuleAddress: "module.mymodule", + Type: "kubernetes_role_binding", + Name: "user_is_view", + UpdateType: DestroyResource, + AttributeChanges: []attributeChange{ + &AttributeChange{ + Name: "id", + OldValue: "my-namespace/user_is_view", + NewValue: nil, + UpdateType: DestroyResource, + }, + &MapAttributeChange{ + Name: "metadata", + AttributeChanges: []attributeChange{ + &MapAttributeChange{ + Name: "annotations", + AttributeChanges: []attributeChange{ + &JSONEncodeAttributeChange{ + Name: "encoded", + AttributeChanges: []attributeChange{ + &MapAttributeChange{ + AttributeChanges: []attributeChange{ + &AttributeChange{ + Name: "apiVersion", + OldValue: "rbac.authorization.k8s.io/v1", + NewValue: nil, + UpdateType: DestroyResource, + }, + &AttributeChange{ + Name: "kind", + OldValue: "RoleBinding", + NewValue: nil, + UpdateType: DestroyResource, + }, + &MapAttributeChange{ + Name: "metadata", + AttributeChanges: []attributeChange{ + &MapAttributeChange{ + Name: "annotations", + AttributeChanges: []attributeChange{ + &AttributeChange{ + Name: "my-annotation", + OldValue: "annot", + NewValue: nil, + UpdateType: DestroyResource, + }, + }, + UpdateType: DestroyResource, + }, + &AttributeChange{ + Name: "creationTimestamp", + OldValue: "null", + NewValue: nil, + UpdateType: DestroyResource, + }, + &MapAttributeChange{ + Name: "labels", + AttributeChanges: []attributeChange{ + &AttributeChange{ + Name: "my-label", + OldValue: "label", + NewValue: nil, + UpdateType: DestroyResource, + }, + }, + UpdateType: DestroyResource, + }, + &AttributeChange{ + Name: "name", + OldValue: "user-is-view", + NewValue: nil, + UpdateType: DestroyResource, + }, + &AttributeChange{ + Name: "namespace", + OldValue: "my-namespace", + NewValue: nil, + UpdateType: DestroyResource, + }, + }, + UpdateType: DestroyResource, + }, + &MapAttributeChange{ + Name: "roleRef", + AttributeChanges: []attributeChange{ + &AttributeChange{ + Name: "apiGroup", + OldValue: "rbac.authorization.k8s.io", + NewValue: nil, + UpdateType: DestroyResource, + }, + &AttributeChange{ + Name: "kind", + OldValue: "ClusterRole", + NewValue: nil, + UpdateType: DestroyResource, + }, + &AttributeChange{ + Name: "name", + OldValue: "view", + NewValue: nil, + UpdateType: DestroyResource, + }, + }, + UpdateType: DestroyResource, + }, + &ArrayAttributeChange{ + Name: "subjects", + AttributeChanges: []attributeChange{ + &MapAttributeChange{ + Name: "", + AttributeChanges: []attributeChange{ + &AttributeChange{ + Name: "apiGroup", + OldValue: "rbac.authorization.k8s.io", + NewValue: nil, + UpdateType: DestroyResource, + }, + &AttributeChange{ + Name: "kind", + OldValue: "User", + NewValue: nil, + UpdateType: DestroyResource, + }, + &AttributeChange{ + Name: "name", + OldValue: "user@email.com", + NewValue: nil, + UpdateType: DestroyResource, + }, + }, + UpdateType: DestroyResource, + }, + }, + UpdateType: DestroyResource, + }, + }, + // TODO: this should be DestroyResource + UpdateType: NoOpResource, + }, + }, + UpdateType: DestroyResource, + }, + }, + UpdateType: DestroyResource, + }, + &AttributeChange{ + Name: "generation", + OldValue: 0, + NewValue: nil, + UpdateType: DestroyResource, + }, + &AttributeChange{ + Name: "labels", + OldValue: nil, + NewValue: nil, + UpdateType: DestroyResource, + }, + &AttributeChange{ + Name: "name", + OldValue: "user-is-view", + NewValue: nil, + UpdateType: DestroyResource, + }, + &AttributeChange{ + Name: "namespace", + OldValue: "my-namespace", + NewValue: nil, + UpdateType: DestroyResource, + }, + &AttributeChange{ + Name: "resource_version", + OldValue: "123", + NewValue: nil, + UpdateType: DestroyResource, + }, + &AttributeChange{ + Name: "self_link", + OldValue: "/apis/rbac.authorization.k8s.io/v1/namespaces/my-namespace/rolebindings/user-is-view", + NewValue: nil, + UpdateType: DestroyResource, + }, + &AttributeChange{ + Name: "uid", + OldValue: "some-uid", + NewValue: nil, + UpdateType: DestroyResource, + }, + }, + UpdateType: DestroyResource, + }, + &MapAttributeChange{ + Name: "role_ref", + AttributeChanges: []attributeChange{ + &AttributeChange{ + Name: "api_group", + OldValue: "rbac.authorization.k8s.io", + NewValue: nil, + UpdateType: DestroyResource, + }, + &AttributeChange{ + Name: "kind", + OldValue: "ClusterRole", + NewValue: nil, + UpdateType: DestroyResource, + }, + &AttributeChange{ + Name: "name", + OldValue: "view", + NewValue: nil, + UpdateType: DestroyResource, + }, + }, + UpdateType: DestroyResource, + }, + &MapAttributeChange{ + Name: "subject", + AttributeChanges: []attributeChange{ + &AttributeChange{ + Name: "api_group", + OldValue: "rbac.authorization.k8s.io", + NewValue: nil, + UpdateType: DestroyResource, + }, + &AttributeChange{ + Name: "kind", + OldValue: "User", + NewValue: nil, + UpdateType: DestroyResource, + }, + &AttributeChange{ + Name: "name", + OldValue: "user@email.com", + NewValue: nil, + UpdateType: DestroyResource, + }, + }, + UpdateType: DestroyResource, + }, + }, + }, + }, + }, } for name, tc := range cases { t.Run(name, func(t *testing.T) { diff --git a/test/anothermap.stdout b/test/anothermap.stdout index 2fc880a..0fc89f5 100644 --- a/test/anothermap.stdout +++ b/test/anothermap.stdout @@ -2,7 +2,8 @@ An execution plan has been generated and is shown below. Resource actions are indicated with the following symbols: - ~ update in-place + + create + - destroy Terraform will perform the following actions: diff --git a/test/jsonencode.stdout b/test/jsonencode.stdout new file mode 100644 index 0000000..4540854 --- /dev/null +++ b/test/jsonencode.stdout @@ -0,0 +1,67 @@ +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + - destroy + +Terraform will perform the following actions: + +# module.mymodule.kubernetes_role_binding.user_is_view will be destroyed + - resource "kubernetes_role_binding" "user_is_view" { + - id = "my-namespace/user_is_view" -> null + + - metadata { + - annotations = { + - "encoded" = jsonencode( + { + - apiVersion = "rbac.authorization.k8s.io/v1" + - kind = "RoleBinding" + - metadata = { + - annotations = { + - my-annotation = "annot" + } + - creationTimestamp = null + - labels = { + - my-label = "label" + } + - name = "user-is-view" -> null + - namespace = "my-namespace" -> null + } + - roleRef = { + - apiGroup = "rbac.authorization.k8s.io" + - kind = "ClusterRole" + - name = "view" + } + - subjects = [ + - { + - apiGroup = "rbac.authorization.k8s.io" + - kind = "User" + - name = "user@email.com" + }, + ] + } + ) + } -> null + - generation = 0 -> null + - labels = {} -> null + - name = "user-is-view" -> null + - namespace = "my-namespace" -> null + - resource_version = "123" -> null + - self_link = "/apis/rbac.authorization.k8s.io/v1/namespaces/my-namespace/rolebindings/user-is-view" -> null + - uid = "some-uid" -> null + } + + - role_ref { + - api_group = "rbac.authorization.k8s.io" + - kind = "ClusterRole" + - name = "view" + } + + - subject { + - api_group = "rbac.authorization.k8s.io" + - kind = "User" + - name = "user@email.com" + } + } + +Plan: 0 to add, 0 to change, 1 to destroy. \ No newline at end of file