Repository: serverless/serverless (JavaScript)
Date: 2017-06-27 11:38:29+00:00
Task type: Feature

Issue:
Serverless config should allow override of profiles
# This is a Feature Proposal

## Description

When trying to setup an existing profile I get an error
```
serverless config credentials --provider aws --key XXX --secret YYY --profile serverless-admin
Serverless: Setting up AWS...
Serverless: Saving your AWS profile in "~/.aws/credentials"...
Serverless: Failed! ~/.aws/credentials exists and already has a "serverless-admin" profile.
```

At the very least I would expect a prompt asking me if I want to override credentials as existing ones have already been found

## Additional Data

* ***Serverless Framework Version you're using***: 1.15.3
* ***Operating System***: Mac



Issue discussion:
Hey @simplesteph thanks for opening 👍 

The main purpose for the error message is that we don't want to manipulate existing configurations due to potential loss / corruption of data.

Allowing only the creation of non-existent profiles enables us a way to streamline the onboarding process while still maintaining a safe way to deal with the `credentials` file.
@pmuens 
An overwrite option is also a clean and simple solution.
I would be happy to help in this 👍 

> An overwrite option is also a clean and simple solution.

@medhoover Yes, that would be a good solution. This should be forced by the user so that he's aware of "potential damage".

> I would be happy to help in this 👍

Great! I'll update the status of the issue. Let us know if you need any help with the implementation 👍 
@pmuens I had a quick look at the possible options to update the credentials file and It seems that we have to implement all the logic as I couldn't find a suitable package to handle this. We could imitate the [aws-cli config writer](https://github.com/aws/aws-cli/blob/e2295b022db35eea9fec7e6c5540d06dbd6e588b/awscli/customizations/configure/writer.py)
For the option flag, I suggest: ` --overwrite | -o`
Hey @medhoover thanks for researching and working on the proposal 👍 

> For the option flag, I suggest: --overwrite | -o

Yes, that sounds like a good choice for the flag.

> we have to implement all the logic as I couldn't find a suitable package to handle this. We could imitate the aws-cli config writer

