| // Copyright IBM Corp. 2014, 2026 | |
| // SPDX-License-Identifier: BUSL-1.1 | |
| package oci | |
| import ( | |
| "bytes" | |
| "context" | |
| "crypto/md5" | |
| "encoding/base64" | |
| "encoding/json" | |
| "errors" | |
| "fmt" | |
| "io" | |
| "github.com/hashicorp/go-hclog" | |
| "github.com/hashicorp/terraform/internal/states/remote" | |
| "github.com/hashicorp/terraform/internal/states/statemgr" | |
| "github.com/hashicorp/terraform/internal/tfdiags" | |
| "github.com/oracle/oci-go-sdk/v65/common" | |
| "github.com/oracle/oci-go-sdk/v65/objectstorage" | |
| ) | |
| type RemoteClient struct { | |
| objectStorageClient *objectstorage.ObjectStorageClient | |
| namespace string | |
| bucketName string | |
| path string | |
| lockFilePath string | |
| kmsKeyID string | |
| SSECustomerKey string | |
| SSECustomerKeySHA256 string | |
| SSECustomerAlgorithm string | |
| } | |
| func (c *RemoteClient) Get() (*remote.Payload, tfdiags.Diagnostics) { | |
| var diags tfdiags.Diagnostics | |
| logger := logWithOperation("download-state-file").Named(c.path) | |
| logger.Info("Downloading remote state") | |
| ctx := context.WithValue(context.Background(), "logger", logger) | |
| payload, err := c.getObject(ctx) | |
| if err != nil || len(payload.Data) == 0 { | |
| return nil, diags.Append(err) | |
| } | |
| // md5 hash of whole state | |
| sum := md5.Sum(payload.Data) | |
| payload.MD5 = sum[:] | |
| return payload, diags | |
| } | |
| func (c *RemoteClient) getObject(ctx context.Context) (*remote.Payload, error) { | |
| logger := ctx.Value("logger").(hclog.Logger) | |
| headRequest := objectstorage.HeadObjectRequest{ | |
| NamespaceName: common.String(c.namespace), | |
| ObjectName: common.String(c.path), | |
| BucketName: common.String(c.bucketName), | |
| RequestMetadata: common.RequestMetadata{ | |
| RetryPolicy: getDefaultRetryPolicy(), | |
| }, | |
| } | |
| if c.SSECustomerKey != "" && c.SSECustomerKeySHA256 != "" { | |
| headRequest.OpcSseCustomerKey = common.String(c.SSECustomerKey) | |
| headRequest.OpcSseCustomerKeySha256 = common.String(c.SSECustomerKeySHA256) | |
| headRequest.OpcSseCustomerAlgorithm = common.String(c.SSECustomerAlgorithm) | |
| } | |
| // Get object from OCI | |
| headResponse, headErr := c.objectStorageClient.HeadObject(ctx, headRequest) | |
| if headErr != nil { | |
| var ociHeadErr common.ServiceError | |
| if errors.As(headErr, &ociHeadErr) && ociHeadErr.GetHTTPStatusCode() == 404 { | |
| logger.Debug(" State file '%s' not found. Initializing Terraform state...", c.path) | |
| return &remote.Payload{}, nil | |
| } else { | |
| return nil, fmt.Errorf("failed to access object '%s' in bucket '%s': %w", c.path, c.bucketName, headErr) | |
| } | |
| } | |
| getRequest := objectstorage.GetObjectRequest{ | |
| NamespaceName: common.String(c.namespace), | |
| ObjectName: common.String(c.path), | |
| BucketName: common.String(c.bucketName), | |
| IfMatch: headResponse.ETag, | |
| RequestMetadata: common.RequestMetadata{ | |
| RetryPolicy: getDefaultRetryPolicy(), | |
| }, | |
| } | |
| if c.SSECustomerKey != "" && c.SSECustomerKeySHA256 != "" { | |
| getRequest.OpcSseCustomerKey = common.String(c.SSECustomerKey) | |
| getRequest.OpcSseCustomerKeySha256 = common.String(c.SSECustomerKeySHA256) | |
| getRequest.OpcSseCustomerAlgorithm = common.String(c.SSECustomerAlgorithm) | |
| } | |
| // Get object from OCI | |
| getResponse, err := c.objectStorageClient.GetObject(ctx, getRequest) | |
| if err != nil { | |
| var ociErr common.ServiceError | |
| if errors.As(err, &ociErr) { | |
| return nil, fmt.Errorf("failed to access object HttpStatusCode: %d\nOpcRequestId: %s\n message: %s\n ErrorCode: %s", ociErr.GetHTTPStatusCode(), ociErr.GetOpcRequestID(), ociErr.GetMessage(), ociErr.GetCode()) | |
| } | |
| return nil, fmt.Errorf("failed to access object '%s' in bucket '%s': %w", c.path, c.bucketName, err) | |
| } | |
| defer getResponse.Content.Close() | |
| // Read object content | |
| contentArray, err := io.ReadAll(getResponse.Content) | |
| if err != nil { | |
| return nil, fmt.Errorf("unable to read 'content' from response: %w", err) | |
| } | |
| // Compute MD5 hash | |
| md5Hash := getResponse.ContentMd5 | |
| if md5Hash == nil || len(*md5Hash) == 0 { | |
| md5Hash = getResponse.OpcMultipartMd5 | |
| } | |
| // Construct payload | |
| payload := &remote.Payload{ | |
| Data: contentArray, | |
| MD5: []byte(*md5Hash), | |
| } | |
| // Return an error instead of `nil, nil` if the object is empty | |
| if len(payload.Data) == 0 { | |
| return nil, fmt.Errorf("object %q is empty", c.path) | |
| } | |
| return payload, nil | |
| } | |
| func (c *RemoteClient) Put(data []byte) tfdiags.Diagnostics { | |
| var diags tfdiags.Diagnostics | |
| logger := logWithOperation("upload-state-file").Named(c.path) | |
| ctx := context.WithValue(context.Background(), "logger", logger) | |
| dataSize := int64(len(data)) | |
| sum := md5.Sum(data) | |
| var err error | |
| if dataSize > DefaultFilePartSize { | |
| logger.Info("Using Multipart Feature") | |
| var multipartUploadData = MultipartUploadData{ | |
| client: c, | |
| Data: data, | |
| RequestMetadata: common.RequestMetadata{ | |
| RetryPolicy: getDefaultRetryPolicy(), | |
| }, | |
| } | |
| err = multipartUploadData.multiPartUploadImpl(ctx) | |
| if err != nil && dataSize <= MaxFilePartSize { | |
| logger.Error(fmt.Sprintf("Multipart upload failed, falling back to single part upload: %v", err)) | |
| err = c.uploadSinglePartObject(ctx, data, sum[:]) | |
| } | |
| } else { | |
| err = c.uploadSinglePartObject(ctx, data, sum[:]) | |
| } | |
| if err != nil { | |
| return diags.Append(err) | |
| } | |
| return diags | |
| } | |
| func (c *RemoteClient) uploadSinglePartObject(ctx context.Context, data, sum []byte) error { | |
| logger := ctx.Value("logger").(hclog.Logger).Named("singlePartUpload") | |
| logger.Info("Uploading single part object") | |
| if len(data) == 0 { | |
| return fmt.Errorf("uploadSinglePartObject: data is empty") | |
| } | |
| contentType := "application/json" | |
| putRequest := objectstorage.PutObjectRequest{ | |
| ContentType: common.String(contentType), | |
| NamespaceName: common.String(c.namespace), | |
| ObjectName: common.String(c.path), | |
| BucketName: common.String(c.bucketName), | |
| PutObjectBody: io.NopCloser(bytes.NewReader(data)), | |
| ContentMD5: common.String(base64.StdEncoding.EncodeToString(sum)), | |
| RequestMetadata: common.RequestMetadata{ | |
| RetryPolicy: getDefaultRetryPolicy(), | |
| }, | |
| } | |
| // Handle encryption settings | |
| if c.kmsKeyID != "" { | |
| putRequest.OpcSseKmsKeyId = common.String(c.kmsKeyID) | |
| } else if c.SSECustomerKey != "" && c.SSECustomerKeySHA256 != "" { | |
| putRequest.OpcSseCustomerKey = common.String(c.SSECustomerKey) | |
| putRequest.OpcSseCustomerKeySha256 = common.String(c.SSECustomerKeySHA256) | |
| putRequest.OpcSseCustomerAlgorithm = common.String(c.SSECustomerAlgorithm) | |
| } | |
| logger.Info(fmt.Sprintf("Uploading remote state: %s", c.path)) | |
| putResponse, err := c.objectStorageClient.PutObject(ctx, putRequest) | |
| if err != nil { | |
| return fmt.Errorf("failed to upload object: %w", err) | |
| } | |
| logger.Info("Uploaded state file response: %+v\n", putResponse) | |
| return nil | |
| } | |
| func (c *RemoteClient) Delete() tfdiags.Diagnostics { | |
| var diags tfdiags.Diagnostics | |
| return diags.Append(c.DeleteAllObjectVersions()) | |
| } | |
| func (c *RemoteClient) DeleteAllObjectVersions() error { | |
| request := objectstorage.ListObjectVersionsRequest{ | |
| BucketName: common.String(c.bucketName), | |
| NamespaceName: common.String(c.namespace), | |
| Prefix: common.String(c.path), | |
| RequestMetadata: common.RequestMetadata{ | |
| RetryPolicy: getDefaultRetryPolicy(), | |
| }, | |
| } | |
| response, err := c.objectStorageClient.ListObjectVersions(context.Background(), request) | |
| if err != nil { | |
| return err | |
| } | |
| request.Page = response.OpcNextPage | |
| for request.Page != nil { | |
| request.RequestMetadata.RetryPolicy = getDefaultRetryPolicy() | |
| listResponse, err := c.objectStorageClient.ListObjectVersions(context.Background(), request) | |
| if err != nil { | |
| return err | |
| } | |
| response.Items = append(response.Items, listResponse.Items...) | |
| request.Page = listResponse.OpcNextPage | |
| } | |
| var diagErr tfdiags.Diagnostics | |
| for _, objectVersion := range response.Items { | |
| deleteObjectVersionRequest := objectstorage.DeleteObjectRequest{ | |
| BucketName: common.String(c.bucketName), | |
| NamespaceName: common.String(c.namespace), | |
| ObjectName: objectVersion.Name, | |
| VersionId: objectVersion.VersionId, | |
| RequestMetadata: common.RequestMetadata{ | |
| RetryPolicy: getDefaultRetryPolicy(), | |
| }, | |
| } | |
| _, err := c.objectStorageClient.DeleteObject(context.Background(), deleteObjectVersionRequest) | |
| if err != nil { | |
| diagErr = diagErr.Append(err) | |
| } | |
| } | |
| if diagErr != nil { | |
| return diagErr.Err() | |
| } | |
| return nil | |
| } | |
| func (c *RemoteClient) Lock(info *statemgr.LockInfo) (string, error) { | |
| logger := logWithOperation("lock-state-file").Named(c.lockFilePath) | |
| logger.Info("Locking remote state") | |
| ctx := context.TODO() | |
| info.Path = c.path | |
| infoBytes, err := json.Marshal(info) | |
| if err != nil { | |
| return "", err | |
| } | |
| putObjReq := objectstorage.PutObjectRequest{ | |
| BucketName: common.String(c.bucketName), | |
| NamespaceName: common.String(c.namespace), | |
| ObjectName: common.String(c.lockFilePath), | |
| IfNoneMatch: common.String("*"), | |
| PutObjectBody: io.NopCloser(bytes.NewReader(infoBytes)), | |
| RequestMetadata: common.RequestMetadata{ | |
| RetryPolicy: getDefaultRetryPolicy(), | |
| }, | |
| } | |
| putResponse, putErr := c.objectStorageClient.PutObject(ctx, putObjReq) | |
| if putErr != nil { | |
| lockInfo, _, err := c.getLockInfo(ctx) | |
| if err != nil { | |
| putErr = errors.Join(putErr, err) | |
| } | |
| return "", &statemgr.LockError{ | |
| Err: putErr, | |
| Info: lockInfo, | |
| } | |
| } | |
| logger.Info("state lock response code: %+d\n", putResponse.RawResponse.StatusCode) | |
| return info.ID, nil | |
| } | |
| // getLockInfo retrieves and parses a lock file from an oci bucket. | |
| func (c *RemoteClient) getLockInfo(ctx context.Context) (*statemgr.LockInfo, string, error) { | |
| // Attempt to retrieve the lock file from | |
| getRequest := objectstorage.GetObjectRequest{ | |
| NamespaceName: common.String(c.namespace), | |
| ObjectName: common.String(c.lockFilePath), | |
| BucketName: common.String(c.bucketName), | |
| RequestMetadata: common.RequestMetadata{ | |
| RetryPolicy: getDefaultRetryPolicy(), | |
| }, | |
| } | |
| getResponse, err := c.objectStorageClient.GetObject(ctx, getRequest) | |
| if err != nil { | |
| return nil, "", fmt.Errorf("failed to get existing lock file: %w", err) | |
| } | |
| lockByteData, err := io.ReadAll(getResponse.Content) | |
| if err != nil { | |
| return nil, *getResponse.ETag, fmt.Errorf("failed to read existing lock file content: %w", err) | |
| } | |
| lockInfo := &statemgr.LockInfo{} | |
| if err := json.Unmarshal(lockByteData, lockInfo); err != nil { | |
| return lockInfo, "", fmt.Errorf("failed to unmarshal JSON data into LockInfo struct: %w", err) | |
| } | |
| return lockInfo, *getResponse.ETag, nil | |
| } | |
| func (c *RemoteClient) Unlock(id string) error { | |
| ctx := context.TODO() | |
| logger := logWithOperation("unlock-state-file").Named(c.lockFilePath) | |
| logger.Info("unlocking remote state") | |
| lockInfo, etag, err := c.getLockInfo(ctx) | |
| if err != nil { | |
| return fmt.Errorf("Failed to retrieve lock information from OCI Object Storage: %w", err) | |
| } | |
| // Verify that the provided lock ID matches the lock ID of the retrieved lock file. | |
| if lockInfo.ID != id { | |
| return &statemgr.LockError{ | |
| Info: lockInfo, | |
| Err: fmt.Errorf("lock ID '%s' does not match the existing lock ID '%s'", id, lockInfo.ID), | |
| } | |
| } | |
| deleteRequest := objectstorage.DeleteObjectRequest{ | |
| NamespaceName: common.String(c.namespace), | |
| ObjectName: common.String(c.lockFilePath), | |
| BucketName: common.String(c.bucketName), | |
| IfMatch: common.String(etag), | |
| RequestMetadata: common.RequestMetadata{ | |
| RetryPolicy: getDefaultRetryPolicy(), | |
| }, | |
| } | |
| deleteResponse, err := c.objectStorageClient.DeleteObject(ctx, deleteRequest) | |
| if err != nil { | |
| return &statemgr.LockError{ | |
| Info: lockInfo, | |
| Err: err, | |
| } | |
| } | |
| logger.Info("Unlock response: %v\n", deleteResponse.RawResponse.StatusCode) | |
| return nil | |
| } |