Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(cli): Introduce zapier canary command #861

Merged
merged 16 commits into from
Sep 19, 2024
Merged

feat(cli): Introduce zapier canary command #861

merged 16 commits into from
Sep 19, 2024

Conversation

pragmatic-zac
Copy link
Contributor

Adds a new command - zapier canary 🐦

This command enables users to divert a portion of traffic from one app version to a different app version for a specified amount of time.

Here's a gif of the command working...

  • First, we create a canary sending 10% of traffic from version 1.0.0 to 1.0.1 for 120 seconds
  • Then, we list all active canaries in a table
  • Next, we delete the active canary
  • Lastly, we re-list and see we've successfully deleted the canary

@pragmatic-zac pragmatic-zac requested a review from a team as a code owner September 12, 2024 19:50
@pragmatic-zac pragmatic-zac self-assigned this Sep 12, 2024
@standielpls
Copy link
Contributor

Nice this looks so promising! And great turnaround
my quick initial feedback is i was thinking more on the lines of sub-commands like zapier env:get, zapier env:set (ref)

@pragmatic-zac
Copy link
Contributor Author

Ah gotcha, yeah I like the subcommands better! I'll swap those in instead

@pragmatic-zac
Copy link
Contributor Author

@standielpls just updated this to use subcommands so now we have canary:create, canary:list, and canary:delete. It's ready for a look whenever you have a chance 😃

duration: flags.integer({char: 'd', description: 'Duration of the canary in seconds', required: true}),
},
opts: {
format: true
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why format: true? It also uses the -f flag so it will collide with versionFrom

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed, thanks for catching that - it was a remnant from using a table for formatting

}

validateDuration(duration) {
if (!duration || duration <= 30 || duration > 24 * 60 * 60) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we want duration < 30

Comment on lines 60 to 61
versionFrom: flags.string({char: 'f', description: 'Version to route traffic from', required: true}),
versionTo: flags.string({char: 't', description: 'Version to canary traffic to', required: true}),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's move the from/to versions as "arguments" - these are things the command should act on. (migrate example)
Percent i think it's closer to a required argument as well, since this should be set by the user, I don't want to default to anything.
Flags are treated more like options, and are things we can use to alter the behaviour (like duration).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 I'd initially put them in flags because I thought it was little more descriptive, but I see what you mean. I moved everything to args

Do we want duration to be a flag though? Seems like a required arg to me, I'd prefer the user to be explicit about what they're wanting to do

return activeCanaries.objects.find(c => c.from_version === versionFrom && c.to_version === versionTo) || null;
}

validateVersions(versionFrom, versionTo) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should check if versionFrom === versionTo as well

}
});
CanaryCreateCommand.description =
'Create a new canary deployment, diverting a specified percentage of traffic from one version to another for a specified duration.'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd be a little bit more explicit here so it is clear to users what is going on.

Create a new canary deployment, diverting a specified percentage of traffic from one version to another for a specified duration. 

Only one canary can be active at the same time. You can run `zapier canary:list` to check. If you would like to create a new canary with different parameters, you can wait for the canary to finish, or delete it using `zapier canary:delete X Y`. 

Note: this is similar to `zapier migrate` but different in that this is temporary and will "revert" the changes once the specified duration is expired. 

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 I want to say something about breaking changes as well (1.X.Y to 2.X.Y) and advise against it, or to block it from letting the user do it entirely, what do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I incorporated your longer description, thank you!

Re: the breaking changes, I added a similar line from the migration command: **Only use this command to canary traffic between non-breaking versions!**

Here's what I was thinking. The endpoint for canary currently has a TODO to check for breaking changes before allowing a canary.

So I thought for now (for this v1) we could skip that check and simply warn the user not to do it. Then as part of v2, add the check to the API and do something more specific with the result here in the CLI. What do you think?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sounds good to me

const existingCanary = await this.findExistingCanary(versionFrom, versionTo);
if (existingCanary) {
const secondsRemaining = existingCanary.until_timestamp - Math.floor(Date.now() / 1000);
this.log(`A canary deployment already exists from version ${versionFrom} to version ${versionTo}, there are ${secondsRemaining} seconds remaining`);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe add a helper in there?

If you would like to stop this canary now, run `zapier canary:delete {versionFrom} {versionTo}` 

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added 👍

}
}

CanaryDeleteCommand.flags = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to create, can we make these into arguments

return;
}

const confirmed = await this.confirm(`Are you sure you want to delete the canary from ${versionFrom} to ${versionTo}?`);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice


async findExistingCanary(versionFrom, versionTo) {
const activeCanaries = await listCanaries();
return activeCanaries.objects.find(c => c.from_version === versionFrom && c.to_version === versionTo) || null;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need || null isn't .find() falsy if not found already ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope, removed

return activeCanaries.objects.find(c => c.from_version === versionFrom && c.to_version === versionTo) || null;
}

validateVersions(versionFrom, versionTo) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same, check if version from and to are the same

if (activeCanaries.objects.length > 0) {
const existingCanary = activeCanaries.objects[0];
const secondsRemaining = existingCanary.until_timestamp - Math.floor(Date.now() / 1000);
this.log(`A canary deployment already exists from version ${existingCanary.from_version} to version ${existingCanary.to_version}, there are ${secondsRemaining} seconds remaining.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@standielpls one thing I'll call out here - since the API currently only allows one canary at a time, I've changed this code to block creation of a new one if any canary exists. Initially it checked for a canary matching the versionTo and versionFrom args, but I think this is simpler and offers less potential for confusion

So tldr, only one canary can exist at a time

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also don't super love the activeCanaries.objects[0] but the API returns the array right now, so 🤷

Copy link
Contributor

@standielpls standielpls left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NICE - just a couple more small things

Comment on lines 83 to 89
'Create a new canary deployment, diverting a specified percentage of traffic from one version to another for a specified duration. \n' +
'\n' +
'Only one canary can be active at the same time. You can run `zapier canary:list` to check. If you would like to create a new canary with different parameters, you can wait for the canary to finish, or delete it using `zapier canary:delete a.b.c x.y.z`. \n' +
'\n' +
'Note: this is similar to `zapier migrate` but different in that this is temporary and will "revert" the changes once the specified duration is expired. \n' +
'\n' +
'**Only use this command to canary traffic between non-breaking versions!**';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use the same pattern as migrate and use string literals

CanaryCreateCommand.description = `Create a new canary deployment ....
You can run \`zapier canary:list\` 
`

'zapier canary:create --versionFrom=1.0.0 --versionTo=1.1.0 --percent=25 --duration=720',
'zapier canary:create -f 2.0.0 -t 2.1.0 -p 50 -d 300'
]
'zapier canary:create 1.0.0 1.1.0 25 720',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Idk if this is a problem or not, but the percent and duration numbers can get mixed up easily. It'd be bad if they wanted 100s of canary and 30% traffic, but it was in the wrong order, creating a canary that is 100% traffic for 30s.

So I think it'd be nice to either

  1. Show the summary upfront but ask for confirmation
  2. turn percent/duration into options (so it can be passed in like -p 25, -d 30 for example)

I'm fine with either option

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I moved percent and duration both back to options - either --percent or -p will work and same for duration

I feel better about this now. The potential for confusion was bothering me a bit too

Copy link
Contributor

@standielpls standielpls left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

woooo!

@pragmatic-zac pragmatic-zac merged commit 760595a into main Sep 19, 2024
14 checks passed
@pragmatic-zac pragmatic-zac deleted the canary-v1 branch September 19, 2024 16:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants