├── .gitignore ├── 010-prepare-backend ├── backend.js ├── main.tf └── terraform.tfvars.template ├── 020-use-backend ├── backend.tf └── main.tf ├── LICENSE ├── README.md ├── terraform ├── backend.tf └── main.tf └── xdocs ├── architecture.drawio └── step-010.png /.gitignore: -------------------------------------------------------------------------------- 1 | *.tfstate* 2 | *.tfvars 3 | .terraform 4 | .terraform.lock.hcl 5 | local.env 6 | test.txt 7 | hello.txt 8 | backend.env 9 | -------------------------------------------------------------------------------- /010-prepare-backend/backend.js: -------------------------------------------------------------------------------- 1 | const COS = require('ibm-cos-sdk'); 2 | const querystring = require('querystring'); 3 | 4 | const EMPTY_STATE = { 5 | version: 3, 6 | serial: 0, 7 | modules: [{ 8 | path: ['root'], 9 | outputs: {}, 10 | resources: {} 11 | }] 12 | }; 13 | 14 | function makeFilename(env) { 15 | if (env) { 16 | return `states/named/${env}.tfstate`; 17 | } else { 18 | return `states/default.tfstate`; 19 | } 20 | } 21 | 22 | function makeVersionFilename(env, serial) { 23 | if (env) { 24 | return `versions/named/${env}-${serial}.tfstate`; 25 | } else { 26 | return `versions/default-${serial}.tfstate`; 27 | } 28 | } 29 | 30 | function makeLockFilename(env) { 31 | if (env) { 32 | return `locks/named/${env}.lock`; 33 | } else { 34 | return `locks/default.lock`; 35 | } 36 | } 37 | 38 | function extractBodyFromParams(params) { 39 | return JSON.parse(Buffer.from(params.__ow_body, 'base64').toString('utf-8')); 40 | } 41 | 42 | function returnFailure(statusCode, errorBody) { 43 | return { 44 | headers: { 45 | 'Content-Type': 'application/json' 46 | }, 47 | statusCode, 48 | body: Buffer.from(JSON.stringify(errorBody, null, 2)).toString('base64'), 49 | }; 50 | } 51 | 52 | /** 53 | * Primitives to work with Cloud Object Storage 54 | */ 55 | class Storage { 56 | constructor(endpoint, apiKeyId, serviceInstanceId, bucket) { 57 | console.log('Initializing COS.S3', endpoint, serviceInstanceId, bucket); 58 | this.cos = new COS.S3({ 59 | endpoint, 60 | apiKeyId, 61 | serviceInstanceId, 62 | }); 63 | this.bucket = bucket; 64 | } 65 | 66 | /** 67 | * Returns the given object 68 | * 69 | * @param {string} filename 70 | */ 71 | load(filename) { 72 | console.log(`Loading cos://${this.bucket}/${filename}`); 73 | return this.cos.getObject({ 74 | Bucket: this.bucket, 75 | Key: filename, 76 | }).promise().then((data) => { 77 | return JSON.parse(data.Body.toString()); 78 | }).catch((err) => { 79 | if (err.code === 'NoSuchKey') { 80 | return null; 81 | } else { 82 | console.log('error', err); 83 | throw err; 84 | } 85 | }); 86 | } 87 | 88 | /** 89 | * Stores the JSON representation of the given Object under the given name 90 | * 91 | * @param {string} filename 92 | * @param {object} content 93 | */ 94 | save(filename, content) { 95 | console.log(`Storing cos://${this.bucket}/${filename}`); 96 | return this.cos.putObject({ 97 | Bucket: this.bucket, 98 | Key: filename, 99 | Body: JSON.stringify(content), 100 | ContentType: 'application/json', 101 | }).promise(); 102 | } 103 | 104 | /** 105 | * Deletes the given file 106 | * 107 | * @param {string} filename 108 | */ 109 | delete(filename) { 110 | console.log(`Deleting cos://${this.bucket}/${filename}`); 111 | return this.cos.deleteObject({ 112 | Bucket: this.bucket, 113 | Key: filename, 114 | }).promise(); 115 | } 116 | } 117 | 118 | class State { 119 | 120 | constructor(storage, env, versioning) { 121 | this.storage = storage; 122 | this.env = env; 123 | this.versioning = versioning; 124 | } 125 | 126 | async lock(lockInfo) { 127 | const lockFilename = makeLockFilename(this.env); 128 | const currentLock = await this.storage.load(lockFilename); 129 | if (currentLock) { 130 | console.log('State is already locked', currentLock); 131 | const err = new Error(); 132 | err.code = 409; 133 | err.body = currentLock; 134 | throw err; 135 | } else { 136 | // lock the state 137 | console.log('Locking state...', lockInfo); 138 | await this.storage.save(lockFilename, lockInfo); 139 | } 140 | } 141 | 142 | unlock() { 143 | return this.storage.delete(makeLockFilename(this.env)); 144 | } 145 | 146 | get() { 147 | const stateFilename = makeFilename(this.env); 148 | return this.storage.load(stateFilename); 149 | } 150 | 151 | async post(newState, requesterId) { 152 | const lockFilename = makeLockFilename(this.env); 153 | const currentLock = await this.storage.load(lockFilename); 154 | if (currentLock) { 155 | console.log(`State is current locked by ID=${currentLock.ID}`) 156 | console.log(`ID=${requesterId} is requesting to update the state`); 157 | if (requesterId !== currentLock.ID) { 158 | const err = new Error(); 159 | err.code = 409; 160 | err.body = currentLock; 161 | throw err; 162 | } 163 | } 164 | 165 | if (this.versioning) { 166 | const currentState = await this.get(); 167 | if (currentState) { 168 | // save a copy 169 | const versionFilename = makeVersionFilename(this.env, currentState.serial); 170 | await this.storage.save(versionFilename, currentState); 171 | } 172 | } 173 | 174 | const stateFilename = makeFilename(this.env); 175 | await this.storage.save(stateFilename, newState); 176 | } 177 | } 178 | 179 | async function main(params) { 180 | const queryParams = params.__ow_query ? querystring.parse(params.__ow_query) : {}; 181 | if (queryParams.debug) { 182 | console.log(params); 183 | } 184 | 185 | // extract the API key from the authorization header username=cos password=apikey 186 | if (!params.__ow_headers.authorization) { 187 | return returnFailure(401, { error: 'missing authentication' }); 188 | } 189 | 190 | const authElements = Buffer.from(params.__ow_headers.authorization.split(' ')[1], 'base64').toString().split(':'); 191 | if (authElements[0] !== 'cos') { 192 | return returnFailure(401, { error: 'invalid username' }); 193 | } 194 | 195 | const storage = new Storage( 196 | params['services.storage.apiEndpoint'], 197 | authElements[1], 198 | params['services.storage.instanceId'], 199 | params['services.storage.bucket'] 200 | ); 201 | 202 | const state = new State(storage, queryParams.env, queryParams.versioning); 203 | 204 | try { 205 | let resultBody; 206 | 207 | switch (params.__ow_method) { 208 | case 'get': { 209 | const currentState = await state.get(); 210 | resultBody = currentState ? currentState : EMPTY_STATE; 211 | break; 212 | } 213 | case 'post': { 214 | const newState = extractBodyFromParams(params); 215 | await state.post(newState, queryParams.ID) 216 | resultBody = newState; 217 | break; 218 | } 219 | case 'put': { 220 | const lockInfo = extractBodyFromParams(params); 221 | await state.lock(lockInfo); 222 | resultBody = lockInfo; 223 | break; 224 | } 225 | case 'delete': { 226 | await state.unlock(); 227 | resultBody = {}; 228 | break; 229 | } 230 | default: { 231 | throw new Error(`Unknown method ${params.__ow_method}`) 232 | } 233 | } 234 | 235 | return { 236 | headers: { 237 | 'Content-Type': 'application/json' 238 | }, 239 | body: Buffer.from(JSON.stringify(resultBody, null, 2)).toString('base64'), 240 | }; 241 | } catch (err) { 242 | console.log('Failed to process method', err); 243 | 244 | let statusCode = 500; 245 | let errorBody = { ok: false } 246 | 247 | if (err.code) { 248 | statusCode = err.code; 249 | errorBody = err.body; 250 | } 251 | 252 | return { 253 | headers: { 254 | 'Content-Type': 'application/json' 255 | }, 256 | statusCode, 257 | body: Buffer.from(JSON.stringify(errorBody, null, 2)).toString('base64'), 258 | }; 259 | } 260 | } -------------------------------------------------------------------------------- /010-prepare-backend/main.tf: -------------------------------------------------------------------------------- 1 | variable "ibmcloud_api_key" { 2 | type = string 3 | } 4 | 5 | variable "ibmcloud_timeout" { 6 | type = number 7 | default = 600 8 | } 9 | 10 | variable "region" { 11 | type = string 12 | default = "us-south" 13 | } 14 | 15 | variable "basename" { 16 | type = string 17 | default = "serverless-terraform-backend" 18 | } 19 | 20 | variable "resource_group" { 21 | type = string 22 | default = "" 23 | } 24 | 25 | variable "tags" { 26 | type = list(string) 27 | default = ["terraform", "tutorial"] 28 | } 29 | 30 | terraform { 31 | required_version = ">=0.13" 32 | required_providers { 33 | ibm = { 34 | source = "IBM-Cloud/ibm" 35 | } 36 | } 37 | } 38 | 39 | provider "ibm" { 40 | ibmcloud_api_key = var.ibmcloud_api_key 41 | region = var.region 42 | ibmcloud_timeout = var.ibmcloud_timeout 43 | } 44 | 45 | # a new or existing resource group to create resources 46 | resource "ibm_resource_group" "group" { 47 | count = var.resource_group != "" ? 0 : 1 48 | name = "${var.basename}-group" 49 | tags = var.tags 50 | } 51 | 52 | data "ibm_resource_group" "group" { 53 | count = var.resource_group != "" ? 1 : 0 54 | name = var.resource_group 55 | } 56 | 57 | locals { 58 | resource_group_id = var.resource_group != "" ? data.ibm_resource_group.group.0.id : ibm_resource_group.group.0.id 59 | } 60 | 61 | # a COS instance 62 | resource "ibm_resource_instance" "cos" { 63 | name = "${var.basename}-cos" 64 | resource_group_id = local.resource_group_id 65 | service = "cloud-object-storage" 66 | plan = "standard" 67 | location = "global" 68 | tags = concat(var.tags, ["service"]) 69 | } 70 | 71 | resource "ibm_resource_key" "cos_key" { 72 | name = "${var.basename}-cos-key" 73 | resource_instance_id = ibm_resource_instance.cos.id 74 | role = "Writer" 75 | } 76 | 77 | # a bucket 78 | resource "ibm_cos_bucket" "bucket" { 79 | bucket_name = "${var.basename}-bucket" 80 | resource_instance_id = ibm_resource_instance.cos.id 81 | region_location = var.region 82 | storage_class = "smart" 83 | } 84 | 85 | # a namespace for the backend functions 86 | resource "ibm_function_namespace" "namespace" { 87 | name = "${var.basename}-namespace" 88 | resource_group_id = local.resource_group_id 89 | } 90 | 91 | # a package to group the functions 92 | resource "ibm_function_package" "package" { 93 | name = "${var.basename}-package" 94 | namespace = ibm_function_namespace.namespace.name 95 | user_defined_parameters = < It creates the resources and generates a `backend.env` file in 020-use-backend with the backend address and password. 34 | 35 | ## Step 2 - Test the backend 36 | 37 | 1. Change to the step directory 38 | ``` 39 | cd 020-use-backend 40 | ``` 41 | 1. Load the backend configuration variables 42 | ``` 43 | source backend.env 44 | ``` 45 | 1. Test the backend 46 | ``` 47 | terraform init 48 | terraform apply 49 | 1. In IBM Cloud console, 50 | - go to the COS service instance, 51 | - select the bucket 52 | - find the `dev.tfstate` under `states/named` 53 | 54 | ## Advanced configuration 55 | 56 | You can configure the generated `backend.env` to suit your needs by changing the value of the `env` and `versioning` parameters or commenting the lock/unlock address to disable locking: 57 | 58 | ``` 59 | # TF_HTTP_ADDRESS points to the Cloud Functions action implementing the backend. 60 | # It is reused for locking implementation too. 61 | # 62 | # env: name for the terraform state, e.g mystate, us/south/staging (.tfstate will be added automatically) 63 | # versioning: set to true to keep multiple copies of the states in the storage 64 | export TF_HTTP_ADDRESS="https://us-south.functions.cloud.ibm.com/api/v1/web/1234-5678/serverless-terraform-backend-package/backend?env=dev&versioning=true" 65 | export TF_HTTP_PASSWORD="" 66 | 67 | # comment the following variables to disable locking 68 | export TF_HTTP_LOCK_ADDRESS=$TF_HTTP_ADDRESS 69 | export TF_HTTP_UNLOCK_ADDRESS=$TF_HTTP_ADDRESS 70 | ``` 71 | 72 | ## License 73 | 74 | This project is licensed under the Apache License Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0). 75 | -------------------------------------------------------------------------------- /terraform/backend.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | backend "http" { 3 | # Serverless backend endpoint 4 | # Optional query parameters: 5 | # env: name for the terraform state, e.g mystate, us/south/staging (.tfstate will be added automatically) 6 | # versioning: set to true to keep multiple copies of the states in the storage 7 | address = "https://API_GATEWAY_URL?env=name&versioning=true" 8 | 9 | # Uncomment to enable locking. Set to same value as address 10 | # lock_address = "https://API_GATEWAY_URL?env=name&versioning=true" 11 | # unlock_address = "https://API_GATEWAY_URL?env=name&versioning=true" 12 | 13 | # API Key for Cloud Object Storage 14 | password = "SET_YOUR_KEY" 15 | 16 | # Do not change the following 17 | username = "cos" 18 | update_method = "POST" 19 | lock_method = "PUT" 20 | unlock_method = "DELETE" 21 | skip_cert_verification = "false" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /terraform/main.tf: -------------------------------------------------------------------------------- 1 | resource "local_file" "output" { 2 | content = <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 -------------------------------------------------------------------------------- /xdocs/step-010.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l2fprod/serverless-terraform-backend/eb59a68f400a5998995030bfcb38409b26955b07/xdocs/step-010.png --------------------------------------------------------------------------------