Initial commit for DeploymentManager on k8s.

pull/2/head
Victor Agababov 9 years ago
commit 94db53d080

6
.gitignore vendored

@ -0,0 +1,6 @@
expandybird/pkg/*
expandybird/expansion/*.pyc
resourcifier/pkg/*
resourcifier/bin/*
manager/pkg/*

@ -0,0 +1,25 @@
# Contributing guidelines
## How to become a contributor and submit your own code
### Contributor License Agreements
We'd love to accept your patches! Before we can take them, we have to jump a couple of legal hurdles.
Please fill out either the individual or corporate Contributor License Agreement (CLA).
* If you are an individual writing original source code and you're sure you own the intellectual property, then you'll need to sign an [individual CLA](http://code.google.com/legal/individual-cla-v1.0.html).
* If you work for a company that wants to allow you to contribute your work, then you'll need to sign a [corporate CLA](http://code.google.com/legal/corporate-cla-v1.0.html).
Follow either of the two links above to access the appropriate CLA and instructions for how to sign and return it. Once we receive it, we'll be able to accept your pull requests.
***NOTE***: Only original source code from you and other people that have signed the CLA can be accepted into the main repository.
### Contributing A Patch
1. Submit an issue describing your proposed change to the repo in question.
1. The repo owner will respond to your issue promptly.
1. If your proposed change is accepted, and you haven't already done so, sign a Contributor License Agreement (see details above).
1. Fork the desired repo, develop and test your code changes.
1. Submit a pull request.

@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

@ -0,0 +1,36 @@
SUBDIRS := expandybird/. resourcifier/. manager/. client/.
TARGETS := all build test push container clean
SUBDIRS_TARGETS := \
$(foreach t,$(TARGETS),$(addsuffix $t,$(SUBDIRS)))
GO_DEPS := util/... version/... expandybird/... resourcifier/... manager/... client/...
.PHONY : all build test clean $(TARGETS) $(SUBDIRS_TARGETS) .project .docker
all: build
clean:
go clean -v $(GO_DEPS)
test: build
-go test -v $(GO_DEPS)
build:
go get -v $(GO_DEPS)
go install -v $(GO_DEPS)
push: container
container: .project .docker
.project:
@if [[ -z "${PROJECT}" ]]; then echo "PROJECT variable must be set"; exit 1; fi
.docker:
@if [[ -z `which docker` ]] || ! docker version &> /dev/null; then echo "docker is not installed correctly"; exit 1; fi
$(TARGETS) : % : $(addsuffix %,$(SUBDIRS))
$(SUBDIRS_TARGETS) :
$(MAKE) -C $(@D) $(@F:.%=%)

@ -0,0 +1,110 @@
# Deployment Manager
Deployment Manager lets you define and deploy simple declarative configuration
for your Kubernetes resources.
You can also use Python or [Jinja](http://jinja.pocoo.org/) to create powerful
parameterizable abstract types called **Templates**. You can create general
abstract building blocks to reuse, like a
[Replicated Service](examples/guestbook/replicatedservice.py), or create
more concrete types like a [Redis cluster](examples/guestbook/redis.jinja).
You can find more examples of Templates and configurations in our
[examples](examples).
Deployment Manager uses the same concepts and languages as
[Google Cloud Deployment Manager](https://cloud.google.com/deployment-manager/overview),
but works directly within your Kubernetes cluster.
## Getting started
For the following steps, it is assumed that you have a Kubernetes cluster up
and running, and that you can run kubectl commands against it. It is also
assumed that you're working with a clone of the repository installed in the src
folder of your GOPATH, and that your PATH contains $GOPATH/bin, per convention.
Since Deployment Manager uses Python and will be running locally on your
machine, you will first need to make sure the necessary Python packages are
installed. This assumes that you have already installed the pip package
management system on your machine.
```
pip install -r expandybird/requirements.txt
```
Next, you'll build and install the binaries, and bootstrap Deployment Manager
into the cluster. Finally, you'll deploy an example application on the
cluster using Deployment Manager.
### Building and installing the binaries
In this step, you're going to build and install the Deployment Manager binaries.
You can do this by running make in the repository root.
```
make
```
### Bootstrapping Deployment Manager
In this step, you're going to bootstrap Deployment Manager into the cluster.
Next, start the three Deployment Manager binaries on localhost using the supplied
bootstrap script.
```
./examples/bootstrap/bootstrap.sh
```
The script starts the following binaries:
* manager (frontend service) running on port 8080
* expandybird (expands templates) running on port 8081
* resourcifier (reifies primitive Kubernetes resources) running on port 8082
It also starts kubectl proxy on port 8001.
Next, use the Deployment Manager running on localhost to deploy itself onto the
cluster using the supplied command line tool and template.
```
client --name test --service=http://localhost:8080 examples/bootstrap/bootstrap.yaml
```
You should now have Deployment Manager running on your cluster, and it should be
visible using kubectl (kubectl get pod,rc,service).
### Deploying your first application (Guestbook)
In this step, you're going to deploy the canonical guestbook example to your
Kubernetes cluster.
```
client --name guestbook --service=http://localhost:8001/api/v1/proxy/namespaces/default/services/manager-service:manager examples/guestbook/guestbook.yaml
```
You should now have guestbook up and running. To verify, get the list of services
running on the cluster:
```
kubectl get service
```
You should see frontend-service running. If your cluster supports external
load balancing, it will have an external IP assigned to it, and you should be
able to navigate to it to see the guestbook in action.
## Building the container images
This project runs Deployment Manager on Kubernetes as three replicated services.
By default, prebuilt images stored in Google Container Registry are used to create
them. However, you can build your own container images and push them to your own
project in the registry.
To build and push your own images to Google Container Registry, first set the
environment variable PROJECT to the name of a project known to gcloud. Then, run
the following command:
```
make push
```

@ -0,0 +1,12 @@
# Makefile for the Docker image gcr.io/$(PROJECT)/expandybird
# MAINTAINER: Jack Greenfield <jackgr@google.com>
# If you update this image please check the tag value before pushing.
.PHONY : all build test push container clean
test: client
client --action=expand test/guestbook.yaml test/replicatedservice.py test/redis.jinja > /dev/null
client:
go get -v ./...
go install -v ./...

@ -0,0 +1,148 @@
/*
Copyright 2015 The Kubernetes Authors All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package main
import (
"expandybird/expander"
"bytes"
"encoding/json"
"flag"
"fmt"
"io"
"io/ioutil"
"log"
"net/http"
"os"
"strings"
"time"
)
var (
action = flag.String("action", "deploy", "expand | deploy | list | get | delete | update")
name = flag.String("name", "", "Name of template or deployment")
service = flag.String("service", "http://localhost:8080", "URL for deployment manager")
binary = flag.String("binary", "../expandybird/expansion/expansion.py",
"Path to template expansion binary")
)
var usage = func() {
message := "usage: %s [<flags>] (name | (<template> [<import1>...<importN>]))\n"
fmt.Fprintf(os.Stderr, message, os.Args[0])
flag.PrintDefaults()
}
func main() {
flag.Parse()
name := getNameArgument()
switch *action {
case "expand":
backend := expander.NewExpander(*binary)
template := loadTemplate(name)
output, err := backend.ExpandTemplate(template)
if err != nil {
log.Fatalf("cannot expand %s: %s\n", name, err)
}
fmt.Println(output)
case "deploy":
callService("deployments", "POST", name, readTemplate(name))
case "list":
callService("deployments", "GET", name, nil)
case "get":
path := fmt.Sprintf("deployments/%s", name)
callService(path, "GET", name, nil)
case "delete":
path := fmt.Sprintf("deployments/%s", name)
callService(path, "DELETE", name, nil)
case "update":
path := fmt.Sprintf("deployments/%s", name)
callService(path, "PUT", name, readTemplate(name))
}
}
func callService(path, method, name string, reader io.ReadCloser) {
action := strings.ToLower(method)
if action == "post" {
action = "deploy"
}
url := fmt.Sprintf("%s/%s", *service, path)
request, err := http.NewRequest(method, url, reader)
request.Header.Add("Content-Type", "application/json")
response, err := http.DefaultClient.Do(request)
if err != nil {
log.Fatalf("cannot %s template named %s: %s\n", action, name, err)
}
defer response.Body.Close()
body, err := ioutil.ReadAll(response.Body)
if err != nil {
log.Fatalf("cannot %s template named %s: %s\n", action, name, err)
}
if response.StatusCode < http.StatusOK ||
response.StatusCode >= http.StatusMultipleChoices {
message := fmt.Sprintf("status code: %d status: %s", response.StatusCode, response.Status)
log.Fatalf("cannot %s template named %s: %s\n", action, name, message)
}
fmt.Println(string(body))
}
func readTemplate(name string) io.ReadCloser {
return marshalTemplate(loadTemplate(name))
}
func loadTemplate(name string) *expander.Template {
args := flag.Args()
if len(args) < 1 {
usage()
os.Exit(1)
}
var template *expander.Template
var err error
if len(args) == 1 {
template, err = expander.NewTemplateFromRootTemplate(args[0])
} else {
template, err = expander.NewTemplateFromFileNames(args[0], args[1:])
}
if err != nil {
log.Fatalf("cannot create template from supplied file names: %s\n", err)
}
if name != "" {
template.Name = name
}
return template
}
func marshalTemplate(template *expander.Template) io.ReadCloser {
j, err := json.Marshal(template)
if err != nil {
log.Fatalf("cannot deploy template %s: %s\n", template.Name, err)
}
return ioutil.NopCloser(bytes.NewReader(j))
}
func getNameArgument() string {
if *name == "" {
*name = fmt.Sprintf("manifest-%d", time.Now().UTC().UnixNano())
}
return *name
}

@ -0,0 +1,49 @@
######################################################################
# Copyright 2015 The Kubernetes Authors All rights reserved.
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
######################################################################
imports:
- path: replicatedservice.py
resources:
- name: expandybird
type: replicatedservice.py
properties:
service_port: 8081
target_port: 8080
container_port: 8080
external_service: false
replicas: 2
image: gcr.io/PROJECT/expandybird:latest
labels:
app: dm
- name: resourcifier
type: replicatedservice.py
properties:
service_port: 8082
target_port: 8080
container_port: 8080
external_service: false
replicas: 2
image: gcr.io/PROJECT/resourcifier:latest
labels:
app: dm
- name: manager
type: replicatedservice.py
properties:
service_port: 8080
target_port: 8080
container_port: 8080
external_service: true
replicas: 1
image: gcr.io/PROJECT/manager:latest
labels:
app: dm

@ -0,0 +1,28 @@
######################################################################
# Copyright 2015 The Kubernetes Authors All rights reserved.
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
######################################################################
imports:
- path: redis.jinja
- path: replicatedservice.py
resources:
- name: frontend
type: replicatedservice.py
properties:
service_port: 80
container_port: 80
external_service: true
replicas: 3
image: gcr.io/google_containers/example-guestbook-php-redis:v3
- name: redis
type: redis.jinja
properties: null

@ -0,0 +1,32 @@
{% set REDIS_PORT = 6379 %}
{% set WORKERS = properties['workers'] or 2 %}
resources:
- name: redis-master
type: replicatedservice.py
properties:
# This has to be overwritten since service names are hard coded in the code
service_name: redis-master
service_port: {{ REDIS_PORT }}
target_port: {{ REDIS_PORT }}
container_port: {{ REDIS_PORT }}
replicas: 1
container_name: master
image: redis
- name: redis-slave
type: replicatedservice.py
properties:
# This has to be overwritten since service names are hard coded in the code
service_name: redis-slave
service_port: {{ REDIS_PORT }}
container_port: {{ REDIS_PORT }}
replicas: {{ WORKERS }}
container_name: worker
image: kubernetes/redis-slave:v2
# An example of how to specify env variables.
env:
- name: GET_HOSTS_FROM
value: env
- name: REDIS_MASTER_SERVICE_HOST
value: redis-master

@ -0,0 +1,200 @@
######################################################################
# Copyright 2015 The Kubernetes Authors All rights reserved.
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
######################################################################
"""Defines a ReplicatedService type by creating both a Service and an RC.
This module creates a typical abstraction for running a service in a
Kubernetes cluster, namely a replication controller and a service packaged
together into a single unit.
"""
import yaml
SERVICE_TYPE_COLLECTION = 'Service'
RC_TYPE_COLLECTION = 'ReplicationController'
def GenerateConfig(context):
"""Generates a Replication Controller and a matching Service.
Args:
context: Template context, which can contain the following properties:
container_name - Name to use for container. If omitted, name is
used.
namespace - Namespace to create the resources in. If omitted,
'default' is used.
service_name - Name to use for service. If omitted name-service is
used.
protocol - Protocol to use for the service
service_port - Port to use for the service
target_port - Target port for the service
container_port - Container port to use
replicas - Number of replicas to create in RC
image - Docker image to use for replicas. Required.
labels - labels to apply.
env - Environmental variables to apply (list of maps). Format
should be:
[{'name': ENV_VAR_NAME, 'value':'ENV_VALUE'},
{'name': ENV_VAR_NAME_2, 'value':'ENV_VALUE_2'}]
external_service - If set to true, enable external Load Balancer
Returns:
A Container Manifest as a YAML string.
"""
# YAML config that we're going to create for both RC & Service
config = {'resources': []}
name = context.env['name']
container_name = context.properties.get('container_name', name)
namespace = context.properties.get('namespace', 'default')
# Define things that the Service cares about
service_name = context.properties.get('service_name', name + '-service')
service_type = SERVICE_TYPE_COLLECTION
# Define things that the Replication Controller (rc) cares about
rc_name = name + '-rc'
rc_type = RC_TYPE_COLLECTION
service = {
'name': service_name,
'type': service_type,
'properties': {
'apiVersion': 'v1',
'kind': 'Service',
'namespace': namespace,
'metadata': {
'name': service_name,
'labels': GenerateLabels(context, service_name),
},
'spec': {
'ports': [GenerateServicePorts(context, container_name)],
'selector': GenerateLabels(context, name)
}
}
}
set_up_external_lb = context.properties.get('external_service', None)
if set_up_external_lb:
service['properties']['spec']['type'] = 'LoadBalancer'
config['resources'].append(service)
rc = {
'name': rc_name,
'type': rc_type,
'properties': {
'apiVersion': 'v1',
'kind': 'ReplicationController',
'namespace': namespace,
'metadata': {
'name': rc_name,
'labels': GenerateLabels(context, rc_name),
},
'spec': {
'replicas': context.properties['replicas'],
'selector': GenerateLabels(context, name),
'template': {
'metadata': {
'labels': GenerateLabels(context, name),
},
'spec': {
'containers': [
{
'env': GenerateEnv(context),
'name': container_name,
'image': context.properties['image'],
'ports': [
{
'name': container_name,
'containerPort': context.properties['container_port'],
}
]
}
]
}
}
}
}
}
config['resources'].append(rc)
return yaml.dump(config)
# Generates labels either from the context.properties['labels'] or generates
# a default label 'name':name
def GenerateLabels(context, name):
"""Generates labels from context.properties['labels'] or creates default.
We make a deep copy of the context.properties['labels'] section to avoid
linking in the yaml document, which I believe reduces readability of the
expanded template. If no labels are given, generate a default 'name':name.
Args:
context: Template context, which can contain the following properties:
labels - Labels to generate
Returns:
A dict containing labels in a name:value format
"""
tmp_labels = context.properties.get('labels', None)
ret_labels = {'name': name}
if isinstance(tmp_labels, dict):
for key, value in tmp_labels.iteritems():
ret_labels[key] = value
return ret_labels
def GenerateServicePorts(context, name):
"""Generates a ports section for a service.
Args:
context: Template context, which can contain the following properties:
service_port - Port to use for the service
target_port - Target port for the service
protocol - Protocol to use.
Returns:
A dict containing a port definition
"""
service_port = context.properties.get('service_port', None)
target_port = context.properties.get('target_port', None)
protocol = context.properties.get('protocol')
ports = {}
if name:
ports['name'] = name
if service_port:
ports['port'] = service_port
if target_port:
ports['targetPort'] = target_port
if protocol:
ports['protocol'] = protocol
return ports
def GenerateEnv(context):
"""Generates environmental variables for a pod.
Args:
context: Template context, which can contain the following properties:
env - Environment variables to set.
Returns:
A list containing env variables in dict format {name: 'name', value: 'value'}
"""
env = []
tmp_env = context.properties.get('env', [])
for entry in tmp_env:
if isinstance(entry, dict):
env.append({'name': entry.get('name'), 'value': entry.get('value')})
return env

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

@ -0,0 +1,340 @@
# Deployment Manager Design
## Overview
Deployment Manager is a service which can be run in a Kubernetes cluster that
provides a declarative configuration language to describe Kubernetes
resources and a mechanism for deploying, updating, and deleting configurations.
This document describes the configuration language, object model, and
architecture of the service in detail.
## Configuration Language
The configuration language in Deployment Manager consists of two parts: a
YAML-based language for describing resources, and a templating mechanism for
creating abstract parameterizable types.
A configuration consists of a list of resources in YAML. Resources have three
properties:
* name: the name to use when managing the resource
* type: the type of the resource being managed
* properties: the configuration properties of the resource
An example snippet of a configuration looks like:
```
resources:
- name: my-rc
type: ReplicationController
properties:
metadata:
name: my-rc
spec:
replicas: 1
...
- name: my-service
type: Service
properties:
...
```
### References
Resources can reference values from other resources. The version of Deployment
Manager running in the Google Cloud Platform uses references to understand
dependencies between resources and properly order the operations it performs on
a configuration. This version doesn't yet have this functionality, but will have
it shortly.
A reference follows this syntax: **$(ref.NAME.PATH)**, where _NAME_ is the name
of the resource being referenced, and _PATH_ is a JSON path to the value in the
resource object.
For example:
```
$(ref.my-service.metadata.name)
```
In this case, _my-service_ is the name of the resource, and _metadata.name_ is
the JSON path to the value being referenced.
### Configurable Resources
Configurable resources are the primitive resources that can be configured in
Deployment Manager, including:
* Pod
* ReplicationController
* Service
Deployment Manager processes configurable resources by passing their
configuration properties directly to kubectl on the cluster to create, update,
or delete the resource.
### Templates
Templates are abstract types that can be created using Python or
[Jinja](http://jinja.pocoo.org/). Templates take a set of properties and must
output a valid YAML configuration string. Properties are bound to values when a
template is instantiated in a configuration.
Templates are expanded as a pre-processing step before configurable resources
are processed. They can output configurations containing configurable resources,
or additional nested templates. Nested templates will be processed recursively.
An example of a template in python is:
```
import yaml
def GenerateConfig(context):
resources = [{
'name': context.env['name'] + '-service',
'type': 'Service',
'properties': {
'prop1': context.properties['prop1'],
...
}
}]
return yaml.dump({'resources': resources})
```
and in Jinja is:
```
resources:
- name: {{ env['name'] }}-service
type: Service
properties:
prop1: {{ properties['prop1'] }}
...
```
Templates provide access to several sets of data, which can be used for
parameterizing or further customizing a configuration:
* env: a map of values defined by Deployment Manager, including _deployment_,
_name_, and _type_
* properties: a map of the key/value pairs passed in the properties section when
instantiating the template
* imports: a map of import file name to file contents of all imports originally
specified for the configuration
In Python, this data is available from the _context_ object passed into the
_GenerateConfig_ method.
### Template Schemas
A schema can be provided for a template. The schema describes the template in
more details, including:
* info: more information about the template, including long description and
title
* required: properties which are required when instantiating the template
* properties: JSON Schema descriptions of each property the template accepts
An example of a template schema is:
```
info:
title: The Example
description: A template being used as an example to illustrate concepts.
required:
- prop1
properties:
prop1:
description: The first property
type: string
default: prop-value
```
Schemas are used by Deployment Manager to validate properties being used during
template instantiation and provide default value semantics on properties.
Schemas must be imported along-side the templates which they describe when
passing configuration to Deployment Manager.
### Instantiating Templates
Templates can be instantiated in the same way that a configurable resource is
used, but must be imported and included as part of the configuration.
```
imports:
- path: example.py
resources:
- name: example
type: example.py
properties:
prop1: prop-value
```
The _imports_ list is not understood by the Deployment Manager service, but is a
directive to client-side tooling to specify what additional files should be
included when passing a configuration to the API.
## API Model
Deployment Manager exposes a set of RESTful collections over HTTP/JSON.
### Deployments
Deployments are the primary resource in the Deployment Manager service. The
inputs to a deployment are:
* name
* targetConfig
When creating a deployment, users pass their YAML configuration, as well as any
import files (templates, datafiles, etc.) in as the _targetConfig_.
Creating, updating and deleting a deployment creates a new manifest for the
deployment, and then processes the new configuration. In the case of deleting a
deployment, the deployment is first updated to an empty manifest containing no
resources, and then is removed from the system.
Deployments are available at the HTTP endpoint:
```
http://manager-service/deployments
```
### Manifests
A manifest is created for a deployment every time it is mutated, including
creation, update, and deletion.
A manifest contains three major pieces of data:
* inputConfig: the original input configuration for the manifest, including YAML
configuration and imports
* expandedConfig: the final expanded configuration to be used when processing
resources for the manifest
* layout: the hierarchical structure of the manifest
Manifests are available at the HTTP endpoint:
```
http://manager-service/deployments/<deployment>/manifests
```
#### Expanded Configuration
Given a new _inputConfig_, Deployment Manager expands all template
instantiations recursively until there is a flat set of configurable resources.
This final set is stored as the _expandedConfig_ and is used during resource
processing.
#### Layout
Users can use templates to build a rich, deep hierarchical architecture in their
configuration. Expansion flattens this hierarchy and removes the template
relationships from the configuration to create a format optimized for the process
of instantiating the resources. However, the structural information contained in
the original configuration has many uses, so rather than discard it, Deployment
Manager preserves it in the form of a _layout_.
The _layout_ looks very much like an input configuration. It is a YAML list of
resources, where each resource contains the following information:
* name: name of the resource
* type: type of the resource
* properties: properties of the resource, set only for templates
* resources: sub-resources from expansion, set only for templates
An example layout is:
```
resources:
- name: rs
type: replicatedservice.py
propertes:
replicas: 2
resources:
- name: rs-rc
type: ReplicationController
- name: rs-service
type: Service
```
The layout can be used for visualizing the architecture of resources, including
their hierarchical structure and reference relationships.
### Types
The types API provides information about existing types being used the cluster.
It can be used to list all known types that are in use in existing deployments:
```
http://manager-service/types
```
It can be used to list all active instances of a specific type in the cluster:
```
http://manager-service/types/<type>/instances
```
Passing _all_ as the type shows all instances of all types in the cluster. Type
instances include the following information:
* name: name of resource
* type: type of resource
* deployment: name of deployment in which the resource resides
* manifest: name of manifest in which the resource configuration resides
* path: JSON path to the entry for the resource in the manifest layout
## Architecture
The Deployment Manager service is built to run as a service within a Kubernetes
cluster. It has three major components to manage deployments. The following
diagram illustrates the relationships between the components, which are described
in more detail below.
![Architecture Diagram](architecture.png "Architecture Diagram")
Currently there are two caveats in the design of the service:
* Synchronous API: the API is currently designed to block on all processing for
a deployment request. In the future, this design will change to an
asynchronous operation-based mode.
* Non-persistence: the service currently stores all metadata in memory, so will
lose all knowledge of deployments and their metadata on restart. In the
future, the service will persist all deployment metadata in the cluster.
### Manager
The **manager** service acts as both the API server and the workflow engine for
processing deployments. The process for a deployment is:
1. Create a new deployment with a manifest containing _inputConfig_ from the
user request
1. Call out to **expandybird** service to perform expansion on the _inputConfig_
1. Store the resulting _expandedConfig_ and _layout_
1. Call out to **resourcifier** service to perform processing on resources from
the _expandedConfig_
1. Respond with success or error messages to the original API request
The manager is responsible for all persistence of metadata associated with
deployments, manifests, type instances, and other resources in the Deployment
Manager model.
### Expandybird
The **expandybird** service takes in input configurations, including the YAML
configuration and import files, performs all template expansion, and returns the
resulting flat configuration and layout. It is completely stateless and handles
requests synchronously.
Because templates are Python or Jinja, the actual expansion process is performed
in a sub-process running a Python interpreter. A new sub-process is created for
every request to expandybird.
Currently expansion is not sandboxed, but the intention of templates is to be
reproducable hermetically sealed entities, so future designs may
introduce a sandbox to limit external interaction like network and disk access
during expansion.
### Resourcifier
The **resourcifier** service takes in flat expanded configurations containing
only configurable resources, and makes the respective kubectl calls to process
each resource. It is completely stateless and handles requests synchronously.
Processing may be to create, update, or delete a resource,
depending on the request. The resourcifier handles references, and is the major
workflow engine for resource processing. In the future. it will also handle
dependencies between resources, as described earlier.
The resourcifier service returns either success or error messages encountered
during resource processing.

@ -0,0 +1,44 @@
#!/bin/bash
KUBECTL=`which kubectl`
if [[ -z $KUBECTL ]] ; then
echo Cannot find kubectl
exit 1
fi
echo "Starting resourcifier..."
RESOURCIFIER=`which resourcifier`
if [[ -z $RESOURCIFIER ]] ; then
echo Cannot find resourcifier
exit 1
fi
pkill -f $RESOURCIFIER
$RESOURCIFIER > resourcifier.log 2>&1 --kubectl=$KUBECTL --port=8082 &
echo
echo "Starting expandybird..."
EXPANDYBIRD=`which expandybird`
if [[ -z $EXPANDYBIRD ]] ; then
echo Cannot find expandybird
exit 1
fi
pkill -f $EXPANDYBIRD
$EXPANDYBIRD > expandybird.log 2>&1 --port=8081 --expansion_binary=expandybird/expansion/expansion.py &
echo
echo "Starting deployment manager..."
MANAGER=`which manager`
if [[ -z $MANAGER ]] ; then
echo Cannot find manager
exit 1
fi
pkill -f $MANAGER
$MANAGER > manager.log 2>&1 --port=8080 --expanderURL=http://localhost:8081 --deployerURL=http://localhost:8082 &
echo
echo "Starting kubectl proxy..."
pkill -f "$KUBECTL proxy"
$KUBECTL proxy --port=8001 &
sleep 1s
echo
echo "Done."

@ -0,0 +1,36 @@
imports:
- path: replicatedservice.py
resources:
- name: expandybird
type: replicatedservice.py
properties:
service_port: 8081
target_port: 8080
container_port: 8080
external_service: false
replicas: 2
image: gcr.io/dm-k8s-testing/expandybird:latest
labels:
app: dm
- name: resourcifier
type: replicatedservice.py
properties:
service_port: 8082
target_port: 8080
container_port: 8080
external_service: false
replicas: 2
image: gcr.io/dm-k8s-testing/resourcifier:latest
labels:
app: dm
- name: manager
type: replicatedservice.py
properties:
service_port: 8080
target_port: 8080
container_port: 8080
external_service: false
replicas: 1
image: gcr.io/dm-k8s-testing/manager:latest
labels:
app: dm

@ -0,0 +1,187 @@
"""Defines a ReplicatedService type by creating both a Service and an RC.
This module creates a typical abstraction for running a service in a
Kubernetes cluster, namely a replication controller and a service packaged
together into a single unit.
"""
import yaml
SERVICE_TYPE_COLLECTION = 'Service'
RC_TYPE_COLLECTION = 'ReplicationController'
def GenerateConfig(context):
"""Generates a Replication Controller and a matching Service.
Args:
context: Template context, which can contain the following properties:
container_name - Name to use for container. If omitted, name is
used.
namespace - Namespace to create the resources in. If omitted,
'default' is used.
service_name - Name to use for service. If omitted name-service is
used.
protocol - Protocol to use for the service
service_port - Port to use for the service
target_port - Target port for the service
container_port - Container port to use
replicas - Number of replicas to create in RC
image - Docker image to use for replicas. Required.
labels - labels to apply.
env - Environmental variables to apply (list of maps). Format
should be:
[{'name': ENV_VAR_NAME, 'value':'ENV_VALUE'},
{'name': ENV_VAR_NAME_2, 'value':'ENV_VALUE_2'}]
external_service - If set to true, enable external Load Balancer
Returns:
A Container Manifest as a YAML string.
"""
# YAML config that we're going to create for both RC & Service
config = {'resources': []}
name = context.env['name']
container_name = context.properties.get('container_name', name)
namespace = context.properties.get('namespace', 'default')
# Define things that the Service cares about
service_name = context.properties.get('service_name', name + '-service')
service_type = SERVICE_TYPE_COLLECTION
# Define things that the Replication Controller (rc) cares about
rc_name = name + '-rc'
rc_type = RC_TYPE_COLLECTION
service = {
'name': service_name,
'type': service_type,
'properties': {
'apiVersion': 'v1',
'kind': 'Service',
'namespace': namespace,
'metadata': {
'name': service_name,
'labels': GenerateLabels(context, service_name),
},
'spec': {
'ports': [GenerateServicePorts(context, container_name)],
'selector': GenerateLabels(context, name)
}
}
}
set_up_external_lb = context.properties.get('external_service', None)
if set_up_external_lb:
service['properties']['spec']['type'] = 'LoadBalancer'
config['resources'].append(service)
rc = {
'name': rc_name,
'type': rc_type,
'properties': {
'apiVersion': 'v1',
'kind': 'ReplicationController',
'namespace': namespace,
'metadata': {
'name': rc_name,
'labels': GenerateLabels(context, rc_name),
},
'spec': {
'replicas': context.properties['replicas'],
'selector': GenerateLabels(context, name),
'template': {
'metadata': {
'labels': GenerateLabels(context, name),
},
'spec': {
'containers': [
{
'env': GenerateEnv(context),
'name': container_name,
'image': context.properties['image'],
'ports': [
{
'name': container_name,
'containerPort': context.properties['container_port'],
}
]
}
]
}
}
}
}
}
config['resources'].append(rc)
return yaml.dump(config)
# Generates labels either from the context.properties['labels'] or generates
# a default label 'name':name
def GenerateLabels(context, name):
"""Generates labels from context.properties['labels'] or creates default.
We make a deep copy of the context.properties['labels'] section to avoid
linking in the yaml document, which I believe reduces readability of the
expanded template. If no labels are given, generate a default 'name':name.
Args:
context: Template context, which can contain the following properties:
labels - Labels to generate
Returns:
A dict containing labels in a name:value format
"""
tmp_labels = context.properties.get('labels', None)
ret_labels = {'name': name}
if isinstance(tmp_labels, dict):
for key, value in tmp_labels.iteritems():
ret_labels[key] = value
return ret_labels
def GenerateServicePorts(context, name):
"""Generates a ports section for a service.
Args:
context: Template context, which can contain the following properties:
service_port - Port to use for the service
target_port - Target port for the service
protocol - Protocol to use.
Returns:
A dict containing a port definition
"""
service_port = context.properties.get('service_port', None)
target_port = context.properties.get('target_port', None)
protocol = context.properties.get('protocol')
ports = {}
if name:
ports['name'] = name
if service_port:
ports['port'] = service_port
if target_port:
ports['targetPort'] = target_port
if protocol:
ports['protocol'] = protocol
return ports
def GenerateEnv(context):
"""Generates environmental variables for a pod.
Args:
context: Template context, which can contain the following properties:
env - Environment variables to set.
Returns:
A list containing env variables in dict format {name: 'name', value: 'value'}
"""
env = []
tmp_env = context.properties.get('env', [])
for entry in tmp_env:
if isinstance(entry, dict):
env.append({'name': entry.get('name'), 'value': entry.get('value')})
return env

@ -0,0 +1,15 @@
imports:
- path: redis.jinja
- path: replicatedservice.py
resources:
- name: frontend
type: replicatedservice.py
properties:
service_port: 80
container_port: 80
external_service: true
replicas: 3
image: gcr.io/google_containers/example-guestbook-php-redis:v3
- name: redis
type: redis.jinja
properties: null

@ -0,0 +1,32 @@
{% set REDIS_PORT = 6379 %}
{% set WORKERS = properties['workers'] or 2 %}
resources:
- name: redis-master
type: replicatedservice.py
properties:
# This has to be overwritten since service names are hard coded in the code
service_name: redis-master
service_port: {{ REDIS_PORT }}
target_port: {{ REDIS_PORT }}
container_port: {{ REDIS_PORT }}
replicas: 1
container_name: master
image: redis
- name: redis-slave
type: replicatedservice.py
properties:
# This has to be overwritten since service names are hard coded in the code
service_name: redis-slave
service_port: {{ REDIS_PORT }}
container_port: {{ REDIS_PORT }}
replicas: {{ WORKERS }}
container_name: worker
image: kubernetes/redis-slave:v2
# An example of how to specify env variables.
env:
- name: GET_HOSTS_FROM
value: env
- name: REDIS_MASTER_SERVICE_HOST
value: redis-master

@ -0,0 +1,187 @@
"""Defines a ReplicatedService type by creating both a Service and an RC.
This module creates a typical abstraction for running a service in a
Kubernetes cluster, namely a replication controller and a service packaged
together into a single unit.
"""
import yaml
SERVICE_TYPE_COLLECTION = 'Service'
RC_TYPE_COLLECTION = 'ReplicationController'
def GenerateConfig(context):
"""Generates a Replication Controller and a matching Service.
Args:
context: Template context, which can contain the following properties:
container_name - Name to use for container. If omitted, name is
used.
namespace - Namespace to create the resources in. If omitted,
'default' is used.
service_name - Name to use for service. If omitted name-service is
used.
protocol - Protocol to use for the service
service_port - Port to use for the service
target_port - Target port for the service
container_port - Container port to use
replicas - Number of replicas to create in RC
image - Docker image to use for replicas. Required.
labels - labels to apply.
env - Environmental variables to apply (list of maps). Format
should be:
[{'name': ENV_VAR_NAME, 'value':'ENV_VALUE'},
{'name': ENV_VAR_NAME_2, 'value':'ENV_VALUE_2'}]
external_service - If set to true, enable external Load Balancer
Returns:
A Container Manifest as a YAML string.
"""
# YAML config that we're going to create for both RC & Service
config = {'resources': []}
name = context.env['name']
container_name = context.properties.get('container_name', name)
namespace = context.properties.get('namespace', 'default')
# Define things that the Service cares about
service_name = context.properties.get('service_name', name + '-service')
service_type = SERVICE_TYPE_COLLECTION
# Define things that the Replication Controller (rc) cares about
rc_name = name + '-rc'
rc_type = RC_TYPE_COLLECTION
service = {
'name': service_name,
'type': service_type,
'properties': {
'apiVersion': 'v1',
'kind': 'Service',
'namespace': namespace,
'metadata': {
'name': service_name,
'labels': GenerateLabels(context, service_name),
},
'spec': {
'ports': [GenerateServicePorts(context, container_name)],
'selector': GenerateLabels(context, name)
}
}
}
set_up_external_lb = context.properties.get('external_service', None)
if set_up_external_lb:
service['properties']['spec']['type'] = 'LoadBalancer'
config['resources'].append(service)
rc = {
'name': rc_name,
'type': rc_type,
'properties': {
'apiVersion': 'v1',
'kind': 'ReplicationController',
'namespace': namespace,
'metadata': {
'name': rc_name,
'labels': GenerateLabels(context, rc_name),
},
'spec': {
'replicas': context.properties['replicas'],
'selector': GenerateLabels(context, name),
'template': {
'metadata': {
'labels': GenerateLabels(context, name),
},
'spec': {
'containers': [
{
'env': GenerateEnv(context),
'name': container_name,
'image': context.properties['image'],
'ports': [
{
'name': container_name,
'containerPort': context.properties['container_port'],
}
]
}
]
}
}
}
}
}
config['resources'].append(rc)
return yaml.dump(config)
# Generates labels either from the context.properties['labels'] or generates
# a default label 'name':name
def GenerateLabels(context, name):
"""Generates labels from context.properties['labels'] or creates default.
We make a deep copy of the context.properties['labels'] section to avoid
linking in the yaml document, which I believe reduces readability of the
expanded template. If no labels are given, generate a default 'name':name.
Args:
context: Template context, which can contain the following properties:
labels - Labels to generate
Returns:
A dict containing labels in a name:value format
"""
tmp_labels = context.properties.get('labels', None)
ret_labels = {'name': name}
if isinstance(tmp_labels, dict):
for key, value in tmp_labels.iteritems():
ret_labels[key] = value
return ret_labels
def GenerateServicePorts(context, name):
"""Generates a ports section for a service.
Args:
context: Template context, which can contain the following properties:
service_port - Port to use for the service
target_port - Target port for the service
protocol - Protocol to use.
Returns:
A dict containing a port definition
"""
service_port = context.properties.get('service_port', None)
target_port = context.properties.get('target_port', None)
protocol = context.properties.get('protocol')
ports = {}
if name:
ports['name'] = name
if service_port:
ports['port'] = service_port
if target_port:
ports['targetPort'] = target_port
if protocol:
ports['protocol'] = protocol
return ports
def GenerateEnv(context):
"""Generates environmental variables for a pod.
Args:
context: Template context, which can contain the following properties:
env - Environment variables to set.
Returns:
A list containing env variables in dict format {name: 'name', value: 'value'}
"""
env = []
tmp_env = context.properties.get('env', [])
for entry in tmp_env:
if isinstance(entry, dict):
env.append({'name': entry.get('name'), 'value': entry.get('value')})
return env

@ -0,0 +1,16 @@
FROM python:2-onbuild
RUN ln -s /usr/local/bin/python /usr/bin/python
RUN mkdir -p /var/expandybird/expansion
WORKDIR /var/expandybird
ADD expandybird ./expandybird
ADD expansion ./expansion
ADD requirements.txt ./requirements.txt
RUN pip install -r ./requirements.txt
EXPOSE 8080
ENTRYPOINT ["./expandybird"]

@ -0,0 +1,28 @@
# Makefile for the Docker image gcr.io/$(PROJECT)/expandybird
# MAINTAINER: Jack Greenfield <jackgr@google.com>
# If you update this image please check the tag value before pushing.
.PHONY : all build test push container clean
PREFIX := gcr.io/$(PROJECT)
IMAGE := expandybird
TAG := latest
DIR := .
push: container
gcloud docker push $(PREFIX)/$(IMAGE):$(TAG)
container: expandybird
cp $(shell which expandybird) .
docker build -t $(PREFIX)/$(IMAGE):$(TAG) $(DIR)
rm -f expandybird
expandybird:
go get -v ./...
go install -v ./...
clean:
-docker rmi $(PREFIX)/$(IMAGE):$(TAG)
rm -f expandybird

@ -0,0 +1,237 @@
/*
Copyright 2015 The Kubernetes Authors All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package expander
import (
"path/filepath"
"bytes"
"fmt"
"io/ioutil"
"log"
"os/exec"
"path"
"github.com/ghodss/yaml"
)
// Expander abstracts interactions with the expander and deployer services.
type Expander interface {
ExpandTemplate(template *Template) (string, error)
}
type expander struct {
ExpansionBinary string
}
// NewExpander returns a new initialized Expander.
func NewExpander(binary string) Expander {
return &expander{binary}
}
// ImportFile describes a file that we import into our templates
// TODO: Encode the Content so that it doesn't get mangled.
type ImportFile struct {
Name string `json:"name,omitempty"`
Content string `json:"content"`
}
// A Template defines a single deployment.
type Template struct {
Name string `json:"name"`
Content string `json:"content"`
Imports []*ImportFile `json:"imports"`
}
// NewTemplateFromRootTemplate creates and returns a new template whose content
// and imported files are constructed from reading the root template, parsing out
// the imports section and reading the imports from there
func NewTemplateFromRootTemplate(templateFileName string) (*Template, error) {
templateDir := filepath.Dir(templateFileName)
content, err := ioutil.ReadFile(templateFileName)
if err != nil {
return nil, fmt.Errorf("cannot read template file (%s): %s", err, templateFileName)
}
var c map[string]interface{}
err = yaml.Unmarshal([]byte(content), &c)
if err != nil {
log.Fatalf("Cannot parse template: %v", err)
}
// For each of the imports, grab the import file
var imports []string
if c["imports"] != nil {
for _, importFile := range c["imports"].([]interface{}) {
var fileName = importFile.(map[string]interface{})["path"].(string)
imports = append(imports, templateDir+"/"+fileName)
}
}
return NewTemplateFromFileNames(templateFileName, imports[0:])
}
// NewTemplateFromFileNames creates and returns a new template whose content
// and imported files are read from the supplied file names.
func NewTemplateFromFileNames(
templateFileName string,
importFileNames []string,
) (*Template, error) {
name := path.Base(templateFileName)
content, err := ioutil.ReadFile(templateFileName)
if err != nil {
return nil, fmt.Errorf("cannot read template file (%s): %s", err, templateFileName)
}
imports := []*ImportFile{}
for _, importFileName := range importFileNames {
importFileData, err := ioutil.ReadFile(importFileName)
if err != nil {
return nil, fmt.Errorf("cannot read import file (%s): %s", err, importFileName)
}
imports = append(imports,
&ImportFile{
Name: path.Base(importFileName),
Content: string(importFileData),
})
}
return &Template{
Name: name,
Content: string(content),
Imports: imports,
}, nil
}
// ExpansionResult describes the unmarshalled output of ExpandTemplate.
type ExpansionResult struct {
Config map[string]interface{}
Layout map[string]interface{}
}
// NewExpansionResult creates and returns a new expansion result from
// the raw output of ExpandTemplate.
func NewExpansionResult(output string) (*ExpansionResult, error) {
eResponse := &ExpansionResult{}
if err := yaml.Unmarshal([]byte(output), eResponse); err != nil {
return nil, fmt.Errorf("cannot unmarshal expansion result (%s):\n%s", err, output)
}
return eResponse, nil
}
// Marshal creates and returns an ExpansionResponse from an ExpansionResult.
func (eResult *ExpansionResult) Marshal() (*ExpansionResponse, error) {
configYaml, err := yaml.Marshal(eResult.Config)
if err != nil {
return nil, fmt.Errorf("cannot marshal manifest template (%s):\n%s", err, eResult.Config)
}
layoutYaml, err := yaml.Marshal(eResult.Layout)
if err != nil {
return nil, fmt.Errorf("cannot marshal manifest layout (%s):\n%s", err, eResult.Layout)
}
return &ExpansionResponse{
Config: string(configYaml),
Layout: string(layoutYaml),
}, nil
}
// ExpansionResponse describes the results of marshaling an ExpansionResult.
type ExpansionResponse struct {
Config string `json:"config"`
Layout string `json:"layout"`
}
// NewExpansionResponse creates and returns a new expansion response from
// the raw output of ExpandTemplate.
func NewExpansionResponse(output string) (*ExpansionResponse, error) {
eResult, err := NewExpansionResult(output)
if err != nil {
return nil, err
}
eResponse, err := eResult.Marshal()
if err != nil {
return nil, err
}
return eResponse, nil
}
// Unmarshal creates and returns an ExpansionResult from an ExpansionResponse.
func (eResponse *ExpansionResponse) Unmarshal() (*ExpansionResult, error) {
var config map[string]interface{}
if err := yaml.Unmarshal([]byte(eResponse.Config), &config); err != nil {
return nil, fmt.Errorf("cannot unmarshal config (%s):\n%s", err, eResponse.Config)
}
var layout map[string]interface{}
if err := yaml.Unmarshal([]byte(eResponse.Layout), &layout); err != nil {
return nil, fmt.Errorf("cannot unmarshal layout (%s):\n%s", err, eResponse.Layout)
}
return &ExpansionResult{
Config: config,
Layout: layout,
}, nil
}
// ExpandTemplate passes the given configuration to the expander and returns the
// expanded configuration as a string on success.
func (e *expander) ExpandTemplate(template *Template) (string, error) {
if e.ExpansionBinary == "" {
message := fmt.Sprintf("expansion binary cannot be empty")
return "", fmt.Errorf("error expanding template %s: %s", template.Name, message)
}
// Those are automatically increasing buffers, so writing arbitrary large
// data here won't block the child process.
var stdout bytes.Buffer
var stderr bytes.Buffer
cmd := &exec.Cmd{
Path: e.ExpansionBinary,
// Note, that binary name still has to be passed argv[0].
Args: []string{e.ExpansionBinary, template.Content},
// TODO(vagababov): figure out whether do we even need "PROJECT" and
// "DEPLOYMENT_NAME" variables here.
Env: []string{
"PROJECT=" + template.Name,
"DEPLOYMENT_NAME=" + template.Name,
},
Stdout: &stdout,
Stderr: &stderr,
}
for _, imp := range template.Imports {
cmd.Args = append(cmd.Args, imp.Name, imp.Content)
}
if err := cmd.Start(); err != nil {
log.Printf("error starting expansion process: %s", err)
return "", err
}
cmd.Wait()
log.Printf("Expansion process: pid: %d SysTime: %v UserTime: %v", cmd.ProcessState.Pid(),
cmd.ProcessState.SystemTime(), cmd.ProcessState.UserTime())
if stderr.String() != "" {
return "", fmt.Errorf("error expanding template %s: %s", template.Name, stderr.String())
}
return stdout.String(), nil
}

@ -0,0 +1,146 @@
/*
Copyright 2015 The Kubernetes Authors All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package expander
import (
"fmt"
"io/ioutil"
"reflect"
"strings"
"testing"
)
const invalidFileName = "afilethatdoesnotexist"
var importFileNames = []string{
"../test/replicatedservice.py",
}
var outputFileName = "../test/ExpectedOutput.yaml"
type ExpanderTestCase struct {
Description string
TemplateFileName string
ImportFileNames []string
ExpectedError string
}
func (etc *ExpanderTestCase) GetTemplate(t *testing.T) *Template {
template, err := NewTemplateFromFileNames(etc.TemplateFileName, etc.ImportFileNames)
if err != nil {
t.Errorf("cannot create template for test case '%s': %s\n", etc.Description, err)
}
return template
}
func GetOutputString(t *testing.T, description string) string {
output, err := ioutil.ReadFile(outputFileName)
if err != nil {
t.Errorf("cannot read output file for test case '%s': %s\n", description, err)
}
return string(output)
}
func TestNewTemplateFromFileNames(t *testing.T) {
if _, err := NewTemplateFromFileNames(invalidFileName, importFileNames); err == nil {
t.Errorf("expected error did not occur for invalid template file name")
}
_, err := NewTemplateFromFileNames(invalidFileName, []string{"afilethatdoesnotexist"})
if err == nil {
t.Errorf("expected error did not occur for invalid import file names")
}
}
var ExpanderTestCases = []ExpanderTestCase{
{
"expect error for invalid file name",
"../test/InvalidFileName.yaml",
importFileNames,
"ExpansionError: Exception",
},
{
"expect error for invalid property",
"../test/InvalidProperty.yaml",
importFileNames,
"ExpansionError: Exception",
},
{
"expect error for malformed content",
"../test/MalformedContent.yaml",
importFileNames,
"ExpansionError: Error parsing YAML: mapping values are not allowed here",
},
{
"expect error for missing imports",
"../test/MissingImports.yaml",
importFileNames,
"ExpansionError: Exception",
},
{
"expect error for missing resource name",
"../test/MissingResourceName.yaml",
importFileNames,
"ExpansionError: Resource does not have a name",
},
{
"expect error for missing type name",
"../test/MissingTypeName.yaml",
importFileNames,
"ExpansionError: Resource does not have type defined",
},
{
"expect success",
"../test/ValidContent.yaml",
importFileNames,
"",
},
}
func TestExpandTemplate(t *testing.T) {
backend := NewExpander("../expansion/expansion.py")
for _, etc := range ExpanderTestCases {
template := etc.GetTemplate(t)
actualOutput, err := backend.ExpandTemplate(template)
if err != nil {
message := err.Error()
if !strings.Contains(message, etc.ExpectedError) {
t.Errorf("error in test case '%s': %s\n", etc.Description, message)
}
} else {
if etc.ExpectedError != "" {
t.Errorf("expected error did not occur in test case '%s': %s\n",
etc.Description, etc.ExpectedError)
}
actualResult, err := NewExpansionResult(actualOutput)
if err != nil {
t.Errorf("error in test case '%s': %s\n", etc.Description, err)
}
expectedOutput := GetOutputString(t, etc.Description)
expectedResult, err := NewExpansionResult(expectedOutput)
if err != nil {
t.Errorf("error in test case '%s': %s\n", etc.Description, err)
}
if !reflect.DeepEqual(actualResult, expectedResult) {
message := fmt.Sprintf("want: %s\nhave: %s\n", expectedOutput, actualOutput)
t.Errorf("error in test case '%s': %s\n", etc.Description, message)
}
}
}
}

@ -0,0 +1,372 @@
#!/usr/bin/env python
######################################################################
# Copyright 2015 The Kubernetes Authors All rights reserved.
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
######################################################################
"""Template expansion utilities."""
import os.path
import sys
import traceback
import jinja2
import yaml
from sandbox_loader import FileAccessRedirector
import schema_validation
def Expand(config, imports=None, env=None, validate_schema=False):
"""Expand the configuration with imports.
Args:
config: string, the raw config to be expanded.
imports: map from import file name, e.g. "helpers/constants.py" to
its contents.
env: map from string to string, the map of environment variable names
to their values
validate_schema: True to run schema validation; False otherwise
Returns:
YAML containing the expanded configuration and its layout, in the following
format:
config:
...
layout:
...
Raises:
ExpansionError: if there is any error occurred during expansion
"""
try:
return _Expand(config, imports=imports, env=env,
validate_schema=validate_schema)
except Exception as e:
# print traceback.format_exc()
raise ExpansionError('config', str(e))
def _Expand(config, imports=None, env=None, validate_schema=False):
"""Expand the configuration with imports."""
FileAccessRedirector.redirect(imports)
yaml_config = None
try:
yaml_config = yaml.safe_load(config)
except yaml.scanner.ScannerError as e:
# Here we know that YAML parser could not parse the template we've given it.
# YAML raises a ScannerError that specifies which file had the problem, as
# well as line and column, but since we're giving it the template from
# string, error message contains <string>, which is not very helpful on the
# user end, so replace it with word "template" and make it obvious that YAML
# contains a syntactic error.
msg = str(e).replace('"<string>"', 'template')
raise Exception('Error parsing YAML: %s' % msg)
# Handle empty file case
if not yaml_config:
return ''
# If the configuration does not have ':' in it, the yaml_config will be a
# string. If this is the case just return the str. The code below it assumes
# yaml_config is a map for common cases.
if type(yaml_config) is str:
return yaml_config
if not yaml_config.has_key('resources') or yaml_config['resources'] is None:
yaml_config['resources'] = []
config = {'resources': []}
layout = {'resources': []}
_ValidateUniqueNames(yaml_config['resources'])
# Iterate over all the resources to process.
for resource in yaml_config['resources']:
processed_resource = _ProcessResource(resource, imports, env,
validate_schema)
config['resources'].extend(processed_resource['config']['resources'])
layout['resources'].append(processed_resource['layout'])
result = {'config': config, 'layout': layout}
return yaml.safe_dump(result, default_flow_style=False)
def _ProcessResource(resource, imports, env, validate_schema=False):
"""Processes a resource and expands if template.
Args:
resource: the resource to be processed, as a map.
imports: map from string to string, the map of imported files names
and contents
env: map from string to string, the map of environment variable names
to their values
validate_schema: True to run schema validation; False otherwise
Returns:
A map containing the layout and configuration of the expanded
resource and any sub-resources, in the format:
{'config': ..., 'layout': ...}
Raises:
ExpansionError: if there is any error occurred during expansion
"""
# A resource has to have to a name.
if not resource.has_key('name'):
raise ExpansionError(resource, 'Resource does not have a name.')
# A resource has to have a type.
if not resource.has_key('type'):
raise ExpansionError(resource, 'Resource does not have type defined.')
config = {'resources': []}
# Initialize layout with basic resource information.
layout = {'name': resource['name'],
'type': resource['type']}
if IsTemplate(resource['type']) and resource['type'] in imports:
# A template resource, which contains sub-resources.
expanded_template = ExpandTemplate(resource, imports, env, validate_schema)
if expanded_template['resources'] is not None:
_ValidateUniqueNames(expanded_template['resources'], resource['type'])
# Process all sub-resources of this template.
for resource_to_process in expanded_template['resources']:
processed_resource = _ProcessResource(resource_to_process, imports, env)
# Append all sub-resources to the config resources, and the resulting
# layout of sub-resources.
config['resources'].extend(processed_resource['config']['resources'])
# Lazy-initialize resources key here because it is not set for
# non-template layouts.
if 'resources' not in layout:
layout['resources'] = []
layout['resources'].append(processed_resource['layout'])
if 'properties' in resource:
layout['properties'] = resource['properties']
else:
# A normal resource has only itself for config.
config['resources'] = [resource]
return {'config': config,
'layout': layout}
def _ValidateUniqueNames(template_resources, template_name='config'):
"""Make sure that every resource name in the given template is unique."""
names = set()
# Validate that every resource name is unique
for resource in template_resources:
if 'name' in resource:
if resource['name'] in names:
raise ExpansionError(
resource,
'Resource name \'%s\' is not unique in %s.' % (resource['name'],
template_name))
names.add(resource['name'])
# If this resource doesn't have a name, we will report that error later
def IsTemplate(resource_type):
"""Returns whether a given resource type is a Template."""
return resource_type.endswith('.py') or resource_type.endswith('.jinja')
def ExpandTemplate(resource, imports, env, validate_schema=False):
"""Expands a template, calling expansion mechanism based on type.
Args:
resource: resource object, the resource that contains parameters to the
jinja file
imports: map from string to string, the map of imported files names
and contents
env: map from string to string, the map of environment variable names
to their values
validate_schema: True to run schema validation; False otherwise
Returns:
The final expanded template
Raises:
ExpansionError: if there is any error occurred during expansion
"""
source_file = resource['type']
# Look for Template in imports.
if source_file not in imports:
raise ExpansionError(
source_file,
'Unable to find source file %s in imports.' % (source_file))
resource['imports'] = imports
# Populate the additional environment variables.
if env is None:
env = {}
env['name'] = resource['name']
env['type'] = resource['type']
resource['env'] = env
schema = source_file + '.schema'
if validate_schema and schema in imports:
properties = resource['properties'] if 'properties' in resource else {}
try:
resource['properties'] = schema_validation.Validate(
properties, schema, source_file, imports)
except schema_validation.ValidationErrors as e:
raise ExpansionError(resource, e.message)
if source_file.endswith('jinja'):
expanded_template = ExpandJinja(imports[source_file], resource, imports)
elif source_file.endswith('py'):
# This is a Python template.
expanded_template = ExpandPython(
imports[source_file], source_file, resource)
else:
# The source file is not a jinja file or a python file.
# This in fact should never happen due to the IsTemplate check above.
raise ExpansionError(
resource['source'],
'Unsupported source file: %s.' % (source_file))
parsed_template = yaml.safe_load(expanded_template)
if parsed_template is None or 'resources' not in parsed_template:
raise ExpansionError(resource['type'],
'Template did not return a \'resources:\' field.')
return parsed_template
def ExpandJinja(source_template, resource, imports):
"""Render the jinja template using jinja libraries.
Args:
source_template: string, the content of jinja file to be render
resource: resource object, the resource that contains parameters to the
jinja file
imports: map from string to string, the map of imported files names
and contents
Returns:
The final expanded template
"""
# The standard jinja loader doesn't work in the sandbox as it calls getmtime()
# and this system call is not supported.
env = jinja2.Environment(loader=jinja2.DictLoader(imports))
template = env.from_string(source_template)
if (resource.has_key('properties') or resource.has_key('env') or
resource.has_key('imports')):
return template.render(resource)
else:
return template.render()
def ExpandPython(python_source, file_name, params):
"""Run python script to get the expanded template.
Args:
python_source: string, the python source file to run
file_name: string, the name of the python source file
params: object that contains 'imports' and 'params', the parameters to
the python script
Returns:
The final expanded template.
"""
try:
# Compile the python code to be run.
constructor = {}
compiled_code = compile(python_source, '<string>', 'exec')
exec compiled_code in constructor # pylint: disable=exec-used
# Construct the parameters to the python script.
evaluation_context = PythonEvaluationContext(params)
return constructor['GenerateConfig'](evaluation_context)
except Exception:
st = 'Exception in %s\n%s' % (file_name, traceback.format_exc())
raise ExpansionError(file_name, st)
class PythonEvaluationContext(object):
"""The python evaluation context.
Attributes:
params -- the parameters to be used in the expansion
"""
def __init__(self, params):
if params.has_key('properties'):
self.properties = params['properties']
else:
self.properties = None
if params.has_key('imports'):
self.imports = params['imports']
else:
self.imports = None
if params.has_key('env'):
self.env = params['env']
else:
self.env = None
class ExpansionError(Exception):
"""Exception raised for errors during expansion process.
Attributes:
resource: the resource processed that results in the error
message: the detailed message of the error
"""
def __init__(self, resource, message):
self.resource = resource
self.message = message + ' Resource: ' + str(resource)
super(ExpansionError, self).__init__(self.message)
def main():
if len(sys.argv) < 2:
print >> sys.stderr, 'No input specified.'
sys.exit(1)
template = sys.argv[1]
idx = 2
imports = {}
while idx < len(sys.argv):
if idx + 1 == len(sys.argv):
print >>sys.stderr, 'Invalid import definition at argv pos %d' % idx
sys.exit(1)
name = sys.argv[idx]
value = sys.argv[idx + 1]
imports[name] = value
idx += 2
env = {}
env['deployment'] = os.environ['DEPLOYMENT_NAME']
env['project'] = os.environ['PROJECT']
validate_schema = 'VALIDATE_SCHEMA' in os.environ
# Call the expansion logic to actually expand the template.
print Expand(template, imports, env=env, validate_schema=validate_schema)
if __name__ == '__main__':
main()

@ -0,0 +1,48 @@
######################################################################
# Copyright 2015 The Kubernetes Authors All rights reserved.
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
######################################################################
"""App allowing expansion from file names instead of cmdline arguments."""
import os.path
import sys
from expansion import Expand
def main():
if len(sys.argv) < 2:
print >>sys.stderr, 'No template specified.'
sys.exit(1)
template = ''
imports = {}
try:
with open(sys.argv[1]) as f:
template = f.read()
for imp in sys.argv[2:]:
import_contents = ''
with open(imp) as f:
import_contents = f.read()
import_name = os.path.basename(imp)
imports[import_name] = import_contents
except IOError as e:
print 'IOException: ', str(e)
sys.exit(1)
env = {}
env['deployment'] = os.environ['DEPLOYMENT_NAME']
env['project'] = os.environ['PROJECT']
validate_schema = 'VALIDATE_SCHEMA' in os.environ
print Expand(template, imports, env=env, validate_schema=validate_schema)
if __name__ == '__main__':
main()

@ -0,0 +1,153 @@
######################################################################
# Copyright 2015 The Kubernetes Authors All rights reserved.
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
######################################################################
"""Loader for loading modules from a user provided dictionary of imports."""
import imp
from os import sep
import os.path
import sys
class AllowedImportsLoader(object):
# Dictionary with modules loaded from user provided imports
user_modules = {}
@staticmethod
def get_filename(name):
return '%s.py' % name.replace('.', '/')
def load_module(self, name, etc=None): # pylint: disable=unused-argument
"""Implements loader.load_module() for loading user provided imports."""
if name in AllowedImportsLoader.user_modules:
return AllowedImportsLoader.user_modules[name]
module = imp.new_module(name)
try:
data = FileAccessRedirector.allowed_imports[self.get_filename(name)]
except Exception: # pylint: disable=broad-except
return None
# Run the module code.
exec data in module.__dict__ # pylint: disable=exec-used
AllowedImportsLoader.user_modules[name] = module
# We need to register the module in module registry, since new_module
# doesn't do this, but we need it for hierarchical references.
sys.modules[name] = module
# If this module has children load them recursively.
if name in FileAccessRedirector.parents:
for child in FileAccessRedirector.parents[name]:
full_name = name + '.' + child
self.load_module(full_name)
# If we have helpers/common.py package, then for it to be successfully
# resolved helpers.common name must resolvable, hence, once we load
# child package we attach it to parent module immeadiately.
module.__dict__[child] = AllowedImportsLoader.user_modules[full_name]
return module
class AllowedImportsHandler(object):
def find_module(self, name, path=None): # pylint: disable=unused-argument
filename = AllowedImportsLoader.get_filename(name)
if filename in FileAccessRedirector.allowed_imports:
return AllowedImportsLoader()
else:
return None
def process_imports(imports):
"""Processes the imports by copying them and adding necessary parent packages.
Copies the imports and then for all the hierarchical packages creates
dummy entries for those parent packages, so that hierarchical imports
can be resolved. In the process parent child relationship map is built.
For example: helpers/extra/common.py will generate helpers, helpers.extra
and helpers.extra.common packages along with related .py files.
Args:
imports: map of files to their relative paths.
Returns:
dictionary of imports to their contents and parent-child pacakge
relationship map.
"""
# First clone all the existing ones.
ret = {}
parents = {}
for k in imports:
ret[k] = imports[k]
# Now build the hierarchical modules.
for k in imports.keys():
if imports[k].endswith('.jinja'):
continue
# Normalize paths and trim .py extension, if any.
normalized = os.path.splitext(os.path.normpath(k))[0]
# If this is actually a path and not an absolute name, split it and process
# the hierarchical packages.
if sep in normalized:
parts = normalized.split(sep)
# Create dummy file entries for package levels and also retain
# parent-child relationships.
for i in xrange(0, len(parts)-1):
# Generate the partial package path.
path = os.path.join(parts[0], *parts[1:i+1])
# __init__.py file might have been provided and non-empty by the user.
if path not in ret:
# exec requires at least new line to be present to successfully
# compile the file.
ret[path + '.py'] = '\n'
else:
# To simplify our code, we'll store both versions in that case, since
# loader code expects files with .py extension.
ret[path + '.py'] = ret[path]
# Generate fully qualified package name.
fqpn = '.'.join(parts[0:i+1])
if fqpn in parents:
parents[fqpn].append(parts[i+1])
else:
parents[fqpn] = [parts[i+1]]
return ret, parents
class FileAccessRedirector(object):
# Dictionary with user provided imports.
allowed_imports = {}
# Dictionary that shows parent child relationships, key is the parent, value
# is the list of child packages.
parents = {}
@staticmethod
def redirect(imports):
"""Restricts imports and builtin 'open' to the set of user provided imports.
Imports already available in sys.modules will continue to be available.
Args:
imports: map from string to string, the map of imported files names
and contents.
"""
if imports is not None:
imps, parents = process_imports(imports)
FileAccessRedirector.allowed_imports = imps
FileAccessRedirector.parents = parents
# Prepend our module handler before standard ones.
sys.meta_path = [AllowedImportsHandler()] + sys.meta_path

@ -0,0 +1,213 @@
######################################################################
# Copyright 2015 The Kubernetes Authors All rights reserved.
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
######################################################################
"""Validation of Template properties for deployment manager v2."""
import jsonschema
import yaml
import schema_validation_utils
IMPORTS = "imports"
PROPERTIES = "properties"
# This validator will set default values in properties.
# This does not return a complete set of errors; use only for setting defaults.
# Pass this object a schema to get a validator for that schema.
DEFAULT_VALIDATOR = schema_validation_utils.OnlyValidateProperties(
schema_validation_utils.ExtendWithDefault(jsonschema.Draft4Validator))
# This is a regular validator, use after using the DEFAULT_VALIDATOR
# Pass this object a schema to get a validator for that schema.
VALIDATOR = schema_validation_utils.OnlyValidateProperties(
jsonschema.Draft4Validator)
# This is a validator using the default Draft4 metaschema,
# use it to validate user schemas.
SCHEMA_VALIDATOR = jsonschema.Draft4Validator(
jsonschema.Draft4Validator.META_SCHEMA)
# JsonSchema to be used to validate the user's "imports:" section
IMPORT_SCHEMA = """
properties:
imports:
type: array
items:
type: object
required:
- path
properties:
path:
type: string
name:
type: string
additionalProperties: false
uniqueItems: true
"""
# Validator to be used against the "imports:" section of a schema
IMPORT_SCHEMA_VALIDATOR = jsonschema.Draft4Validator(
yaml.safe_load(IMPORT_SCHEMA))
def Validate(properties, schema_name, template_name, imports):
"""Given a set of properties, validates it against the given schema.
Args:
properties: dict, the properties to be validated
schema_name: name of the schema file to validate
template_name: name of the template whose's properties are being validated
imports: map from string to string, the map of imported files names
and contents
Returns:
Dict containing the validated properties, with defaults filled in
Raises:
ValidationErrors: A list of ValidationError errors that occurred when
validating the properties and schema
"""
if schema_name not in imports:
raise ValidationErrors(schema_name, template_name,
["Could not find schema file '"
+ schema_name + "'."])
else:
raw_schema = imports[schema_name]
if properties is None:
properties = {}
schema = yaml.safe_load(raw_schema)
# If the schema is empty, do nothing.
if schema is None:
return properties
schema_errors = []
validating_imports = IMPORTS in schema and schema[IMPORTS]
# Validate the syntax of the optional "imports:" section of the schema
if validating_imports:
schema_errors.extend(list(IMPORT_SCHEMA_VALIDATOR.iter_errors(schema)))
# Validate the syntax of the optional "properties:" section of the schema
if PROPERTIES in schema and schema[PROPERTIES]:
try:
schema_errors.extend(
list(SCHEMA_VALIDATOR.iter_errors(schema[PROPERTIES])))
except jsonschema.RefResolutionError as e:
# Calls to iter_errors could throw a RefResolution exception
raise ValidationErrors(schema_name, template_name,
list(e), is_schema_error=True)
if schema_errors:
raise ValidationErrors(schema_name, template_name,
schema_errors, is_schema_error=True)
######
# Assume we have a valid schema
######
errors = []
# Validate that all files specified as "imports:" were included
if validating_imports:
# We have already validated that "imports:"
# is a list of unique "path/name" maps
for import_object in schema[IMPORTS]:
if "name" in import_object:
import_name = import_object["name"]
else:
import_name = import_object["path"]
if import_name not in imports:
errors.append(("File '" + import_name + "' requested in schema '"
+ schema_name + "' but not included with imports."))
try:
# This code block uses DEFAULT_VALIDATOR and VALIDATOR for two very
# different purposes.
# DEFAULT_VALIDATOR is based on JSONSchema 4, but uses modified validators:
# - The 'required' validator does nothing
# - The 'properties' validator sets default values on user properties
# With these changes, the validator does not report errors correctly.
#
# So, we do error reporting in two steps:
# 1) Use DEFAULT_VALIDATOR to set default values in the user's properties
# 2) Use the unmodified VALIDATOR to report all of the errors
# Calling iter_errors mutates properties in place, adding default values.
# You must call list()! This is a generator, not a function!
list(DEFAULT_VALIDATOR(schema).iter_errors(properties))
# Now that we have default values, validate the properties
errors.extend(list(VALIDATOR(schema).iter_errors(properties)))
if errors:
raise ValidationErrors(schema_name, template_name, errors)
except jsonschema.RefResolutionError as e:
# Calls to iter_errors could throw a RefResolution exception
raise ValidationErrors(schema_name, template_name,
list(e), is_schema_error=True)
except TypeError as e:
raise ValidationErrors(
schema_name, template_name,
[e, "Perhaps you forgot to put 'quotes' around your reference."],
is_schema_error=True)
return properties
class ValidationErrors(Exception):
"""Exception raised for errors during validation process.
The errors could have occured either in the schema xor in the properties
Attributes:
is_schema_error: Boolean, either an invalid schema, or invalid properties
errors: List of ValidationError type objects
"""
def BuildMessage(self):
"""Builds a human readable message from a list of jsonschema errors.
Returns:
A string in a human readable message format.
"""
if self.is_schema_error:
message = "Invalid schema '%s':\n" % self.schema_name
else:
message = "Invalid properties for '%s':\n" % self.template_name
for error in self.errors:
if type(error) is jsonschema.exceptions.ValidationError:
error_message = error.message
location = list(error.path)
if location and len(location):
error_message += " at " + str(location)
# If location is empty the error happened at the root of the schema
else:
error_message = str(error)
message += error_message + "\n"
return message
def __init__(self, schema_name, template_name, errors, is_schema_error=False):
self.schema_name = schema_name
self.template_name = template_name
self.errors = errors
self.is_schema_error = is_schema_error
self.message = self.BuildMessage()
super(ValidationErrors, self).__init__(self.message)

@ -0,0 +1,124 @@
######################################################################
# Copyright 2015 The Kubernetes Authors All rights reserved.
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
######################################################################
"""Helper functions for Schema Validation."""
import jsonschema
DEFAULT = "default"
PROPERTIES = "properties"
REF = "$ref"
REQUIRED = "required"
def OnlyValidateProperties(validator_class):
"""Takes a validator and makes it process only the 'properties' top level.
Args:
validator_class: A class to add a new validator to
Returns:
A validator_class that will validate properties against things
under the top level "properties" field
"""
def PropertiesValidator(unused_validator, inputs, instance, schema):
if inputs is None:
inputs = {}
for error in validator_class(schema).iter_errors(instance, inputs):
yield error
# This makes sure the only keyword jsonschema will validate is 'properties'
new_validators = ClearValidatorMap(validator_class.VALIDATORS)
new_validators.update({PROPERTIES: PropertiesValidator})
return jsonschema.validators.extend(
validator_class, new_validators)
def ExtendWithDefault(validator_class):
"""Takes a validator and makes it set default values on properties.
Args:
validator_class: A class to add our overridden validators to
Returns:
A validator_class that will set default values and ignore required fields
"""
def SetDefaultsInProperties(validator, properties, instance, unused_schema):
if properties is None:
properties = {}
SetDefaults(validator, properties, instance)
return jsonschema.validators.extend(
validator_class, {PROPERTIES: SetDefaultsInProperties,
REQUIRED: IgnoreKeyword})
def SetDefaults(validator, properties, instance):
"""Populate the default values of properties.
Args:
validator: A generator that validates the "properties" keyword
properties: User properties on which to set defaults
instance: Piece of user schema containing "properties"
"""
if not properties:
return
for dm_property, subschema in properties.iteritems():
# If the property already has a value, we don't need it's default
if dm_property in instance:
return
# The ordering of these conditions assumes that '$ref' blocks override
# all other schema info, which is what the jsonschema library assumes.
# If the subschema has a reference,
# see if that reference defines a 'default' value
if REF in subschema:
out = ResolveReferencedDefault(validator, subschema[REF])
instance.setdefault(dm_property, out)
# Otherwise, see if the subschema has a 'default' value
elif DEFAULT in subschema:
instance.setdefault(dm_property, subschema[DEFAULT])
def ResolveReferencedDefault(validator, ref):
"""Resolves a reference, and returns any default value it defines.
Args:
validator: A generator the validates the "$ref" keyword
ref: The target of the "$ref" keyword
Returns:
The value of the 'default' field found in the referenced schema, or None
"""
with validator.resolver.resolving(ref) as resolved:
if DEFAULT in resolved:
return resolved[DEFAULT]
def ClearValidatorMap(validators):
"""Remaps all JsonSchema validators to make them do nothing."""
ignore_validators = {}
for keyword in validators:
ignore_validators.update({keyword: IgnoreKeyword})
return ignore_validators
def IgnoreKeyword(
unused_validator, unused_required, unused_instance, unused_schema):
"""Validator for JsonSchema that does nothing."""
pass

@ -0,0 +1,51 @@
/*
Copyright 2015 The Kubernetes Authors All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package main
import (
"expandybird/expander"
"expandybird/service"
"version"
"flag"
"fmt"
"log"
"net/http"
restful "github.com/emicklei/go-restful"
)
// port that we are going to listen on
var port = flag.Int("port", 8080, "Port to listen on")
// path to expansion binary
var expansionBinary = flag.String("expansion_binary", "../expansion/expansion.py",
"The path to the expansion binary that will be used to expand the template.")
func main() {
flag.Parse()
backend := expander.NewExpander(*expansionBinary)
wrapper := service.NewService(service.NewExpansionHandler(backend))
address := fmt.Sprintf(":%d", *port)
container := restful.DefaultContainer
server := &http.Server{
Addr: address,
Handler: container,
}
wrapper.Register(container)
log.Printf("Version: %s", version.DeploymentManagerVersion)
log.Printf("Listening on %s...", address)
log.Fatal(server.ListenAndServe())
}

@ -0,0 +1,3 @@
pyyaml
Jinja2
Jsonschema

@ -0,0 +1,91 @@
/*
Copyright 2015 The Kubernetes Authors All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package service
import (
"expandybird/expander"
"util"
"errors"
"fmt"
"net/http"
restful "github.com/emicklei/go-restful"
)
// A Service wraps a web service that performs template expansion.
type Service struct {
*restful.WebService
}
// NewService creates and returns a new Service, initalized with a new
// restful.WebService configured with a route that dispatches to the supplied
// handler. The new Service must be registered before accepting traffic by
// calling Register.
func NewService(handler restful.RouteFunction) *Service {
restful.EnableTracing(true)
webService := new(restful.WebService)
webService.Consumes(restful.MIME_JSON, restful.MIME_XML)
webService.Produces(restful.MIME_JSON, restful.MIME_XML)
webService.Route(webService.POST("/expand").To(handler).
Doc("Expand a template.").
Reads(&expander.Template{}))
return &Service{webService}
}
// Register adds the web service wrapped by the Service to the supplied
// container. If the supplied container is nil, then the default container is
// used, instead.
func (s *Service) Register(container *restful.Container) {
if container == nil {
container = restful.DefaultContainer
}
container.Add(s.WebService)
}
// NewExpansionHandler returns a route function that handles an incoming
// template expansion request, bound to the supplied expander.
func NewExpansionHandler(backend expander.Expander) restful.RouteFunction {
return func(req *restful.Request, resp *restful.Response) {
util.LogHandlerEntry("expandybird: expand", req.Request)
template := &expander.Template{}
if err := req.ReadEntity(&template); err != nil {
logAndReturnErrorFromHandler(http.StatusBadRequest, err.Error(), resp)
return
}
output, err := backend.ExpandTemplate(template)
if err != nil {
message := fmt.Sprintf("error (%s) expanding template:\n%v\n", err, template)
logAndReturnErrorFromHandler(http.StatusBadRequest, message, resp)
return
}
response, err := expander.NewExpansionResponse(output)
if err != nil {
message := fmt.Sprintf("error (%s) marshaling output:\n%v\n", err, output)
logAndReturnErrorFromHandler(http.StatusBadRequest, message, resp)
return
}
util.LogHandlerExit("expandybird", http.StatusOK, "OK", resp.ResponseWriter)
resp.WriteEntity(response)
}
}
func logAndReturnErrorFromHandler(statusCode int, message string, resp *restful.Response) {
util.LogHandlerExit("expandybird: expand", statusCode, message, resp.ResponseWriter)
resp.WriteError(statusCode, errors.New(message))
}

@ -0,0 +1,222 @@
/*
Copyright 2015 The Kubernetes Authors All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package service
import (
"bytes"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net/http"
"reflect"
"testing"
"expandybird/expander"
"util"
restful "github.com/emicklei/go-restful"
)
func GetTemplateReader(t *testing.T, description string, templateFileName string) io.Reader {
template, err := expander.NewTemplateFromFileNames(templateFileName, importFileNames)
if err != nil {
t.Errorf("cannot create template for test case (%s): %s\n", err, description)
}
templateData, err := json.Marshal(template)
if err != nil {
t.Errorf("cannot marshal template for test case (%s): %s\n", err, description)
}
reader := bytes.NewReader(templateData)
return reader
}
func GetOutputString(t *testing.T, description string) string {
output, err := ioutil.ReadFile(outputFileName)
if err != nil {
t.Errorf("cannot read output file for test case (%s): %s\n", err, description)
}
return string(output)
}
const (
httpGETMethod = "GET"
httpPOSTMethod = "POST"
validServiceURL = "/expand"
invalidServiceURL = "http://localhost:8080/invalidurlpath"
jsonContentType = "application/json"
invalidContentType = "invalid/content-type"
inputFileName = "../test/ValidContent.yaml"
outputFileName = "../test/ExpectedOutput.yaml"
)
var importFileNames = []string{
"../test/replicatedservice.py",
}
type ServiceWrapperTestCase struct {
Description string
HTTPMethod string
ServiceURLPath string
ContentType string
StatusCode int
}
var ServiceWrapperTestCases = []ServiceWrapperTestCase{
{
"expect error for invalid HTTP verb",
httpGETMethod,
validServiceURL,
jsonContentType,
http.StatusMethodNotAllowed,
},
{
"expect error for invalid URL path",
httpPOSTMethod,
invalidServiceURL,
jsonContentType,
http.StatusNotFound,
},
{
"expect error for invalid content type",
httpPOSTMethod,
validServiceURL,
invalidContentType,
http.StatusUnsupportedMediaType,
},
{
"expect success",
httpPOSTMethod,
validServiceURL,
jsonContentType,
http.StatusOK,
},
}
func TestServiceWrapper(t *testing.T) {
backend := expander.NewExpander("../expansion/expansion.py")
wrapper := NewService(NewExpansionHandler(backend))
container := restful.DefaultContainer
wrapper.Register(container)
defer container.Remove(wrapper.WebService)
handlerTester := util.NewHandlerTester(container)
for _, swtc := range ServiceWrapperTestCases {
reader := GetTemplateReader(t, swtc.Description, inputFileName)
w, err := handlerTester(swtc.HTTPMethod, swtc.ServiceURLPath, swtc.ContentType, reader)
if err != nil {
t.Errorf("error in test case '%s': %s\n", swtc.Description, err)
}
if w.Code != http.StatusOK {
if w.Code != swtc.StatusCode {
message := fmt.Sprintf("test returned code:%d, status: %s", w.Code, w.Body.String())
t.Errorf("error in test case '%s': %s\n", swtc.Description, message)
}
} else {
if swtc.StatusCode != http.StatusOK {
t.Errorf("expected error did not occur in test case '%s': want: %d have: %d\n",
swtc.Description, swtc.StatusCode, w.Code)
}
body := w.Body.Bytes()
actualResponse := &expander.ExpansionResponse{}
if err := json.Unmarshal(body, actualResponse); err != nil {
t.Errorf("error in test case '%s': %s\n", swtc.Description, err)
}
actualResult, err := actualResponse.Unmarshal()
if err != nil {
t.Errorf("error in test case '%s': %s\n", swtc.Description, err)
}
expectedOutput := GetOutputString(t, swtc.Description)
expectedResult := expandOutputOrDie(t, expectedOutput, swtc.Description)
if !reflect.DeepEqual(expectedResult, actualResult) {
message := fmt.Sprintf("want: %s\nhave: %s\n",
util.ToYAMLOrError(expectedResult), util.ToYAMLOrError(actualResult))
t.Errorf("error in test case '%s':\n%s\n", swtc.Description, message)
}
}
}
}
type ExpansionHandlerTestCase struct {
Description string
TemplateFileName string
}
var ExpansionHandlerTestCases = []ExpansionHandlerTestCase{
{
"expect error while expanding template",
"../test/InvalidFileName.yaml",
},
{
"expect error while marshaling output",
"../test/InvalidTypeName.yaml",
},
}
var malformedExpansionOutput = []byte(`
this is malformed output
`)
type mockExpander struct {
}
// ExpandTemplate passes the given configuration to the expander and returns the
// expanded configuration as a string on success.
func (e *mockExpander) ExpandTemplate(template *expander.Template) (string, error) {
switch template.Name {
case "InvalidFileName":
return "", fmt.Errorf("expansion error")
case "InvalidTypeName":
return string(malformedExpansionOutput), nil
}
panic("unknown test case")
}
func TestExpansionHandler(t *testing.T) {
backend := &mockExpander{}
wrapper := NewService(NewExpansionHandler(backend))
container := restful.DefaultContainer
wrapper.Register(container)
defer container.Remove(wrapper.WebService)
handlerTester := util.NewHandlerTester(container)
for _, ehtc := range ExpansionHandlerTestCases {
reader := GetTemplateReader(t, ehtc.Description, ehtc.TemplateFileName)
w, err := handlerTester(httpPOSTMethod, validServiceURL, jsonContentType, reader)
if err != nil {
t.Errorf("error in test case '%s': %s\n", ehtc.Description, err)
}
if w.Code != http.StatusBadRequest {
t.Errorf("expected error did not occur in test case '%s': want: %d have: %d\n",
ehtc.Description, http.StatusBadRequest, w.Code)
}
}
}
func expandOutputOrDie(t *testing.T, output, description string) *expander.ExpansionResult {
result, err := expander.NewExpansionResult(output)
if err != nil {
t.Errorf("cannot expand output for test case '%s': %s\n", description, err)
}
return result
}

@ -0,0 +1,81 @@
######################################################################
# Copyright 2015 The Kubernetes Authors All rights reserved.
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
######################################################################
config:
resources:
- name: expandybird-service
properties:
apiVersion: v1
kind: Service
metadata:
labels:
app: expandybird
name: expandybird-service
name: expandybird-service
namespace: default
spec:
ports:
- name: expandybird
port: 8080
targetPort: 8080
selector:
app: expandybird
name: expandybird
type: LoadBalancer
type: Service
- name: expandybird-rc
properties:
apiVersion: v1
kind: ReplicationController
metadata:
labels:
app: expandybird
name: expandybird-rc
name: expandybird-rc
namespace: default
spec:
replicas: 3
selector:
app: expandybird
name: expandybird
template:
metadata:
labels:
app: expandybird
name: expandybird
spec:
containers:
- image: b.gcr.io/dm-k8s-testing/expandybird
name: expandybird
ports:
- containerPort: 8080
name: expandybird
type: ReplicationController
layout:
resources:
- name: expandybird
properties:
container_port: 8080
external_service: true
image: b.gcr.io/dm-k8s-testing/expandybird
labels:
app: expandybird
replicas: 3
service_port: 8080
target_port: 8080
resources:
- name: expandybird-service
type: Service
- name: expandybird-rc
type: ReplicationController
type: replicatedservice.py

@ -0,0 +1,22 @@
######################################################################
# Copyright 2015 The Kubernetes Authors All rights reserved.
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
######################################################################
imports:
- path: invalidfilename.py
resources:
- name: expandybird
type: replicatedservice.py
properties:
service_port: 8080
target_port: 8080
image: b.gcr.io/dm-k8s-testing/expandybird

@ -0,0 +1,22 @@
######################################################################
# Copyright 2015 The Kubernetes Authors All rights reserved.
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
######################################################################
imports:
- path: replicatedservice.py
resources:
- name: expandybird
type: replicatedservice.py
properties:
service_port: 8080
target_port: 8080
invalidproperty: b.gcr.io/dm-k8s-testing/expandybird

@ -0,0 +1,22 @@
######################################################################
# Copyright 2015 The Kubernetes Authors All rights reserved.
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
######################################################################
imports:
- path: replicatedservice.py
resources:
- name: expandybird
type: invalidtypename.py
properties:
service_port: 8080
target_port: 8080
image: b.gcr.io/dm-k8s-testing/expandybird

@ -0,0 +1,20 @@
######################################################################
# Copyright 2015 The Kubernetes Authors All rights reserved.
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
######################################################################
imports:
- path: replicatedservice.py
resources:
- name: expandybird
type: replicatedservice.py
thisisnotalist: somevalue
shouldnotbehere: anothervalue

@ -0,0 +1,21 @@
######################################################################
# Copyright 2015 The Kubernetes Authors All rights reserved.
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
######################################################################
imports:
resources:
- name: expandybird
type: replicatedservice.py
properties:
service_port: 8080
target_port: 8080
image: b.gcr.io/dm-k8s-testing/expandybird

@ -0,0 +1,21 @@
######################################################################
# Copyright 2015 The Kubernetes Authors All rights reserved.
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
######################################################################
imports:
- path: replicatedservice.py
resources:
- type: replicatedservice.py
properties:
service_port: 8080
target_port: 8080
image: b.gcr.io/dm-k8s-testing/expandybird

@ -0,0 +1,21 @@
######################################################################
# Copyright 2015 The Kubernetes Authors All rights reserved.
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
######################################################################
imports:
- path: replicatedservice.py
resources:
- name: expandybird
properties:
service_port: 8080
target_port: 8080
image: b.gcr.io/dm-k8s-testing/expandybird

@ -0,0 +1,27 @@
######################################################################
# Copyright 2015 The Kubernetes Authors All rights reserved.
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
######################################################################
imports:
- path: replicatedservice.py
resources:
- name: expandybird
type: replicatedservice.py
properties:
service_port: 8080
target_port: 8080
container_port: 8080
external_service: true
replicas: 3
image: b.gcr.io/dm-k8s-testing/expandybird
labels:
app: expandybird

@ -0,0 +1,176 @@
######################################################################
# Copyright 2015 The Kubernetes Authors All rights reserved.
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
######################################################################
"""Defines a ReplicatedService type by creating both a Service and an RC.
This module creates a typical abstraction for running a service in a
Kubernetes cluster, namely a replication controller and a service packaged
together into a single unit.
"""
import yaml
SERVICE_TYPE_COLLECTION = 'Service'
RC_TYPE_COLLECTION = 'ReplicationController'
def GenerateConfig(context):
"""Generates a Replication Controller and a matching Service.
Args:
context: Template context, which can contain the following properties:
container_name - Name to use for container. If omitted, name is
used.
namespace - Namespace to create the resources in. If omitted,
'default' is used.
protocol - Protocol to use for the service
service_port - Port to use for the service
target_port - Target port for the service
container_port - Container port to use
replicas - Number of replicas to create in RC
image - Docker image to use for replicas. Required.
labels - labels to apply.
external_service - If set to true, enable external Load Balancer
Returns:
A Container Manifest as a YAML string.
"""
# YAML config that we're going to create for both RC & Service
config = {'resources': []}
name = context.env['name']
container_name = context.properties.get('container_name', name)
namespace = context.properties.get('namespace', 'default')
# Define things that the Service cares about
service_name = name + '-service'
service_type = SERVICE_TYPE_COLLECTION
# Define things that the Replication Controller (rc) cares about
rc_name = name + '-rc'
rc_type = RC_TYPE_COLLECTION
service = {
'name': service_name,
'type': service_type,
'properties': {
'apiVersion': 'v1',
'kind': 'Service',
'namespace': namespace,
'metadata': {
'name': service_name,
'labels': GenerateLabels(context, service_name),
},
'spec': {
'ports': [GenerateServicePorts(context, container_name)],
'selector': GenerateLabels(context, name)
}
}
}
set_up_external_lb = context.properties.get('external_service', None)
if set_up_external_lb:
service['properties']['spec']['type'] = 'LoadBalancer'
config['resources'].append(service)
rc = {
'name': rc_name,
'type': rc_type,
'properties': {
'apiVersion': 'v1',
'kind': 'ReplicationController',
'namespace': namespace,
'metadata': {
'name': rc_name,
'labels': GenerateLabels(context, rc_name),
},
'spec': {
'replicas': context.properties['replicas'],
'selector': GenerateLabels(context, name),
'template': {
'metadata': {
'labels': GenerateLabels(context, name),
},
'spec': {
'containers': [
{
'name': container_name,
'image': context.properties['image'],
'ports': [
{
'name': container_name,
'containerPort': context.properties['container_port'],
}
]
}
]
}
}
}
}
}
config['resources'].append(rc)
return yaml.dump(config)
# Generates labels either from the context.properties['labels'] or generates
# a default label 'name':name
def GenerateLabels(context, name):
"""Generates labels from context.properties['labels'] or creates default.
We make a deep copy of the context.properties['labels'] section to avoid
linking in the yaml document, which I believe reduces readability of the
expanded template. If no labels are given, generate a default 'name':name.
Args:
context: Template context, which can contain the following properties:
labels - Labels to generate
Returns:
A dict containing labels in a name:value format
"""
tmp_labels = context.properties.get('labels', None)
ret_labels = {'name': name}
if isinstance(tmp_labels, dict):
for key, value in tmp_labels.iteritems():
ret_labels[key] = value
return ret_labels
def GenerateServicePorts(context, name):
"""Generates a ports section for a service.
Args:
context: Template context, which can contain the following properties:
service_port - Port to use for the service
target_port - Target port for the service
protocol - Protocol to use.
Returns:
A dict containing a port definition
"""
service_port = context.properties.get('service_port', None)
target_port = context.properties.get('target_port', None)
protocol = context.properties.get('protocol')
ports = {}
if name:
ports['name'] = name
if service_port:
ports['port'] = service_port
if target_port:
ports['targetPort'] = target_port
if protocol:
ports['protocol'] = protocol
return ports

@ -0,0 +1,34 @@
# Copyright 2015 Google, Inc. All Rights Reserved
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
FROM golang:1.4
MAINTAINER Jack Greenfield <jackgr@google.com>
WORKDIR /go/src
RUN mkdir -p manager
COPY manager manager
RUN mkdir -p util
COPY util util
RUN mkdir -p version
COPY version version
RUN go-wrapper download manager/...
RUN go-wrapper install manager/...
EXPOSE 8080
ENTRYPOINT ["go-wrapper", "run"]

@ -0,0 +1,22 @@
# Makefile for the Docker image gcr.io/$(PROJECT)/manager
# MAINTAINER: Jack Greenfield <jackgr@google.com>
# If you update this image please check the tag value before pushing.
.PHONY : all build test push container clean .project
PREFIX := gcr.io/$(PROJECT)
IMAGE := manager
TAG := latest
ROOT_DIR := $(abspath ./..)
DIR = $(ROOT_DIR)
push: container
gcloud docker push $(PREFIX)/$(IMAGE):$(TAG)
container:
docker build -t $(PREFIX)/$(IMAGE):$(TAG) -f Dockerfile $(DIR)
clean:
-docker rmi $(PREFIX)/$(IMAGE):$(TAG)

@ -0,0 +1,310 @@
/*
Copyright 2015 The Kubernetes Authors All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package main
import (
"encoding/json"
"flag"
"fmt"
"io"
"io/ioutil"
"log"
"net"
"net/http"
"os"
"strings"
"github.com/ghodss/yaml"
"github.com/gorilla/mux"
"manager/manager"
"manager/repository"
"util"
)
var deployments = []Route{
{"ListDeployments", "/deployments", "GET", listDeploymentsHandlerFunc, ""},
{"GetDeployment", "/deployments/{deployment}", "GET", getDeploymentHandlerFunc, ""},
{"CreateDeployment", "/deployments", "POST", createDeploymentHandlerFunc, "JSON"},
{"DeleteDeployment", "/deployments/{deployment}", "DELETE", deleteDeploymentHandlerFunc, ""},
{"PutDeployment", "/deployments/{deployment}", "PUT", putDeploymentHandlerFunc, "JSON"},
{"ListManifests", "/deployments/{deployment}/manifests", "GET", listManifestsHandlerFunc, ""},
{"GetManifest", "/deployments/{deployment}/manifests/{manifest}", "GET", getManifestHandlerFunc, ""},
{"ListTypes", "/types", "GET", listTypesHandlerFunc, ""},
{"ListTypeInstances", "/types/{type}/instances", "GET", listTypeInstancesHandlerFunc, ""},
}
var (
maxLength = flag.Int64("maxLength", 1024, "The maximum length (KB) of a template.")
expanderName = flag.String("expander", "expandybird-service", "The DNS name of the expander service.")
expanderURL = flag.String("expanderURL", "", "The URL for the expander service.")
deployerName = flag.String("deployer", "resourcifier-service", "The DNS name of the deployer service.")
deployerURL = flag.String("deployerURL", "", "The URL for the deployer service.")
)
var backend manager.Manager
func init() {
if !flag.Parsed() {
flag.Parse()
}
routes = append(routes, deployments...)
backend = newManager()
}
func newManager() manager.Manager {
expander := manager.NewExpander(getServiceURL(*expanderURL, *expanderName), manager.NewTypeResolver())
deployer := manager.NewDeployer(getServiceURL(*deployerURL, *deployerName))
r := repository.NewMapBasedRepository()
return manager.NewManager(expander, deployer, r)
}
func getServiceURL(serviceURL, serviceName string) string {
if serviceURL == "" {
serviceURL = makeEnvVariableURL(serviceName)
if serviceURL == "" {
addrs, err := net.LookupHost(serviceName)
if err != nil || len(addrs) < 1 {
log.Fatalf("cannot resolve service:%v. environment:%v", serviceName, os.Environ())
}
serviceURL = fmt.Sprintf("https://%s", addrs[0])
}
}
return serviceURL
}
// makeEnvVariableURL takes a service name and returns the value of the
// environment variable that identifies its URL, if it exists, or the empty
// string, if it doesn't.
func makeEnvVariableURL(str string) string {
prefix := makeEnvVariableName(str)
url := os.Getenv(prefix + "_PORT")
return strings.Replace(url, "tcp", "http", 1)
}
// makeEnvVariableName is copied from the Kubernetes source,
// which is referenced by the documentation for service environment variables.
func makeEnvVariableName(str string) string {
// TODO: If we simplify to "all names are DNS1123Subdomains" this
// will need two tweaks:
// 1) Handle leading digits
// 2) Handle dots
return strings.ToUpper(strings.Replace(str, "-", "_", -1))
}
func listDeploymentsHandlerFunc(w http.ResponseWriter, r *http.Request) {
handler := "manager: list deployments"
util.LogHandlerEntry(handler, r)
l, err := backend.ListDeployments()
if err != nil {
util.LogAndReturnError(handler, http.StatusInternalServerError, err, w)
return
}
var names []string
for _, d := range l {
names = append(names, d.Name)
}
util.LogHandlerExitWithJSON(handler, w, names, http.StatusOK)
}
func getDeploymentHandlerFunc(w http.ResponseWriter, r *http.Request) {
handler := "manager: get deployment"
util.LogHandlerEntry(handler, r)
name, err := getPathVariable(w, r, "deployment", handler)
if err != nil {
return
}
d, err := backend.GetDeployment(name)
if err != nil {
util.LogAndReturnError(handler, http.StatusBadRequest, err, w)
return
}
util.LogHandlerExitWithJSON(handler, w, d, http.StatusOK)
}
func createDeploymentHandlerFunc(w http.ResponseWriter, r *http.Request) {
handler := "manager: create deployment"
util.LogHandlerEntry(handler, r)
defer r.Body.Close()
t := getTemplate(w, r, handler)
if t != nil {
d, err := backend.CreateDeployment(t)
if err != nil {
util.LogAndReturnError(handler, http.StatusBadRequest, err, w)
return
}
util.LogHandlerExitWithJSON(handler, w, d, http.StatusCreated)
return
}
}
func deleteDeploymentHandlerFunc(w http.ResponseWriter, r *http.Request) {
handler := "manager: delete deployment"
util.LogHandlerEntry(handler, r)
defer r.Body.Close()
name, err := getPathVariable(w, r, "deployment", handler)
if err != nil {
return
}
d, err := backend.DeleteDeployment(name, true)
if err != nil {
util.LogAndReturnError(handler, http.StatusBadRequest, err, w)
return
}
util.LogHandlerExitWithJSON(handler, w, d, http.StatusOK)
}
func putDeploymentHandlerFunc(w http.ResponseWriter, r *http.Request) {
handler := "manager: update deployment"
util.LogHandlerEntry(handler, r)
defer r.Body.Close()
name, err := getPathVariable(w, r, "deployment", handler)
if err != nil {
return
}
t := getTemplate(w, r, handler)
if t != nil {
d, err := backend.PutDeployment(name, t)
if err != nil {
util.LogAndReturnError(handler, http.StatusBadRequest, err, w)
return
}
util.LogHandlerExitWithJSON(handler, w, d, http.StatusCreated)
}
}
func getPathVariable(w http.ResponseWriter, r *http.Request, variable, handler string) (string, error) {
vars := mux.Vars(r)
variable, ok := vars[variable]
if !ok {
e := fmt.Errorf("%s parameter not found in URL", variable)
util.LogAndReturnError(handler, http.StatusBadRequest, e, w)
return "", e
}
return variable, nil
}
func getTemplate(w http.ResponseWriter, r *http.Request, handler string) *manager.Template {
util.LogHandlerEntry(handler, r)
b := io.LimitReader(r.Body, *maxLength*1024)
y, err := ioutil.ReadAll(b)
if err != nil {
util.LogAndReturnError(handler, http.StatusBadRequest, err, w)
return nil
}
// Reject the input if it exceeded the length limit,
// since we may not have read all of it into the buffer.
if _, err = b.Read(make([]byte, 0, 1)); err != io.EOF {
e := fmt.Errorf("template exceeds maximum length of %d KB", *maxLength)
util.LogAndReturnError(handler, http.StatusBadRequest, e, w)
return nil
}
if err := r.Body.Close(); err != nil {
util.LogAndReturnError(handler, http.StatusInternalServerError, err, w)
return nil
}
j, err := yaml.YAMLToJSON(y)
if err != nil {
e := fmt.Errorf("%v\n%v", err, string(y))
util.LogAndReturnError(handler, http.StatusBadRequest, e, w)
return nil
}
t := &manager.Template{}
if err := json.Unmarshal(j, t); err != nil {
e := fmt.Errorf("%v\n%v", err, string(j))
util.LogAndReturnError(handler, http.StatusBadRequest, e, w)
return nil
}
return t
}
func listManifestsHandlerFunc(w http.ResponseWriter, r *http.Request) {
handler := "manager: list manifests"
util.LogHandlerEntry(handler, r)
deploymentName, err := getPathVariable(w, r, "deployment", handler)
if err != nil {
return
}
m, err := backend.ListManifests(deploymentName)
if err != nil {
util.LogAndReturnError(handler, http.StatusInternalServerError, err, w)
return
}
var manifestNames []string
for _, manifest := range m {
manifestNames = append(manifestNames, manifest.Name)
}
util.LogHandlerExitWithJSON(handler, w, manifestNames, http.StatusOK)
}
func getManifestHandlerFunc(w http.ResponseWriter, r *http.Request) {
handler := "manager: get manifest"
util.LogHandlerEntry(handler, r)
deploymentName, err := getPathVariable(w, r, "deployment", handler)
if err != nil {
return
}
manifestName, err := getPathVariable(w, r, "manifest", handler)
if err != nil {
return
}
m, err := backend.GetManifest(deploymentName, manifestName)
if err != nil {
util.LogAndReturnError(handler, http.StatusBadRequest, err, w)
return
}
util.LogHandlerExitWithJSON(handler, w, m, http.StatusOK)
}
// Putting Type handlers here for now because deployments.go
// currently owns its own Manager backend and doesn't like to share.
func listTypesHandlerFunc(w http.ResponseWriter, r *http.Request) {
handler := "manager: list types"
util.LogHandlerEntry(handler, r)
util.LogHandlerExitWithJSON(handler, w, backend.ListTypes(), http.StatusOK)
}
func listTypeInstancesHandlerFunc(w http.ResponseWriter, r *http.Request) {
handler := "manager: list instances"
util.LogHandlerEntry(handler, r)
typeName, err := getPathVariable(w, r, "type", handler)
if err != nil {
return
}
util.LogHandlerExitWithJSON(handler, w, backend.ListInstances(typeName), http.StatusOK)
}

@ -0,0 +1,73 @@
/*
Copyright 2015 The Kubernetes Authors All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package main
import (
"version"
"flag"
"fmt"
"log"
"net/http"
"os"
"github.com/gorilla/handlers"
"github.com/gorilla/mux"
)
// Route defines a routing table entry to be registered with gorilla/mux.
type Route struct {
Name string
Path string
Methods string
HandlerFunc http.HandlerFunc
Type string
}
var routes = []Route{}
// port to listen on
var port = flag.Int("port", 8080, "The port to listen on")
func main() {
if !flag.Parsed() {
flag.Parse()
}
router := mux.NewRouter()
router.StrictSlash(true)
for _, route := range routes {
handler := http.Handler(http.HandlerFunc(route.HandlerFunc))
switch route.Type {
case "JSON":
handler = handlers.ContentTypeHandler(handler, "application/json")
case "":
break
default:
log.Fatalf("invalid route type: %v", route.Type)
}
r := router.NewRoute()
r.Name(route.Name).
Path(route.Path).
Methods(route.Methods).
Handler(handler)
}
address := fmt.Sprintf(":%d", *port)
handler := handlers.CombinedLoggingHandler(os.Stderr, router)
log.Printf("Version: %s", version.DeploymentManagerVersion)
log.Printf("Listening on port %d...", *port)
log.Fatal(http.ListenAndServe(address, handler))
}

@ -0,0 +1,164 @@
/*
Copyright 2015 The Kubernetes Authors All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package manager
import (
"bytes"
"fmt"
"github.com/ghodss/yaml"
"io"
"io/ioutil"
"log"
"net/http"
"net/url"
"strings"
)
// Deployer abstracts interactions with the expander and deployer services.
type Deployer interface {
GetConfiguration(cached *Configuration) (*Configuration, error)
CreateConfiguration(configuration *Configuration) error
DeleteConfiguration(configuration *Configuration) error
PutConfiguration(configuration *Configuration) error
}
// NewDeployer returns a new initialized Deployer.
func NewDeployer(url string) Deployer {
return &deployer{url}
}
type deployer struct {
deployerURL string
}
func (d *deployer) getBaseURL() string {
return fmt.Sprintf("%s/configurations", d.deployerURL)
}
type formatter func(err error) error
// GetConfiguration reads and returns the actual configuration
// of the resources described by a cached configuration.
func (d *deployer) GetConfiguration(cached *Configuration) (*Configuration, error) {
errors := &Error{}
actual := &Configuration{}
for _, resource := range cached.Resources {
rtype := url.QueryEscape(resource.Type)
rname := url.QueryEscape(resource.Name)
url := fmt.Sprintf("%s/%s/%s", d.getBaseURL(), rtype, rname)
body, err := d.callService("GET", url, nil, func(e error) error {
return fmt.Errorf("cannot get configuration for resource (%s)", e)
})
if err != nil {
log.Println(errors.appendError(err))
continue
}
if len(body) != 0 {
result := &Resource{Name: resource.Name, Type: resource.Type}
if err := yaml.Unmarshal(body, &result.Properties); err != nil {
return nil, fmt.Errorf("cannot get configuration for resource (%v)", err)
}
actual.Resources = append(actual.Resources, result)
}
}
if len(errors.errors) > 0 {
return nil, errors
}
return actual, nil
}
// CreateConfiguration deploys the set of resources described by a configuration.
func (d *deployer) CreateConfiguration(configuration *Configuration) error {
return d.callServiceWithConfiguration("POST", "create", configuration)
}
// DeleteConfiguration deletes the set of resources described by a configuration.
func (d *deployer) DeleteConfiguration(configuration *Configuration) error {
return d.callServiceWithConfiguration("DELETE", "delete", configuration)
}
// PutConfiguration replaces the set of resources described by a configuration.
func (d *deployer) PutConfiguration(configuration *Configuration) error {
return d.callServiceWithConfiguration("PUT", "replace", configuration)
}
func (d *deployer) callServiceWithConfiguration(method, operation string, configuration *Configuration) error {
callback := func(e error) error {
return fmt.Errorf("cannot %s configuration (%s)", operation, e)
}
y, err := yaml.Marshal(configuration)
if err != nil {
return callback(err)
}
reader := ioutil.NopCloser(bytes.NewReader(y))
_, err = d.callService(method, d.getBaseURL(), reader, callback)
return err
}
func (d *deployer) callService(method, url string, reader io.Reader, callback formatter) ([]byte, error) {
request, err := http.NewRequest(method, url, reader)
if err != nil {
return nil, callback(err)
}
if method != "GET" {
request.Header.Add("Content-Type", "application/json")
}
response, err := http.DefaultClient.Do(request)
if err != nil {
return nil, callback(err)
}
defer response.Body.Close()
if response.StatusCode < http.StatusOK ||
response.StatusCode >= http.StatusMultipleChoices {
err := fmt.Errorf("deployer service response:\n%v\n", response)
return nil, callback(err)
}
body, err := ioutil.ReadAll(response.Body)
if err != nil {
return nil, callback(err)
}
return body, nil
}
// Error is an error type that captures errors from the multiple calls to kubectl
// made for a single configuration.
type Error struct {
errors []error
}
// Error returns the string value of an Error.
func (e *Error) Error() string {
errs := []string{}
for _, err := range e.errors {
errs = append(errs, err.Error())
}
return strings.Join(errs, "\n")
}
func (e *Error) appendError(err error) error {
e.errors = append(e.errors, err)
return err
}

@ -0,0 +1,300 @@
/*
Copyright 2015 The Kubernetes Authors All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package manager
import (
"fmt"
"io/ioutil"
"net/http"
"net/http/httptest"
"path"
"reflect"
"strings"
"testing"
"util"
"github.com/ghodss/yaml"
)
var validConfigurationTestCaseData = []byte(`
resources:
- name: test-controller-v1
type: ReplicationController
properties:
kind: ReplicationController
apiVersion: v1
metadata:
name: test-controller-v1
namespace: default
labels:
k8s-app: test
version: v1
spec:
replicas: 1
selector:
k8s-app: test
version: v1
template:
metadata:
labels:
k8s-app: test
version: v1
spec:
containers:
- name: test
image: deployer/test:latest
ports:
- name: test
containerPort: 8080
protocol: TCP
- name: test
type: Service
properties:
apiVersion: v1
kind: Service
metadata:
name: test
namespace: default
labels:
k8s-app: test
version: v1
spec:
type: LoadBalancer
selector:
k8s-app: test
version: v1
ports:
- name: test
port: 8080
targetPort: test
protocol: TCP
`)
type DeployerTestCases struct {
TestCases []DeployerTestCase
}
type DeployerTestCase struct {
Description string
Error string
Handler func(w http.ResponseWriter, r *http.Request)
}
func TestGetConfiguration(t *testing.T) {
valid := getValidConfiguration(t)
tests := []DeployerTestCase{
{
"expect success for GetConfiguration",
"",
func(w http.ResponseWriter, r *http.Request) {
// Get name from path, find in valid, and return its properties.
rtype := path.Base(path.Dir(r.URL.Path))
rname := path.Base(r.URL.Path)
for _, resource := range valid.Resources {
if resource.Type == rtype && resource.Name == rname {
util.LogHandlerExitWithYAML("resourcifier: get configuration", w, resource.Properties, http.StatusOK)
return
}
}
status := fmt.Sprintf("resource %s of type %s not found", rname, rtype)
http.Error(w, status, http.StatusInternalServerError)
},
},
{
"expect error for GetConfiguration",
"cannot get configuration",
deployerErrorHandler,
},
}
for _, dtc := range tests {
ts := httptest.NewServer(http.HandlerFunc(dtc.Handler))
defer ts.Close()
deployer := NewDeployer(ts.URL)
result, err := deployer.GetConfiguration(valid)
if err != nil {
message := err.Error()
if !strings.Contains(message, dtc.Error) {
t.Errorf("error in test case:%s:%s\n", dtc.Description, message)
}
} else {
if dtc.Error != "" {
t.Errorf("expected error:%s\ndid not occur in test case:%s\n",
dtc.Error, dtc.Description)
}
if !reflect.DeepEqual(valid, result) {
t.Errorf("error in test case:%s:\nwant:%s\nhave:%s\n",
dtc.Description, util.ToYAMLOrError(valid), util.ToYAMLOrError(result))
}
}
}
}
func TestCreateConfiguration(t *testing.T) {
valid := getValidConfiguration(t)
tests := []DeployerTestCase{
{
"expect success for CreateConfiguration",
"",
deployerSuccessHandler,
},
{
"expect error for CreateConfiguration",
"cannot create configuration",
deployerErrorHandler,
},
}
for _, dtc := range tests {
ts := httptest.NewServer(http.HandlerFunc(dtc.Handler))
defer ts.Close()
deployer := NewDeployer(ts.URL)
err := deployer.CreateConfiguration(valid)
if err != nil {
message := err.Error()
if !strings.Contains(message, dtc.Error) {
t.Errorf("error in test case:%s:%s\n", dtc.Description, message)
}
} else {
if dtc.Error != "" {
t.Errorf("expected error:%s\ndid not occur in test case:%s\n",
dtc.Error, dtc.Description)
}
}
}
}
func TestDeleteConfiguration(t *testing.T) {
valid := getValidConfiguration(t)
tests := []DeployerTestCase{
{
"expect success for DeleteConfiguration",
"",
deployerSuccessHandler,
},
{
"expect error for DeleteConfiguration",
"cannot delete configuration",
deployerErrorHandler,
},
}
for _, dtc := range tests {
ts := httptest.NewServer(http.HandlerFunc(dtc.Handler))
defer ts.Close()
deployer := NewDeployer(ts.URL)
err := deployer.DeleteConfiguration(valid)
if err != nil {
message := err.Error()
if !strings.Contains(message, dtc.Error) {
t.Errorf("error in test case:%s:%s\n", dtc.Description, message)
}
} else {
if dtc.Error != "" {
t.Errorf("expected error:%s\ndid not occur in test case:%s\n",
dtc.Error, dtc.Description)
}
}
}
}
func TestPutConfiguration(t *testing.T) {
valid := getValidConfiguration(t)
tests := []DeployerTestCase{
{
"expect success for PutConfiguration",
"",
deployerSuccessHandler,
},
{
"expect error for PutConfiguration",
"cannot replace configuration",
deployerErrorHandler,
},
}
for _, dtc := range tests {
ts := httptest.NewServer(http.HandlerFunc(dtc.Handler))
defer ts.Close()
deployer := NewDeployer(ts.URL)
err := deployer.PutConfiguration(valid)
if err != nil {
message := err.Error()
if !strings.Contains(message, dtc.Error) {
t.Errorf("error in test case:%s:%s\n", dtc.Description, message)
}
} else {
if dtc.Error != "" {
t.Errorf("expected error:%s\ndid not occur in test case:%s\n",
dtc.Error, dtc.Description)
}
}
}
}
func getValidConfiguration(t *testing.T) *Configuration {
valid := &Configuration{}
err := yaml.Unmarshal(validConfigurationTestCaseData, valid)
if err != nil {
t.Errorf("cannot unmarshal test case data:%s\n", err)
}
return valid
}
func deployerErrorHandler(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
http.Error(w, "something failed", http.StatusInternalServerError)
}
func deployerSuccessHandler(w http.ResponseWriter, r *http.Request) {
valid := &Configuration{}
err := yaml.Unmarshal(validConfigurationTestCaseData, valid)
if err != nil {
status := fmt.Sprintf("cannot unmarshal test case data:%s", err)
http.Error(w, status, http.StatusInternalServerError)
return
}
defer r.Body.Close()
body, err := ioutil.ReadAll(r.Body)
if err != nil {
status := fmt.Sprintf("cannot read request body:%s", err)
http.Error(w, status, http.StatusInternalServerError)
return
}
result := &Configuration{}
if err := yaml.Unmarshal(body, result); err != nil {
status := fmt.Sprintf("cannot unmarshal request body:%s", err)
http.Error(w, status, http.StatusInternalServerError)
return
}
if !reflect.DeepEqual(valid, result) {
status := fmt.Sprintf("error in http handler:\nwant:%s\nhave:%s\n",
util.ToYAMLOrError(valid), util.ToYAMLOrError(result))
http.Error(w, status, http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}

@ -0,0 +1,235 @@
/*
Copyright 2015 The Kubernetes Authors All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package manager
import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"github.com/ghodss/yaml"
)
const (
// TODO (iantw): Align this with a character not allowed to show up in resource names.
layoutNodeKeySeparator = "#"
)
// ExpandedTemplate is the structure returned by the expansion service.
type ExpandedTemplate struct {
Config *Configuration `json:"config"`
Layout *Layout `json:"layout"`
}
// Expander abstracts interactions with the expander and deployer services.
type Expander interface {
ExpandTemplate(t Template) (*ExpandedTemplate, error)
}
// NewExpander returns a new initialized Expander.
func NewExpander(url string, tr TypeResolver) Expander {
return &expander{url, tr}
}
type expander struct {
expanderURL string
typeResolver TypeResolver
}
func (e *expander) getBaseURL() string {
return fmt.Sprintf("%s/expand", e.expanderURL)
}
func expanderError(t *Template, err error) error {
return fmt.Errorf("cannot expand template named %s (%s):\n%s\n", t.Name, err, t.Content)
}
// ExpanderResponse gives back a layout, which has nested structure
// Resource0
// ResourceDefinition
// Resource0, 0
// ResourceDefinition
// Resource0, 0, 0
// ResourceDefinition
// Resource0, 0, 1
// ResourceDefinition
// Resource0, 1
// ResourceDefinition
//
// All the leaf nodes in this tree are either primitives or a currently unexpandable type.
// Next we will resolve all the unexpandable types and re-enter expansion, at which point
// all primitives are untouched and returned as root siblings with no children in the
// resulting layout. The previously unexpandable nodes will become sibling root nodes,
// but with children. We want to replace the leaf nodes that were formerly unexpandable
// with their respective newly created trees.
//
// So, do as follows:
// 1) Do a walk of the tree and find each leaf. Check its Type and place a pointer to it
// into a map with the resource name and type as key if it is non-primitive.
// 2) Re-expand the template with the new imports.
// 3) For each root level sibling, check if its name exists in the hash map from (1)
// 4) Replace the Layout of the node in the hash map with the current node if applicable.
// 5) Return to (1)
// TODO (iantw): There may be a tricky corner case here where a known template could be
// masked by an unknown template, which on the subsequent expansion could allow a collision
// between the name#template key to exist in the layout given a particular choice of naming.
// In practice, it would be nearly impossible to hit, but consider including properties/name/type
// into a hash of sorts to make this robust...
func walkLayout(l *Layout, toReplace map[string]*LayoutResource) map[string]*LayoutResource {
ret := map[string]*LayoutResource{}
toVisit := l.Resources
for len(toVisit) > 0 {
lr := toVisit[0]
nodeKey := lr.Resource.Name + layoutNodeKeySeparator + lr.Resource.Type
if len(lr.Layout.Resources) == 0 && Primitives[lr.Resource.Type] == false {
ret[nodeKey] = lr
} else if toReplace[nodeKey] != nil {
toReplace[nodeKey].Resources = lr.Resources
}
toVisit = append(toVisit, lr.Resources...)
toVisit = toVisit[1:]
}
return ret
}
// ExpandTemplate expands the supplied template, and returns a configuration.
func (e *expander) ExpandTemplate(t Template) (*ExpandedTemplate, error) {
// We have a fencepost problem here.
// 1. Start by trying to resolve any missing templates
// 2. Expand the configuration using all the of the imports available to us at this point
// 3. Expansion may yield additional templates, so we run the type resolution again
// 4. If type resolution resulted in new imports being available, return to 2.
config := &Configuration{}
if err := yaml.Unmarshal([]byte(t.Content), config); err != nil {
e := fmt.Errorf("Unable to unmarshal configuration (%s): %s\n", err, t.Content)
return nil, e
}
var finalLayout *Layout
needResolve := map[string]*LayoutResource{}
// Start things off by attempting to resolve the templates in a first pass.
newImp, err := e.typeResolver.ResolveTypes(config, t.Imports)
if err != nil {
e := fmt.Errorf("type resolution failed:%s\n", err)
return nil, expanderError(&t, e)
}
t.Imports = append(t.Imports, newImp...)
for {
// Now expand with everything imported.
result, err := e.expandTemplate(&t)
if err != nil {
e := fmt.Errorf("template expansion:%s\n", err)
return nil, expanderError(&t, e)
}
// Once we set this layout, we're operating on the "needResolve" *LayoutResources,
// which are pointers into the original layout structure. After each expansion we
// lose the templates in the previous expansion, so we have to keep the first one
// around and keep appending to the pointers in it as we get more layers of expansion.
if finalLayout == nil {
finalLayout = result.Layout
}
needResolve = walkLayout(result.Layout, needResolve)
newImp, err = e.typeResolver.ResolveTypes(result.Config, nil)
if err != nil {
e := fmt.Errorf("type resolution failed:%s\n", err)
return nil, expanderError(&t, e)
}
// If the new imports contain nothing, we are done. Everything is fully expanded.
if len(newImp) == 0 {
result.Layout = finalLayout
return result, nil
}
t.Imports = append(t.Imports, newImp...)
var content []byte
content, err = yaml.Marshal(result.Config)
t.Content = string(content)
if err != nil {
e := fmt.Errorf("Unable to unmarshal response from expander (%s): %s\n",
err, result.Config)
return nil, expanderError(&t, e)
}
}
}
func (e *expander) expandTemplate(t *Template) (*ExpandedTemplate, error) {
j, err := json.Marshal(t)
if err != nil {
return nil, err
}
response, err := http.Post(e.getBaseURL(), "application/json", ioutil.NopCloser(bytes.NewReader(j)))
if err != nil {
e := fmt.Errorf("http POST failed:%s\n", err)
return nil, e
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
err := fmt.Errorf("expander service response:%v", response)
return nil, err
}
body, err := ioutil.ReadAll(response.Body)
if err != nil {
e := fmt.Errorf("error reading response:%s\n", err)
return nil, e
}
er := &ExpansionResponse{}
if err := json.Unmarshal(body, er); err != nil {
e := fmt.Errorf("cannot unmarshal response body (%s):%s\n", err, body)
return nil, e
}
template, err := er.Unmarshal()
if err != nil {
e := fmt.Errorf("cannot unmarshal response yaml (%s):%v\n", err, er)
return nil, e
}
return template, nil
}
// ExpansionResponse describes the results of marshaling an ExpandedTemplate.
type ExpansionResponse struct {
Config string `json:"config"`
Layout string `json:"layout"`
}
// Unmarshal creates and returns an ExpandedTemplate from an ExpansionResponse.
func (er *ExpansionResponse) Unmarshal() (*ExpandedTemplate, error) {
template := &ExpandedTemplate{}
if err := yaml.Unmarshal([]byte(er.Config), &template.Config); err != nil {
return nil, fmt.Errorf("cannot unmarshal config (%s):\n%s", err, er.Config)
}
if err := yaml.Unmarshal([]byte(er.Layout), &template.Layout); err != nil {
return nil, fmt.Errorf("cannot unmarshal layout (%s):\n%s", err, er.Layout)
}
return template, nil
}

@ -0,0 +1,351 @@
/*
Copyright 2015 The Kubernetes Authors All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package manager
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"util"
"github.com/ghodss/yaml"
)
type mockResolver struct {
responses [][]*ImportFile
t *testing.T
}
func (r *mockResolver) ResolveTypes(c *Configuration, i []*ImportFile) ([]*ImportFile, error) {
if len(r.responses) < 1 {
return nil, nil
}
ret := r.responses[0]
r.responses = r.responses[1:]
return ret, nil
}
var validTemplateTestCaseData = Template{
Name: "TestTemplate",
Content: string(validContentTestCaseData),
Imports: validImportFilesTestCaseData,
}
var validContentTestCaseData = []byte(`
imports:
- path: test-type.py
resources:
- name: test
type: test-type.py
properties:
test-property: test-value
`)
var validImportFilesTestCaseData = []*ImportFile{
&ImportFile{
Name: "test-type.py",
Content: "test-type.py validTemplateTestCaseData content",
},
}
var validConfigTestCaseData = []byte(`
resources:
- name: test-service
properties:
test-property: test-value
type: Service
- name: test-rc
properties:
test-property: test-value
type: ReplicationController
- name: test3-service
properties:
test-property: test-value
type: Service
- name: test3-rc
properties:
test-property: test-value
type: ReplicationController
- name: test4-service
properties:
test-property: test-value
type: Service
- name: test4-rc
properties:
test-property: test-value
type: ReplicationController
`)
var validLayoutTestCaseData = []byte(`
resources:
- name: test
properties:
test-property: test-value
resources:
- name: test-service
type: Service
- name: test-rc
type: ReplicationController
type: test-type.py
- name: test2
properties: null
resources:
- name: test3
properties:
test-property: test-value
resources:
- name: test3-service
type: Service
- name: test3-rc
type: ReplicationController
type: test-type.py
- name: test4
properties:
test-property: test-value
resources:
- name: test4-service
type: Service
- name: test4-rc
type: ReplicationController
type: test-type.py
type: test2.jinja
`)
var validResponseTestCaseData = ExpansionResponse{
Config: string(validConfigTestCaseData),
Layout: string(validLayoutTestCaseData),
}
var roundTripContent = `
config:
resources:
- name: test
type: test
properties:
test: test
`
var roundTripExpanded = `
resources:
- name: test2
type: test2
properties:
test: test
`
var roundTripLayout = `
resources:
- name: test
type: test
properties:
test: test
resources:
- name: test2
type: test2
properties:
test: test
`
var roundTripExpanded2 = `
resources:
- name: test3
type: Service
properties:
test: test
`
var roundTripLayout2 = `
resources:
- name: test2
type: test2
properties:
test: test
resources:
- name: test3
type: Service
properties:
test: test
`
var finalExpanded = `
config:
resources:
- name: test3
type: Service
properties:
test: test
layout:
resources:
- name: test
type: test
properties:
test: test
resources:
- name: test2
type: test2
properties:
test: test
resources:
- name: test3
type: Service
properties:
test: test
`
var roundTripTemplate = Template{
Name: "TestTemplate",
Content: roundTripContent,
Imports: nil,
}
type ExpanderTestCase struct {
Description string
Error string
Handler func(w http.ResponseWriter, r *http.Request)
Resolver TypeResolver
ValidResponse *ExpandedTemplate
}
func TestExpandTemplate(t *testing.T) {
roundTripResponse := &ExpandedTemplate{}
if err := yaml.Unmarshal([]byte(finalExpanded), roundTripResponse); err != nil {
panic(err)
}
tests := []ExpanderTestCase{
{
"expect success for ExpandTemplate",
"",
expanderSuccessHandler,
&mockResolver{},
getValidResponse(t, "expect success for ExpandTemplate"),
},
{
"expect error for ExpandTemplate",
"cannot expand template",
expanderErrorHandler,
&mockResolver{},
nil,
},
{
"expect success for ExpandTemplate with two expansions",
"",
roundTripHandler,
&mockResolver{[][]*ImportFile{
{},
{&ImportFile{Name: "test"}},
}, t},
roundTripResponse,
},
}
for _, etc := range tests {
ts := httptest.NewServer(http.HandlerFunc(etc.Handler))
defer ts.Close()
expander := NewExpander(ts.URL, etc.Resolver)
actualResponse, err := expander.ExpandTemplate(validTemplateTestCaseData)
if err != nil {
message := err.Error()
if etc.Error == "" {
t.Errorf("Error in test case %s when there should not be.", etc.Description)
}
if !strings.Contains(message, etc.Error) {
t.Errorf("error in test case:%s:%s\n", etc.Description, message)
}
} else {
if etc.Error != "" {
t.Errorf("expected error:%s\ndid not occur in test case:%s\n",
etc.Error, etc.Description)
}
expectedResponse := etc.ValidResponse
if !reflect.DeepEqual(expectedResponse, actualResponse) {
t.Errorf("error in test case:%s:\nwant:%s\nhave:%s\n",
etc.Description, util.ToYAMLOrError(expectedResponse), util.ToYAMLOrError(actualResponse))
}
}
}
}
func getValidResponse(t *testing.T, description string) *ExpandedTemplate {
response, err := validResponseTestCaseData.Unmarshal()
if err != nil {
t.Errorf("cannot unmarshal valid response for test case '%s': %s\n", description, err)
}
return response
}
func expanderErrorHandler(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
http.Error(w, "something failed", http.StatusInternalServerError)
}
var roundTripResponse = ExpansionResponse{
Config: roundTripExpanded,
Layout: roundTripLayout,
}
var roundTripResponse2 = ExpansionResponse{
Config: roundTripExpanded2,
Layout: roundTripLayout2,
}
var roundTripResponses = []ExpansionResponse{
roundTripResponse,
roundTripResponse2,
}
func roundTripHandler(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
handler := "expandybird: expand"
util.LogHandlerEntry(handler, r)
util.LogHandlerExitWithJSON(handler, w, roundTripResponses[0], http.StatusOK)
roundTripResponses = roundTripResponses[1:]
}
func expanderSuccessHandler(w http.ResponseWriter, r *http.Request) {
handler := "expandybird: expand"
util.LogHandlerEntry(handler, r)
defer r.Body.Close()
body, err := ioutil.ReadAll(r.Body)
if err != nil {
status := fmt.Sprintf("cannot read request body:%s", err)
http.Error(w, status, http.StatusInternalServerError)
return
}
template := &Template{}
if err := json.Unmarshal(body, template); err != nil {
status := fmt.Sprintf("cannot unmarshal request body:%s\n%s\n", err, body)
http.Error(w, status, http.StatusInternalServerError)
return
}
if !reflect.DeepEqual(validTemplateTestCaseData, *template) {
status := fmt.Sprintf("error in http handler:\nwant:%s\nhave:%s\n",
util.ToJSONOrError(validTemplateTestCaseData), util.ToJSONOrError(template))
http.Error(w, status, http.StatusInternalServerError)
return
}
util.LogHandlerExitWithJSON(handler, w, validResponseTestCaseData, http.StatusOK)
}

@ -0,0 +1,255 @@
/*
Copyright 2015 The Kubernetes Authors All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package manager
import (
"fmt"
"log"
"time"
)
// Manager manages a persistent set of Deployments.
type Manager interface {
ListDeployments() ([]Deployment, error)
GetDeployment(name string) (*Deployment, error)
CreateDeployment(t *Template) (*Deployment, error)
DeleteDeployment(name string, forget bool) (*Deployment, error)
PutDeployment(name string, t *Template) (*Deployment, error)
ListManifests(deploymentName string) (map[string]*Manifest, error)
GetManifest(deploymentName string, manifest string) (*Manifest, error)
ListTypes() []string
ListInstances(typeName string) []*TypeInstance
}
type manager struct {
expander Expander
deployer Deployer
repository Repository
}
// NewManager returns a new initialized Manager.
func NewManager(expander Expander, deployer Deployer, repository Repository) Manager {
return &manager{expander, deployer, repository}
}
// ListDeployments returns the list of deployments
func (m *manager) ListDeployments() ([]Deployment, error) {
l, err := m.repository.ListDeployments()
if err != nil {
return nil, err
}
return l, nil
}
// GetDeployment retrieves the configuration stored for a given deployment
// as well as the current configuration from the cluster.
func (m *manager) GetDeployment(name string) (*Deployment, error) {
d, err := m.repository.GetDeployment(name)
if err != nil {
return nil, err
}
latest := getLatestManifest(d.Manifests)
if latest != nil {
d.Current = latest.ExpandedConfig
}
return d, nil
}
// ListManifests retrieves the manifests for a given deployment
// of each of the deployments in the repository and returns the deployments.
func (m *manager) ListManifests(deploymentName string) (map[string]*Manifest, error) {
l, err := m.repository.ListManifests(deploymentName)
if err != nil {
return nil, err
}
return l, nil
}
// GetManifest retrieves the specified manifest for a given deployment
func (m *manager) GetManifest(deploymentName string, manifestName string) (*Manifest, error) {
d, err := m.repository.GetManifest(deploymentName, manifestName)
if err != nil {
return nil, err
}
return d, nil
}
// CreateDeployment expands the supplied template, creates the resulting
// configuration in the cluster, creates a new deployment that tracks it,
// and stores the deployment in the repository. Returns the deployment.
func (m *manager) CreateDeployment(t *Template) (*Deployment, error) {
log.Printf("Creating deployment: %s", t.Name)
et, err := m.expander.ExpandTemplate(*t)
if err != nil {
log.Printf("Expansion failed %v", err)
return nil, err
}
_, err = m.repository.CreateDeployment(t.Name)
if err != nil {
log.Printf("CreateDeployment failed %v", err)
return nil, err
}
manifest := NewManifest(t.Name, generateManifestName())
manifest.InputConfig = t
manifest.ExpandedConfig = et.Config
manifest.Layout = et.Layout
err = m.repository.AddManifest(t.Name, manifest)
if err != nil {
log.Printf("AddManifest failed %v", err)
return nil, err
}
// TODO: Mark this as failed instead of deleting.
if err := m.deployer.CreateConfiguration(et.Config); err != nil {
m.repository.DeleteDeployment(t.Name, true)
return nil, err
}
// Finally update the type instances for this deployment.
m.addTypeInstances(t.Name, manifest.Name, manifest.Layout)
return m.repository.GetValidDeployment(t.Name)
}
func (m *manager) addTypeInstances(deploymentName string, manifestName string, layout *Layout) {
m.repository.ClearTypeInstances(deploymentName)
instances := make(map[string][]*TypeInstance)
for i, r := range layout.Resources {
addTypeInstances(&instances, r, deploymentName, manifestName, fmt.Sprintf("$.resources[%d]", i))
}
m.repository.SetTypeInstances(deploymentName, instances)
}
func addTypeInstances(instances *map[string][]*TypeInstance, r *LayoutResource, deploymentName string, manifestName string, jsonPath string) {
// Add this resource.
inst := &TypeInstance{
Name: r.Name,
Type: r.Type,
Deployment: deploymentName,
Manifest: manifestName,
Path: jsonPath,
}
(*instances)[r.Type] = append((*instances)[r.Type], inst)
// Add all sub resources if they exist.
for i, sr := range r.Resources {
addTypeInstances(instances, sr, deploymentName, manifestName, fmt.Sprintf("%s.resources[%d]", jsonPath, i))
}
}
// DeleteDeployment deletes the configuration for the deployment with
// the supplied identifier from the cluster.repository. If forget is true, then
// the deployment is removed from the repository. Otherwise, it is marked
// as deleted and retained.
func (m *manager) DeleteDeployment(name string, forget bool) (*Deployment, error) {
log.Printf("Deleting deployment: %s", name)
d, err := m.repository.GetValidDeployment(name)
if err != nil {
return nil, err
}
// If there's a latest manifest, delete the underlying resources.
latest := getLatestManifest(d.Manifests)
if latest != nil {
log.Printf("Deleting resources from the latest manifest")
if err := m.deployer.DeleteConfiguration(latest.ExpandedConfig); err != nil {
log.Printf("Failed to delete resources from the latest manifest: %v", err)
return nil, err
}
// Create an empty manifest since resources have been deleted.
err = m.repository.AddManifest(name, NewManifest(name, generateManifestName()))
if err != nil {
log.Printf("Failed to add empty manifest")
return nil, err
}
}
d, err = m.repository.DeleteDeployment(name, forget)
if err != nil {
return nil, err
}
// Finally remove the type instances for this deployment.
m.repository.ClearTypeInstances(name)
return d, nil
}
// PutDeployment replaces the configuration of the deployment with
// the supplied identifier in the cluster, and returns the deployment.
func (m *manager) PutDeployment(name string, t *Template) (*Deployment, error) {
_, err := m.repository.GetValidDeployment(name)
if err != nil {
return nil, err
}
// TODO(bmelville): This should just return a new manifest filled in.
et, err := m.expander.ExpandTemplate(*t)
if err != nil {
return nil, err
}
if err := m.deployer.PutConfiguration(et.Config); err != nil {
return nil, err
}
manifest := NewManifest(t.Name, generateManifestName())
manifest.InputConfig = t
manifest.ExpandedConfig = et.Config
manifest.Layout = et.Layout
err = m.repository.AddManifest(t.Name, manifest)
if err != nil {
return nil, err
}
// Finally update the type instances for this deployment.
m.addTypeInstances(t.Name, manifest.Name, manifest.Layout)
return m.repository.GetValidDeployment(t.Name)
}
func (m *manager) ListTypes() []string {
return m.repository.ListTypes()
}
func (m *manager) ListInstances(typeName string) []*TypeInstance {
return m.repository.GetTypeInstances(typeName)
}
func generateManifestName() string {
return fmt.Sprintf("manifest-%d", time.Now().UTC().UnixNano())
}
// Given a map of manifests, finds the largest time stamp, hence probably the latest manifest.
// This is a hack until we get a real story for storage.
func getLatestManifest(l map[string]*Manifest) *Manifest {
var latest = 0
var ret *Manifest
for k, v := range l {
var i = 0
fmt.Sscanf(k, "manifest-%d", &i)
if i > latest {
latest = i
ret = v
}
}
return ret
}

@ -0,0 +1,400 @@
/*
Copyright 2015 The Kubernetes Authors All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package manager
import (
"errors"
"reflect"
"strings"
"testing"
)
var template = Template{Name: "test", Content: "test"}
var layout = Layout{
Resources: []*LayoutResource{&LayoutResource{Resource: Resource{Name: "test", Type: "test"}}},
}
var configuration = Configuration{
Resources: []*Resource{&Resource{Name: "test", Type: "test"}},
}
var expandedConfig = ExpandedTemplate{
Config: &configuration,
Layout: &layout,
}
var deploymentName = "deployment"
var deploymentNoManifestName = "deploymentNoManifest"
var manifestName = "manifest-2"
var manifest = Manifest{Name: manifestName, ExpandedConfig: &configuration, Layout: &layout}
var manifestMap = map[string]*Manifest{manifest.Name: &manifest}
var deploymentNoManifest = Deployment{
Name: "test",
}
var deployment = Deployment{
Name: "test",
Manifests: manifestMap,
}
var deploymentWithConfiguration = Deployment{
Name: "test",
Manifests: manifestMap,
Current: &configuration,
}
var deploymentList = []Deployment{deployment, {Name: "test2"}}
var typeInstMap = map[string][]string{"test": []string{"test"}}
var errTest = errors.New("test")
type expanderStub struct{}
func (expander *expanderStub) ExpandTemplate(t Template) (*ExpandedTemplate, error) {
if reflect.DeepEqual(t, template) {
return &expandedConfig, nil
}
return nil, errTest
}
type deployerStub struct {
FailCreate bool
Created []*Configuration
FailDelete bool
Deleted []*Configuration
}
func (deployer *deployerStub) reset() {
deployer.FailCreate = false
deployer.Created = make([]*Configuration, 0)
deployer.FailDelete = false
deployer.Deleted = make([]*Configuration, 0)
}
func newDeployerStub() *deployerStub {
ret := &deployerStub{}
return ret
}
func (deployer *deployerStub) GetConfiguration(cached *Configuration) (*Configuration, error) {
return nil, nil
}
func (deployer *deployerStub) CreateConfiguration(configuration *Configuration) error {
if deployer.FailCreate {
return errTest
}
deployer.Created = append(deployer.Created, configuration)
return nil
}
func (deployer *deployerStub) DeleteConfiguration(configuration *Configuration) error {
if deployer.FailDelete {
return errTest
}
deployer.Deleted = append(deployer.Deleted, configuration)
return nil
}
func (deployer *deployerStub) PutConfiguration(configuration *Configuration) error {
return nil
}
type repositoryStub struct {
FailListDeployments bool
Created []string
ManifestAdd map[string]*Manifest
Deleted []string
GetValid []string
TypeInstances map[string][]string
TypeInstancesCleared bool
GetTypeInstancesCalled bool
ListTypesCalled bool
}
func (repository *repositoryStub) reset() {
repository.FailListDeployments = false
repository.Created = make([]string, 0)
repository.ManifestAdd = make(map[string]*Manifest)
repository.Deleted = make([]string, 0)
repository.GetValid = make([]string, 0)
repository.TypeInstances = make(map[string][]string)
repository.TypeInstancesCleared = false
repository.GetTypeInstancesCalled = false
repository.ListTypesCalled = false
}
func newRepositoryStub() *repositoryStub {
ret := &repositoryStub{}
return ret
}
func (repository *repositoryStub) ListDeployments() ([]Deployment, error) {
if repository.FailListDeployments {
return deploymentList, errTest
}
return deploymentList, nil
}
func (repository *repositoryStub) GetDeployment(d string) (*Deployment, error) {
if d == deploymentName {
return &deployment, nil
}
if d == deploymentNoManifestName {
return &deploymentNoManifest, nil
}
return nil, errTest
}
func (repository *repositoryStub) GetValidDeployment(d string) (*Deployment, error) {
repository.GetValid = append(repository.GetValid, d)
return &deploymentWithConfiguration, nil
}
func (repository *repositoryStub) CreateDeployment(d string) (*Deployment, error) {
repository.Created = append(repository.Created, d)
return &deploymentWithConfiguration, nil
}
func (repository *repositoryStub) DeleteDeployment(d string, forget bool) (*Deployment, error) {
repository.Deleted = append(repository.Deleted, d)
return &deploymentWithConfiguration, nil
}
func (repository *repositoryStub) AddManifest(d string, manifest *Manifest) error {
repository.ManifestAdd[d] = manifest
return nil
}
func (repository *repositoryStub) ListManifests(d string) (map[string]*Manifest, error) {
if d == deploymentName {
return manifestMap, nil
}
return nil, errTest
}
func (repository *repositoryStub) GetManifest(d string, m string) (*Manifest, error) {
if d == deploymentName && m == manifestName {
return &manifest, nil
}
return nil, errTest
}
func (r *repositoryStub) ListTypes() []string {
r.ListTypesCalled = true
return []string{}
}
func (r *repositoryStub) GetTypeInstances(t string) []*TypeInstance {
r.GetTypeInstancesCalled = true
return []*TypeInstance{}
}
func (r *repositoryStub) ClearTypeInstances(d string) {
r.TypeInstancesCleared = true
}
func (r *repositoryStub) SetTypeInstances(d string, is map[string][]*TypeInstance) {
for k, _ := range is {
r.TypeInstances[d] = append(r.TypeInstances[d], k)
}
}
var testExpander = &expanderStub{}
var testRepository = newRepositoryStub()
var testDeployer = newDeployerStub()
var testManager = NewManager(testExpander, testDeployer, testRepository)
func TestListDeployments(t *testing.T) {
testRepository.reset()
d, err := testManager.ListDeployments()
if !reflect.DeepEqual(d, deploymentList) || err != nil {
t.FailNow()
}
}
func TestListDeploymentsFail(t *testing.T) {
testRepository.reset()
testRepository.FailListDeployments = true
d, err := testManager.ListDeployments()
if d != nil || err != errTest {
t.FailNow()
}
}
func TestGetDeployment(t *testing.T) {
testRepository.reset()
d, err := testManager.GetDeployment(deploymentName)
if !reflect.DeepEqual(d, &deploymentWithConfiguration) || err != nil {
t.FailNow()
}
}
func TestGetDeploymentNoManifest(t *testing.T) {
testRepository.reset()
d, err := testManager.GetDeployment(deploymentNoManifestName)
if !reflect.DeepEqual(d, &deploymentNoManifest) || err != nil {
t.FailNow()
}
}
func TestListManifests(t *testing.T) {
testRepository.reset()
m, err := testManager.ListManifests(deploymentName)
if !reflect.DeepEqual(m, manifestMap) || err != nil {
t.FailNow()
}
}
func TestGetManifest(t *testing.T) {
testRepository.reset()
m, err := testManager.GetManifest(deploymentName, manifestName)
if !reflect.DeepEqual(m, &manifest) || err != nil {
t.FailNow()
}
}
func TestCreateDeployment(t *testing.T) {
testRepository.reset()
testDeployer.reset()
d, err := testManager.CreateDeployment(&template)
if !reflect.DeepEqual(d, &deploymentWithConfiguration) || err != nil {
t.Errorf("Expected a different set of response values from invoking CreateDeployment."+
"Received: %s, %s. Expected: %s, %s.", d, err, &deploymentWithConfiguration, "nil")
}
if testRepository.Created[0] != template.Name {
t.Errorf("Repository CreateDeployment was called with %s but expected %s.",
testRepository.Created[0], template.Name)
}
if !strings.HasPrefix(testRepository.ManifestAdd[template.Name].Name, "manifest-") {
t.Errorf("Repository AddManifest was called with %s but expected manifest name"+
"to begin with manifest-.", testRepository.ManifestAdd[template.Name].Name)
}
if !reflect.DeepEqual(*testDeployer.Created[0], configuration) || err != nil {
t.Errorf("Deployer CreateConfiguration was called with %s but expected %s.",
testDeployer.Created[0], configuration)
}
if !testRepository.TypeInstancesCleared {
t.Error("Repository did not clear type instances during creation")
}
if !reflect.DeepEqual(testRepository.TypeInstances, typeInstMap) {
t.Errorf("Unexpected type instances after CreateDeployment: %s", testRepository.TypeInstances)
}
}
func TestCreateDeploymentCreationFailure(t *testing.T) {
testRepository.reset()
testDeployer.reset()
testDeployer.FailCreate = true
d, err := testManager.CreateDeployment(&template)
if testRepository.Created[0] != template.Name {
t.Errorf("Repository CreateDeployment was called with %s but expected %s.",
testRepository.Created[0], template.Name)
}
if testRepository.Deleted[0] != template.Name {
t.Errorf("Repository DeleteDeployment was called with %s but expected %s.",
testRepository.Created[0], template.Name)
}
if !strings.HasPrefix(testRepository.ManifestAdd[template.Name].Name, "manifest-") {
t.Errorf("Repository AddManifest was called with %s but expected manifest name"+
"to begin with manifest-.", testRepository.ManifestAdd[template.Name].Name)
}
if err != errTest || d != nil {
t.Errorf("Expected a different set of response values from invoking CreateDeployment."+
"Received: %s, %s. Expected: %s, %s.", d, err, "nil", errTest)
}
if testRepository.TypeInstancesCleared {
t.Error("Unexpected change to type instances during CreateDeployment failure.")
}
}
func TestDeleteDeploymentForget(t *testing.T) {
testRepository.reset()
testDeployer.reset()
d, err := testManager.CreateDeployment(&template)
if !reflect.DeepEqual(d, &deploymentWithConfiguration) || err != nil {
t.Errorf("Expected a different set of response values from invoking CreateDeployment."+
"Received: %s, %s. Expected: %s, %s.", d, err, &deploymentWithConfiguration, "nil")
}
if testRepository.Created[0] != template.Name {
t.Errorf("Repository CreateDeployment was called with %s but expected %s.",
testRepository.Created[0], template.Name)
}
if !strings.HasPrefix(testRepository.ManifestAdd[template.Name].Name, "manifest-") {
t.Errorf("Repository AddManifest was called with %s but expected manifest name"+
"to begin with manifest-.", testRepository.ManifestAdd[template.Name].Name)
}
if !reflect.DeepEqual(*testDeployer.Created[0], configuration) || err != nil {
t.Errorf("Deployer CreateConfiguration was called with %s but expected %s.",
testDeployer.Created[0], configuration)
}
oldManifestName := testRepository.ManifestAdd[template.Name].Name
d, err = testManager.DeleteDeployment("test", true)
if err != nil {
t.Errorf("DeleteDeployment failed with %v", err)
}
if testRepository.ManifestAdd[template.Name].Name == oldManifestName {
t.Errorf("New manifest was not created, is still: %s", oldManifestName)
}
if testRepository.ManifestAdd[template.Name].InputConfig != nil {
t.Errorf("New manifest has non-nil config, is still: %v", testRepository.ManifestAdd[template.Name].InputConfig)
}
// Make sure the resources were deleted through deployer.
if !reflect.DeepEqual(*testDeployer.Deleted[0], configuration) || err != nil {
t.Errorf("Deployer DeleteConfiguration was called with %s but expected %s.",
testDeployer.Created[0], configuration)
}
if !testRepository.TypeInstancesCleared {
t.Error("Expected type instances to be cleared during DeleteDeployment.")
}
}
func TestListTypes(t *testing.T) {
testRepository.reset()
testManager.ListTypes()
if !testRepository.ListTypesCalled {
t.Error("expected repository ListTypes() call.")
}
}
func TestListInstances(t *testing.T) {
testRepository.reset()
testManager.ListInstances("all")
if !testRepository.GetTypeInstancesCalled {
t.Error("expected repository GetTypeInstances() call.")
}
}

@ -0,0 +1,172 @@
/*
Copyright 2015 The Kubernetes Authors All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package manager
import (
"fmt"
"net/http"
"time"
"util"
"github.com/ghodss/yaml"
)
const (
maxURLImports = 100
schemaSuffix = ".schema"
)
// TypeResolver finds Types in a Configuration which aren't yet reduceable to an import file
// or primitive, and attempts to replace them with a template from a URL.
type TypeResolver interface {
ResolveTypes(config *Configuration, imports []*ImportFile) ([]*ImportFile, error)
}
type typeResolver struct {
getter util.HTTPClient
maxUrls int
}
// NewTypeResolver returns a new initialized TypeResolver.
func NewTypeResolver() TypeResolver {
ret := &typeResolver{}
client := http.DefaultClient
//TODO (iantw): Make this a flag
timeout, _ := time.ParseDuration("10s")
client.Timeout = timeout
ret.getter = util.NewHTTPClient(3, client, util.NewSleeper())
ret.maxUrls = maxURLImports
return ret
}
func resolverError(c *Configuration, err error) error {
return fmt.Errorf("cannot resolve types in configuration %s due to: \n%s\n",
c, err)
}
func performHTTPGet(g util.HTTPClient, u string, allowMissing bool) (content string, err error) {
r, code, err := g.Get(u)
if err != nil {
return "", err
}
if allowMissing && code == http.StatusNotFound {
return "", nil
}
if code != http.StatusOK {
return "", fmt.Errorf(
"Received status code %d attempting to fetch Type at %s", code, u)
}
return r, nil
}
// ResolveTypes resolves the types in the supplied configuration and returns
// resolved type definitions in t.ImportFiles. Types can be either
// primitive (i.e., built in), resolved (i.e., already t.ImportFiles), or remote
// (i.e., described by a URL that must be fetched to resolve the type).
func (tr *typeResolver) ResolveTypes(config *Configuration, imports []*ImportFile) ([]*ImportFile, error) {
existing := map[string]bool{}
for _, v := range imports {
existing[v.Name] = true
}
fetched := map[string][]*ImportFile{}
toFetch := make([]string, 0, tr.maxUrls)
for _, r := range config.Resources {
if !Primitives[r.Type] && !existing[r.Type] {
toFetch = append(toFetch, r.Type)
fetched[r.Type] = append(fetched[r.Type], &ImportFile{Name: r.Type})
}
}
count := 0
for len(toFetch) > 0 {
//1. Fetch import URL. Exit if no URLs left
//2. Check/handle HTTP status
//3. Store results in all ImportFiles from that URL
//4. Check for the optional schema file at import URL + .schema
//5. Repeat 2,3 for schema file
//6. Add each schema import to fetch if not already done
//7. Mark URL done. Return to 1.
if count >= tr.maxUrls {
return nil, resolverError(config,
fmt.Errorf("Number of imports exceeds maximum of %d", tr.maxUrls))
}
url := toFetch[0]
template, err := performHTTPGet(tr.getter, url, false)
if err != nil {
return nil, resolverError(config, err)
}
for _, i := range fetched[url] {
i.Content = template
}
schemaURL := url + schemaSuffix
sch, err := performHTTPGet(tr.getter, schemaURL, true)
if err != nil {
return nil, resolverError(config, err)
}
if sch != "" {
var s Schema
if err := yaml.Unmarshal([]byte(sch), &s); err != nil {
return nil, resolverError(config, err)
}
// Here we handle any nested imports in the schema we've just fetched.
for _, v := range s.Imports {
i := &ImportFile{Name: v.Name}
var existingSchema string
if len(fetched[v.Path]) == 0 {
// If this import URL is new to us, add it to the URLs to fetch.
toFetch = append(toFetch, v.Path)
} else {
// If this is not a new import URL and we've already fetched its contents,
// reuse them. Also, check if we also found a schema for that import URL and
// record those contents for re-use as well.
if fetched[v.Path][0].Content != "" {
i.Content = fetched[v.Path][0].Content
if len(fetched[v.Path+schemaSuffix]) > 0 {
existingSchema = fetched[v.Path+schemaSuffix][0].Content
}
}
}
fetched[v.Path] = append(fetched[v.Path], i)
if existingSchema != "" {
fetched[v.Path+schemaSuffix] = append(fetched[v.Path+schemaSuffix],
&ImportFile{Name: v.Name + schemaSuffix, Content: existingSchema})
}
}
// Add the schema we've fetched as the schema for any templates which used this URL.
for _, i := range fetched[url] {
schemaImportName := i.Name + schemaSuffix
fetched[schemaURL] = append(fetched[schemaURL],
&ImportFile{Name: schemaImportName, Content: sch})
}
}
count = count + 1
toFetch = toFetch[1:]
}
ret := []*ImportFile{}
for _, v := range fetched {
ret = append(ret, v...)
}
return ret, nil
}

@ -0,0 +1,264 @@
/*
Copyright 2015 The Kubernetes Authors All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package manager
import (
"errors"
"net/http"
"reflect"
"strings"
"testing"
"github.com/ghodss/yaml"
)
type responseAndError struct {
err error
code int
resp string
}
type resolverTestCase struct {
config string
imports []*ImportFile
responses map[string]responseAndError
urlcount int
expectedErr error
importOut []*ImportFile
}
type testGetter struct {
responses map[string]responseAndError
count int
test *testing.T
}
func (tg *testGetter) Get(url string) (body string, code int, err error) {
tg.count = tg.count + 1
ret := tg.responses[url]
return ret.resp, ret.code, ret.err
}
func testDriver(c resolverTestCase, t *testing.T) {
g := &testGetter{test: t, responses: c.responses}
r := &typeResolver{getter: g, maxUrls: 5}
conf := &Configuration{}
dataErr := yaml.Unmarshal([]byte(c.config), conf)
if dataErr != nil {
panic("bad test data")
}
result, err := r.ResolveTypes(conf, c.imports)
if g.count != c.urlcount {
t.Errorf("Expected %d url GETs but only %d found", c.urlcount, g.count)
}
if (err != nil && c.expectedErr == nil) || (err == nil && c.expectedErr != nil) {
t.Errorf("Expected error %s but found %s", c.expectedErr, err)
} else if err != nil && !strings.Contains(err.Error(), c.expectedErr.Error()) {
t.Errorf("Expected error %s but found %s", c.expectedErr, err)
}
resultImport := map[ImportFile]bool{}
expectedImport := map[ImportFile]bool{}
for _, i := range result {
resultImport[*i] = true
}
for _, i := range c.importOut {
expectedImport[*i] = true
}
if !reflect.DeepEqual(resultImport, expectedImport) {
t.Errorf("Expected imports %+v but found %+v", expectedImport, resultImport)
}
}
var simpleContent = `
resources:
- name: test
type: ReplicationController
`
func TestNoImports(t *testing.T) {
test := resolverTestCase{config: simpleContent}
testDriver(test, t)
}
var includeImport = `
resources:
- name: foo
type: foo.py
`
func TestIncludedImport(t *testing.T) {
imports := []*ImportFile{&ImportFile{Name: "foo.py"}}
test := resolverTestCase{
config: includeImport,
imports: imports,
}
testDriver(test, t)
}
var templateSingleURL = `
resources:
- name: foo
type: my-fake-url
`
func TestSingleUrl(t *testing.T) {
finalImports := []*ImportFile{&ImportFile{Name: "my-fake-url", Content: "my-content"}}
responses := map[string]responseAndError{
"my-fake-url": responseAndError{nil, http.StatusOK, "my-content"},
"my-fake-url.schema": responseAndError{nil, http.StatusNotFound, ""},
}
test := resolverTestCase{
config: templateSingleURL,
importOut: finalImports,
urlcount: 2,
responses: responses,
}
testDriver(test, t)
}
func TestSingleUrlWith500(t *testing.T) {
responses := map[string]responseAndError{
"my-fake-url": responseAndError{nil, http.StatusInternalServerError, "my-content"},
}
test := resolverTestCase{
config: templateSingleURL,
urlcount: 1,
responses: responses,
expectedErr: errors.New("Received status code 500"),
}
testDriver(test, t)
}
var schema1 = `
imports:
- path: my-next-url
name: schema-import
`
func TestSingleUrlWithSchema(t *testing.T) {
finalImports := []*ImportFile{
&ImportFile{Name: "my-fake-url", Content: "my-content"},
&ImportFile{Name: "schema-import", Content: "schema-import"},
&ImportFile{Name: "my-fake-url.schema", Content: schema1},
}
responses := map[string]responseAndError{
"my-fake-url": responseAndError{nil, http.StatusOK, "my-content"},
"my-fake-url.schema": responseAndError{nil, http.StatusOK, schema1},
"my-next-url": responseAndError{nil, http.StatusOK, "schema-import"},
"my-next-url.schema": responseAndError{nil, http.StatusNotFound, ""},
}
test := resolverTestCase{
config: templateSingleURL,
importOut: finalImports,
urlcount: 4,
responses: responses,
}
testDriver(test, t)
}
var templateExceedsMax = `
resources:
- name: foo
type: my-fake-url
- name: foo1
type: my-fake-url1
- name: foo2
type: my-fake-url2
- name: foo3
type: my-fake-url3
- name: foo4
type: my-fake-url4
- name: foo5
type: my-fake-url5
`
func TestTooManyImports(t *testing.T) {
responses := map[string]responseAndError{
"my-fake-url": responseAndError{nil, http.StatusOK, "my-content"},
"my-fake-url.schema": responseAndError{nil, http.StatusNotFound, ""},
"my-fake-url1": responseAndError{nil, http.StatusOK, "my-content"},
"my-fake-url1.schema": responseAndError{nil, http.StatusNotFound, ""},
"my-fake-url2": responseAndError{nil, http.StatusOK, "my-content"},
"my-fake-url2.schema": responseAndError{nil, http.StatusNotFound, ""},
"my-fake-url3": responseAndError{nil, http.StatusOK, "my-content"},
"my-fake-url3.schema": responseAndError{nil, http.StatusNotFound, ""},
"my-fake-url4": responseAndError{nil, http.StatusOK, "my-content"},
"my-fake-url4.schema": responseAndError{nil, http.StatusNotFound, ""},
"my-fake-url5": responseAndError{nil, http.StatusOK, "my-content"},
"my-fake-url5.schema": responseAndError{nil, http.StatusNotFound, ""},
}
test := resolverTestCase{
config: templateExceedsMax,
urlcount: 10,
responses: responses,
expectedErr: errors.New("Number of imports exceeds maximum of 5"),
}
testDriver(test, t)
}
var templateSharesImport = `
resources:
- name: foo
type: my-fake-url
- name: foo1
type: my-fake-url1
`
var schema2 = `
imports:
- path: my-next-url
name: schema-import-1
`
func TestSharedImport(t *testing.T) {
finalImports := []*ImportFile{
&ImportFile{Name: "my-fake-url", Content: "my-content"},
&ImportFile{Name: "my-fake-url1", Content: "my-content-1"},
&ImportFile{Name: "schema-import", Content: "schema-import"},
&ImportFile{Name: "schema-import-1", Content: "schema-import"},
&ImportFile{Name: "my-fake-url.schema", Content: schema1},
&ImportFile{Name: "my-fake-url1.schema", Content: schema2},
}
responses := map[string]responseAndError{
"my-fake-url": responseAndError{nil, http.StatusOK, "my-content"},
"my-fake-url.schema": responseAndError{nil, http.StatusOK, schema1},
"my-fake-url1": responseAndError{nil, http.StatusOK, "my-content-1"},
"my-fake-url1.schema": responseAndError{nil, http.StatusOK, schema2},
"my-next-url": responseAndError{nil, http.StatusOK, "schema-import"},
"my-next-url.schema": responseAndError{nil, http.StatusNotFound, ""},
}
test := resolverTestCase{
config: templateSharesImport,
urlcount: 6,
responses: responses,
importOut: finalImports,
}
testDriver(test, t)
}

@ -0,0 +1,165 @@
/*
Copyright 2015 The Kubernetes Authors All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package manager
import (
"time"
)
// This map defines the primitives that DM knows how to handle implicitly.
// TODO (iantw): Make these come from the resourcifier(?). Add more as appropriate...
var Primitives = map[string]bool{
"Pod": true,
"ReplicationController": true,
"Service": true,
"Namespace": true,
"Volume": true,
}
// SchemaImport represents an import as declared in a schema file.
type SchemaImport struct {
Path string `json:"path"`
Name string `json:"name"`
}
// Schema is a partial DM schema. We only need access to the imports object at this level.
type Schema struct {
Imports []SchemaImport `json:"imports"`
}
// Repository manages storage for all Deployment Manager entities, as well as
// the common operations to store, access and manage them.
type Repository interface {
// Deployments.
ListDeployments() ([]Deployment, error)
GetDeployment(name string) (*Deployment, error)
GetValidDeployment(name string) (*Deployment, error)
CreateDeployment(name string) (*Deployment, error)
DeleteDeployment(name string, forget bool) (*Deployment, error)
// Manifests.
AddManifest(deploymentName string, manifest *Manifest) error
ListManifests(deploymentName string) (map[string]*Manifest, error)
GetManifest(deploymentName string, manifestName string) (*Manifest, error)
// Types.
ListTypes() []string
GetTypeInstances(typeName string) []*TypeInstance
ClearTypeInstances(deploymentName string)
SetTypeInstances(deploymentName string, instances map[string][]*TypeInstance)
}
// Deployment defines a deployment that describes
// the creation, modification and/or deletion of a set of resources.
type Deployment struct {
Name string `json:"name"`
ID int `json:"id"`
CreatedAt time.Time `json:"createdAt,omitempty"`
DeployedAt time.Time `json:"deployedAt,omitempty"`
ModifiedAt time.Time `json:"modifiedAt,omitempty"`
DeletedAt time.Time `json:"deletedAt,omitempty"`
Status deploymentStatus `json:"status,omitempty"`
Current *Configuration `json:"current,omitEmpty"`
Manifests map[string]*Manifest `json:"manifests,omitempty"`
}
// NewDeployment creates a new deployment.
func NewDeployment(name string, id int) *Deployment {
return &Deployment{Name: name, ID: id, CreatedAt: time.Now(), Status: CreatedStatus,
Manifests: make(map[string]*Manifest, 0)}
}
// NewManifest creates a new manifest.
func NewManifest(deploymentName string, manifestName string) *Manifest {
return &Manifest{Deployment: deploymentName, Name: manifestName}
}
// deploymentStatus is an enumeration type for the status of a deployment.
type deploymentStatus string
// These constants implement the deploymentStatus enumeration type.
const (
CreatedStatus deploymentStatus = "Created"
DeletedStatus deploymentStatus = "Deleted"
DeployedStatus deploymentStatus = "Deployed"
FailedStatus deploymentStatus = "Failed"
ModifiedStatus deploymentStatus = "Modified"
)
func (s deploymentStatus) String() string {
return string(s)
}
// LayoutResource defines the structure of resources in the manifest layout.
type LayoutResource struct {
Resource
Layout
}
// Layout defines the structure of a layout as returned from expansion.
type Layout struct {
Resources []*LayoutResource `json:"resources,omitempty"`
}
// Manifest contains the input configuration for a deployment, the fully
// expanded configuration, and the layout structure of the manifest.
//
type Manifest struct {
Deployment string `json:"deployment"`
Name string `json:"name"`
InputConfig *Template `json:"inputConfig"`
ExpandedConfig *Configuration `json:"expandedConfig,omitempty"`
Layout *Layout `json:"layout,omitempty"`
}
// Template describes a set of resources to be deployed.
// Manager expands a Template into a Configuration, which
// describes the set in a form that can be instantiated.
type Template struct {
Name string `json:"name"`
Content string `json:"content"`
Imports []*ImportFile `json:"imports"`
}
// ImportFile describes a base64 encoded file imported by a Template.
type ImportFile struct {
Name string `json:"name,omitempty"`
Content string `json:"content"`
}
// Configuration describes a set of resources in a form
// that can be instantiated.
type Configuration struct {
Resources []*Resource `json:"resources"`
}
// Resource describes a resource in a configuration. A resource has
// a name, a type and a set of properties. The name and type are used
// to identify the resource in Kubernetes. The properties are passed
// to Kubernetes as the resource configuration.
type Resource struct {
Name string `json:"name"`
Type string `json:"type"`
Properties map[string]interface{} `json:"properties,omitempty"`
}
// TypeInstance defines the metadata for an instantiation of a template type
// in a deployment.
type TypeInstance struct {
Name string `json:"name"` // instance name
Type string `json:"type"` // instance type
Deployment string `json:"deployment"` // deployment name
Manifest string `json:"manifest"` // manifest name
Path string `json:"path"` // JSON path within manifest
}

@ -0,0 +1,248 @@
/*
Copyright 2015 The Kubernetes Authors All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Package repository implements a deployment repository using a map.
// It can be easily replaced by a deployment repository that uses some
// form of persistent storage.
package repository
import (
"fmt"
"log"
"manager/manager"
"sync"
"time"
)
// deploymentTypeInstanceMap stores type instances mapped by deployment name.
// This allows for simple updating and deleting of per-deployment instances
// when deployments are created/updated/deleted.
type deploymentTypeInstanceMap map[string][]*manager.TypeInstance
type typeInstanceMap map[string]deploymentTypeInstanceMap
type mapBasedRepository struct {
sync.RWMutex
deployments map[string]manager.Deployment
instances typeInstanceMap
lastID int
}
// NewMapBasedRepository returns a new map based repository.
func NewMapBasedRepository() manager.Repository {
return &mapBasedRepository{
deployments: make(map[string]manager.Deployment, 0),
instances: typeInstanceMap{},
}
}
// ListDeployments returns of all of the deployments in the repository.
func (r *mapBasedRepository) ListDeployments() ([]manager.Deployment, error) {
r.RLock()
defer r.RUnlock()
l := []manager.Deployment{}
for _, deployment := range r.deployments {
l = append(l, deployment)
}
return l, nil
}
// GetDeployment returns the deployment with the supplied name.
// If the deployment is not found, it returns an error.
func (r *mapBasedRepository) GetDeployment(name string) (*manager.Deployment, error) {
d, ok := r.deployments[name]
if !ok {
return nil, fmt.Errorf("deployment %s not found", name)
}
return &d, nil
}
// GetValidDeployment returns the deployment with the supplied name.
// If the deployment is not found or marked as deleted, it returns an error.
func (r *mapBasedRepository) GetValidDeployment(name string) (*manager.Deployment, error) {
d, err := r.GetDeployment(name)
if err != nil {
return nil, err
}
if d.Status == manager.DeletedStatus {
return nil, fmt.Errorf("deployment %s is deleted", name)
}
return d, nil
}
// CreateDeployment creates a new deployment and stores it in the repository.
func (r *mapBasedRepository) CreateDeployment(name string) (*manager.Deployment, error) {
d, err := func() (*manager.Deployment, error) {
r.Lock()
defer r.Unlock()
exists, _ := r.GetValidDeployment(name)
if exists != nil {
return nil, fmt.Errorf("Deployment %s already exists", name)
}
r.lastID++
d := manager.NewDeployment(name, r.lastID)
d.Status = manager.CreatedStatus
d.DeployedAt = time.Now()
r.deployments[name] = *d
return d, nil
}()
if err != nil {
return nil, err
}
log.Printf("created deployment: %v", d)
return d, nil
}
func (r *mapBasedRepository) AddManifest(deploymentName string, manifest *manager.Manifest) error {
err := func() error {
r.Lock()
defer r.Unlock()
d, err := r.GetValidDeployment(deploymentName)
if err != nil {
return err
}
// Make sure the manifest doesn't already exist, and if not, add the manifest to
// map of manifests this deployment has
if _, ok := d.Manifests[manifest.Name]; ok {
return fmt.Errorf("Manifest %s already exists in deployment %s", manifest.Name, deploymentName)
}
d.Manifests[manifest.Name] = manifest
r.deployments[deploymentName] = *d
return nil
}()
if err != nil {
return err
}
log.Printf("Added manifest %s to deployment: %s", manifest.Name, deploymentName)
return nil
}
// DeleteDeployment deletes the deployment with the supplied name.
// If forget is true, then the deployment is removed from the repository.
// Otherwise, it is marked as deleted and retained.
func (r *mapBasedRepository) DeleteDeployment(name string, forget bool) (*manager.Deployment, error) {
d, err := func() (*manager.Deployment, error) {
r.Lock()
defer r.Unlock()
d, err := r.GetValidDeployment(name)
if err != nil {
return nil, err
}
if !forget {
d.DeletedAt = time.Now()
d.Status = manager.DeletedStatus
r.deployments[name] = *d
} else {
delete(r.deployments, name)
}
return d, nil
}()
if err != nil {
return nil, err
}
log.Printf("deleted deployment: %v", d)
return d, nil
}
func (r *mapBasedRepository) ListManifests(deploymentName string) (map[string]*manager.Manifest, error) {
d, err := r.GetValidDeployment(deploymentName)
if err != nil {
return nil, err
}
return d.Manifests, nil
}
func (r *mapBasedRepository) GetManifest(deploymentName string, manifestName string) (*manager.Manifest, error) {
d, err := r.GetValidDeployment(deploymentName)
if err != nil {
return nil, err
}
if m, ok := d.Manifests[manifestName]; ok {
return m, nil
}
return nil, fmt.Errorf("manifest %s not found in deployment %s", manifestName, deploymentName)
}
// ListTypes returns all types known from existing instances.
func (r *mapBasedRepository) ListTypes() []string {
var keys []string
for k := range r.instances {
keys = append(keys, k)
}
return keys
}
// GetTypeInstances returns all instances of a given type. If type is empty,
// returns all instances for all types.
func (r *mapBasedRepository) GetTypeInstances(typeName string) []*manager.TypeInstance {
r.Lock()
defer r.Unlock()
var instances []*manager.TypeInstance
for t, dInstMap := range r.instances {
if t == typeName || typeName == "all" {
for _, i := range dInstMap {
instances = append(instances, i...)
}
}
}
return instances
}
// ClearTypeInstances deletes all instances associated with the given
// deployment name from the type instance repository.
func (r *mapBasedRepository) ClearTypeInstances(deploymentName string) {
r.Lock()
defer r.Unlock()
for t, dMap := range r.instances {
delete(dMap, deploymentName)
if len(dMap) == 0 {
delete(r.instances, t)
}
}
}
// SetTypeInstances sets all type instances for a given deployment name.
//
// To clear the current set of instances first, caller should first use
// ClearTypeInstances().
func (r *mapBasedRepository) SetTypeInstances(deploymentName string, instances map[string][]*manager.TypeInstance) {
r.Lock()
defer r.Unlock()
// Add each instance list to the appropriate type map.
for t, is := range instances {
if r.instances[t] == nil {
r.instances[t] = make(deploymentTypeInstanceMap)
}
r.instances[t][deploymentName] = is
}
}

@ -0,0 +1,332 @@
/*
Copyright 2015 The Kubernetes Authors All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package repository
import (
"manager/manager"
"testing"
)
func TestRepositoryListEmpty(t *testing.T) {
r := NewMapBasedRepository()
d, err := r.ListDeployments()
if err != nil {
t.Fatal("List Deployments failed")
}
if len(d) != 0 {
t.Fatal("Returned non zero list")
}
}
func TestRepositoryGetFailsWithNonExistentDeployment(t *testing.T) {
r := NewMapBasedRepository()
_, err := r.GetDeployment("nothere")
if err == nil {
t.Fatal("GetDeployment didn't fail with non-existent deployment")
}
if err.Error() != "deployment nothere not found" {
t.Fatal("Error message doesn't match")
}
}
func TestRepositoryCreateDeploymentWorks(t *testing.T) {
var deploymentName = "mydeployment"
var manifestName = "manifest-0"
r := NewMapBasedRepository()
manifest := manager.Manifest{Deployment: deploymentName, Name: manifestName}
d, err := r.CreateDeployment(deploymentName)
if err != nil {
t.Fatalf("CreateDeployment failed: %v", err)
}
l, err := r.ListDeployments()
if err != nil {
t.Fatalf("ListDeployments failed: %v", err)
}
if len(l) != 1 {
t.Fatalf("List of deployments is not 1: %d", len(l))
}
dNew, err := r.GetDeployment(deploymentName)
if err != nil {
t.Fatalf("GetDeployment failed: %v", err)
}
if dNew.Name != d.Name {
t.Fatalf("Deployment Names don't match, got: %v, expected %v", dNew, d)
}
if len(dNew.Manifests) != 0 {
t.Fatalf("Deployment has non-zero manifest count: %d", len(dNew.Manifests))
}
err = r.AddManifest(deploymentName, &manifest)
if err != nil {
t.Fatalf("AddManifest failed: %v", err)
}
dNew, err = r.GetDeployment(deploymentName)
if err != nil {
t.Fatalf("GetDeployment failed: %v", err)
}
if len(dNew.Manifests) != 1 {
t.Fatalf("Fetched deployment does not have manifest count of 1: %d", len(dNew.Manifests))
}
manifestList, err := r.ListManifests(deploymentName)
if err != nil {
t.Fatalf("ListManifests failed: %v", err)
}
if len(manifestList) != 1 {
t.Fatalf("ManifestList does not have manifest count of 1: %d", len(manifestList))
}
m, err := r.GetManifest(deploymentName, manifestName)
if err != nil {
t.Fatalf("GetManifest failed: %v", err)
}
if m.Name != manifestName {
t.Fatalf("manifest name doesn't match: %v", m)
}
}
func TestRepositoryMultipleManifestsWorks(t *testing.T) {
var deploymentName = "mydeployment"
var manifestName = "manifest-0"
var manifestName2 = "manifest-1"
r := NewMapBasedRepository()
manifest := manager.Manifest{Deployment: deploymentName, Name: manifestName}
manifest2 := manager.Manifest{Deployment: deploymentName, Name: manifestName2}
d, err := r.CreateDeployment(deploymentName)
if err != nil {
t.Fatalf("CreateDeployment failed: %v", err)
}
dNew, err := r.GetDeployment(deploymentName)
if err != nil {
t.Fatalf("GetDeployment failed: %v", err)
}
if dNew.Name != d.Name {
t.Fatalf("Deployment Names don't match, got: %v, expected %v", dNew, d)
}
if len(dNew.Manifests) != 0 {
t.Fatalf("Deployment has non-zero manifest count: %d", len(dNew.Manifests))
}
err = r.AddManifest(deploymentName, &manifest)
if err != nil {
t.Fatalf("AddManifest failed: %v", err)
}
dNew, err = r.GetDeployment(deploymentName)
if err != nil {
t.Fatalf("GetDeployment failed: %v", err)
}
if len(dNew.Manifests) != 1 {
t.Fatalf("Fetched deployment does not have manifest count of 1: %d", len(dNew.Manifests))
}
manifestList, err := r.ListManifests(deploymentName)
if err != nil {
t.Fatalf("ListManifests failed: %v", err)
}
if len(manifestList) != 1 {
t.Fatalf("ManifestList does not have manifest count of 1: %d", len(manifestList))
}
m, err := r.GetManifest(deploymentName, manifestName)
if err != nil {
t.Fatalf("GetManifest failed: %v", err)
}
if m.Name != manifestName {
t.Fatalf("manifest name doesn't match: %v", m)
}
err = r.AddManifest(deploymentName, &manifest2)
if err != nil {
t.Fatalf("AddManifest failed: %v", err)
}
dNew, err = r.GetDeployment(deploymentName)
if err != nil {
t.Fatalf("GetDeployment failed: %v", err)
}
if len(dNew.Manifests) != 2 {
t.Fatalf("Fetched deployment does not have manifest count of 2: %d", len(dNew.Manifests))
}
manifestList, err = r.ListManifests(deploymentName)
if err != nil {
t.Fatalf("ListManifests failed: %v", err)
}
if len(manifestList) != 2 {
t.Fatalf("ManifestList does not have manifest count of 1: %d", len(manifestList))
}
m, err = r.GetManifest(deploymentName, manifestName)
if err != nil {
t.Fatalf("GetManifest failed: %v", err)
}
if m.Name != manifestName {
t.Fatalf("manifest name doesn't match: %v", m)
}
m, err = r.GetManifest(deploymentName, manifestName2)
if err != nil {
t.Fatalf("GetManifest failed: %v", err)
}
if m.Name != manifestName2 {
t.Fatalf("manifest name doesn't match: %v", m)
}
}
func TestRepositoryDeleteFailsWithNonExistentDeployment(t *testing.T) {
var deploymentName = "mydeployment"
r := NewMapBasedRepository()
d, err := r.DeleteDeployment(deploymentName, false)
if err == nil {
t.Fatalf("DeleteDeployment didn't fail with non existent deployment")
}
if d != nil {
t.Fatalf("DeleteDeployment returned non-nil for non existent deployment")
}
}
func TestRepositoryDeleteWorksWithNoLatestManifest(t *testing.T) {
var deploymentName = "mydeployment"
r := NewMapBasedRepository()
_, err := r.CreateDeployment(deploymentName)
if err != nil {
t.Fatalf("CreateDeployment failed: %v", err)
}
dDeleted, err := r.DeleteDeployment(deploymentName, false)
if err != nil {
t.Fatalf("DeleteDeployment failed: %v", err)
}
if dDeleted.Status != manager.DeletedStatus {
t.Fatalf("Deployment Status is not deleted")
}
if len(dDeleted.Manifests) != 0 {
t.Fatalf("manifests count is not 0, is: %d", len(dDeleted.Manifests))
}
}
func TestRepositoryDeleteDeploymentWorksNoForget(t *testing.T) {
var deploymentName = "mydeployment"
var manifestName = "manifest-0"
r := NewMapBasedRepository()
manifest := manager.Manifest{Deployment: deploymentName, Name: manifestName}
_, err := r.CreateDeployment(deploymentName)
if err != nil {
t.Fatalf("CreateDeployment failed: %v", err)
}
err = r.AddManifest(deploymentName, &manifest)
if err != nil {
t.Fatalf("AddManifest failed: %v", err)
}
dDeleted, err := r.DeleteDeployment(deploymentName, false)
if err != nil {
t.Fatalf("DeleteDeployment failed: %v", err)
}
if dDeleted.Status != manager.DeletedStatus {
t.Fatalf("Deployment Status is not deleted")
}
}
func TestRepositoryDeleteDeploymentWorksForget(t *testing.T) {
var deploymentName = "mydeployment"
var manifestName = "manifest-0"
r := NewMapBasedRepository()
manifest := manager.Manifest{Deployment: deploymentName, Name: manifestName}
_, err := r.CreateDeployment(deploymentName)
if err != nil {
t.Fatalf("CreateDeployment failed: %v", err)
}
err = r.AddManifest(deploymentName, &manifest)
if err != nil {
t.Fatalf("AddManifest failed: %v", err)
}
dDeleted, err := r.DeleteDeployment(deploymentName, true)
if err != nil {
t.Fatalf("DeleteDeployment failed: %v", err)
}
if dDeleted.Status != manager.CreatedStatus {
t.Fatalf("Deployment Status is not created")
}
}
func TestRepositoryTypeInstances(t *testing.T) {
r := NewMapBasedRepository()
d1Map := map[string][]*manager.TypeInstance{
"t1": []*manager.TypeInstance{
&manager.TypeInstance{
Name: "i1",
Type: "t1",
Deployment: "d1",
Manifest: "m1",
Path: "p1",
},
},
}
d2Map := map[string][]*manager.TypeInstance{
"t2": []*manager.TypeInstance{
&manager.TypeInstance{
Name: "i2",
Type: "t2",
Deployment: "d2",
Manifest: "m2",
Path: "p2",
},
},
}
d3Map := map[string][]*manager.TypeInstance{
"t2": []*manager.TypeInstance{
&manager.TypeInstance{
Name: "i3",
Type: "t2",
Deployment: "d3",
Manifest: "m3",
Path: "p3",
},
},
}
if instances := r.GetTypeInstances("noinstances"); len(instances) != 0 {
t.Fatalf("expected no instances: %v", instances)
}
if types := r.ListTypes(); len(types) != 0 {
t.Fatalf("expected no types: %v", types)
}
r.SetTypeInstances("d1", d1Map)
r.SetTypeInstances("d2", d2Map)
r.SetTypeInstances("d3", d3Map)
if instances := r.GetTypeInstances("unknowntype"); len(instances) != 0 {
t.Fatalf("expected no instances: %v", instances)
}
if instances := r.GetTypeInstances("t1"); len(instances) != 1 {
t.Fatalf("expected one instance: %v", instances)
}
if instances := r.GetTypeInstances("t2"); len(instances) != 2 {
t.Fatalf("expected two instances: %v", instances)
}
if instances := r.GetTypeInstances("all"); len(instances) != 3 {
t.Fatalf("expected three total instances: %v", instances)
}
if types := r.ListTypes(); len(types) != 2 {
t.Fatalf("expected two total types: %v", types)
}
r.ClearTypeInstances("d1")
if instances := r.GetTypeInstances("t1"); len(instances) != 0 {
t.Fatalf("expected no instances after clear: %v", instances)
}
if types := r.ListTypes(); len(types) != 1 {
t.Fatalf("expected one total type: %v", types)
}
}
// TODO(vaikas): Add more tests

@ -0,0 +1,54 @@
# Copyright 2015 Google, Inc. All Rights Reserved
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
FROM golang:1.4
MAINTAINER Jack Greenfield <jackgr@google.com>
WORKDIR /usr/local/bin
RUN apt-get update \
&& apt-get install -y wget
ENV KUBE_PACKAGE kubernetes.tar.gz
ENV KUBE_VERSION v1.0.5
ENV KUBE_BASE https://github.com/kubernetes/kubernetes
ENV KUBE_RELEASE "$KUBE_BASE"/releases/download
ENV KUBE_DOWLOAD "$KUBE_RELEASE"/"$KUBE_VERSION"/"$KUBE_PACKAGE"
ENV KUBE_COMMAND kubernetes/platforms/linux/amd64/kubectl
RUN wget -O - "$KUBE_DOWLOAD" 2> /dev/null | tar xzf - -C /tmp "$KUBE_COMMAND" \
&& mv /tmp/"$KUBE_COMMAND" . \
&& rm -rf /tmp/kubernetes
RUN apt-get purge -y --auto-remove wget
WORKDIR /go/src
RUN mkdir -p expandybird
COPY expandybird expandybird
RUN mkdir -p resourcifier
COPY resourcifier resourcifier
RUN mkdir -p util
COPY util util
RUN mkdir -p version
COPY version version
RUN go-wrapper download resourcifier/...
RUN go-wrapper install resourcifier/...
EXPOSE 8080
ENTRYPOINT ["go-wrapper", "run", "--kubectl=/usr/local/bin/kubectl"]

@ -0,0 +1,21 @@
# Makefile for the Docker image gcr.io/$(PROJECT)/resourcifier
# MAINTAINER: Jack Greenfield <jackgr@google.com>
# If you update this image please check the tag value before pushing.
.PHONY : all build test push container clean
PREFIX := gcr.io/$(PROJECT)
IMAGE := resourcifier
TAG := latest
ROOT_DIR := $(abspath ./..)
DIR = $(ROOT_DIR)
push: container
gcloud docker push $(PREFIX)/$(IMAGE):$(TAG)
container:
docker build -t $(PREFIX)/$(IMAGE):$(TAG) -f Dockerfile $(DIR)
clean:
-docker rmi $(PREFIX)/$(IMAGE):$(TAG)

@ -0,0 +1,292 @@
/*
Copyright 2015 The Kubernetes Authors All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package main
import (
"resourcifier/configurator"
"util"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"io/ioutil"
"log"
"net"
"net/http"
"net/url"
"os"
"github.com/ghodss/yaml"
"github.com/gorilla/mux"
)
var configurations = []Route{
{"ListConfigurations", "/configurations/{type}", "GET", listConfigurationsHandlerFunc, ""},
{"GetConfiguration", "/configurations/{type}/{name}", "GET", getConfigurationHandlerFunc, ""},
{"CreateConfiguration", "/configurations", "POST", createConfigurationHandlerFunc, "JSON"},
{"DeleteConfiguration", "/configurations", "DELETE", deleteConfigurationHandlerFunc, "JSON"},
{"PutConfiguration", "/configurations", "PUT", putConfigurationHandlerFunc, "JSON"},
}
var (
maxLength = flag.Int64("maxLength", 1024*8, "The maximum length (KB) of a configuration.")
kubePath = flag.String("kubectl", "./kubectl", "The path to the kubectl binary.")
kubeService = flag.String("service", "", "The DNS name of the kubernetes service.")
kubeServer = flag.String("server", "", "The IP address and optional port of the kubernetes master.")
kubeInsecure = flag.Bool("insecure-skip-tls-verify", false, "Do not check the server's certificate for validity.")
kubeConfig = flag.String("config", "", "Path to a kubeconfig file.")
kubeCertAuth = flag.String("certificate-authority", "", "Path to a file for the certificate authority.")
kubeClientCert = flag.String("client-certificate", "", "Path to a client certificate file.")
kubeClientKey = flag.String("client-key", "", "Path to a client key file.")
kubeToken = flag.String("token", "", "A service account token.")
kubeUsername = flag.String("username", "", "The username to use for basic auth.")
kubePassword = flag.String("password", "", "The password to use for basic auth.")
)
var backend *configurator.Configurator
func init() {
if !flag.Parsed() {
flag.Parse()
}
routes = append(routes, configurations...)
backend = getConfigurator()
}
func listConfigurationsHandlerFunc(w http.ResponseWriter, r *http.Request) {
handler := "resourcifier: list configurations"
util.LogHandlerEntry(handler, r)
rtype, err := getPathVariable(w, r, "type", handler)
if err != nil {
return
}
c := &configurator.Configuration{
[]configurator.Resource{
{Type: rtype},
},
}
output, err := backend.Configure(c, configurator.GetOperation)
if err != nil {
util.LogAndReturnError(handler, http.StatusBadRequest, err, w)
return
}
util.LogHandlerExit(handler, http.StatusOK, output, w)
util.WriteYAML(handler, w, []byte(output), http.StatusOK)
}
func getConfigurationHandlerFunc(w http.ResponseWriter, r *http.Request) {
handler := "resourcifier: get configuration"
util.LogHandlerEntry(handler, r)
rtype, err := getPathVariable(w, r, "type", handler)
if err != nil {
return
}
rname, err := getPathVariable(w, r, "name", handler)
if err != nil {
return
}
c := &configurator.Configuration{
[]configurator.Resource{
{Name: rname, Type: rtype},
},
}
output, err := backend.Configure(c, configurator.GetOperation)
if err != nil {
util.LogAndReturnError(handler, http.StatusBadRequest, err, w)
return
}
util.LogHandlerExit(handler, http.StatusOK, output, w)
util.WriteYAML(handler, w, []byte(output), http.StatusOK)
}
func createConfigurationHandlerFunc(w http.ResponseWriter, r *http.Request) {
handler := "resourcifier: create configuration"
util.LogHandlerEntry(handler, r)
defer r.Body.Close()
c := getConfiguration(w, r, handler)
if c != nil {
_, err := backend.Configure(c, configurator.CreateOperation)
if err != nil {
util.LogAndReturnError(handler, http.StatusBadRequest, err, w)
return
}
util.LogHandlerExitWithYAML(handler, w, c, http.StatusCreated)
return
}
util.LogHandlerExit(handler, http.StatusOK, "OK", w)
}
func deleteConfigurationHandlerFunc(w http.ResponseWriter, r *http.Request) {
handler := "resourcifier: delete configuration"
util.LogHandlerEntry(handler, r)
defer r.Body.Close()
c := getConfiguration(w, r, handler)
if c != nil {
if _, err := backend.Configure(c, configurator.DeleteOperation); err != nil {
e := errors.New("cannot delete configuration: " + err.Error() + "\n")
util.LogAndReturnError(handler, http.StatusBadRequest, e, w)
return
}
w.WriteHeader(http.StatusNoContent)
util.LogHandlerExit(handler, http.StatusNoContent, "No Content", w)
return
}
util.LogHandlerExit(handler, http.StatusOK, "OK", w)
}
func putConfigurationHandlerFunc(w http.ResponseWriter, r *http.Request) {
handler := "resourcifier: update configuration"
util.LogHandlerEntry(handler, r)
defer r.Body.Close()
c := getConfiguration(w, r, handler)
if c != nil {
if _, err := backend.Configure(c, configurator.ReplaceOperation); err != nil {
e := errors.New("cannot replace configuration: " + err.Error() + "\n")
util.LogAndReturnError(handler, http.StatusBadRequest, e, w)
return
}
util.LogHandlerExitWithYAML(handler, w, c, http.StatusCreated)
return
}
util.LogHandlerExit(handler, http.StatusOK, "OK", w)
}
func getConfigurator() *configurator.Configurator {
if *kubePath == "" {
log.Fatalf("kubectl path cannot be empty")
}
// If a configuration file is specified, then it will provide the server
// address and credentials. If not, then we check for the server address
// and credentials as individual flags.
var args []string
if *kubeConfig != "" {
*kubeConfig = os.ExpandEnv(*kubeConfig)
args = append(args, fmt.Sprintf("--kubeconfig=%s", *kubeConfig))
} else {
if *kubeServer != "" {
args = append(args, fmt.Sprintf("--server=https://%s", *kubeServer))
} else if *kubeService != "" {
addrs, err := net.LookupHost(*kubeService)
if err != nil || len(addrs) < 1 {
log.Fatalf("cannot resolve DNS name: %v", *kubeService)
}
args = append(args, fmt.Sprintf("--server=https://%s", addrs[0]))
}
if *kubeInsecure {
args = append(args, "--insecure-skip-tls-verify")
} else {
if *kubeCertAuth != "" {
args = append(args, fmt.Sprintf("--certificate-authority=%s", *kubeCertAuth))
if *kubeClientCert == "" {
args = append(args, fmt.Sprintf("--client-certificate=%s", *kubeClientCert))
}
if *kubeClientKey == "" {
args = append(args, fmt.Sprintf("--client-key=%s", *kubeClientKey))
}
}
if *kubeToken == "" {
args = append(args, fmt.Sprintf("--token=%s", *kubeToken))
} else {
if *kubeUsername != "" {
args = append(args, fmt.Sprintf("--username=%s", *kubeUsername))
}
if *kubePassword != "" {
args = append(args, fmt.Sprintf("--password=%s", *kubePassword))
}
}
}
}
return configurator.NewConfigurator(*kubePath, args)
}
func getPathVariable(w http.ResponseWriter, r *http.Request, variable, handler string) (string, error) {
vars := mux.Vars(r)
variable, ok := vars[variable]
if !ok {
e := errors.New(fmt.Sprintf("%s name not found in URL", variable))
util.LogAndReturnError(handler, http.StatusBadRequest, e, w)
return "", e
}
unescaped, err := url.QueryUnescape(variable)
if err != nil {
e := fmt.Errorf("cannot decode name (%v)", variable)
util.LogAndReturnError(handler, http.StatusBadRequest, e, w)
return "", e
}
return unescaped, nil
}
func getConfiguration(w http.ResponseWriter, r *http.Request, handler string) *configurator.Configuration {
b := io.LimitReader(r.Body, *maxLength*1024)
y, err := ioutil.ReadAll(b)
if err != nil {
util.LogAndReturnError(handler, http.StatusBadRequest, err, w)
return nil
}
// Reject the input if it exceeded the length limit,
// since we may not have read all of it into the buffer.
if _, err = b.Read(make([]byte, 0, 1)); err != io.EOF {
e := fmt.Errorf("configuration exceeds maximum length of %d KB.", *maxLength)
util.LogAndReturnError(handler, http.StatusBadRequest, e, w)
return nil
}
j, err := yaml.YAMLToJSON(y)
if err != nil {
e := errors.New(err.Error() + "\n" + string(y))
util.LogAndReturnError(handler, http.StatusBadRequest, e, w)
return nil
}
c := &configurator.Configuration{}
if err := json.Unmarshal(j, c); err != nil {
e := errors.New(err.Error() + "\n" + string(j))
util.LogAndReturnError(handler, http.StatusBadRequest, e, w)
return nil
}
if len(c.Resources) < 1 {
e := fmt.Errorf("configuration is empty")
util.LogAndReturnError(handler, http.StatusBadRequest, e, w)
return nil
}
return c
}

@ -0,0 +1,153 @@
/*
Copyright 2015 The Kubernetes Authors All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package configurator
import (
"bytes"
"fmt"
"github.com/ghodss/yaml"
"log"
"os/exec"
"strings"
)
// TODO(jackgr): Define an interface and a struct type for Configurator and move initialization to the caller.
// Configuration describes a configuration deserialized from a YAML or JSON file.
type Configuration struct {
Resources []Resource `json:"resources"`
}
// Resource describes a resource in a deserialized configuration. A resource has
// a name, a type and a set of properties. The properties are passed directly to
// kubectl as the definition of the resource on the server.
type Resource struct {
Name string `json:"name"`
Type string `json:"type"`
Properties map[string]interface{} `json:"properties"`
}
type Configurator struct {
KubePath string
Arguments []string
}
func NewConfigurator(kubectlPath string, arguments []string) *Configurator {
return &Configurator{kubectlPath, arguments}
}
// operation is an enumeration type for kubectl operations.
type operation string
// These constants implement the operation enumeration type.
const (
CreateOperation operation = "create"
DeleteOperation operation = "delete"
GetOperation operation = "get"
ReplaceOperation operation = "replace"
)
func (o operation) String() string {
return string(o)
}
// TODO(jackgr): Configure resources without dependencies in parallel.
// Error is an error type that captures errors from the multiple calls to kubectl
// made for a single configuration.
type Error struct {
errors []error
}
// Error returns the string value of an Error.
func (e *Error) Error() string {
errs := []string{}
for _, err := range e.errors {
errs = append(errs, err.Error())
}
return strings.Join(errs, "\n")
}
func (e *Error) appendError(err error) error {
e.errors = append(e.errors, err)
return err
}
// Configure passes the configuration in the given deployment to kubectl
// and then updates the deployment with the completion status and completion time.
func (a *Configurator) Configure(c *Configuration, o operation) (string, error) {
errors := &Error{}
var output []string
for _, resource := range c.Resources {
args := []string{o.String()}
if o == GetOperation {
args = append(args, "-o", "yaml")
if resource.Type != "" {
args = append(args, resource.Type)
if resource.Name != "" {
args = append(args, resource.Name)
}
}
}
var y []byte
if len(resource.Properties) > 0 {
var err error
y, err = yaml.Marshal(resource.Properties)
if err != nil {
e := fmt.Errorf("yaml marshal failed for resource: %v: %v", resource.Name, err)
log.Println(errors.appendError(e))
continue
}
}
if len(y) > 0 {
args = append(args, "-f", "-")
}
args = append(args, a.Arguments...)
cmd := exec.Command(a.KubePath, args...)
cmd.Stdin = bytes.NewBuffer(y)
// Combine stdout and stderr into a single dynamically resized buffer
combined := &bytes.Buffer{}
cmd.Stdout = combined
cmd.Stderr = combined
// log.Printf("starting command:%s %s\nin directory: %s\nwith environment: %s\nwith stdin:\n%s\n",
// cmd.Path, strings.Join(cmd.Args, " "), cmd.Dir, strings.Join(cmd.Env, "\n"), string(y))
if err := cmd.Start(); err != nil {
e := fmt.Errorf("cannot start kubetcl for resource: %v: %v", resource.Name, err)
log.Println(errors.appendError(e))
continue
}
if err := cmd.Wait(); err != nil {
e := fmt.Errorf("kubetcl failed for resource: %v: %v: %v", resource.Name, err, combined.String())
log.Println(errors.appendError(e))
continue
}
output = append(output, combined.String())
log.Printf("kubectl succeeded for resource: %v: SysTime: %v UserTime: %v\n%v",
resource.Name, cmd.ProcessState.SystemTime(), cmd.ProcessState.UserTime(), combined.String())
}
if len(errors.errors) > 0 {
return "", errors
}
return strings.Join(output, "\n"), nil
}

@ -0,0 +1,73 @@
/*
Copyright 2015 The Kubernetes Authors All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package main
import (
"version"
"flag"
"fmt"
"log"
"net/http"
"os"
"github.com/gorilla/handlers"
"github.com/gorilla/mux"
)
// Route defines a routing table entry to be registered with gorilla/mux.
type Route struct {
Name string
Path string
Methods string
HandlerFunc http.HandlerFunc
Type string
}
var routes = []Route{}
// port to listen on
var port = flag.Int("port", 8080, "The port to listen on")
func main() {
if !flag.Parsed() {
flag.Parse()
}
router := mux.NewRouter()
router.StrictSlash(true)
for _, route := range routes {
handler := http.Handler(http.HandlerFunc(route.HandlerFunc))
switch route.Type {
case "JSON":
handler = handlers.ContentTypeHandler(handler, "application/json")
case "":
break
default:
log.Fatalf("invalid route type: %v", route)
}
r := router.NewRoute()
r.Name(route.Name).
Path(route.Path).
Methods(route.Methods).
Handler(handler)
}
address := fmt.Sprintf(":%d", *port)
handler := handlers.CombinedLoggingHandler(os.Stderr, router)
log.Printf("Version: %s", version.DeploymentManagerVersion)
log.Printf("Listening on port %d...", *port)
log.Fatal(http.ListenAndServe(address, handler))
}

@ -0,0 +1,153 @@
/*
Copyright 2015 The Kubernetes Authors All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package util
import (
"compress/gzip"
"fmt"
"io"
"io/ioutil"
"net/http"
"time"
)
const (
accHeader = "Accept-Encoding"
typeHeader = "Content-Type"
encHeader = "Content-Encoding"
gzipHeader = "gzip"
)
// TODO (iantw): Consider creating the Duration objects up front... May just need an all around
// refactor if we want to support other types of backoff etc.
var sleepIntervals = []int{1, 1, 2, 3, 5, 8, 10}
// Sleeper exposes a Sleep func which causes the current goroutine to sleep for the requested
// duration.
type Sleeper interface {
Sleep(d time.Duration)
}
type sleeper struct {
}
func (s sleeper) Sleep(d time.Duration) {
time.Sleep(d)
}
// NewSleeper returns a new Sleeper.
func NewSleeper() Sleeper {
return sleeper{}
}
// HTTPDoer is an interface for something that can 'Do' an http.Request and return an http.Response
// and error.
type HTTPDoer interface {
Do(req *http.Request) (resp *http.Response, err error)
}
// HTTPClient is a higher level HTTP client which takes a URL and returns the response body as a
// string, along with the resulting status code and any errors.
type HTTPClient interface {
Get(url string) (body string, code int, err error)
}
type httpClient struct {
retries uint
client HTTPDoer
sleep Sleeper
}
// NewHTTPClient returns a new HTTPClient.
func NewHTTPClient(retries uint, c HTTPDoer, s Sleeper) HTTPClient {
ret := httpClient{}
ret.client = c
ret.sleep = s
ret.retries = retries
return ret
}
func readBody(b io.ReadCloser, ctype string, encoding string) (body string, err error) {
defer b.Close()
var r io.Reader
if encoding == gzipHeader {
gr, err := gzip.NewReader(b)
if err != nil {
return "", err
}
r = gr
defer gr.Close()
} else if encoding == "" {
r = b
} else {
return "", fmt.Errorf("Unknown %s: %s", encHeader, encoding)
}
// TODO(iantw): If we find a need, allow character set conversions...
// Unlikely to be an issue for now.
// if ctype != "" {
// r, err = charset.NewReader(r, ctype)
//
// if err != nil {
// return "", err
// }
// }
bytes, err := ioutil.ReadAll(r)
return string(bytes), err
}
func (client httpClient) Get(url string) (body string, code int, err error) {
retryCount := client.retries
numRetries := uint(0)
shouldRetry := true
for shouldRetry {
body = ""
code = 0
err = nil
var req *http.Request
req, err = http.NewRequest("GET", url, nil)
if err != nil {
return "", 0, err
}
req.Header.Add(accHeader, gzipHeader)
var r *http.Response
r, err = client.client.Do(req)
if err == nil {
body, err = readBody(r.Body, r.Header.Get(typeHeader), r.Header.Get(encHeader))
if err == nil {
code = r.StatusCode
}
}
if code != 200 {
if numRetries < retryCount {
numRetries = numRetries + 1
sleepIndex := int(numRetries)
if numRetries >= uint(len(sleepIntervals)) {
sleepIndex = len(sleepIntervals) - 1
}
d, _ := time.ParseDuration(string(sleepIntervals[sleepIndex]) + "s")
client.sleep.Sleep(d)
} else {
shouldRetry = false
}
} else {
shouldRetry = false
}
}
return
}

@ -0,0 +1,160 @@
/*
Copyright 2015 The Kubernetes Authors All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package util
import (
"bytes"
"compress/gzip"
"errors"
"io"
"net/http"
"strings"
"testing"
"time"
)
type mockSleeper struct {
args []time.Duration
}
func (m *mockSleeper) Sleep(d time.Duration) {
m.args = append(m.args, d)
}
type responseAndError struct {
err error
resp *http.Response
}
type testBody struct {
closed bool
body io.Reader
}
func (tb *testBody) Read(p []byte) (n int, err error) {
return tb.body.Read(p)
}
func (tb *testBody) Close() error {
tb.closed = true
return nil
}
func createResponse(err error, code int, body string, shouldClose bool,
headers map[string]string) responseAndError {
httpBody := testBody{body: strings.NewReader(body), closed: !shouldClose}
header := http.Header{}
for k, v := range headers {
header.Add(k, v)
}
httpResponse := &http.Response{
Body: &httpBody,
ContentLength: int64(len(body)),
StatusCode: code,
Header: header,
}
return responseAndError{err: err, resp: httpResponse}
}
type mockDoer struct {
resp []responseAndError
t *testing.T
url string
headers map[string]string
}
func (doer *mockDoer) Do(req *http.Request) (res *http.Response, err error) {
if req.URL.String() != doer.url {
doer.t.Errorf("Expected url %s but got url %s", doer.url, req.URL.String())
}
for k, v := range doer.headers {
if req.Header.Get(k) != v {
doer.t.Errorf("Expected header %s with value %s but found %s", k, v, req.Header.Get(k))
}
}
if len(doer.resp) == 0 {
doer.t.Errorf("Do method was called more times than expected.")
}
res = doer.resp[0].resp
err = doer.resp[0].err
doer.resp = doer.resp[1:]
return
}
func testClientDriver(md mockDoer, ms mockSleeper, expectedErr error, code int,
result string, t *testing.T) {
expectedCalls := len(md.resp)
client := NewHTTPClient(uint(expectedCalls)-1, &md, &ms)
r, c, e := client.Get(md.url)
if expectedCalls-1 != len(ms.args) {
t.Errorf("Expected %d calls to sleeper but found %d", expectedCalls-1, len(ms.args))
}
if r != result {
t.Errorf("Expected result %s but received %s", result, r)
}
if c != code {
t.Errorf("Expected status code %d but received %d", code, c)
}
if e != expectedErr {
t.Errorf("Expected error %s but received %s", expectedErr, e)
}
}
func TestGzip(t *testing.T) {
doer := mockDoer{}
var b bytes.Buffer
gz := gzip.NewWriter(&b)
gz.Write([]byte("Test"))
gz.Flush()
gz.Close()
result := b.String()
doer.resp = []responseAndError{
createResponse(nil, 200, result, true, map[string]string{"Content-Encoding": "gzip"}),
}
sleeper := mockSleeper{}
testClientDriver(doer, sleeper, nil, 200, "Test", t)
}
func TestRetry(t *testing.T) {
doer := mockDoer{}
doer.resp = []responseAndError{
createResponse(nil, 404, "", true, map[string]string{}),
createResponse(nil, 200, "Test", true, map[string]string{}),
}
sleeper := mockSleeper{}
testClientDriver(doer, sleeper, nil, 200, "Test", t)
}
func TestFail(t *testing.T) {
doer := mockDoer{}
err := errors.New("Error")
doer.resp = []responseAndError{
createResponse(nil, 404, "", true, map[string]string{}),
createResponse(err, 0, "", false, map[string]string{}),
}
sleeper := mockSleeper{}
testClientDriver(doer, sleeper, err, 0, "", t)
}

@ -0,0 +1,202 @@
/*
Copyright 2015 The Kubernetes Authors All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package util
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"github.com/ghodss/yaml"
)
// A HandlerTester is a function that takes an HTTP method, an URL path, and a
// reader for a request body, creates a request from them, and serves it to the
// handler to which it was bound and returns a response recorder describing the
// outcome.
type HandlerTester func(method, path, ctype string, reader io.Reader) (*httptest.ResponseRecorder, error)
// NewHandlerTester creates and returns a new HandlerTester for an http.Handler.
func NewHandlerTester(handler http.Handler) HandlerTester {
return func(method, path, ctype string, reader io.Reader) (*httptest.ResponseRecorder, error) {
r, err := http.NewRequest(method, path, reader)
if err != nil {
return nil, err
}
r.Header.Set("Content-Type", ctype)
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
return w, nil
}
}
// A ServerTester is a function that takes an HTTP method, an URL path, and a
// reader for a request body, creates a request from them, and serves it to a
// test server using the handler to which it was bound and returns the response.
type ServerTester func(method, path, ctype string, reader io.Reader) (*http.Response, error)
// NewServerTester creates and returns a new NewServerTester for an http.Handler.
func NewServerTester(handler http.Handler) ServerTester {
return func(method, path, ctype string, reader io.Reader) (*http.Response, error) {
server := httptest.NewServer(handler)
defer server.Close()
request := fmt.Sprintf("%s/%s", server.URL, path)
r, err := http.NewRequest(method, request, reader)
if err != nil {
return nil, err
}
r.Header.Set("Content-Type", ctype)
return http.DefaultClient.Do(r)
}
}
const formContentType = "application/x-www-form-urlencoded; param=value"
// TestWithURL invokes a HandlerTester with the given HTTP method, an URL path
// parsed from the given URL string, and a string reader on the query parameters
// parsed from the given URL string.
func (h HandlerTester) TestWithURL(method, urlString string) (*httptest.ResponseRecorder, error) {
request, err := url.Parse(urlString)
if err != nil {
return nil, err
}
reader := strings.NewReader(request.Query().Encode())
return h(method, request.Path, formContentType, reader)
}
// TestHandlerWithURL creates a HandlerTester with the given handler, and tests
// it with the given HTTP method and URL string using HandlerTester.TestWithURL.
func TestHandlerWithURL(handler http.Handler, method, urlString string) (*httptest.ResponseRecorder, error) {
return NewHandlerTester(handler).TestWithURL(method, urlString)
}
// LogHandlerEntry logs the start of the given handler handling the given request.
func LogHandlerEntry(handler string, r *http.Request) {
log.Printf("%s: handling request:%s %s\n", handler, r.Method, r.URL.RequestURI())
}
// LogHandlerExit logs the response from the given handler with the given results.
func LogHandlerExit(handler string, statusCode int, status string, w http.ResponseWriter) {
log.Printf("%s: returning response: status code:%d, status:%s\n", handler, statusCode, status)
}
// LogAndReturnError logs the given error and status to stderr,
// and then returns them as the HTTP response.
func LogAndReturnError(handler string, statusCode int, err error, w http.ResponseWriter) {
LogHandlerExit(handler, statusCode, err.Error(), w)
http.Error(w, err.Error(), statusCode)
}
// LogHandlerExitWithJSON marshals the given object as JSON,
// writes it to the response body, returns the given status, and then logs the
// response.
func LogHandlerExitWithJSON(handler string, w http.ResponseWriter, v interface{}, statusCode int) {
j := MarshalAndWriteJSON(handler, w, v, statusCode)
LogHandlerExit(handler, statusCode, string(j), w)
}
// MarshalAndWriteJSON marshals the given object as JSON, writes it
// to the response body, and then returns the given status.
func MarshalAndWriteJSON(handler string, w http.ResponseWriter, v interface{}, statusCode int) []byte {
j, err := json.Marshal(v)
if err != nil {
LogAndReturnError(handler, http.StatusInternalServerError, err, w)
return nil
}
WriteJSON(handler, w, j, statusCode)
return j
}
// WriteJSON writes the given bytes to the response body, sets the content type
// to "application/json; charset=UTF-8", and then returns the given status.
func WriteJSON(handler string, w http.ResponseWriter, j []byte, status int) {
WriteResponse(handler, w, j, "application/json; charset=UTF-8", status)
}
// LogHandlerExitWithYAML marshals the given object as YAML,
// writes it to the response body, returns the given status, and then logs the
// response.
func LogHandlerExitWithYAML(handler string, w http.ResponseWriter, v interface{}, statusCode int) {
y := MarshalAndWriteYAML(handler, w, v, statusCode)
LogHandlerExit(handler, statusCode, string(y), w)
}
// MarshalAndWriteYAML marshals the given object as YAML, writes it
// to the response body, and then returns the given status.
func MarshalAndWriteYAML(handler string, w http.ResponseWriter, v interface{}, statusCode int) []byte {
y, err := yaml.Marshal(v)
if err != nil {
LogAndReturnError(handler, http.StatusInternalServerError, err, w)
return nil
}
WriteYAML(handler, w, y, statusCode)
return y
}
// WriteYAML writes the given bytes to the response body, sets the content type
// to "application/x-yaml; charset=UTF-8", and then returns the given status.
func WriteYAML(handler string, w http.ResponseWriter, y []byte, status int) {
WriteResponse(handler, w, y, "application/x-yaml; charset=UTF-8", status)
}
// WriteResponse writes the given bytes to the response body, sets the content
// type to the given value, and then returns the given status.
func WriteResponse(handler string, w http.ResponseWriter, v []byte, ct string, status int) {
// Header must be set before status is written
if len(v) > 0 {
w.Header().Set("Content-Type", ct)
}
// Header and status must be written before content is written
w.WriteHeader(status)
if len(v) > 0 {
if _, err := w.Write(v); err != nil {
LogAndReturnError(handler, http.StatusInternalServerError, err, w)
}
}
}
// ToYAMLOrError marshals the given object to YAML and returns either the
// resulting YAML or an error message. Useful when marshaling an object for
// a log entry.
func ToYAMLOrError(v interface{}) string {
y, err := yaml.Marshal(v)
if err != nil {
return fmt.Sprintf("yaml marshal failed:%s\n%v\n", err, v)
}
return string(y)
}
// ToJSONOrError marshals the given object to JSON and returns either the
// resulting YAML or an error message. Useful when marshaling an object for
// a log entry.
func ToJSONOrError(v interface{}) string {
j, err := json.Marshal(v)
if err != nil {
return fmt.Sprintf("json marshal failed:%s\n%v\n", err, v)
}
return string(j)
}

@ -0,0 +1,23 @@
// Copyright 2015 Google Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package version
// DeploymentManagerVersion is the current version of the Deployment Manager.
// Update this whenever making a new release.
// The version is of the format Major.Minor.Patch
// Increment major number for new feature additions and behavioral changes.
// Increment minor number for bug fixes and performance enhancements.
// Increment patch number for critical fixes to existing releases.
const DeploymentManagerVersion = "0.0.1"
Loading…
Cancel
Save