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

RFC: #79 CDK v2.0 #105

Closed
wants to merge 1 commit into from
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
339 changes: 339 additions & 0 deletions text/0079-cdk-v2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,339 @@
---
feature name: cdk-v2
start date: 2020-28-01
rfc pr: (leave this empty)
related issue: #79
---

# Summary

[Version 1.0 of the AWS CDK](https://github.com/aws/aws-cdk/releases/tag/v1.0.0)
have been released about 6 months ago (July 2019) after about a year in
developer preview. Since then, we have released over 25 minor versions (and
about 10 patches) which included hundreds of bugs fixes and features.

Throughout this time, we were able to deliver changes to the CDK without
introducing (intentional) breaking changes to APIs marked as STABLE (when an
unintentional break was introduced, we quickly released a patch to revert it).
In a few cases, we introduced breaking changes to EXPERIMENTAL modules.

This RFC proposes content and explores the implications of releasing a new major
version for the AWS CDK (v2.0).

# Motivation

In the past few weeks, a few issues have been accumulated that warrant the
consideration of a major version release:

1. Challenges when managing CDK dependencies
2. Confusion around the usage of experimental features
3. Desire to use constructs for other domains

The solutions we are considering to address these issues will have an impact
on how users consume the AWS CDK, and therefore will require a major version
bump.

The following sections describe each one of the motivations above.

## Dependency Management

The CDK consists of 100+ packages, one for every AWS service and then some, with
complex interdependencies. For example, the *aws-ecs* package depends on *core*,
*aws-iam*, *aws-ecr*, *aws-ec2* and more packages to do its work. In fact, it
depends on 23 other packages.

This means that when a user wishes to use the *aws-ecs* module, their package
manager needs to fetch all 23 dependencies.

### Unacceptable experience with peer npm dependencies

Most languages (Java, Python, .NET) only support a single instance of a module
at runtime. This means that if two modules declare that they take a dependency
on a third module, the dependency manager will resolve a common version and
fetch it.

Contrary to most languages, the Node.js ecosystem supports the co-existence of
multiple versions of a module at runtime (since `node_modules` first searched as
a relative path). This is problematic for the CDK case since CDK modules often
expose types from other modules in their API (e.g. an `s3.Bucket` accepts a
`kms.Key` as an option).

To model this type of dependency, npm allows declaring a dependency as a ["peer
dependency"](https://nodejs.org/es/blog/npm/peer-dependencies). This means
consumers are expected to **directly** install all transitive dependencies. In
the *aws-ecs* case, this means that instead of declaring a dependency only on
*aws-ecs*, users would have to declare a dependency on all 23 transitive modules
that *aws-ecs* depend on.

This is an unacceptable user experience with our current tools.

See the [appendix](#appendix-cdk-and-npm-dependencies) written by
[@rix0rrr](https://github.com/rix0rrr) which describes this problem in detail.

### Poor ergonomics due to too many modules

In addition to the peer npm dependency issue described above, due to the fact
that CDK is "hyper modular", in all languages, we have poor ergonomics when it
comes to declaring dependencies. Since users are required to explicitly install
a module for each service they use, even simple projects end up with dozens of
direct CDK dependencies.

## Experimental Modules

In some cases, a CDK module can include both stable and experimental constructs.
For example, a module like **@aws-cdk/aws-eks** includes stable CFN resources (L1s)
but also a variety of experimental L2 classes.

Our API stability guarantees and adherence to semantic versioning only applies
to STABLE APIs and not to EXPERIMENTAL APIs.

This situation is confusing to users. It is not uncommon for users to use
experimental constructs without being aware and then be surprised by breaking
changes in minor versions.

The current approach makes it hard for us to specify the stability at the module
level since the L1s are stable by definition, but all high-level constructs are
still experimental.

## Reusing constructs

The AWS CDK demonstrates that leveraging general purpose programming languages
to define complex configuration modules brings value to users. At the heart of
the AWS CDK lies the "constructs programming model" which consists of a simple
pattern that enables composition and synthesis.

Recently, there were a few initiatives to build CDKs for other domains with
similar characteristics. Examples are Kubernetes, Terraform and GitHub Actions.

We wish to enable the creation of these "sister" CDK projects by extracting the
constructs programming model into an independent library, which can be leveraged
for any domain without taking a dependency on the AWS CDK.

In order to enable composition of constructs across domains (e.g. Kubernetes app
defined within an AWS CDK app), we will need to extract the `Construct` base
class from the @aws-cdk/core module and into a separate module and AWS CDK users
will need to take a dependency on this new module.

# Design Summary

To address the issues related to **dependencies**, we propose to package the
entire AWS CDK as a single module, where each [sub]module will be represented
through a namespace. This has the potential to address both issues related to
dependencies: it will be possible to model this single AWS CDK module as a
single peer dependency; and also users will need to only install a single module
to use the entire AWS CDK. This will dramatically improve the consumption
ergonomics.

To improve **transparency about experimental modules**, we propose to move all
experimental classes into a separate [sub]module with the `-experimental`
postfix (e.g. `@aws-cdk/aws-eks-experimental`). This means that the non
"-experimental" modules will always have stable APIs (including L1s) and will
give us a way to graduate classes from the experimental module to the stable
module. Users who use a module with the name "xxx-experimental" will have more
awareness that this API may break in minor versions.

To **enable the reuse of the constructs programming model** we will extract and
release the programming model into a separate, independent, library, which will
be reused by the AWS CDK (and other CDKs in the future).

We will also take the opportunity to maintain "API hygiene" as we release a new
major version by removing deprecated APIs and [feature
flags](https://github.com/aws/aws-cdk-rfcs/blob/master/text/0055-feature-flags.md).

# Detailed Design

## Monolithic CDK

## Experimental Modules

## Constructs Programming Model

# Drawbacks

# Rationale and Alternatives

# Adoption Strategy

# Unresolved questions

# Future Possibilities

# Appendix: CDK and NPM Dependencies

This section is based on a whitepaper by [@rix0rrr](https://github.com/rix0rrr).

The ultimate root cause of the issues we are encountering is a specific feature of the NPM package manager, one that both encourages a package ecosystem that “just works” with minimal maintenance and versioning problems, as well one that significantly simplifies the package manager:

_Packages can appear multiple times in the dependency graph, at different versions._

It simplifies the package manager because the package manager does not have to resolve version conflicts to arrive at a single version that will work across the entire dependency tree, and report a usable error if fails to do so. In the presence of version ranges, this is an open research problem, which NPM conveniently sidesteps.

### The Feature

As an example, a package *tablify* could depend on a package called *leftpad* at version 2.0, while the application using *tablify* could be using *leftpad* at an older and incompatible version 1.0. The following dependency graph is a valid NPM dependency graph:

```
app (1.0)
+-- tablify (1.0)
| +-- leftpad (2.0)
+-- leftpad (1.0)
```

Loading dependencies is done in NPM by calling require("leftpad") or import { ... } from "leftpad". If this statement is executed in a source file of *app*, it will load *[email protected]*, and if it is executed in a source file of *tablify* it will load *[email protected]*.

> The feature of multiple package copies that get loaded in a context-sensitive way is not a common one. The only other package manager that I know of that supports this is Rust’s package manager, Cargo. Most other package managers require all packages to occur a single time in the dependency closure. To do so, I believe most employ a conflict resolution mechanism that boils down to “version specification closest to the root wins” (feel free to add counterexamples to an appendix if I got this wrong).

### The Problem

All is well with this strategy of keeping multiple copies of a package in the dependency tree, as long as no types “escape” from the boundary of the package’s mini-closure. In the typical case of *tablify* and *leftpad*, [one can reasonably assume that] all values going in and out of the libraries are types from the standard, shared runtime (in this case *strings*), and the only thing being depended upon is a specific behavior provided by the library.

However, let’s say that *leftpad* contains a class called Paddable that *tablify* accepts in its public interface, and that the interface of the class had undergone a breaking change between version 1 and 2 (in JavaScript):

```ts
// --------------- leftpad 2.0 ---------------------------
class Paddable {
constructor(s) { ... }
padLeft(n) { ... }
}

// --------------- tablify 1.0 ---------------------------

// Expected: Array<Array<Paddable>>
function tablify(rows) {
return rows.map(row => row.map(cell => cell.padLeft(10));
}

// --------------- leftpad 1.0 ---------------------------
class Paddable {
constructor(s) { ... }

// Oops, forgot to camelCase, rectified in version 2.0
padleft(n) { ... }
}

// --------------- app 1.0 ---------------------------
import { Paddable } from 'leftpad';
import { tablify } from 'tablify';

const p = new Paddable('text');
tablify([[p]]);
```

In this code sample, a *Paddable@1* gets constructed and passed to a function which expects to be able to use it as a *Paddable@2*, and the code explodes.

Every individual package here was locally correct, but the combination of package versions didn’t work.

### TypeScript to the rescue

The CDK uses TypeScript, and the TypeScript compiler actually protects against this issue. If we actually typed the *tablify* function as

```ts
import { Paddable } from 'leftpad';

function tablify(rows:* *Array<Array<Paddable>>) {
...
}
```

The TypeScript compiler would correctly resolve that type to Array<Array<Paddable@2>>, and refuse to compile the calling of *tablify* with an argument of type Array<Array<Paddable@1>>, because of the detected incompatibility between the types.

_TypeScript will refuse to compile if types of multiple copies of a package are mixed(*)._

> (*) This is true for classes, where it will do nominal typing. For interfaces, TypeScript will do structural typing, which means any object that has members matching an interface definition is considered to implement that interface, whether it has been declared to implement it or not, and so a class from one copy of a library may be considered to implement an interface from a different copy.

Here is an example for a compiler error caused by a dependency mix:

```
Unable to compile TypeScript: bin/app.ts(10,36): error TS2345:
Argument of type 'import("node_modules/@aws-cdk/core/lib/app").App' is not assignable to parameter of type 'import("node_modules/my-module/node_modules/@aws-cdk/core/lib/app").App'.
Types have separate declarations of a private property '_assembly'.
```

### Implications on CDK

This situation arises the following use cases:

* User wants to use an older version than the latest published one (rollback).
* User is using a 3rd-party construct library.

Let’s look at these in turn.

#### User wants to use an old version

The first one, colloquially called “rollback” is easy to understand and seemingly has a simple solution, so let’s look at it first. This situation comes up when the user is in one of 2 situations:

* CDK team has just (accidentally) released a buggy version and they want to roll back to an older version.
* CDK team has just released a breaking change (valid in experimental modules) and the user is not prepared to invest the effort yet to migrate, so they want to stay at an older version.

For an example, let’s say CDK has just released version *1.13.0* but the user wants to stay at *1.12.0*. The only way they have to achieve this is to control the versions in their own app’s *package.json*, so they write:

```json
"dependencies": {
"@aws-cdk/aws-ecs": "1.12.0",
"@aws-cdk/core": "1.12.0",
}
```

The lack of a caret makes it a fixed version, indicating they really want *1.12.0* and not “at least *1.12.0*” (which would resolve to *1.13.0* in this situation). However, because of the transitive caret dependency the complete dependency graph would look like this:

```
[email protected] -> aws-ecs==1.12.0, core==1.12.0
[email protected] -> core^1.12.0

=== { [email protected] is available } ===>

app
+-- [email protected]
+-- [email protected]
+-- [email protected]
```

The dependency tree ends up with 2 versions of *core* in it, and we end up in a broken state.

THE OBVIOUS SOLUTION to this is to get rid of the transitive caret dependency, and make intra-CDK dependencies fixed version (*==1.12.0, ==1.13.0, etc*).. This puts all control over the specific version that gets pulled in in the hands of the user:

```
[email protected] -> aws-ecs==1.12.0, core==1.12.0
[email protected] -> core==1.12.0

=== { [email protected] is available, but not selected }===>

app
+-- [email protected]
+-- [email protected]
```

This works fine for the CDK, but breaks in the face of 3rd party construct
libraries that still use a caret dependency (see the next section).

#### User is using 3rd party construct library

The previously discussed solution works fine for first-party libraries, because
they are all authored, versioned, tested and released together.

One of the stated goals of the CDK however is to foster an ecosystem of 3rd
party construct libraries. These construct libraries are going to depend on the
1st party CDK packages the same way the packages depend on each other, and so
they run into the same issues:

* If a 3rd-party library uses a caret dependency, then effectively all consumers
of the package are forced to always be on the latest CDK version (or the
dependency tree will end up in a broken state).
* If a 3rd-party library uses a fixed version dependency on CDK then:
* The consumer must use the same fixed version dependency on CDK, because if
they use a caret dependency the tree will end up in a broken state.
* The 3rd-party library author is forced to release an update every time the
CDK releases an update (which is every week, or even more often). The
users of the 3rd party library will not be able to migrate to the new CDK
version until the author does so.

These conditions will make it exceedingly onerous to maintain or consume a 3rd
party library, requiring manual action every week for authors or be locked into
old versions for consumers if and when authors fail to update.

_If library writing is not a “pit of success” condition, I don’t see how any 3rd
party library ecosystem will ever evolve._

Given that according to this analysis, 3rd party library authors should neither
use caret dependencies nor fixed version dependencies, the only remaining
conclusion is that they shouldn’t use dependencies at all.