That sounds good to me 👍. Is there maybe smth. in the AWS Node.js SDK we could utilize?
We could use the [SharedIniFileCredentials](http://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/SharedIniFileCredentials.html) to check if the profile exists and to load the existing properties, this should reduce the amount of work.

I'll start the work.


Solution patch:
diff --git a/docs/providers/aws/cli-reference/config-credentials.md b/docs/providers/aws/cli-reference/config-credentials.md
index 79d8751b385..e43f2a49865 100644
--- a/docs/providers/aws/cli-reference/config-credentials.md
+++ b/docs/providers/aws/cli-reference/config-credentials.md
@@ -22,6 +22,7 @@ serverless config credentials --provider provider --key key --secret secret
 - `--key` or `-k` The `aws_access_key_id`. **Required**.
 - `--secret` or `-s` The `aws_secret_access_key`. **Required**.
 - `--profile` or `-n` The name of the profile which should be created.
+- `--overwrite` or `-o` Overwrite the profile if it exists.
 
 ## Provided lifecycle events
 
@@ -44,3 +45,12 @@ serverless config credentials --provider aws --key 1234 --secret 5678 --profile
 ```
 
 This example create and configure a `custom-profile` profile with the `aws_access_key_id` of `1234` and the `aws_secret_access_key` of `5678`.
+
+### Update an existing profile
+
+```bash
+serverless config credentials --provider aws --key 1234 --secret 5678 --profile custom-profile --overwrite
+```
+
+This example overwrite `custom-profile` profile with the `aws_access_key_id` of `1234` and the `aws_secret_access_key` of `5678`.
+If the profile do not exist, it will be added anyway.
diff --git a/lib/plugins/aws/configCredentials/awsConfigCredentials.js b/lib/plugins/aws/configCredentials/awsConfigCredentials.js
index 066018a9611..03b960a99fd 100644
--- a/lib/plugins/aws/configCredentials/awsConfigCredentials.js
+++ b/lib/plugins/aws/configCredentials/awsConfigCredentials.js
@@ -4,6 +4,7 @@ const BbPromise = require('bluebird');
 const path = require('path');
 const fse = require('fs-extra');
 const os = require('os');
+const _ = require('lodash');
 
 class AwsConfigCredentials {
   constructor(serverless, options) {
@@ -35,12 +36,25 @@ class AwsConfigCredentials {
                 usage: 'Name of the profile you wish to create. Defaults to "default"',
                 shortcut: 'n',
               },
+              overwrite: {
+                usage: 'Overwrite the existing profile configuration in the credentials file',
+                shortcut: 'o',
+              },
             },
           },
         },
       },
     };
 
+    if (!os.homedir()) {
+      throw new this.serverless.classes
+        .Error('Can\'t find home directory on your local file system.');
+    }
+
+    this.credentialsFilePath = path.join(os.homedir(), '.aws', 'credentials');
+    // Create the credentials file alongside the .aws directory if it's not yet present
+    fse.ensureFileSync(this.credentialsFilePath);
+
     this.hooks = {
       'config:credentials:config': () => BbPromise.bind(this)
         .then(this.configureCredentials),
@@ -63,45 +77,93 @@ class AwsConfigCredentials {
     }
 
     this.serverless.cli.log('Setting up AWS...');
-    this.serverless.cli.log('Saving your AWS profile in "~/.aws/credentials"...');
-
-    if (!os.homedir()) {
-      throw new this.serverless.classes
-        .Error('Can\'t find home directory on your local file system.');
-    }
 
-    // check if ~/.aws/credentials exists
-    const configDir = path.join(os.homedir(), '.aws');
-    const credsPath = path.join(configDir, 'credentials');
+    this.credentials = this.getCredentials();
 
-    if (this.serverless.utils.fileExistsSync(credsPath)) {
-      // check if credentials files contains anything
-      const credsFile = this.serverless.utils.readFileSync(credsPath);
+    // Get the profile start line and end line numbers inside the credentials array
+    const profileBoundaries = this.getProfileBoundaries();
 
-      // if credentials file exists w/ profile, exit
-      if (credsFile.length && credsFile.indexOf(`[${this.options.profile}]`) > -1) {
+    // Check if the profile exists
+    const isNewProfile = profileBoundaries.start === -1;
+    if (isNewProfile) {
+      this.addProfile();
+    } else {
+      // Only update the profile if the overwrite flag was set
+      if (!this.options.overwrite) {
         this.serverless.cli.log(
-          `Failed! ~/.aws/credentials exists and already has a "${this.options.profile}" profile.`);
+          `Failed! ~/.aws/credentials already has a "${this.options.profile}" profile.
+          Use the overwrite flag ("-o" or "--overwrite") to force the update`);
         return BbPromise.resolve();
       }
-    } else {
-      // create the credentials file alongside the .aws directory if it's not yet present
-      fse.ensureFileSync(credsPath);
+
+      this.updateProfile(profileBoundaries);
     }
 
-    // write credentials file with 'default' profile
-    this.serverless.utils.appendFileSync(
-      credsPath,
-      `[${this.options.profile}]
-aws_access_key_id=${this.options.key}
-aws_secret_access_key=${this.options.secret}
-`); // Keep line break at the end. Otherwise will break AWS CLI.
+    return this.saveCredentialsFile();
+  }
 
-    this.serverless.cli.log(
-      `Success! Your AWS access keys were stored under the "${this.options.profile}" profile.`);
+  getCredentials() {
+    // Get the credentials file lines
+    const credentialsFileContent = this.serverless.utils.readFileSync(this.credentialsFilePath);
+    return credentialsFileContent ? credentialsFileContent.split('\n') : [];
+  }
 
-    return BbPromise.resolve();
+  addProfile() {
+    this.credentials.push(`[${this.options.profile}]`,
+      `aws_access_key_id = ${this.options.key}`,
+      `aws_secret_access_key = ${this.options.secret}`);
   }
+
+  updateProfile(profileBoundries) {
+    let currentLine = profileBoundries.start;
+    let endLine = profileBoundries.end;
+
+    // Remove existing 'aws_access_key_id' and 'aws_secret_access_key' properties
+    while (currentLine < endLine) {
+      const line = this.credentials[currentLine];
+      if (
+        line.indexOf('aws_access_key_id') > -1 ||
+        line.indexOf('aws_secret_access_key') > -1
+      ) {
+        this.credentials.splice(currentLine, 1);
+        endLine--;
+      } else {
+        currentLine++;
+      }
+    }
+
+    // Add the key and the secret to the beginning of the section
+    const keyLine = `aws_access_key_id = ${this.options.key}`;
+    const secretLine = `aws_secret_access_key = ${this.options.secret}`;
+
+    this.credentials.splice(profileBoundries.start + 1, 0, secretLine);
+    this.credentials.splice(profileBoundries.start + 1, 0, keyLine);
+  }
+
+  saveCredentialsFile() {
+    // Generate the file content and add a line break at the end
+    const updatedCredsFileContent = `${this.credentials.join('\n')}\n`;
+
+    this.serverless.cli.log('Saving your AWS profile in "~/.aws/credentials"...');
+
+    return this.serverless.utils.writeFile(this.credentialsFilePath, updatedCredsFileContent)
+      .then(() => {
+        this.serverless.cli.log(
+          `Success! Your AWS access keys were stored under the "${this.options.profile}" profile.`);
+      });
+  }
+
+  getProfileBoundaries() {
+    // Get the line number of the aws profile definition, defaults to -1
+    const start = this.credentials.indexOf(`[${this.options.profile}]`);
+
+    const nextProfile = _.findIndex(this.credentials, line => /\[[^\]]+\]/.test(line), start + 1);
+    // Get the line number of the next aws profile definition, defaults to the file lines number
+    const end = nextProfile + 1 || this.credentials.length;
+
+    return { start, end };
+  }
+
 }
 
 module.exports = AwsConfigCredentials;


Test patch:
diff --git a/lib/plugins/aws/configCredentials/awsConfigCredentials.test.js b/lib/plugins/aws/configCredentials/awsConfigCredentials.test.js
index 6daad7908ae..21838e17881 100644
--- a/lib/plugins/aws/configCredentials/awsConfigCredentials.test.js
+++ b/lib/plugins/aws/configCredentials/awsConfigCredentials.test.js
@@ -12,9 +12,24 @@ const Serverless = require('../../../Serverless');
 
 describe('AwsConfigCredentials', () => {
   let awsConfigCredentials;
+  let credentialsFileContent;
+  let credentialsFilePath;
   let serverless;
+  let sandbox;
+  let tmpDirPath;
 
   beforeEach(() => {
+    sandbox = sinon.sandbox.create();
+
+    tmpDirPath = testUtils.getTmpDirPath();
+    credentialsFilePath = path.join(tmpDirPath, '.aws', 'credentials');
+    credentialsFileContent = '[my-profile]\n';
+    credentialsFileContent += 'aws_access_key_id = my-old-profile-key\n';
+    credentialsFileContent += 'aws_secret_access_key = my-old-profile-secret\n';
+
+    // stub homedir handler to return the tmpDirPath
+    sandbox.stub(os, 'homedir').returns(tmpDirPath);
+
     serverless = new Serverless();
     serverless.init();
     const options = {
@@ -25,6 +40,10 @@ describe('AwsConfigCredentials', () => {
     awsConfigCredentials = new AwsConfigCredentials(serverless, options);
   });
 
+  afterEach(() => {
+    sandbox.restore();
+  });
+
   describe('#constructor()', () => {
     it('should have the command "config"', () => {
       expect(awsConfigCredentials.commands.config).to.not.equal(undefined);
@@ -66,29 +85,24 @@ describe('AwsConfigCredentials', () => {
         awsConfigCredentials.configureCredentials.restore();
       });
     });
-  });
-
-  describe('#configureCredentials()', () => {
-    let homeDir;
-    let tmpDirPath;
-    let credentialsFilePath;
 
-    beforeEach(() => {
-      // create a new tmpDir for the homeDir path
-      tmpDirPath = testUtils.getTmpDirPath();
-      fse.mkdirsSync(tmpDirPath);
+    it('should throw an error if the home directory was not found', () => {
+      os.homedir.returns(null);
+      expect(() => new AwsConfigCredentials(serverless, {})).to.throw(Error);
+    });
 
-      // create the .aws/credetials directory and file
-      credentialsFilePath = path.join(tmpDirPath, '.aws', 'credentials');
-      fse.ensureFileSync(credentialsFilePath);
+    it('should create the .aws/credentials file if not yet present', () => {
+      // remove the .aws directory which was created in the before hook of the test
+      const awsDirectoryPath = path.join(tmpDirPath, '.aws');
+      fse.removeSync(awsDirectoryPath);
 
-      // save the homeDir so that we can reset this later on
-      homeDir = os.homedir();
-      process.env.HOME = tmpDirPath;
-      process.env.HOMEPATH = tmpDirPath;
-      process.env.USERPROFILE = tmpDirPath;
+      awsConfigCredentials = new AwsConfigCredentials(serverless, {});
+      const isCredentialsFilePresent = fs.existsSync(path.join(awsDirectoryPath, 'credentials'));
+      expect(isCredentialsFilePresent).to.equal(true);
     });
+  });
 
+  describe('#configureCredentials()', () => {
     it('should lowercase the provider option', () => {
       awsConfigCredentials.options.provider = 'SOMEPROVIDER';
 
@@ -116,11 +130,75 @@ describe('AwsConfigCredentials', () => {
       expect(() => awsConfigCredentials.configureCredentials()).to.throw(Error);
     });
 
-    it('should resolve if profile is already given in credentials file', (done) => {
+    it('should not update the profile if the overwrite flag is not set', () => {
       awsConfigCredentials.options.profile = 'my-profile';
-      serverless.utils.appendFileSync(credentialsFilePath, '[my-profile]');
+      awsConfigCredentials.options.key = 'my-new-profile-key';
+      awsConfigCredentials.options.secret = 'my-new-profile-secret';
 
-      awsConfigCredentials.configureCredentials().then(() => done());
+      fse.outputFileSync(credentialsFilePath, credentialsFileContent);
+
+      return awsConfigCredentials.configureCredentials();
+    });
+
+    it('should update the profile', () => {
+      awsConfigCredentials.options.profile = 'my-profile';
+      awsConfigCredentials.options.key = 'my-new-profile-key';
+      awsConfigCredentials.options.secret = 'my-new-profile-secret';
+      awsConfigCredentials.options.overwrite = true;
+
+      fse.outputFileSync(credentialsFilePath, credentialsFileContent);
+
+      return awsConfigCredentials.configureCredentials().then(() => {
+        const UpdatedCredentialsFileContent = fs.readFileSync(credentialsFilePath).toString();
+        const lineByLineContent = UpdatedCredentialsFileContent.split('\n');
+
+        expect(lineByLineContent[0]).to.equal('[my-profile]');
+        expect(lineByLineContent[1]).to.equal('aws_access_key_id = my-new-profile-key');
+        expect(lineByLineContent[2]).to.equal('aws_secret_access_key = my-new-profile-secret');
+      });
+    });
+
+    it('should not alter other profiles when updating a profile', () => {
+      awsConfigCredentials.options.profile = 'my-profile';
+      awsConfigCredentials.options.key = 'my-new-profile-key';
+      awsConfigCredentials.options.secret = 'my-new-profile-secret';
+      awsConfigCredentials.options.overwrite = true;
+
+      credentialsFileContent += '[my-other-profile]\n';
+      credentialsFileContent += 'aws_secret_access_key = my-other-profile-secret\n';
+
+      fse.outputFileSync(credentialsFilePath, credentialsFileContent);
+
+      return awsConfigCredentials.configureCredentials().then(() => {
+        const UpdatedCredentialsFileContent = fs.readFileSync(credentialsFilePath).toString();
+        const lineByLineContent = UpdatedCredentialsFileContent.split('\n');
+
+        expect(lineByLineContent[0]).to.equal('[my-profile]');
+        expect(lineByLineContent[1]).to.equal('aws_access_key_id = my-new-profile-key');
+        expect(lineByLineContent[2]).to.equal('aws_secret_access_key = my-new-profile-secret');
+        expect(lineByLineContent[4]).to.equal('aws_secret_access_key = my-other-profile-secret');
+      });
+    });
+
+    it('should add the missing credentials to the updated profile', () => {
+      credentialsFileContent = '[my-profile]\n';
+      credentialsFileContent += 'aws_secret_access_key = my-profile-secret\n';
+
+      awsConfigCredentials.options.profile = 'my-profile';
+      awsConfigCredentials.options.key = 'my-new-profile-key';
+      awsConfigCredentials.options.secret = 'my-new-profile-secret';
+      awsConfigCredentials.options.overwrite = true;
+
+      fse.outputFileSync(credentialsFilePath, credentialsFileContent);
+
+      return awsConfigCredentials.configureCredentials().then(() => {
+        const UpdatedCredentialsFileContent = fs.readFileSync(credentialsFilePath).toString();
+        const lineByLineContent = UpdatedCredentialsFileContent.split('\n');
+
+        expect(lineByLineContent[0]).to.equal('[my-profile]');
+        expect(lineByLineContent[1]).to.equal('aws_access_key_id = my-new-profile-key');
+        expect(lineByLineContent[2]).to.equal('aws_secret_access_key = my-new-profile-secret');
+      });
     });
 
     it('should append the profile to the credentials file', () => {
@@ -129,32 +207,70 @@ describe('AwsConfigCredentials', () => {
       awsConfigCredentials.options.secret = 'my-profile-secret';
 
       return awsConfigCredentials.configureCredentials().then(() => {
-        const credentialsFileContent = fs.readFileSync(credentialsFilePath).toString();
-        const lineByLineContent = credentialsFileContent.split('\n');
+        const UpdatedCredentialsFileContent = fs.readFileSync(credentialsFilePath).toString();
+        const lineByLineContent = UpdatedCredentialsFileContent.split('\n');
 
         expect(lineByLineContent[0]).to.equal('[my-profile]');
-        expect(lineByLineContent[1]).to.equal('aws_access_key_id=my-profile-key');
-        expect(lineByLineContent[2]).to.equal('aws_secret_access_key=my-profile-secret');
+        expect(lineByLineContent[1]).to.equal('aws_access_key_id = my-profile-key');
+        expect(lineByLineContent[2]).to.equal('aws_secret_access_key = my-profile-secret');
       });
     });
+  });
 
-    it('should create the .aws/credentials file if not yet present', () => {
-      // remove the .aws directory which was created in the before hook of the test
-      const awsDirectoryPath = path.join(tmpDirPath, '.aws');
-      fse.removeSync(awsDirectoryPath);
+  describe('#getCredentials()', () => {
+    it('should load credentials file and return the credentials lines', () => {
+      fse.outputFileSync(credentialsFilePath, credentialsFileContent);
+      const credentials = awsConfigCredentials.getCredentials();
 
-      return awsConfigCredentials.configureCredentials().then(() => {
-        const isCredentialsFilePresent = fs.existsSync(path.join(awsDirectoryPath, 'credentials'));
+      expect(credentials[0]).to.equal('[my-profile]');
+      expect(credentials[1]).to.equal('aws_access_key_id = my-old-profile-key');
+    });
 
-        expect(isCredentialsFilePresent).to.equal(true);
-      });
+    it('should return an empty array if the file is empty', () => {
+      const credentials = awsConfigCredentials.getCredentials();
+
+      expect(credentials.length).to.equal(0);
     });
+  });
+
+  describe('#getProfileBoundaries()', () => {
+    it('should return the start and end numbers of the profile', () => {
+      awsConfigCredentials.options.profile = 'my-profile';
+      awsConfigCredentials.credentials = [
+        '[my-profile]',
+        'aws_access_key_id = my-other-profile-key',
+      ];
+
+      const profileBoundaries = awsConfigCredentials.getProfileBoundaries();
+
+      expect(profileBoundaries.start).to.equal(0);
+      expect(profileBoundaries.end).to.equal(2);
+    });
+
+    it('should set the start property to -1 if the profile was not found', () => {
+      awsConfigCredentials.options.profile = 'my-not-yet-saved-profile';
+      awsConfigCredentials.credentials = [
+        '[my-profile]',
+        'aws_access_key_id = my-other-profile-key',
+      ];
+
+      const profileBoundaries = awsConfigCredentials.getProfileBoundaries();
+
+      expect(profileBoundaries.start).to.equal(-1);
+    });
+
+    it('should set the end to the credentials lentgh if no other profile was found', () => {
+      awsConfigCredentials.options.profile = 'my-profile';
+      awsConfigCredentials.credentials = [
+        '[my-profile]',
+        'aws_access_key_id = my-other-profile-key',
+        '# a comment',
+        'aws_secret_access_key = my-profile-secret',
+      ];
+
+      const profileBoundaries = awsConfigCredentials.getProfileBoundaries();
 
-    afterEach(() => {
-      // recover the homeDir
-      process.env.HOME = homeDir;
-      process.env.HOMEPATH = homeDir;
-      process.env.USERPROFILE = homeDir;
+      expect(profileBoundaries.end).to.equal(4);
     });
   });
 });