diff --git a/spinifex/handlers/ec2/instance/service_impl.go b/spinifex/handlers/ec2/instance/service_impl.go index 4ad7e0ea2..d4e8c6f2c 100644 --- a/spinifex/handlers/ec2/instance/service_impl.go +++ b/spinifex/handlers/ec2/instance/service_impl.go @@ -3092,12 +3092,12 @@ func (s *InstanceServiceImpl) DescribeInstanceAttribute(ctx context.Context, inp case ec2.InstanceAttributeNameUserData: // AWS returns user-data base64-encoded; RunInstancesInput.UserData is the - // canonical base64 store, set at launch and kept in sync by Modify. - var val string - if instance.RunInstancesInput != nil && instance.RunInstancesInput.UserData != nil { - val = *instance.RunInstancesInput.UserData + // canonical base64 store, set at launch and kept in sync by Modify. An + // instance with no user data carries no Value at all, not an empty one. + output.UserData = &ec2.AttributeValue{} + if instance.RunInstancesInput != nil && aws.StringValue(instance.RunInstancesInput.UserData) != "" { + output.UserData.Value = aws.String(*instance.RunInstancesInput.UserData) } - output.UserData = &ec2.AttributeValue{Value: &val} case ec2.InstanceAttributeNameDisableApiTermination: // Read under the manager lock so we serialise with a concurrent diff --git a/spinifex/handlers/ec2/instance/service_impl_test.go b/spinifex/handlers/ec2/instance/service_impl_test.go index a845ce5b4..68f3a616c 100644 --- a/spinifex/handlers/ec2/instance/service_impl_test.go +++ b/spinifex/handlers/ec2/instance/service_impl_test.go @@ -4822,3 +4822,35 @@ func TestPrepareRunInstances_PreCreatedENIAttachErrorSurfaced(t *testing.T) { assert.Equal(t, 1, eni.attachCalls) assert.Equal(t, 0, eni.createCalls) } + +// TestDescribeInstanceAttribute_UserDataUnsetCarriesNoValue pins the shape AWS +// returns for an instance launched without user data: the attribute is present +// and its Value is absent. The Terraform provider reads user_data only when +// Value is non-nil, so an empty string makes it record the hash of "" and +// propose a change on every plan. +func TestDescribeInstanceAttribute_UserDataUnsetCarriesNoValue(t *testing.T) { + const id = "i-userdata-unset" + const owner = "000000000000" + + for _, tc := range []struct { + name string + input *ec2.RunInstancesInput + }{ + {name: "no RunInstancesInput", input: nil}, + {name: "nil UserData", input: &ec2.RunInstancesInput{}}, + {name: "empty UserData", input: &ec2.RunInstancesInput{UserData: aws.String("")}}, + } { + t.Run(tc.name, func(t *testing.T) { + v := &vm.VM{ID: id, AccountID: owner, RunInstancesInput: tc.input} + svc := &InstanceServiceImpl{vmMgr: mgrWith(map[string]*vm.VM{id: v})} + + out, err := svc.DescribeInstanceAttribute(context.Background(), &ec2.DescribeInstanceAttributeInput{ + InstanceId: aws.String(id), + Attribute: aws.String(ec2.InstanceAttributeNameUserData), + }, owner) + require.NoError(t, err) + require.NotNil(t, out.UserData, "AWS returns the attribute itself even when it holds nothing") + assert.Nil(t, out.UserData.Value, "an instance with no user data carries no Value, not an empty one") + }) + } +}