diff --git a/.gitignore b/.gitignore index 9f5e2ad3e..9e60fc9f2 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/cmd/vela/main.go b/cmd/vela/main.go index a170fbc63..5fb2c5ca4 100644 --- a/cmd/vela/main.go +++ b/cmd/vela/main.go @@ -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 diff --git a/design/appfile-design.md b/design/appfile-design.md new file mode 100644 index 000000000..1ae880f4b --- /dev/null +++ b/design/appfile-design.md @@ -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 + } + } + ] + } + }] + } + } +``` \ No newline at end of file diff --git a/examples/testapp/.dockerignore b/examples/testapp/.dockerignore new file mode 100644 index 000000000..5171c5408 --- /dev/null +++ b/examples/testapp/.dockerignore @@ -0,0 +1,2 @@ +node_modules +npm-debug.log \ No newline at end of file diff --git a/examples/testapp/Dockerfile b/examples/testapp/Dockerfile new file mode 100644 index 000000000..97c5c1979 --- /dev/null +++ b/examples/testapp/Dockerfile @@ -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"] diff --git a/examples/testapp/package.json b/examples/testapp/package.json new file mode 100644 index 000000000..95a6970e9 --- /dev/null +++ b/examples/testapp/package.json @@ -0,0 +1,13 @@ +{ + "name": "docker_web_app", + "version": "1.0.0", + "description": "Node.js on Docker", + "author": "First Last ", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "express": "^4.16.1" + } +} diff --git a/examples/testapp/server.js b/examples/testapp/server.js new file mode 100644 index 000000000..74b8c8c48 --- /dev/null +++ b/examples/testapp/server.js @@ -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}`); + diff --git a/examples/testapp/vela.yml b/examples/testapp/vela.yml new file mode 100644 index 000000000..d18a84980 --- /dev/null +++ b/examples/testapp/vela.yml @@ -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 diff --git a/hack/appfile/add_def.sh b/hack/appfile/add_def.sh new file mode 100755 index 000000000..449b94abb --- /dev/null +++ b/hack/appfile/add_def.sh @@ -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 diff --git a/hack/appfile/cue-templates/canary.cue b/hack/appfile/cue-templates/canary.cue new file mode 100644 index 000000000..0f192fcce --- /dev/null +++ b/hack/appfile/cue-templates/canary.cue @@ -0,0 +1,7 @@ +#canary: { + replica: int + headers: [...string] +} + +parameter: #canary + diff --git a/hack/appfile/cue-templates/route.cue b/hack/appfile/cue-templates/route.cue new file mode 100644 index 000000000..123067088 --- /dev/null +++ b/hack/appfile/cue-templates/route.cue @@ -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 + } + } + ] + } + }] + } +} diff --git a/hack/appfile/cue-templates/scale.cue b/hack/appfile/cue-templates/scale.cue new file mode 100644 index 000000000..d19a016a4 --- /dev/null +++ b/hack/appfile/cue-templates/scale.cue @@ -0,0 +1,11 @@ +#scale: { + replica: *1 | int + auto: { + range: string + cpu: int + qps: int + } +} + +parameter: #scale + diff --git a/hack/appfile/cue-templates/webservice.cue b/hack/appfile/cue-templates/webservice.cue new file mode 100644 index 000000000..2e99a218b --- /dev/null +++ b/hack/appfile/cue-templates/webservice.cue @@ -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 + }] + } + } + } +} diff --git a/hack/appfile/definitions/canary.yaml b/hack/appfile/definitions/canary.yaml new file mode 100644 index 000000000..7decfc668 --- /dev/null +++ b/hack/appfile/definitions/canary.yaml @@ -0,0 +1,9 @@ +apiVersion: core.oam.dev/v1alpha2 +kind: TraitDefinition +metadata: + name: canary +spec: + definitionRef: + name: canary.standard.oam.dev + extension: + template: | diff --git a/hack/appfile/definitions/route.yaml b/hack/appfile/definitions/route.yaml new file mode 100644 index 000000000..89fcaa703 --- /dev/null +++ b/hack/appfile/definitions/route.yaml @@ -0,0 +1,9 @@ +apiVersion: core.oam.dev/v1alpha2 +kind: TraitDefinition +metadata: + name: route +spec: + definitionRef: + name: routes.standard.oam.dev + extension: + template: | diff --git a/hack/appfile/definitions/scale.yaml b/hack/appfile/definitions/scale.yaml new file mode 100644 index 000000000..916adac41 --- /dev/null +++ b/hack/appfile/definitions/scale.yaml @@ -0,0 +1,9 @@ +apiVersion: core.oam.dev/v1alpha2 +kind: TraitDefinition +metadata: + name: scale +spec: + definitionRef: + name: scales.standard.oam.dev + extension: + template: | diff --git a/hack/appfile/definitions/webservice.yaml b/hack/appfile/definitions/webservice.yaml new file mode 100644 index 000000000..8269207e2 --- /dev/null +++ b/hack/appfile/definitions/webservice.yaml @@ -0,0 +1,9 @@ +apiVersion: core.oam.dev/v1alpha2 +kind: WorkloadDefinition +metadata: + name: webservice +spec: + definitionRef: + name: containerizeds.standard.oam.dev + extension: + template: | diff --git a/hack/appfile/deploy/canary.yaml b/hack/appfile/deploy/canary.yaml new file mode 100644 index 000000000..1ac8b6f24 --- /dev/null +++ b/hack/appfile/deploy/canary.yaml @@ -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 + + diff --git a/hack/appfile/deploy/route.yaml b/hack/appfile/deploy/route.yaml new file mode 100644 index 000000000..9906d2377 --- /dev/null +++ b/hack/appfile/deploy/route.yaml @@ -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 + } + } + ] + } + }] + } + } + diff --git a/hack/appfile/deploy/scale.yaml b/hack/appfile/deploy/scale.yaml new file mode 100644 index 000000000..f88f8eee2 --- /dev/null +++ b/hack/appfile/deploy/scale.yaml @@ -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 + + diff --git a/hack/appfile/deploy/webservice.yaml b/hack/appfile/deploy/webservice.yaml new file mode 100644 index 000000000..a62f9b9bf --- /dev/null +++ b/hack/appfile/deploy/webservice.yaml @@ -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 + }] + } + } + } + } + diff --git a/hack/appfile/test.sh b/hack/appfile/test.sh new file mode 100755 index 000000000..2b34a7865 --- /dev/null +++ b/hack/appfile/test.sh @@ -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 ../.. diff --git a/pkg/appfile/appfile.go b/pkg/appfile/appfile.go new file mode 100644 index 000000000..31296eb8c --- /dev/null +++ b/pkg/appfile/appfile.go @@ -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 + +} diff --git a/pkg/appfile/build.go b/pkg/appfile/build.go new file mode 100644 index 000000000..02939c744 --- /dev/null +++ b/pkg/appfile/build.go @@ -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 +} diff --git a/pkg/appfile/service.go b/pkg/appfile/service.go new file mode 100644 index 000000000..3bc07a93a --- /dev/null +++ b/pkg/appfile/service.go @@ -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 +} diff --git a/pkg/appfile/template/manager.go b/pkg/appfile/template/manager.go new file mode 100644 index 000000000..7f6bafeb4 --- /dev/null +++ b/pkg/appfile/template/manager.go @@ -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 +} diff --git a/pkg/commands/template.go b/pkg/commands/template.go new file mode 100644 index 000000000..473bae0b6 --- /dev/null +++ b/pkg/commands/template.go @@ -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 +} diff --git a/pkg/commands/up.go b/pkg/commands/up.go new file mode 100644 index 000000000..525cd03aa --- /dev/null +++ b/pkg/commands/up.go @@ -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 \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 +} diff --git a/pkg/cue/base_template.go b/pkg/cue/base_template.go new file mode 100644 index 000000000..499fddafd --- /dev/null +++ b/pkg/cue/base_template.go @@ -0,0 +1,9 @@ +package cue + +const BaseTemplate = ` + +context: { + name: string + image: string +} +` diff --git a/pkg/cue/convert.go b/pkg/cue/convert.go index fe477abd3..f69670537 100644 --- a/pkg/cue/convert.go +++ b/pkg/cue/convert.go @@ -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 }