Custom schemas
This page is a work in progress. Content may change or be incomplete.
Your company tracks which team owns every application and which cost center pays for it. The built-in Application kind knows nothing about teams or cost centers. Today that information lives in a spreadsheet, and nobody checks it. Last month the agent that added billing-service invented a team name that did not exist, and the invoice went to nobody.
You want ASL to understand a Team, to require a cost center on every application, and to refuse an application that points to a team that does not exist.
A custom schema is how you teach ASL a new kind, or add fields to an existing one. It is a YAML file that defines what a manifest of that kind may contain. asl uses that definition to validate every manifest of the kind, exactly as it does for the built-in ones. You can write a definition from scratch, or inherit a built-in one and extend it.
Your kinds live in your own group (for example acme/v1). They are added to the built-in kinds and never replace them.
Load a bundle
Point asl at a directory with the --schemas-bundle-path flag:
asl ./architecture --schemas-bundle-path ./schemas-bundle
The bundle is a tree of <group>/<version>/<Kind>.yaml files:
schemas-bundle/
acme/v1/Team.yaml
acme/v1/Application.yaml
A file at acme/v1/Team.yaml defines manifests with apiVersion: acme/v1 and kind: Team. The built-in kinds follow the same layout under asl/v1/.
Without the flag, asl does not know your kinds and rejects them:
error: unknown apiVersion "acme/v1"
--> architecture/billing.asl.yaml:1
policy: asl.builtin.api-contract
What is reserved
The asl group belongs to the built-in kinds. A bundle cannot define anything under asl/, so Relationship, CustomPolicy and the other built-in kinds cannot be changed or replaced. Any other group is yours.
A manifest is identified by apiVersion + kind + name. An acme/v1 Application called billing and an asl/v1 Application called billing are different manifests, and so are acme/v1 and acme/v2.
Your first custom kind
Start with the Team. The definition below is written from scratch: it says that a team has a required email, and nothing else.
allOf:
- $ref: ../../asl/v1/Manifest.yaml # apiVersion, kind and metadata
- type: object
required: [spec]
properties:
apiVersion:
const: acme/v1
kind:
const: Team
spec:
type: object
additionalProperties: false
required: [email]
properties:
email:
type: string
unevaluatedProperties: false
examples:
- apiVersion: acme/v1
kind: Team
metadata:
name: payments
spec:
email: payments@acme.example
A few things to notice:
- The first line inherits the shared base definition that every manifest has:
apiVersion,kindandmetadata. You do not repeat them. - The definition is closed: a field you did not list is rejected. That is what catches typos. Close every object you add, including nested ones.
- The
examplesare checked when the bundle loads, so they double as a test of your definition.
Build on a built-in kind
Now the cost center. You do not want to rewrite everything Application already knows about providers, languages and interfaces. Instead, your Application inherits the built-in definition and adds one required field. The resource kinds (Application, APIGateway, Database, Queue and Topic) can all be extended this way:
allOf:
- $ref: ../../asl/v1/Manifest.yaml
- type: object
required: [spec]
properties:
apiVersion:
const: acme/v1
kind:
const: Application
spec:
allOf:
- $ref: ../../asl/v1/Application.yaml#/$defs/spec
required: [costCenter]
properties:
costCenter:
type: string
pattern: "^cc-[0-9]+$"
unevaluatedProperties: false
unevaluatedProperties: false
examples:
- apiVersion: acme/v1
kind: Application
metadata:
name: billing
spec:
providers:
aws: EKS
language: Go
costCenter: cc-1042
An acme/v1 Application accepts everything an asl/v1 one does, validated by the same rules, plus a required costCenter that must look like cc-1042. The built-in kind is untouched.
Use custom kinds in a Relationship
A Team is only useful if something refers to it. Applications and databases still connect through Relationship, and you can point either end of an edge at your own kinds.
In a Relationship, a kind is either a bare name, which means the Relationship's own group (asl/v1), or group/version/Kind:
apiVersion: asl/v1
kind: Relationship
metadata:
name: billing-edges
spec:
edges:
- from: { kind: acme/v1/Application, name: billing }
to: { kind: Database, name: billing-db }
type: readsAndWrites
Writing a bare Team when only acme/v1 Team exists fails: Team would mean asl/v1 Team, which does not exist.
The built-in relationship rules only know the built-in kinds. An edge with a custom-group kind on either side is not judged by them. Write a custom policy for those rules.
Target custom kinds in a policy
This is the part that stops the invented team. A custom policy can select your kind by setting selector.apiVersion. Here, every acme/v1 Application must belong to a Team that exists:
apiVersion: asl/v1
kind: CustomPolicy
metadata:
name: application-has-known-team
namespace: acme
spec:
severity: error
selector:
apiVersion: acme/v1
kind: Application
assertions:
- expression: "'team' in manifest.labels && manifests.exists(m, m.kind == 'Team' && m.name == manifest.labels['team'])"
message: acme/v1 Application must belong to an existing Team
manifest.apiVersion is also available in the expressions. See Custom policies for everything an expression can use.
Putting it together
Your folder now has a team, an application that belongs to it, a database, and the relationship between them:
apiVersion: acme/v1
kind: Team
metadata:
name: payments
spec:
email: payments@acme.example
---
apiVersion: acme/v1
kind: Application
metadata:
name: billing
labels:
team: payments
spec:
providers:
aws: EKS
language: Go
costCenter: cc-1042
---
apiVersion: asl/v1
kind: Database
metadata:
name: billing-db
spec:
providers:
aws: RDSPostgreSQL
---
apiVersion: asl/v1
kind: Relationship
metadata:
name: billing-edges
spec:
edges:
- from: { kind: acme/v1/Application, name: billing }
to: { kind: Database, name: billing-db }
type: readsAndWrites
Run it with both bundles:
asl ./architecture \
--schemas-bundle-path ./schemas-bundle \
--policies-bundle-path ./policies
ok 8.2ms
If the label points to a team that does not exist (team: payment), the policy catches it:
error: acme/v1 Application must belong to an existing Team
--> architecture/billing.asl.yaml:7
policy: acme.application-has-known-team
error: could not compile due to 1 previous error (6.6ms)
If costCenter does not match the pattern (costCenter: finance), the definition catches it first, and the edge that mentions billing then has nothing to point to:
error: spec.costCenter: does not match pattern '^cc-[0-9]+$'
--> architecture/billing.asl.yaml:7
policy: asl.builtin.api-contract
error: dangling reference: from acme/v1/Application/billing not found
--> architecture/billing.asl.yaml:27
policy: asl.builtin.relationship-ref
error: could not compile due to 2 previous errors (6.6ms)
What asl checks in your bundle
An invalid bundle stops the run with exit code 2 before any manifest is read:
compiling ASL schemas: custom schemas: asl/v1/Foo.yaml: apiGroup "asl" is reserved for the built-in schemas
- Every
*.yamlin the bundle must be<group>/<version>/<Kind>.yaml: a lowercase group, a version likev1orv1beta2, and a capitalized kind. Other files, such as a README, are ignored. A*.asl.yamlmanifest inside the bundle is an error. - The group is not
asl, and no file is namedManifest.yaml. - The definition is valid, and it fixes the
apiVersionandkindthat its path implies. - Every entry of
examples:is valid for its definition. - A definition can only inherit from other definitions in the bundle or from the built-in ones. Web addresses and files outside of them are rejected.
A --schemas-bundle-path that is not a directory also exits with 2.
Where it works
The same bundle works wherever ASL runs, not only in the terminal. See How ASL works. For the flag, exit codes and the format of diagnostics, see CLI.