terraform-provider-greenhost/digitalocean/resource_digitalocean_space...

564 lines
14 KiB
Go

package digitalocean
import (
"bytes"
"context"
"encoding/base64"
"fmt"
"io"
"log"
"os"
"strings"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/awserr"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation"
"github.com/mitchellh/go-homedir"
)
func resourceDigitalOceanSpacesBucketObject() *schema.Resource {
return &schema.Resource{
Create: resourceDigitalOceanSpacesBucketObjectCreate,
Read: resourceDigitalOceanSpacesBucketObjectRead,
Update: resourceDigitalOceanSpacesBucketObjectUpdate,
Delete: resourceDigitalOceanSpacesBucketObjectDelete,
CustomizeDiff: resourceDigitalOceanSpacesBucketObjectCustomizeDiff,
Schema: map[string]*schema.Schema{
"region": {
Type: schema.TypeString,
Required: true,
ForceNew: true,
ValidateFunc: validation.StringIsNotEmpty,
},
"bucket": {
Type: schema.TypeString,
Required: true,
ForceNew: true,
ValidateFunc: validation.NoZeroValues,
},
"key": {
Type: schema.TypeString,
Required: true,
ForceNew: true,
ValidateFunc: validation.NoZeroValues,
},
"acl": {
Type: schema.TypeString,
Default: s3.ObjectCannedACLPrivate,
Optional: true,
ValidateFunc: validation.StringInSlice([]string{
s3.ObjectCannedACLPrivate,
s3.ObjectCannedACLPublicRead,
}, false),
},
"cache_control": {
Type: schema.TypeString,
Optional: true,
},
"content_disposition": {
Type: schema.TypeString,
Optional: true,
},
"content_encoding": {
Type: schema.TypeString,
Optional: true,
},
"content_language": {
Type: schema.TypeString,
Optional: true,
},
"metadata": {
Type: schema.TypeMap,
ValidateFunc: validateMetadataIsLowerCase,
Optional: true,
Elem: &schema.Schema{Type: schema.TypeString},
},
"content_type": {
Type: schema.TypeString,
Optional: true,
Computed: true,
},
"source": {
Type: schema.TypeString,
Optional: true,
ConflictsWith: []string{"content", "content_base64"},
},
"content": {
Type: schema.TypeString,
Optional: true,
ConflictsWith: []string{"source", "content_base64"},
},
"content_base64": {
Type: schema.TypeString,
Optional: true,
ConflictsWith: []string{"source", "content"},
},
"etag": {
Type: schema.TypeString,
Optional: true,
Computed: true,
},
"version_id": {
Type: schema.TypeString,
Computed: true,
},
"website_redirect": {
Type: schema.TypeString,
Optional: true,
},
"force_destroy": {
Type: schema.TypeBool,
Optional: true,
Default: false,
},
},
}
}
func s3connFromResourceData(d *schema.ResourceData, meta interface{}) (*s3.S3, error) {
region := d.Get("region").(string)
client, err := meta.(*CombinedConfig).spacesClient(region)
if err != nil {
return nil, err
}
svc := s3.New(client)
return svc, nil
}
func resourceDigitalOceanSpacesBucketObjectPut(d *schema.ResourceData, meta interface{}) error {
s3conn, err := s3connFromResourceData(d, meta)
if err != nil {
return err
}
var body io.ReadSeeker
if v, ok := d.GetOk("source"); ok {
source := v.(string)
path, err := homedir.Expand(source)
if err != nil {
return fmt.Errorf("Error expanding homedir in source (%s): %s", source, err)
}
file, err := os.Open(path)
if err != nil {
return fmt.Errorf("Error opening Spaces bucket object source (%s): %s", path, err)
}
body = file
defer func() {
err := file.Close()
if err != nil {
log.Printf("[WARN] Error closing Spaces bucket object source (%s): %s", path, err)
}
}()
} else if v, ok := d.GetOk("content"); ok {
content := v.(string)
body = bytes.NewReader([]byte(content))
} else if v, ok := d.GetOk("content_base64"); ok {
content := v.(string)
// We can't do streaming decoding here (with base64.NewDecoder) because
// the AWS SDK requires an io.ReadSeeker but a base64 decoder can't seek.
contentRaw, err := base64.StdEncoding.DecodeString(content)
if err != nil {
return fmt.Errorf("error decoding content_base64: %s", err)
}
body = bytes.NewReader(contentRaw)
}
bucket := d.Get("bucket").(string)
key := d.Get("key").(string)
putInput := &s3.PutObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
ACL: aws.String(d.Get("acl").(string)),
Body: body,
}
if v, ok := d.GetOk("cache_control"); ok {
putInput.CacheControl = aws.String(v.(string))
}
if v, ok := d.GetOk("content_type"); ok {
putInput.ContentType = aws.String(v.(string))
}
if v, ok := d.GetOk("metadata"); ok {
putInput.Metadata = stringMapToPointers(v.(map[string]interface{}))
}
if v, ok := d.GetOk("content_encoding"); ok {
putInput.ContentEncoding = aws.String(v.(string))
}
if v, ok := d.GetOk("content_language"); ok {
putInput.ContentLanguage = aws.String(v.(string))
}
if v, ok := d.GetOk("content_disposition"); ok {
putInput.ContentDisposition = aws.String(v.(string))
}
if v, ok := d.GetOk("website_redirect"); ok {
putInput.WebsiteRedirectLocation = aws.String(v.(string))
}
if _, err := s3conn.PutObject(putInput); err != nil {
return fmt.Errorf("Error putting object in Spaces bucket (%s): %s", bucket, err)
}
d.SetId(key)
return resourceDigitalOceanSpacesBucketObjectRead(d, meta)
}
func resourceDigitalOceanSpacesBucketObjectCreate(d *schema.ResourceData, meta interface{}) error {
return resourceDigitalOceanSpacesBucketObjectPut(d, meta)
}
func resourceDigitalOceanSpacesBucketObjectRead(d *schema.ResourceData, meta interface{}) error {
s3conn, err := s3connFromResourceData(d, meta)
if err != nil {
return err
}
bucket := d.Get("bucket").(string)
key := d.Get("key").(string)
resp, err := s3conn.HeadObject(
&s3.HeadObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
})
if err != nil {
// If S3 returns a 404 Request Failure, mark the object as destroyed
if awsErr, ok := err.(awserr.RequestFailure); ok && awsErr.StatusCode() == 404 {
d.SetId("")
log.Printf("[WARN] Error Reading Object (%s), object not found (HTTP status 404)", key)
return nil
}
return err
}
log.Printf("[DEBUG] Reading Spaces Bucket Object meta: %s", resp)
d.Set("cache_control", resp.CacheControl)
d.Set("content_disposition", resp.ContentDisposition)
d.Set("content_encoding", resp.ContentEncoding)
d.Set("content_language", resp.ContentLanguage)
d.Set("content_type", resp.ContentType)
metadata := pointersMapToStringList(resp.Metadata)
// AWS Go SDK capitalizes metadata, this is a workaround. https://github.com/aws/aws-sdk-go/issues/445
for k, v := range metadata {
delete(metadata, k)
metadata[strings.ToLower(k)] = v
}
if err := d.Set("metadata", metadata); err != nil {
return fmt.Errorf("error setting metadata: %s", err)
}
d.Set("version_id", resp.VersionId)
d.Set("website_redirect", resp.WebsiteRedirectLocation)
// See https://forums.aws.amazon.com/thread.jspa?threadID=44003
d.Set("etag", strings.Trim(aws.StringValue(resp.ETag), `"`))
return nil
}
func resourceDigitalOceanSpacesBucketObjectUpdate(d *schema.ResourceData, meta interface{}) error {
// Changes to any of these attributes requires creation of a new object version (if bucket is versioned):
for _, key := range []string{
"cache_control",
"content_base64",
"content_disposition",
"content_encoding",
"content_language",
"content_type",
"content",
"etag",
"metadata",
"source",
"website_redirect",
} {
if d.HasChange(key) {
return resourceDigitalOceanSpacesBucketObjectPut(d, meta)
}
}
conn, err := s3connFromResourceData(d, meta)
if err != nil {
return err
}
bucket := d.Get("bucket").(string)
key := d.Get("key").(string)
if d.HasChange("acl") {
_, err := conn.PutObjectAcl(&s3.PutObjectAclInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
ACL: aws.String(d.Get("acl").(string)),
})
if err != nil {
return fmt.Errorf("error putting Spaces object ACL: %s", err)
}
}
return resourceDigitalOceanSpacesBucketObjectRead(d, meta)
}
func resourceDigitalOceanSpacesBucketObjectDelete(d *schema.ResourceData, meta interface{}) error {
s3conn, err := s3connFromResourceData(d, meta)
if err != nil {
return err
}
bucket := d.Get("bucket").(string)
key := d.Get("key").(string)
// We are effectively ignoring any leading '/' in the key name as aws.Config.DisableRestProtocolURICleaning is false
key = strings.TrimPrefix(key, "/")
if _, ok := d.GetOk("version_id"); ok {
err = deleteAllS3ObjectVersions(s3conn, bucket, key, d.Get("force_destroy").(bool), false)
} else {
err = deleteS3ObjectVersion(s3conn, bucket, key, "", false)
}
if err != nil {
return fmt.Errorf("error deleting Spaces Bucket (%s) Object (%s): %s", bucket, key, err)
}
return nil
}
func validateMetadataIsLowerCase(v interface{}, k string) (ws []string, errors []error) {
value := v.(map[string]interface{})
for k := range value {
if k != strings.ToLower(k) {
errors = append(errors, fmt.Errorf(
"Metadata must be lowercase only. Offending key: %q", k))
}
}
return
}
func resourceDigitalOceanSpacesBucketObjectCustomizeDiff(
ctx context.Context,
d *schema.ResourceDiff,
meta interface{},
) error {
if d.HasChange("etag") {
d.SetNewComputed("version_id")
}
return nil
}
// deleteAllS3ObjectVersions deletes all versions of a specified key from an S3 bucket.
// If key is empty then all versions of all objects are deleted.
// Set force to true to override any S3 object lock protections on object lock enabled buckets.
func deleteAllS3ObjectVersions(conn *s3.S3, bucketName, key string, force, ignoreObjectErrors bool) error {
input := &s3.ListObjectVersionsInput{
Bucket: aws.String(bucketName),
}
if key != "" {
input.Prefix = aws.String(key)
}
var lastErr error
err := conn.ListObjectVersionsPages(input, func(page *s3.ListObjectVersionsOutput, lastPage bool) bool {
if page == nil {
return !lastPage
}
for _, objectVersion := range page.Versions {
objectKey := aws.StringValue(objectVersion.Key)
objectVersionID := aws.StringValue(objectVersion.VersionId)
if key != "" && key != objectKey {
continue
}
err := deleteS3ObjectVersion(conn, bucketName, objectKey, objectVersionID, force)
if isAWSErr(err, "AccessDenied", "") && force {
// Remove any legal hold.
resp, err := conn.HeadObject(&s3.HeadObjectInput{
Bucket: aws.String(bucketName),
Key: objectVersion.Key,
VersionId: objectVersion.VersionId,
})
if err != nil {
log.Printf("[ERROR] Error getting Spaces Bucket (%s) Object (%s) Version (%s) metadata: %s", bucketName, objectKey, objectVersionID, err)
lastErr = err
continue
}
if aws.StringValue(resp.ObjectLockLegalHoldStatus) == s3.ObjectLockLegalHoldStatusOn {
_, err := conn.PutObjectLegalHold(&s3.PutObjectLegalHoldInput{
Bucket: aws.String(bucketName),
Key: objectVersion.Key,
VersionId: objectVersion.VersionId,
LegalHold: &s3.ObjectLockLegalHold{
Status: aws.String(s3.ObjectLockLegalHoldStatusOff),
},
})
if err != nil {
log.Printf("[ERROR] Error putting Spaces Bucket (%s) Object (%s) Version(%s) legal hold: %s", bucketName, objectKey, objectVersionID, err)
lastErr = err
continue
}
// Attempt to delete again.
err = deleteS3ObjectVersion(conn, bucketName, objectKey, objectVersionID, force)
if err != nil {
lastErr = err
}
continue
}
// AccessDenied for another reason.
lastErr = fmt.Errorf("AccessDenied deleting Spaces Bucket (%s) Object (%s) Version: %s", bucketName, objectKey, objectVersionID)
continue
}
if err != nil {
lastErr = err
}
}
return !lastPage
})
if isAWSErr(err, s3.ErrCodeNoSuchBucket, "") {
err = nil
}
if err != nil {
return err
}
if lastErr != nil {
if !ignoreObjectErrors {
return fmt.Errorf("error deleting at least one object version, last error: %s", lastErr)
}
lastErr = nil
}
err = conn.ListObjectVersionsPages(input, func(page *s3.ListObjectVersionsOutput, lastPage bool) bool {
if page == nil {
return !lastPage
}
for _, deleteMarker := range page.DeleteMarkers {
deleteMarkerKey := aws.StringValue(deleteMarker.Key)
deleteMarkerVersionID := aws.StringValue(deleteMarker.VersionId)
if key != "" && key != deleteMarkerKey {
continue
}
// Delete markers have no object lock protections.
err := deleteS3ObjectVersion(conn, bucketName, deleteMarkerKey, deleteMarkerVersionID, false)
if err != nil {
lastErr = err
}
}
return !lastPage
})
if isAWSErr(err, s3.ErrCodeNoSuchBucket, "") {
err = nil
}
if err != nil {
return err
}
if lastErr != nil {
if !ignoreObjectErrors {
return fmt.Errorf("error deleting at least one object delete marker, last error: %s", lastErr)
}
lastErr = nil
}
return nil
}
// deleteS3ObjectVersion deletes a specific bucket object version.
// Set force to true to override any S3 object lock protections.
func deleteS3ObjectVersion(conn *s3.S3, b, k, v string, force bool) error {
input := &s3.DeleteObjectInput{
Bucket: aws.String(b),
Key: aws.String(k),
}
if v != "" {
input.VersionId = aws.String(v)
}
if force {
input.BypassGovernanceRetention = aws.Bool(true)
}
log.Printf("[INFO] Deleting Spaces Bucket (%s) Object (%s) Version: %s", b, k, v)
_, err := conn.DeleteObject(input)
if err != nil {
log.Printf("[WARN] Error deleting Spaces Bucket (%s) Object (%s) Version (%s): %s", b, k, v, err)
}
if isAWSErr(err, s3.ErrCodeNoSuchBucket, "") || isAWSErr(err, s3.ErrCodeNoSuchKey, "") {
return nil
}
return err
}
func stringMapToPointers(m map[string]interface{}) map[string]*string {
list := make(map[string]*string, len(m))
for i, v := range m {
list[i] = aws.String(v.(string))
}
return list
}
func pointersMapToStringList(pointers map[string]*string) map[string]interface{} {
list := make(map[string]interface{}, len(pointers))
for i, v := range pointers {
list[i] = *v
}
return list
}