Appfile: Extensible, User-friendly Application Config Format (#390)

* design doc

Signed-off-by: Hongchao Deng <hongchaodeng1@gmail.com>

* Support deployment via appfile

Signed-off-by: Hongchao Deng <hongchaodeng1@gmail.com>

* design update

Signed-off-by: Hongchao Deng <hongchaodeng1@gmail.com>

* comments

Signed-off-by: Hongchao Deng <hongchaodeng1@gmail.com>

* update

Signed-off-by: Hongchao Deng <hongchaodeng1@gmail.com>

* refactor

* add multi services example in design doc
This commit is contained in:
Hongchao Deng
2020-10-18 11:22:17 +08:00
committed by GitHub
parent 8de3ee27f4
commit b08c6b9441
30 changed files with 1477 additions and 5 deletions
+3 -1
View File
@@ -39,8 +39,10 @@ cmd/vela/fake/source.go
cmd/vela/fake/chart_source.go
charts/vela-core/crds/_.yaml
.vela/
# Dashboard
dashboard/node_modules/
node_modules/
.eslintcache
dashboard/dist/
dashboard/package-lock.json
+3 -2
View File
@@ -77,10 +77,9 @@ func newCommand() *cobra.Command {
// Getting Start
commands.NewInstallCommand(commandArgs, fake.ChartSource, ioStream),
commands.NewEnvCommand(commandArgs, ioStream),
// Getting Start
NewVersionCommand(),
commands.NewInitCommand(commandArgs, ioStream),
commands.NewUpCommand(commandArgs, ioStream),
// Apps
commands.NewAppsCommand(commandArgs, ioStream),
@@ -101,6 +100,8 @@ func newCommand() *cobra.Command {
commands.NewDashboardCommand(commandArgs, ioStream, fake.FrontendSource),
commands.NewLogsCommand(commandArgs, ioStream),
commands.NewTemplateCommand(commandArgs, ioStream),
)
// Traits
+358
View File
@@ -0,0 +1,358 @@
# Appfile: Extensible, User-friendly Application Config Format
- Owner: Hongchao Deng (@hongchaodeng)
- Date: 10/14/2020
- Status: Implemented
## Table of Contents
- [Intro](#intro)
- [Goals](#goals)
- [Proposal](#proposal)
- [Registration via Definition/Capability](#registration-via-definitioncapability)
- [Templating](#templating)
- [CLI/UI interoperability](#cliui-interoperability)
- [vela up](#vela-up)
- [Examples](#examples)
## Intro
Vela supports a user-friendly `docker-compose` style config format called `Appfile`. It allows you to define an application's workloads and traits with an opinionated, simplified API interface.
Here's an example to deploy a NodeJS express service:
```yaml
services:
express-server:
build:
image: oamdev/testapp:v1
docker:
file: Dockerfile
context: .
cmd: ["node", "server.js"]
route:
domain: example.com
http: # match the longest prefix
"/": 8080
env:
- FOO=bar
- FOO2=sec:my-secret # map the key same as the env name (`FOO2`) from my-secret to env var
- FOO3=sec:my-secret:key # map specific key from my-secret to env var
- sec:my-secret # map all KV pairs from my-secret to env var
files: # Mount secret as a file
- /mnt/path=sec:my-secret
scale:
replica: 2
auto: # automatic scale up and down based on given metrics
range: "1-10"
cpu: 80 # if cpu utilization is above 80%, scale up
qps: 1000 # if qps is higher than 1k, scale up
canary: # Auto-create canary deployment. Only upgrade after verify successfully.
replica: 1 # canary deployment size
headers:
- "foo:bar.*"
```
Save this file to project root dir, and run:
```bash
vela up
```
It will build container image, render deployment manifests in yaml, and apply them to the server.
### Extensible Design
The Appfile could be extended with more configurations by adding more capabilities to the OAM system. The config fields in Appfile are strongly correlated to the [capabilities system of OAM](https://github.com/oam-dev/kubevela/blob/master/DESIGN.md#capability-register-and-discovery) Config fields are registered in the capabilities system and exposed via a [CUE template](https://cuelang.org/).
Here is an example of a capability definition that platform builders register:
```yaml
apiVersion: core.oam.dev/v1alpha2
kind: WorkloadDefinition
metadata:
name: webservice
spec:
definitionRef:
name: deployments.apps
extension:
template: |
parameter: #webservice
#webservice: {
// +vela:cli:enabled=true
// +vela:cli:usage=specify commands to run in container
// +vela:cli:short=c
cmd: [...string]
env: [...string]
files: [...string]
}
output: {
apiVersion: "apps/v1"
kind: "Deployment"
metadata:
name: context.name
spec: {
selector: {
matchLabels:
app: context.name
}
template: {
metadata:
labels:
app: context.name
spec: {
containers: [{
name: context.name
image: context.image
command: parameter.cmd
}]
}
}
}
}
```
Apply the file to APIServer, and the fields will be extended into Appfile. Note that there are some conventions that differs Workloads and Traits, and around CLI flags. We will cover that more detailedly below.
## Goals
The Appfile design has the following goals:
1. Provide a user friendly, `docker-compose` style config format to developers.
2. Configuration fields can be extended by registering more capabilities into OAM runtime.
In the following, we will discuss technical details of the proposed design.
## Proposal
### Registration via Definition/Capability
Vela allows platform builders to extend Appfile config fields by registering them via [capabilities system of OAM](https://github.com/oam-dev/kubevela/blob/master/DESIGN.md#capability-register-and-discovery).
The entire template should be put under `spec.extension.template` as raw string:
```yaml
apiVersion: core.oam.dev/v1alpha2
kind: WorkloadDefinition | TraitDefinition
...
spec:
extension:
template: |
parameter: #webservice
...
```
By running `vela system update` or other similar commands, vela cli will read all definitions from APIServer and sink necessary information locally including templates. The templates will be further used to render final deploy manifests.
### Templating
Vela allows platform builders to write bespoke templates to extend Appfile configs.
#### Exposing Parameters
A template starts with `parameter` and its definition:
```yaml
parameter: #webservice
#webservice: {
// +vela:cli:enabled=true
// +vela:cli:usage=specify commands to run in container
// +vela:cli:short=c
cmd: [...string]
}
```
Here is the takeout:
* The `parameter` defines the user input fields and is used to render final output with user input values. These fields will be exposed to users in Appfile.
* The definition `#webservice` is used to tell the name of the template. This name is used to correlate workload and trait types to fields in Appfile.
Note that there is difference in how Workload and Trait expose parameters.
For Workload, each service will have a reserved field called `type` which is *webservice* by default.
Then all parameters are exposed as first level field under the service.
```yaml
services:
express-server:
# type: webservice (default) | task
cmd: ["node", "server.js"]
```
For Trait, its type will be used as the name to contain its parameters. There is a restriction that the trait type should not conflict any of the Workload parameters' first level name.
```yaml
services:
express-server:
route: # trait type
domain: example.com
http: # match the longest prefix
"/": 8080
# Workload parameters. The first level names do not conflict with trait type.
cmd: ...
env: ...
```
#### Rendering Outputs
A template should also have an `output` block:
```yaml
output: {
apiVersion: "apps/v1"
kind: "Deployment"
metadata:
name: context.name
spec: {
...
containers: [{
name: context.name
image: context.image
command: parameter.cmd
}]
}
}
```
Here is the takeout:
* The object defined within `output` block will be the final manifest which is to `kubectl apply`.
* `parameter` is used here to render user config values in.
* A new object called `context` is used to render output. This is defined within vela-cli and vela-cli will fill its values based on each service dynamically. In above example, here is the value of the `context`:
```yaml
context:
name: express-server
image: oamdev/testapp:v1
```
You can check the definition of `context` block via `vela template context`.
Note that a TraitDefinition can have multiple outputs. In such case, just dismiss the `output` block and provide `outputs` block:
```yaml
outputs: service: {
...
}
outputs: ingress: {
...
}
```
Under the hood, vela-cli will iterate over all services and generate one AppConfig to contain them, and for each service generate one Component and multiple traits.
### CLI/UI Interoperability
For UI, The definition in a template will be used to generate v3 OpenAPI Schema and the UI will use that to render forms.
For CLI, a one level parameter can be exposed via CLI by adding the following "tags" in the comment:
```yaml
parameter: #webservice
#webservice: {
// +vela:cli:enabled=true
// +vela:cli:usage=specify commands to run in container
// +vela:cli:short=c
cmd: [...string]
...
}
```
Here is the takeout:
- The name of the parameter will be added as a flag, i.e. `--cmd`
- "enabled" indicates whether this parameter should be exposed
- "usage" is shown in help info
- "short" is the short flag, i.e. `-c`
### `vela up`
The vela-cli will have an `up` command to provide seamless workflow experience. Provide an `vela.yml` Appfile in the same directory that you will run `vela up` and it is good to go. There is an example under `examples/testapp/` .
## Examples
## Multiple Services
```yaml
services:
frontend:
build:
image: oamdev/frontend:v1
docker:
file: ./frontend/Dockerfile
context: ./frontend
cmd: ["node", "server.js"]
backend:
build:
image: oamdev/backend:v1
docker:
file: ./backend/Dockerfile
context: ./backend
cmd: ["node", "server.js"]
```
### Multiple Outputs in TraitDefinition
```yaml
apiVersion: core.oam.dev/v1alpha2
kind: TraitDefinition
metadata:
name: route
spec:
definitionRef:
name: routes.standard.oam.dev
extension:
template: |
parameter: #route
#route: {
domain: string
http: [string]: int
}
// trait template can have multiple outputs and they are all traits
outputs: service: {
apiVersion: "v1"
kind: "Service"
metadata:
name: context.name
spec: {
selector:
app: context.name
ports: [
for k, v in parameter.http {
port: v
targetPort: v
}
]
}
}
outputs: ingress: {
apiVersion: "networking.k8s.io/v1beta1"
kind: "Ingress"
spec: {
rules: [{
host: parameter.domain
http: {
paths: [
for k, v in parameter.http {
path: k
backend: {
serviceName: context.name
servicePort: v
}
}
]
}
}]
}
}
```
+2
View File
@@ -0,0 +1,2 @@
node_modules
npm-debug.log
+45
View File
@@ -0,0 +1,45 @@
# FROM node:12
# # See: https://nodejs.org/en/docs/guides/nodejs-docker-webapp/
# # Create app directory
# WORKDIR /usr/src/app
# # Install app dependencies
# # A wildcard is used to ensure both package.json AND package-lock.json are copied
# # where available (npm@5+)
# COPY package*.json ./
# RUN npm install
# # If you are building your code for production
# # RUN npm ci --only=production
# # Bundle app source
# COPY . .
# EXPOSE 8080
# CMD [ "node", "server.js" ]
# ------
FROM mhart/alpine-node:12
WORKDIR /app
COPY package.json package-lock.json ./
# If you have native dependencies, you'll need extra tools
# RUN apk add --no-cache make gcc g++ python
RUN npm ci --prod
# Then we copy over the modules from above onto a `slim` image
FROM mhart/alpine-node:slim-12
# If possible, run your container using `docker run --init`
# Otherwise, you can use `tini`:
# RUN apk add --no-cache tini
# ENTRYPOINT ["/sbin/tini", "--"]
WORKDIR /app
COPY --from=0 /app .
COPY . .
CMD ["node", "server.js"]
+13
View File
@@ -0,0 +1,13 @@
{
"name": "docker_web_app",
"version": "1.0.0",
"description": "Node.js on Docker",
"author": "First Last <first.last@example.com>",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.16.1"
}
}
+27
View File
@@ -0,0 +1,27 @@
'use strict';
const express = require('express');
// const promMid = require('express-prometheus-middleware');
// Constants
const PORT = 8080;
const HOST = '0.0.0.0';
// App
const app = express();
app.get('/', (req, res) => {
res.send('Hello World');
});
// expose metrics:
// - https://www.npmjs.com/package/express-prometheus-middleware
// - https://medium.com/teamzerolabs/node-js-monitoring-with-prometheus-grafana-3056362ccb80
// app.use(promMid({
// metricsPath: '/metrics',
// collectDefaultMetrics: true,
// requestDurationBuckets: [0.1, 0.5, 1, 1.5],
// }));
app.listen(PORT, HOST);
console.log(`Running on http://${HOST}:${PORT}`);
+45
View File
@@ -0,0 +1,45 @@
version: "1.0-alpha.1"
name: testapp
services:
express-server:
build:
image: oamdev/testapp:v1
docker:
file: Dockerfile
context: .
push: # without any setting, by default push image directly
local: kind
# type: webservice (default) | task
cmd: ["node", "server.js"]
route:
domain: example.com
http: # match the longest prefix
"/": 8080
env:
- FOO=bar
- FOO2=sec:my-secret # map the key same as the env name (`FOO2`) from my-secret to env var
- FOO3=sec:my-secret:key # map specific key from my-secret to env var
- sec:my-secret # map all KV pairs from my-secret to env var
files: # Mount secret as a file
- /mnt/path=sec:my-secret
scale:
replica: 2
auto: # automatic scale up and down based on given metrics
range: "1-10"
cpu: 80 # if cpu utilization is above 80%, scale up
qps: 1000 # if qps is higher than 1k, scale up
canary: # Auto-create canary deployment. Only upgrade after verify successfully.
replica: 1 # canary deployment size
headers:
- "foo:bar.*"
secrets:
my-secret: /local-path/my-secret # load local file into k8s secret
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -e
pushd hack/appfile
rm tmp* 2>/dev/null | true
rm deploy/* 2>/dev/null | true
mkdir deploy 2>/dev/null | true
for filename in `ls cue-templates`; do
cat cue-templates/$filename > tmp
echo "" >> tmp
sed -i.bak 's/^/ /' tmp
fname="${filename%.*}"
cp definitions/${fname}.yaml deploy/${fname}.yaml
cat tmp >> deploy/${fname}.yaml
done
rm tmp*
echo "done"
popd
+7
View File
@@ -0,0 +1,7 @@
#canary: {
replica: int
headers: [...string]
}
parameter: #canary
+44
View File
@@ -0,0 +1,44 @@
parameter: #route
#route: {
domain: string
http: [string]: int
}
// trait template can have multiple outputs and they are all traits
outputs: service: {
apiVersion: "v1"
kind: "Service"
metadata:
name: context.name
spec: {
selector:
app: context.name
ports: [
for k, v in parameter.http {
port: v
targetPort: v
}
]
}
}
outputs: ingress: {
apiVersion: "networking.k8s.io/v1beta1"
kind: "Ingress"
spec: {
rules: [{
host: parameter.domain
http: {
paths: [
for k, v in parameter.http {
path: k
backend: {
serviceName: context.name
servicePort: v
}
}
]
}
}]
}
}
+11
View File
@@ -0,0 +1,11 @@
#scale: {
replica: *1 | int
auto: {
range: string
cpu: int
qps: int
}
}
parameter: #scale
+36
View File
@@ -0,0 +1,36 @@
parameter: #webservice
#webservice: {
// +vela:cli:enbaled=true
// +vela:cli:usage=specify commands to run in container
// +vela:cli:short=c
cmd: [...string]
env: [...string]
files: [...string]
}
output: {
apiVersion: "apps/v1"
kind: "Deployment"
metadata:
name: context.name
spec: {
selector: {
matchLabels:
app: context.name
}
template: {
metadata:
labels:
app: context.name
spec: {
containers: [{
name: context.name
image: context.image
command: parameter.cmd
}]
}
}
}
}
+9
View File
@@ -0,0 +1,9 @@
apiVersion: core.oam.dev/v1alpha2
kind: TraitDefinition
metadata:
name: canary
spec:
definitionRef:
name: canary.standard.oam.dev
extension:
template: |
+9
View File
@@ -0,0 +1,9 @@
apiVersion: core.oam.dev/v1alpha2
kind: TraitDefinition
metadata:
name: route
spec:
definitionRef:
name: routes.standard.oam.dev
extension:
template: |
+9
View File
@@ -0,0 +1,9 @@
apiVersion: core.oam.dev/v1alpha2
kind: TraitDefinition
metadata:
name: scale
spec:
definitionRef:
name: scales.standard.oam.dev
extension:
template: |
+9
View File
@@ -0,0 +1,9 @@
apiVersion: core.oam.dev/v1alpha2
kind: WorkloadDefinition
metadata:
name: webservice
spec:
definitionRef:
name: containerizeds.standard.oam.dev
extension:
template: |
+17
View File
@@ -0,0 +1,17 @@
apiVersion: core.oam.dev/v1alpha2
kind: TraitDefinition
metadata:
name: canary
spec:
definitionRef:
name: canary.standard.oam.dev
extension:
template: |
#canary: {
replica: int
headers: [...string]
}
parameter: #canary
+54
View File
@@ -0,0 +1,54 @@
apiVersion: core.oam.dev/v1alpha2
kind: TraitDefinition
metadata:
name: route
spec:
definitionRef:
name: routes.standard.oam.dev
extension:
template: |
parameter: #route
#route: {
domain: string
http: [string]: int
}
// trait template can have multiple outputs and they are all traits
outputs: service: {
apiVersion: "v1"
kind: "Service"
metadata:
name: context.name
spec: {
selector:
app: context.name
ports: [
for k, v in parameter.http {
port: v
targetPort: v
}
]
}
}
outputs: ingress: {
apiVersion: "networking.k8s.io/v1beta1"
kind: "Ingress"
spec: {
rules: [{
host: parameter.domain
http: {
paths: [
for k, v in parameter.http {
path: k
backend: {
serviceName: context.name
servicePort: v
}
}
]
}
}]
}
}
+21
View File
@@ -0,0 +1,21 @@
apiVersion: core.oam.dev/v1alpha2
kind: TraitDefinition
metadata:
name: scale
spec:
definitionRef:
name: scales.standard.oam.dev
extension:
template: |
#scale: {
replica: *1 | int
auto: {
range: string
cpu: int
qps: int
}
}
parameter: #scale
+46
View File
@@ -0,0 +1,46 @@
apiVersion: core.oam.dev/v1alpha2
kind: WorkloadDefinition
metadata:
name: webservice
spec:
definitionRef:
name: containerizeds.standard.oam.dev
extension:
template: |
parameter: #webservice
#webservice: {
// +vela:cli:enabled=true
// +vela:cli:usage=specify commands to run in container
// +vela:cli:short=c
cmd: [...string]
env: [...string]
files: [...string]
}
output: {
apiVersion: "apps/v1"
kind: "Deployment"
metadata:
name: context.name
spec: {
selector: {
matchLabels:
app: context.name
}
template: {
metadata:
labels:
app: context.name
spec: {
containers: [{
name: context.name
image: context.image
command: parameter.cmd
}]
}
}
}
}
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -e
echo "building binary"
echo "========"
go build -o bin/vela ./cmd/vela/
export PATH=bin/:$PATH
echo "vela up"
echo "========"
cd examples/testapp
vela up
echo "cat deploy yaml"
echo "========"
cat .vela/deploy.yaml
cd ../..
+91
View File
@@ -0,0 +1,91 @@
package appfile
import (
"errors"
"io/ioutil"
"time"
"github.com/crossplane/oam-kubernetes-runtime/apis/core/v1alpha2"
"github.com/ghodss/yaml"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/oam-dev/kubevela/pkg/appfile/template"
cmdutil "github.com/oam-dev/kubevela/pkg/commands/util"
)
const DefaultAppfilePath = "./vela.yml"
type AppFile struct {
Name string `json:"name"`
Version string `json:"version"`
CreateTime time.Time `json:"createTime,omitempty"`
UpdateTime time.Time `json:"updateTime,omitempty"`
Services map[string]Service `json:"services"`
Secrets map[string]string `json:"secrets"`
}
func Load() (*AppFile, error) {
return LoadFromFile(DefaultAppfilePath)
}
func LoadFromFile(filename string) (*AppFile, error) {
b, err := ioutil.ReadFile(filename)
if err != nil {
return nil, err
}
af := &AppFile{}
err = yaml.Unmarshal(b, af)
if err != nil {
return nil, err
}
return af, af.Validate()
}
func (app *AppFile) Validate() error {
if app.Name == "" {
return errors.New("name is required")
}
if len(app.Services) == 0 {
return errors.New("at least one service component is required")
}
return nil
}
// BuildOAM renders Appfile into AppConfig, Components. It also builds images for services if defined.
func (app *AppFile) BuildOAM(ns string, io cmdutil.IOStreams) (
[]*v1alpha2.Component, *v1alpha2.ApplicationConfiguration, error) {
io.Info("Loading templates ...")
tm, err := template.Load()
if err != nil {
return nil, nil, err
}
appConfig := &v1alpha2.ApplicationConfiguration{
ObjectMeta: metav1.ObjectMeta{
Name: app.Name,
Namespace: ns,
},
}
var comps []*v1alpha2.Component
for sname, svc := range app.GetServices() {
build := svc.GetBuild()
io.Infof("\nBuilding service (%s)...\n", sname)
if err := build.BuildImage(io); err != nil {
return nil, nil, err
}
io.Infof("\nRendering component configs for service (%s)...\n", sname)
acComp, comp, err := svc.RenderService(tm, app.Name, ns, build.Image)
if err != nil {
return nil, nil, err
}
appConfig.Spec.Components = append(appConfig.Spec.Components, *acComp)
comps = append(comps, comp)
}
return comps, appConfig, nil
}
+49
View File
@@ -0,0 +1,49 @@
package appfile
import (
"os/exec"
cmdutil "github.com/oam-dev/kubevela/pkg/commands/util"
)
type Build struct {
Image string `json:"image,omitempty"`
Push Push `json:"push,omitempty"`
Docker Docker `json:"docker,omitempty"`
}
type Docker struct {
File string `json:"file"`
Context string `json:"context"`
}
type Push struct {
Local string `json:"local,omitempty"`
Registry string `json:"registry,omitempty"`
}
func (b *Build) BuildImage(io cmdutil.IOStreams) error {
cmd := exec.Command("docker", "build", "-t", b.Image, "-f", b.Docker.File, b.Docker.Context)
out, err := cmd.CombinedOutput()
io.Infof("%s\n", out)
if err != nil {
return err
}
return b.pushImage(io)
}
func (b *Build) pushImage(io cmdutil.IOStreams) error {
io.Infof("pushing image (%s)...\n", b.Image)
switch {
case b.Push.Local == "kind":
cmd := exec.Command("kind", "load", "docker-image", b.Image)
out, err := cmd.CombinedOutput()
io.Infof("%s\n", out)
return err
}
cmd := exec.Command("docker", "push", b.Image)
out, err := cmd.CombinedOutput()
io.Infof("%s\n", out)
return err
}
+237
View File
@@ -0,0 +1,237 @@
package appfile
import (
"encoding/json"
"errors"
"fmt"
"cuelang.org/go/cue"
cueJson "cuelang.org/go/pkg/encoding/json"
"github.com/crossplane/oam-kubernetes-runtime/apis/core/v1alpha2"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"github.com/oam-dev/kubevela/pkg/appfile/template"
mycue "github.com/oam-dev/kubevela/pkg/cue"
)
type Service map[string]interface{}
func (s Service) GetBuild() *Build {
v, ok := s["build"]
if !ok {
return nil
}
b, err := json.Marshal(v)
if err != nil {
panic(err)
}
build := &Build{}
err = json.Unmarshal(b, build)
if err != nil {
panic(err)
}
return build
}
// RenderService render all capabilities of a service to CUE values of a Component.
// It outputs a Component which will be marshaled as standalone Component and also returned AppConfig Component section.
func (s Service) RenderService(tm template.Manager, name, ns, image string) (
*v1alpha2.ApplicationConfigurationComponent, *v1alpha2.Component, error) {
// sort out configs by workload/trait
workloadKeys := map[string]interface{}{}
traitKeys := map[string]interface{}{}
wtype := "webservice"
outerLoop:
for k, v := range s {
switch k {
case "build": // skip
continue outerLoop
case "type":
wtype = v.(string)
}
if tm.IsTrait(k) {
traitKeys[k] = v
} else if tm.IsWorkload(k) {
workloadKeys[k] = v
}
}
// render component
component := &v1alpha2.Component{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: ns,
},
}
// only render webservice workload for now.
ctxData := map[string]string{
"name": name,
"image": image,
}
u, err := evalComponent(tm.LoadTemplate(wtype), ctxData, intifyValues(workloadKeys))
if err != nil {
return nil, nil, fmt.Errorf("eval component failed: %w", err)
}
component.Spec.Workload.Object = u
// render traits
traits := []v1alpha2.ComponentTrait{}
for k, v := range traitKeys {
ts, err := evalTraits(tm.LoadTemplate(k), ctxData, intifyValues(v))
if err != nil {
return nil, nil, fmt.Errorf("eval traits failed: %w", err)
}
for _, t := range ts {
traits = append(traits, v1alpha2.ComponentTrait{
Trait: runtime.RawExtension{
Object: t,
}},
)
}
}
acComp := &v1alpha2.ApplicationConfigurationComponent{
ComponentName: component.Name,
Traits: traits,
}
return acComp, component, nil
}
func (af *AppFile) GetServices() map[string]Service {
return af.Services
}
func isIntegral(val float64) bool {
return val == float64(int(val))
}
// JSON marshalling of user values will put integer into float,
// we have to change it back so that CUE check will succeed.
func intifyValues(raw interface{}) interface{} {
switch v := raw.(type) {
case map[string]interface{}:
return intifyMap(v)
case []interface{}:
return intifyList(v)
case float64:
if isIntegral(v) {
return int(v)
}
return v
default:
return raw
}
}
func intifyList(l []interface{}) interface{} {
l2 := make([]interface{}, 0, len(l))
for _, v := range l {
l2 = append(l2, intifyValues(v))
}
return l2
}
func intifyMap(m map[string]interface{}) interface{} {
m2 := make(map[string]interface{}, len(m))
for k, v := range m {
m2[k] = intifyValues(v)
}
return m2
}
func getValueStruct(raw string, ctxValues, userValues interface{}) (*cue.Struct, error) {
r := &cue.Runtime{}
template, err := r.Compile("", raw+mycue.BaseTemplate)
if err != nil {
return nil, fmt.Errorf("compile CUE template failed: %w", err)
}
// fill values
rootValue := template.Value()
rootValue = rootValue.Fill(ctxValues, "context")
rootValue = rootValue.Fill(intifyValues(userValues), "parameter")
appValue, err := rootValue.Eval().Struct()
if err != nil {
return nil, fmt.Errorf("eval CUE template failed: %w", err)
}
return appValue, nil
}
func renderOneOutput(appValue *cue.Struct) (*unstructured.Unstructured, error) {
outputField, err := appValue.FieldByName("output", true)
if err != nil {
return nil, fmt.Errorf("FieldByName('output'): %w", err)
}
final := outputField.Value
data, err := cueJson.Marshal(final)
if err != nil {
return nil, fmt.Errorf("marshal final value err %v", err)
}
obj := make(map[string]interface{})
if err = json.Unmarshal([]byte(data), &obj); err != nil {
return nil, err
}
return &unstructured.Unstructured{
Object: obj,
}, nil
}
func renderAllOutputs(field cue.FieldInfo) ([]*unstructured.Unstructured, error) {
iter, err := field.Value.Fields()
if err != nil {
return nil, err
}
us := []*unstructured.Unstructured{}
for iter.Next() {
final := iter.Value()
data, err := cueJson.Marshal(final)
if err != nil {
return nil, fmt.Errorf("marshal final value err %v", err)
}
// need to unmarshal it to a map to get rid of the outer spec name
obj := make(map[string]interface{})
if err = json.Unmarshal([]byte(data), &obj); err != nil {
return nil, err
}
u := &unstructured.Unstructured{Object: obj}
us = append(us, u)
}
return us, nil
}
func evalComponent(raw string, ctxValues, userValues interface{}) (*unstructured.Unstructured, error) {
appValue, err := getValueStruct(raw, ctxValues, userValues)
if err != nil {
return nil, err
}
return renderOneOutput(appValue)
}
func evalTraits(raw string, ctxValues, userValues interface{}) ([]*unstructured.Unstructured, error) {
appValue, err := getValueStruct(raw, ctxValues, userValues)
if err != nil {
return nil, err
}
_, err = appValue.FieldByName("output", true)
if err != nil {
outputField, err := appValue.FieldByName("outputs", true)
if err != nil {
return nil, errors.New("both output and outputs fields not found")
}
return renderAllOutputs(outputField)
}
u, err := renderOneOutput(appValue)
if err != nil {
return nil, err
}
return []*unstructured.Unstructured{u}, nil
}
+66
View File
@@ -0,0 +1,66 @@
package template
import (
"github.com/oam-dev/kubevela/api/types"
"github.com/oam-dev/kubevela/pkg/plugins"
)
type Manager interface {
IsTrait(key string) bool
IsWorkload(key string) bool
LoadTemplate(key string) string
}
func Load() (Manager, error) {
caps, err := plugins.LoadAllInstalledCapability()
if err != nil {
return nil, err
}
m := newManager()
for _, cap := range caps {
t := &template{}
t.captype = cap.Type
t.raw = cap.CueTemplate
m.templates[cap.Name] = t
}
return m, nil
}
type manager struct {
templates map[string]*template
}
func newManager() *manager {
return &manager{
templates: make(map[string]*template),
}
}
type template struct {
captype types.CapType
raw string
}
func (m *manager) IsTrait(key string) bool {
t, ok := m.templates[key]
if !ok {
return false
}
return t.captype == types.TypeTrait
}
func (m *manager) IsWorkload(key string) bool {
t, ok := m.templates[key]
if !ok {
return false
}
return t.captype == types.TypeWorkload
}
func (m *manager) LoadTemplate(key string) string {
t, ok := m.templates[key]
if !ok {
return ""
}
return t.raw
}
+43
View File
@@ -0,0 +1,43 @@
package commands
import (
"github.com/spf13/cobra"
"github.com/oam-dev/kubevela/api/types"
cmdutil "github.com/oam-dev/kubevela/pkg/commands/util"
mycue "github.com/oam-dev/kubevela/pkg/cue"
)
func NewTemplateCommand(c types.Args, ioStream cmdutil.IOStreams) *cobra.Command {
cmd := &cobra.Command{
Use: "template",
DisableFlagsInUseLine: true,
Short: "Manage templates",
Long: "Manage templates",
Annotations: map[string]string{
types.TagCommandType: types.TypeSystem,
},
}
cmd.SetOut(ioStream.Out)
cmd.AddCommand(NewTemplateContextCommand(ioStream))
return cmd
}
func NewTemplateContextCommand(ioStream cmdutil.IOStreams) *cobra.Command {
cmd := &cobra.Command{
Use: "context",
DisableFlagsInUseLine: true,
Short: "show context parameters",
Long: "show context parameter",
Example: `vela template context`,
Annotations: map[string]string{
types.TagCommandType: types.TypeSystem,
},
RunE: func(cmd *cobra.Command, args []string) error {
ioStream.Info(mycue.BaseTemplate)
return nil
},
}
cmd.SetOut(ioStream.Out)
return cmd
}
+158
View File
@@ -0,0 +1,158 @@
package commands
import (
"bytes"
"context"
"fmt"
"io/ioutil"
"os"
"os/exec"
"path/filepath"
"github.com/crossplane/oam-kubernetes-runtime/apis/core/v1alpha2"
"github.com/ghodss/yaml"
"github.com/kyokomi/emoji"
"github.com/spf13/cobra"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
apitypes "k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
"github.com/oam-dev/kubevela/api/types"
"github.com/oam-dev/kubevela/pkg/appfile"
cmdutil "github.com/oam-dev/kubevela/pkg/commands/util"
)
var (
emojiRocket = emoji.Sprint(":rocket")
)
func NewUpCommand(c types.Args, ioStream cmdutil.IOStreams) *cobra.Command {
cmd := &cobra.Command{
Use: "up",
DisableFlagsInUseLine: true,
Short: "Apply an appfile",
Long: "Apply an appfile, by default vela.yml",
Annotations: map[string]string{
types.TagCommandType: types.TypeStart,
},
RunE: func(cmd *cobra.Command, args []string) error {
velaEnv, err := GetEnv(cmd)
if err != nil {
return err
}
kubecli, err := client.New(c.Config, client.Options{Scheme: c.Schema})
if err != nil {
return err
}
o := &appfileOptions{
Kubecli: kubecli,
IO: ioStream,
Env: velaEnv,
}
return o.Run()
},
}
cmd.SetOut(ioStream.Out)
return cmd
}
type appfileOptions struct {
Kubecli client.Client
IO cmdutil.IOStreams
Env *types.EnvMeta
}
func (o *appfileOptions) Run() error {
o.IO.Info("Parsing vela.yaml ...")
app, err := appfile.Load()
if err != nil {
return err
}
comps, appConfig, err := app.BuildOAM(o.Env.Namespace, o.IO)
if err != nil {
return err
}
var cfg bytes.Buffer
appConfig.TypeMeta = metav1.TypeMeta{
APIVersion: v1alpha2.ApplicationConfigurationGroupVersionKind.GroupVersion().String(),
Kind: v1alpha2.ApplicationConfigurationKind,
}
b, err := yaml.Marshal(appConfig)
if err != nil {
return fmt.Errorf("marshal AppConfig failed: %w", err)
}
cfg.Write(b)
cfg.WriteByte('\n')
for _, comp := range comps {
cfg.WriteString("---\n")
comp.TypeMeta = metav1.TypeMeta{
APIVersion: v1alpha2.ComponentGroupVersionKind.GroupVersion().String(),
Kind: v1alpha2.ComponentKind,
}
b, err := yaml.Marshal(comp)
if err != nil {
return fmt.Errorf("marshal Component (%s) failed: %w", comp.Name, err)
}
cfg.Write(b)
cfg.WriteByte('\n')
}
deployFilePath := ".vela/deploy.yaml"
o.IO.Infof("writing deploy config to (%s)\n", deployFilePath)
if err := os.MkdirAll(filepath.Dir(deployFilePath), 0700); err != nil {
return err
}
if err := ioutil.WriteFile(deployFilePath, cfg.Bytes(), 0600); err != nil {
return err
}
o.IO.Infof("\nApplying deploy configs ...\n")
return o.ApplyAppConfig(appConfig)
}
// Apply deploy config resources for the app.
// It differs by create and update:
// - for create, it displays app status along with information of url, metrics, ssh, logging.
// - for update, it rolls out a canary deployment and prints its information. User can verify the canary deployment.
// This will wait for user approval. If approved, it continues upgrading the whole; otherwise, it would rollback.
func (o *appfileOptions) ApplyAppConfig(ac *v1alpha2.ApplicationConfiguration) error {
key := apitypes.NamespacedName{
Namespace: ac.Namespace,
Name: ac.Name,
}
o.IO.Infof("\nChecking if app has been deployed...\n")
var tmpAC v1alpha2.ApplicationConfiguration
err := o.Kubecli.Get(context.TODO(), key, &tmpAC)
switch {
case apierrors.IsNotFound(err):
o.IO.Infof("app has not been deployed, creating a new deployment...\n")
case err == nil:
o.IO.Infof("app existed, updating existing deployment...\n")
default:
return err
}
return o.apply(ac)
}
func (o *appfileOptions) apply(ac *v1alpha2.ApplicationConfiguration) error {
cmd := exec.Command("kubectl", "apply", "-f", ".vela/deploy.yaml")
out, err := cmd.CombinedOutput()
o.IO.Infof("deploying======\n%s\n", out)
if err != nil {
return err
}
o.IO.Infof("app has been deployed %s%s%s\n", emojiRocket, emojiRocket, emojiRocket)
o.IO.Infof("\tURL: http://%s/\n", o.Env.Domain)
o.IO.Infof("\tPort forward: vela port-forward %s <port>\n", ac.Name)
o.IO.Infof("\tSSH: vela exec %s\n", ac.Name)
o.IO.Infof("\tLogging: vela log %s\n", ac.Name)
o.IO.Infof("\tMetric: TODO\n")
return nil
}
+9
View File
@@ -0,0 +1,9 @@
package cue
const BaseTemplate = `
context: {
name: string
image: string
}
`
+11 -2
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"strings"
"cuelang.org/go/cue"
@@ -22,7 +23,11 @@ const specValue = "parameter"
// Eval evaluates the spec with the parameter values
func Eval(templatePath string, value map[string]interface{}) (*unstructured.Unstructured, error) {
r := cue.Runtime{}
template, err := r.Compile(templatePath, nil)
b, err := ioutil.ReadFile(templatePath)
if err != nil {
return nil, err
}
template, err := r.Compile("", string(b)+BaseTemplate)
if err != nil {
return nil, fmt.Errorf("compile %s err %v", templatePath, err)
}
@@ -54,7 +59,11 @@ func Eval(templatePath string, value map[string]interface{}) (*unstructured.Unst
func GetParameters(templatePath string) ([]types.Parameter, string, error) {
r := cue.Runtime{}
template, err := r.Compile(templatePath, nil)
b, err := ioutil.ReadFile(templatePath)
if err != nil {
return nil, "", err
}
template, err := r.Compile("", string(b)+BaseTemplate)
if err != nil {
return nil, "", err
}