diff --git a/_data/tutorials.yml b/_data/tutorials.yml index 61555427d1..1af2276a11 100644 --- a/_data/tutorials.yml +++ b/_data/tutorials.yml @@ -55,3 +55,5 @@ toc: section: - title: Running a Single-Instance Stateful Application path: /docs/tutorials/stateful-application/run-stateful-application/ + - title: Running a Replicated Stateful Application + path: /docs/tutorials/stateful-application/run-replicated-stateful-application/ diff --git a/_includes/default-storage-class-prereqs.md b/_includes/default-storage-class-prereqs.md new file mode 100644 index 0000000000..a4747d9032 --- /dev/null +++ b/_includes/default-storage-class-prereqs.md @@ -0,0 +1,6 @@ +You need to either have a dynamic PersistentVolume provisioner with a default +[StorageClass](/docs/user-guide/persistent-volumes/#storageclasses), +or [statically provision PersistentVolumes](/docs/user-guide/persistent-volumes/#provisioning) +yourself to satisfy the [PersistentVolumeClaims](/docs/user-guide/persistent-volumes/#persistentvolumeclaims) +used here. + diff --git a/docs/tutorials/index.md b/docs/tutorials/index.md index 88c5b75807..bcec6590cf 100644 --- a/docs/tutorials/index.md +++ b/docs/tutorials/index.md @@ -21,6 +21,7 @@ each of which has a sequence of steps. #### Stateful Applications * [Running a Single-Instance Stateful Application](/docs/tutorials/stateful-application/run-stateful-application/) +* [Running a Replicated Stateful Application](/docs/tutorials/stateful-application/run-replicated-stateful-application/) ### What's next diff --git a/docs/tutorials/stateful-application/Dockerfile b/docs/tutorials/stateful-application/Dockerfile new file mode 100644 index 0000000000..8016958d83 --- /dev/null +++ b/docs/tutorials/stateful-application/Dockerfile @@ -0,0 +1,17 @@ +# This is an image with Percona XtraBackup, mysql-client and ncat installed. +FROM debian:jessie + +RUN \ + echo "deb http://repo.percona.com/apt jessie main" > /etc/apt/sources.list.d/percona.list \ + && echo "deb-src http://repo.percona.com/apt jessie main" >> /etc/apt/sources.list.d/percona.list \ + && apt-key adv --keyserver keys.gnupg.net --recv-keys 8507EFA5 + +RUN \ + apt-get update && apt-get install -y --no-install-recommends \ + percona-xtrabackup-24 \ + mysql-client \ + nmap \ + && rm -rf /var/lib/apt/lists/* + +CMD ["bash"] + diff --git a/docs/tutorials/stateful-application/mysql-configmap.yaml b/docs/tutorials/stateful-application/mysql-configmap.yaml new file mode 100644 index 0000000000..46d34e422c --- /dev/null +++ b/docs/tutorials/stateful-application/mysql-configmap.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: mysql + labels: + app: mysql +data: + master.cnf: | + # Apply this config only on the master. + [mysqld] + log-bin + slave.cnf: | + # Apply this config only on slaves. + [mysqld] + super-read-only + diff --git a/docs/tutorials/stateful-application/mysql-services.yaml b/docs/tutorials/stateful-application/mysql-services.yaml new file mode 100644 index 0000000000..f538992566 --- /dev/null +++ b/docs/tutorials/stateful-application/mysql-services.yaml @@ -0,0 +1,30 @@ +# Headless service for stable DNS entries of StatefulSet members. +apiVersion: v1 +kind: Service +metadata: + name: mysql + labels: + app: mysql +spec: + ports: + - name: mysql + port: 3306 + clusterIP: None + selector: + app: mysql +--- +# Client service for connecting to any MySQL instance for reads. +# For writes, you must instead connect to the master: mysql-0.mysql. +apiVersion: v1 +kind: Service +metadata: + name: mysql-read + labels: + app: mysql +spec: + ports: + - name: mysql + port: 3306 + selector: + app: mysql + diff --git a/docs/tutorials/stateful-application/mysql-statefulset.yaml b/docs/tutorials/stateful-application/mysql-statefulset.yaml new file mode 100644 index 0000000000..106f77cd99 --- /dev/null +++ b/docs/tutorials/stateful-application/mysql-statefulset.yaml @@ -0,0 +1,165 @@ +apiVersion: apps/v1beta1 +kind: StatefulSet +metadata: + name: mysql +spec: + serviceName: mysql + replicas: 3 + template: + metadata: + labels: + app: mysql + annotations: + pod.beta.kubernetes.io/init-containers: '[ + { + "name": "init-mysql", + "image": "mysql:5.7", + "command": ["bash", "-c", " + set -ex\n + # mysqld --initialize expects an empty data dir.\n + rm -rf /mnt/data/lost+found\n + # Generate mysql server-id from pod ordinal index.\n + [[ `hostname` =~ -([0-9]+)$ ]] || exit 1\n + ordinal=${BASH_REMATCH[1]}\n + echo [mysqld] > /mnt/conf.d/server-id.cnf\n + # Add an offset to avoid reserved server-id=0 value.\n + echo server-id=$((100 + $ordinal)) >> /mnt/conf.d/server-id.cnf\n + # Copy appropriate conf.d files from config-map to emptyDir.\n + if [[ $ordinal -eq 0 ]]; then\n + cp /mnt/config-map/master.cnf /mnt/conf.d/\n + else\n + cp /mnt/config-map/slave.cnf /mnt/conf.d/\n + fi\n + "], + "volumeMounts": [ + {"name": "data", "mountPath": "/mnt/data"}, + {"name": "conf", "mountPath": "/mnt/conf.d"}, + {"name": "config-map", "mountPath": "/mnt/config-map"} + ] + }, + { + "name": "clone-mysql", + "image": "gcr.io/google-samples/xtrabackup:1.0", + "command": ["bash", "-c", " + set -ex\n + # Skip the clone if data already exists.\n + [[ -d /var/lib/mysql/mysql ]] && exit 0\n + # Skip the clone on master (ordinal index 0).\n + [[ `hostname` =~ -([0-9]+)$ ]] || exit 1\n + ordinal=${BASH_REMATCH[1]}\n + [[ $ordinal -eq 0 ]] && exit 0\n + # Clone data from previous peer.\n + ncat --recv-only mysql-$(($ordinal-1)).mysql 3307 | xbstream -x -C /var/lib/mysql\n + # Prepare the backup.\n + xtrabackup --prepare --target-dir=/var/lib/mysql\n + "], + "volumeMounts": [ + {"name": "data", "mountPath": "/var/lib/mysql"}, + {"name": "conf", "mountPath": "/etc/mysql/conf.d"} + ] + } + ]' + spec: + containers: + - name: mysql + image: mysql:5.7 + env: + - name: MYSQL_ALLOW_EMPTY_PASSWORD + value: "1" + ports: + - name: mysql + containerPort: 3306 + volumeMounts: + - name: data + mountPath: /var/lib/mysql + - name: conf + mountPath: /etc/mysql/conf.d + resources: + requests: + cpu: 1 + memory: 1Gi + livenessProbe: + exec: + command: ["mysqladmin", "ping"] + initialDelaySeconds: 30 + timeoutSeconds: 5 + readinessProbe: + exec: + # Check we can execute queries over TCP (skip-networking is off). + command: ["mysql", "-h", "127.0.0.1", "-e", "SELECT 1"] + initialDelaySeconds: 5 + timeoutSeconds: 1 + - name: xtrabackup + image: gcr.io/google-samples/xtrabackup:1.0 + ports: + - name: xtrabackup + containerPort: 3307 + command: + - bash + - "-c" + - | + set -ex + cd /var/lib/mysql + + # Determine binlog position of cloned data, if any. + if [[ -f xtrabackup_slave_info ]]; then + # XtraBackup already generated a partial "CHANGE MASTER TO" query + # because we're cloning from an existing slave. + mv xtrabackup_slave_info change_master_to.sql.in + # Ignore xtrabackup_binlog_info in this case (it's useless). + rm -f xtrabackup_binlog_info + elif [[ -f xtrabackup_binlog_info ]]; then + # We're cloning directly from master. Parse binlog position. + [[ `cat xtrabackup_binlog_info` =~ ^(.*?)[[:space:]]+(.*?)$ ]] || exit 1 + rm xtrabackup_binlog_info + echo "CHANGE MASTER TO MASTER_LOG_FILE='${BASH_REMATCH[1]}',\ + MASTER_LOG_POS=${BASH_REMATCH[2]}" > change_master_to.sql.in + fi + + # Check if we need to complete a clone by starting replication. + if [[ -f change_master_to.sql.in ]]; then + echo "Waiting for mysqld to be ready (accepting connections)" + until mysql -h 127.0.0.1 -e "SELECT 1"; do sleep 1; done + + echo "Initializing replication from clone position" + # In case of container restart, attempt this at-most-once. + mv change_master_to.sql.in change_master_to.sql.orig + mysql -h 127.0.0.1 <.mysql` from within any other Pod in the same Kubernetes +cluster and namespace. + +The Client Service, called `mysql-read`, is a normal Service with its own +cluster IP that distributes connections across all MySQL Pods that report +being Ready. The set of potential endpoints includes the MySQL master and all +slaves. + +Note that only read queries can use the load-balanced Client Service. +Because there is only one MySQL master, clients should connect directly to the +MySQL master Pod (through its DNS entry within the Headless Service) to execute +writes. + +#### StatefulSet + +Finally, create the StatefulSet from the following YAML configuration file: + +```shell +kubectl create -f http://k8s.io/docs/tutorials/stateful-application/mysql-statefulset.yaml +``` + +{% include code.html language="yaml" file="mysql-statefulset.yaml" ghlink="/docs/tutorials/stateful-application/mysql-statefulset.yaml" %} + +You can watch the startup progress by running: + +```shell +kubectl get pods -l app=mysql --watch +``` + +After a while, you should see all 3 Pods become Running: + +``` +NAME READY STATUS RESTARTS AGE +mysql-0 2/2 Running 0 2m +mysql-1 2/2 Running 0 1m +mysql-2 2/2 Running 0 1m +``` + +Press **Ctrl+C** to cancel the watch. +If you don't see any progress, make sure you have a dynamic PersistentVolume +provisioner enabled as mentioned in the [prerequisites](#before-you-begin). + +This manifest uses a variety of techniques for managing stateful Pods as part of +a StatefulSet. The next section highlights some of these techniques to explain +what happens as the StatefulSet creates Pods. + +### Understanding stateful Pod initialization + +The StatefulSet controller starts Pods one at a time, in order by their +ordinal index. +It waits until each Pod reports being Ready before starting the next one. + +In addition, the controller assigns each Pod a unique, stable name of the form +`-`. +In this case, that results in Pods named `mysql-0`, `mysql-1`, and `mysql-2`. + +The Pod template in the above StatefulSet manifest takes advantage of these +properties to perform orderly startup of MySQL replication. + +#### Generating configuration + +Before starting any of the containers in the Pod spec, the Pod first runs any +[Init Containers](/docs/user-guide/production-pods/#handling-initialization) +in the order defined. +In the StatefulSet manifest, you can find these defined within the +`pod.beta.kubernetes.io/init-containers` annotation. + +The first Init Container, named `init-mysql`, generates special MySQL config +files based on the ordinal index. + +The script determines its own ordinal index by extracting it from the end of +the Pod name, which is returned by the `hostname` command. +Then it saves the ordinal (with a numeric offset to avoid reserved values) +into a file called `server-id.cnf` in the MySQL `conf.d` directory. +This translates the unique, stable identity provided by the StatefulSet +controller into the domain of MySQL server IDs, which require the same +properties. + +The script in the `init-mysql` container also applies either `master.cnf` or +`slave.cnf` from the ConfigMap by copying the contents into `conf.d`. +Because the example topology consists of a single MySQL master and any number of +slaves, the script simply assigns ordinal `0` to be the master, and everyone +else to be slaves. +Combined with the StatefulSet controller's +[deployment order guarantee](/docs/concepts/abstractions/controllers/statefulsets/#deployment-and-scaling-guarantee), +this ensures the MySQL master is Ready before creating slaves, so they can begin +replicating. + +#### Cloning existing data + +In general, when a new Pod joins the set as a slave, it must assume the MySQL +master might already have data on it. It also must assume that the replication +logs might not go all the way back to the beginning of time. +These conservative assumptions are the key to allowing a running StatefulSet +to scale up and down over time, rather than being fixed at its initial size. + +The second Init Container, named `clone-mysql`, performs a clone operation on +a slave Pod the first time it starts up on an empty PersistentVolume. +That means it copies all existing data from another running Pod, +so its local state is consistent enough to begin replicating from the master. + +MySQL itself does not provide a mechanism to do this, so the example uses a +popular open-source tool called Percona XtraBackup. +During the clone, the source MySQL server might suffer reduced performance. +To minimize impact on the MySQL master, the script instructs each Pod to clone +from the Pod whose ordinal index is one lower. +This works because the StatefulSet controller always ensures Pod `N` is +Ready before starting Pod `N+1`. + +#### Starting replication + +After the Init Containers complete successfully, the regular containers run. +The MySQL Pods consist of a `mysql` container that runs the actual `mysqld` +server, and an `xtrabackup` container that acts as a +[sidecar](http://blog.kubernetes.io/2015/06/the-distributed-system-toolkit-patterns.html). + +The `xtrabackup` sidecar looks at the cloned data files and determines if +it's necessary to initialize MySQL replication on the slave. +If so, it waits for `mysqld` to be ready and then executes the +`CHANGE MASTER TO` and `START SLAVE` commands with replication parameters +extracted from the XtraBackup clone files. + +Once a slave begins replication, it remembers its MySQL master and +reconnects automatically if the server restarts or the connection dies. +Also, because slaves look for the master at its stable DNS name +(`mysql-0.mysql`), they automatically find the master even if it gets a new +Pod IP due to being rescheduled. + +Lastly, after starting replication, the `xtrabackup` container listens for +connections from other Pods requesting a data clone. +This server remains up indefinitely in case the StatefulSet scales up, or in +case the next Pod loses its PersistentVolumeClaim and needs to redo the clone. + +### Sending client traffic + +You can send test queries to the MySQL master (hostname `mysql-0.mysql`) +by running a temporary container with the `mysql:5.7` image and running the +`mysql` client binary. + +```shell +kubectl run mysql-client --image=mysql:5.7 -i -t --rm --restart=Never --\ + mysql -h mysql-0.mysql <` with the name of the Node you found in the last step. + +This might impact other applications on the Node, so it's best to +**only do this in a test cluster**. + +```shell +kubectl drain --force --delete-local-data --ignore-daemonsets +``` + +Now you can watch as the Pod reschedules on a different Node: + +```shell +kubectl get pod mysql-2 -o wide --watch +``` + +It should look something like this: + +``` +NAME READY STATUS RESTARTS AGE IP NODE +mysql-2 2/2 Terminating 0 15m 10.244.1.56 kubernetes-minion-group-9l2t +[...] +mysql-2 0/2 Pending 0 0s kubernetes-minion-group-fjlm +mysql-2 0/2 Init:0/2 0 0s kubernetes-minion-group-fjlm +mysql-2 0/2 Init:1/2 0 20s 10.244.5.32 kubernetes-minion-group-fjlm +mysql-2 0/2 PodInitializing 0 21s 10.244.5.32 kubernetes-minion-group-fjlm +mysql-2 1/2 Running 0 22s 10.244.5.32 kubernetes-minion-group-fjlm +mysql-2 2/2 Running 0 30s 10.244.5.32 kubernetes-minion-group-fjlm +``` + +And again, you should see server ID `102` disappear from the +`SELECT @@server_id` loop output for a while and then return. + +Now uncordon the Node to return it to a normal state: + +```shell +kubectl uncordon +``` + +### Scaling the number of slaves + +With MySQL replication, you can scale your read query capacity by adding slaves. +With StatefulSet, you can do this with a single command: + +```shell +kubectl scale --replicas=5 statefulset mysql +``` + +Watch the new Pods come up by running: + +```shell +kubectl get pods -l app=mysql --watch +``` + +Once they're up, you should see server IDs `103` and `104` start appearing in +the `SELECT @@server_id` loop output. + +You can also verify that these new servers have the data you added before they +existed: + +```shell +kubectl run mysql-client --image=mysql:5.7 -i -t --rm --restart=Never --\ + mysql -h mysql-3.mysql -e "SELECT * FROM test.messages" +``` + +``` +Waiting for pod default/mysql-client to be running, status is Pending, pod ready: false ++---------+ +| message | ++---------+ +| hello | ++---------+ +pod "mysql-client" deleted +``` + +Scaling back down is also seamless: + +```shell +kubectl scale --replicas=3 statefulset mysql +``` + +Note, however, that while scaling up creates new PersistentVolumeClaims +automatically, scaling down does not automatically delete these PVCs. +This gives you the choice to keep those initialized PVCs around to make +scaling back up quicker, or to extract data before deleting them. + +You can see this by running: + +```shell +kubectl get pvc -l app=mysql +``` + +Which shows that all 5 PVCs still exist, despite having scaled the +StatefulSet down to 3: + +``` +NAME STATUS VOLUME CAPACITY ACCESSMODES AGE +data-mysql-0 Bound pvc-8acbf5dc-b103-11e6-93fa-42010a800002 10Gi RWO 20m +data-mysql-1 Bound pvc-8ad39820-b103-11e6-93fa-42010a800002 10Gi RWO 20m +data-mysql-2 Bound pvc-8ad69a6d-b103-11e6-93fa-42010a800002 10Gi RWO 20m +data-mysql-3 Bound pvc-50043c45-b1c5-11e6-93fa-42010a800002 10Gi RWO 2m +data-mysql-4 Bound pvc-500a9957-b1c5-11e6-93fa-42010a800002 10Gi RWO 2m +``` + +If you don't intend to reuse the extra PVCs, you can delete them: + +```shell +kubectl delete pvc data-mysql-3 +kubectl delete pvc data-mysql-4 +``` + +{% endcapture %} + +{% capture cleanup %} + +1. Cancel the `SELECT @@server_id` loop by pressing **Ctrl+C** in its terminal, + or running the following from another terminal: + + ```shell + kubectl delete pod mysql-client-loop --now + ``` + +1. Delete the StatefulSet. This also begins terminating the Pods. + + ```shell + kubectl delete statefulset mysql + ``` + +1. Verify that the Pods disappear. + They might take some time to finish terminating. + + ```shell + kubectl get pods -l app=mysql + ``` + + You'll know the Pods have terminated when the above returns: + + ``` + No resources found. + ``` + +1. Delete the ConfigMap, Services, and PersistentVolumeClaims. + + ```shell + kubectl delete configmap,service,pvc -l app=mysql + ``` + +1. If you manually provisioned PersistentVolumes, you also need to manually + delete them, as well as release the underlying resources. + If you used a dynamic provisioner, it automatically deletes the + PersistentVolumes when it sees that you deleted the PersistentVolumeClaims. + Some dynamic provisioners (such as those for EBS and PD) also release the + underlying resources upon deleting the PersistentVolumes. + +{% endcapture %} + +{% capture whatsnext %} + +* Look in the [Helm Charts repository](https://github.com/kubernetes/charts) + for other stateful application examples. + +{% endcapture %} + +{% include templates/tutorial.md %} + diff --git a/docs/user-guide/petset.yaml b/docs/user-guide/petset.yaml index 5c29237c48..add3530077 100644 --- a/docs/user-guide/petset.yaml +++ b/docs/user-guide/petset.yaml @@ -14,8 +14,8 @@ spec: selector: app: nginx --- -apiVersion: apps/v1alpha1 -kind: PetSet +apiVersion: apps/v1beta1 +kind: StatefulSet metadata: name: web spec: diff --git a/test/examples_test.go b/test/examples_test.go index 63fea3c5bc..1664267d84 100644 --- a/test/examples_test.go +++ b/test/examples_test.go @@ -17,7 +17,10 @@ limitations under the License. package examples_test import ( + "bufio" + "bytes" "fmt" + "io" "io/ioutil" "os" "path/filepath" @@ -28,6 +31,8 @@ import ( "k8s.io/kubernetes/pkg/api" "k8s.io/kubernetes/pkg/api/testapi" "k8s.io/kubernetes/pkg/api/validation" + "k8s.io/kubernetes/pkg/apis/apps" + apps_validation "k8s.io/kubernetes/pkg/apis/apps/validation" "k8s.io/kubernetes/pkg/apis/batch" batch_validation "k8s.io/kubernetes/pkg/apis/batch/validation" "k8s.io/kubernetes/pkg/apis/extensions" @@ -132,6 +137,16 @@ func validateObject(obj runtime.Object) (errors field.ErrorList) { t.Namespace = api.NamespaceDefault } errors = batch_validation.ValidateCronJob(t) + case *api.ConfigMap: + if t.Namespace == "" { + t.Namespace = api.NamespaceDefault + } + errors = validation.ValidateConfigMap(t) + case *apps.StatefulSet: + if t.Namespace == "" { + t.Namespace = api.NamespaceDefault + } + errors = apps_validation.ValidateStatefulSet(t) default: errors = field.ErrorList{} errors = append(errors, field.InternalError(field.NewPath(""), fmt.Errorf("no validation defined for %#v", obj))) @@ -141,7 +156,7 @@ func validateObject(obj runtime.Object) (errors field.ErrorList) { // Walks inDir for any json/yaml files. Converts yaml to json, and calls fn for // each file found with the contents in data. -func walkConfigFiles(inDir string, fn func(name, path string, data []byte)) error { +func walkConfigFiles(inDir string, fn func(name, path string, data [][]byte)) error { return filepath.Walk(inDir, func(path string, info os.FileInfo, err error) error { if err != nil { return err @@ -160,132 +175,153 @@ func walkConfigFiles(inDir string, fn func(name, path string, data []byte)) erro } name := strings.TrimSuffix(file, ext) + var docs [][]byte if ext == ".yaml" { - out, err := yaml.ToJSON(data) - if err != nil { - return fmt.Errorf("%s: %v", path, err) + // YAML can contain multiple documents. + splitter := yaml.NewYAMLReader(bufio.NewReader(bytes.NewBuffer(data))) + for { + doc, err := splitter.Read() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("%s: %v", path, err) + } + out, err := yaml.ToJSON(doc) + if err != nil { + return fmt.Errorf("%s: %v", path, err) + } + docs = append(docs, out) } - data = out + } else { + docs = append(docs, data) } - fn(name, path, data) + fn(name, path, docs) } return nil }) } func TestExampleObjectSchemas(t *testing.T) { - cases := map[string]map[string]runtime.Object{ + cases := map[string]map[string][]runtime.Object{ "../docs/user-guide/walkthrough": { - "deployment": &extensions.Deployment{}, - "deployment-update": &extensions.Deployment{}, - "pod-nginx": &api.Pod{}, - "pod-nginx-with-label": &api.Pod{}, - "pod-redis": &api.Pod{}, - "pod-with-http-healthcheck": &api.Pod{}, - "podtemplate": &api.PodTemplate{}, - "service": &api.Service{}, + "deployment": {&extensions.Deployment{}}, + "deployment-update": {&extensions.Deployment{}}, + "pod-nginx": {&api.Pod{}}, + "pod-nginx-with-label": {&api.Pod{}}, + "pod-redis": {&api.Pod{}}, + "pod-with-http-healthcheck": {&api.Pod{}}, + "podtemplate": {&api.PodTemplate{}}, + "service": {&api.Service{}}, }, "../docs/user-guide/update-demo": { - "kitten-rc": &api.ReplicationController{}, - "nautilus-rc": &api.ReplicationController{}, + "kitten-rc": {&api.ReplicationController{}}, + "nautilus-rc": {&api.ReplicationController{}}, }, "../docs/user-guide/persistent-volumes/volumes": { - "local-01": &api.PersistentVolume{}, - "local-02": &api.PersistentVolume{}, - "gce": &api.PersistentVolume{}, - "nfs": &api.PersistentVolume{}, + "local-01": {&api.PersistentVolume{}}, + "local-02": {&api.PersistentVolume{}}, + "gce": {&api.PersistentVolume{}}, + "nfs": {&api.PersistentVolume{}}, }, "../docs/user-guide/persistent-volumes/claims": { - "claim-01": &api.PersistentVolumeClaim{}, - "claim-02": &api.PersistentVolumeClaim{}, - "claim-03": &api.PersistentVolumeClaim{}, + "claim-01": {&api.PersistentVolumeClaim{}}, + "claim-02": {&api.PersistentVolumeClaim{}}, + "claim-03": {&api.PersistentVolumeClaim{}}, }, "../docs/user-guide/persistent-volumes/simpletest": { - "namespace": &api.Namespace{}, - "pod": &api.Pod{}, - "service": &api.Service{}, + "namespace": {&api.Namespace{}}, + "pod": {&api.Pod{}}, + "service": {&api.Service{}}, }, "../docs/user-guide/liveness": { - "exec-liveness": &api.Pod{}, - "http-liveness": &api.Pod{}, - "http-liveness-named-port": &api.Pod{}, + "exec-liveness": {&api.Pod{}}, + "http-liveness": {&api.Pod{}}, + "http-liveness-named-port": {&api.Pod{}}, }, "../docs/user-guide/jobs/work-queue-1": { - "job": &batch.Job{}, + "job": {&batch.Job{}}, }, "../docs/user-guide/jobs/work-queue-2": { - "job": &batch.Job{}, - "redis-pod": &api.Pod{}, - "redis-service": &api.Service{}, + "job": {&batch.Job{}}, + "redis-pod": {&api.Pod{}}, + "redis-service": {&api.Service{}}, }, "../docs/user-guide": { - "bad-nginx-deployment": &extensions.Deployment{}, - "counter-pod": &api.Pod{}, - "curlpod": &extensions.Deployment{}, - "deployment": &extensions.Deployment{}, - "ingress": &extensions.Ingress{}, - "job": &batch.Job{}, - "multi-pod": &api.Pod{}, - "new-nginx-deployment": &extensions.Deployment{}, - "nginx-app": &api.Service{}, - "nginx-deployment": &extensions.Deployment{}, - "nginx-init-containers": &api.Pod{}, - "nginx-lifecycle-deployment": &extensions.Deployment{}, - "nginx-probe-deployment": &extensions.Deployment{}, - "nginx-secure-app": &api.Service{}, - "nginx-svc": &api.Service{}, - "petset": &api.Service{}, - "pod": &api.Pod{}, - "pod-w-message": &api.Pod{}, - "redis-deployment": &extensions.Deployment{}, - "redis-resource-deployment": &extensions.Deployment{}, - "redis-secret-deployment": &extensions.Deployment{}, - "run-my-nginx": &extensions.Deployment{}, - "cronjob": &batch.CronJob{}, + "bad-nginx-deployment": {&extensions.Deployment{}}, + "counter-pod": {&api.Pod{}}, + "curlpod": {&extensions.Deployment{}}, + "deployment": {&extensions.Deployment{}}, + "ingress": {&extensions.Ingress{}}, + "job": {&batch.Job{}}, + "multi-pod": {&api.Pod{}, &api.Pod{}}, + "new-nginx-deployment": {&extensions.Deployment{}}, + "nginx-app": {&api.Service{}, &extensions.Deployment{}}, + "nginx-deployment": {&extensions.Deployment{}}, + "nginx-init-containers": {&api.Pod{}}, + "nginx-lifecycle-deployment": {&extensions.Deployment{}}, + "nginx-probe-deployment": {&extensions.Deployment{}}, + "nginx-secure-app": {&api.Service{}, &extensions.Deployment{}}, + "nginx-svc": {&api.Service{}}, + "petset": {&api.Service{}, &apps.StatefulSet{}}, + "pod": {&api.Pod{}}, + "pod-w-message": {&api.Pod{}}, + "redis-deployment": {&extensions.Deployment{}}, + "redis-resource-deployment": {&extensions.Deployment{}}, + "redis-secret-deployment": {&extensions.Deployment{}}, + "run-my-nginx": {&extensions.Deployment{}}, + "cronjob": {&batch.CronJob{}}, }, "../docs/admin": { - "daemon": &extensions.DaemonSet{}, + "daemon": {&extensions.DaemonSet{}}, }, "../docs/user-guide/downward-api": { - "dapi-pod": &api.Pod{}, - "dapi-container-resources": &api.Pod{}, + "dapi-pod": {&api.Pod{}}, + "dapi-container-resources": {&api.Pod{}}, }, "../docs/user-guide/downward-api/volume/": { - "dapi-volume": &api.Pod{}, - "dapi-volume-resources": &api.Pod{}, + "dapi-volume": {&api.Pod{}}, + "dapi-volume-resources": {&api.Pod{}}, }, "../docs/admin/namespaces": { - "namespace-dev": &api.Namespace{}, - "namespace-prod": &api.Namespace{}, + "namespace-dev": {&api.Namespace{}}, + "namespace-prod": {&api.Namespace{}}, }, "../docs/admin/limitrange": { - "invalid-pod": &api.Pod{}, - "limits": &api.LimitRange{}, - "namespace": &api.Namespace{}, - "valid-pod": &api.Pod{}, + "invalid-pod": {&api.Pod{}}, + "limits": {&api.LimitRange{}}, + "namespace": {&api.Namespace{}}, + "valid-pod": {&api.Pod{}}, }, "../docs/user-guide/logging-demo": { - "synthetic_0_25lps": &api.Pod{}, - "synthetic_10lps": &api.Pod{}, + "synthetic_0_25lps": {&api.Pod{}}, + "synthetic_10lps": {&api.Pod{}}, }, "../docs/user-guide/node-selection": { - "pod": &api.Pod{}, - "pod-with-node-affinity": &api.Pod{}, - "pod-with-pod-affinity": &api.Pod{}, + "pod": {&api.Pod{}}, + "pod-with-node-affinity": {&api.Pod{}}, + "pod-with-pod-affinity": {&api.Pod{}}, }, "../docs/admin/resourcequota": { - "best-effort": &api.ResourceQuota{}, - "compute-resources": &api.ResourceQuota{}, - "limits": &api.LimitRange{}, - "namespace": &api.Namespace{}, - "not-best-effort": &api.ResourceQuota{}, - "object-counts": &api.ResourceQuota{}, + "best-effort": {&api.ResourceQuota{}}, + "compute-resources": {&api.ResourceQuota{}}, + "limits": {&api.LimitRange{}}, + "namespace": {&api.Namespace{}}, + "not-best-effort": {&api.ResourceQuota{}}, + "object-counts": {&api.ResourceQuota{}}, }, "../docs/user-guide/secrets": { - "secret-pod": &api.Pod{}, - "secret": &api.Secret{}, - "secret-env-pod": &api.Pod{}, + "secret-pod": {&api.Pod{}}, + "secret": {&api.Secret{}}, + "secret-env-pod": {&api.Pod{}}, + }, + "../docs/tutorials/stateful-application": { + "gce-volume": {&api.PersistentVolume{}}, + "mysql-deployment": {&api.Service{}, &api.PersistentVolumeClaim{}, &extensions.Deployment{}}, + "mysql-services": {&api.Service{}, &api.Service{}}, + "mysql-configmap": {&api.ConfigMap{}}, + "mysql-statefulset": {&apps.StatefulSet{}}, }, } @@ -295,43 +331,52 @@ func TestExampleObjectSchemas(t *testing.T) { for path, expected := range cases { tested := 0 - err := walkConfigFiles(path, func(name, path string, data []byte) { - expectedType, found := expected[name] + numExpected := 0 + err := walkConfigFiles(path, func(name, path string, docs [][]byte) { + expectedTypes, found := expected[name] if !found { t.Errorf("%s: %s does not have a test case defined", path, name) return } - tested++ - if expectedType == nil { - t.Logf("skipping : %s/%s\n", path, name) + numExpected += len(expectedTypes) + if len(expectedTypes) != len(docs) { + t.Errorf("%s: number of expected types (%v) doesn't match number of docs in YAML (%v)", path, len(expectedTypes), len(docs)) return } - if strings.Contains(name, "scheduler-policy-config") { - if err := runtime.DecodeInto(schedulerapilatest.Codec, data, expectedType); err != nil { - t.Errorf("%s did not decode correctly: %v\n%s", path, err, string(data)) + for i, data := range docs { + expectedType := expectedTypes[i] + tested++ + if expectedType == nil { + t.Logf("skipping : %s/%s\n", path, name) return } - // TODO: Add validate method for - // &schedulerapi.Policy, and remove this - // special case - } else { - codec, err := testapi.GetCodecForObject(expectedType) - if err != nil { - t.Errorf("Could not get codec for %s: %s", expectedType, err) - } - if err := runtime.DecodeInto(codec, data, expectedType); err != nil { - t.Errorf("%s did not decode correctly: %v\n%s", path, err, string(data)) - return - } - if errors := validateObject(expectedType); len(errors) > 0 { - t.Errorf("%s did not validate correctly: %v", path, errors) + if strings.Contains(name, "scheduler-policy-config") { + if err := runtime.DecodeInto(schedulerapilatest.Codec, data, expectedType); err != nil { + t.Errorf("%s did not decode correctly: %v\n%s", path, err, string(data)) + return + } + // TODO: Add validate method for + // &schedulerapi.Policy, and remove this + // special case + } else { + codec, err := testapi.GetCodecForObject(expectedType) + if err != nil { + t.Errorf("Could not get codec for %s: %s", expectedType, err) + } + if err := runtime.DecodeInto(codec, data, expectedType); err != nil { + t.Errorf("%s did not decode correctly: %v\n%s", path, err, string(data)) + return + } + if errors := validateObject(expectedType); len(errors) > 0 { + t.Errorf("%s did not validate correctly: %v", path, errors) + } } } }) if err != nil { t.Errorf("Expected no error, Got %v on Path %v", err, path) } - if tested != len(expected) { + if tested != numExpected { t.Errorf("Directory %v: Expected %d examples, Got %d", path, len(expected), tested) } }