564 lines
14 KiB
Go
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
|
|
}
|