diff --git a/.gitignore b/.gitignore index f80a5fb25..a12d96d1c 100644 --- a/.gitignore +++ b/.gitignore @@ -147,6 +147,7 @@ bats/ bundles/ docker/controller-manager/kubeless-controller-manager docker/kafka-controller/kafka-controller +docker/function-image-builder/imbuilder ksonnet-lib/ kubeless-openshift.yaml kubeless-rbac.yaml diff --git a/.travis.yml b/.travis.yml index 1ad741a6a..f5711ac4e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -21,8 +21,10 @@ env: - TEST_TARGET=GKE global: - CONTROLLER_IMAGE_NAME=bitnami/kubeless-controller-manager + - BUILDER_IMAGE_NAME=kubeless/function-image-builder - CONTROLLER_TAG=${TRAVIS_TAG:-build-$TRAVIS_BUILD_ID} - CONTROLLER_IMAGE=${CONTROLLER_IMAGE_NAME}:${CONTROLLER_TAG} + - BUILDER_IMAGE=${BUILDER_IMAGE_NAME}:${CONTROLLER_TAG} - KAFKA_CONTROLLER_IMAGE_NAME=bitnami/kafka-trigger-controller - KAFKA_CONTROLLER_IMAGE=${KAFKA_CONTROLLER_IMAGE_NAME}:${CONTROLLER_TAG} - CGO_ENABLED=0 @@ -94,6 +96,7 @@ script: if [[ "$SHOULD_TEST" == "1" ]]; then make VERSION=${TRAVIS_TAG:-build-$TRAVIS_BUILD_ID} binary make controller-image CONTROLLER_IMAGE=$CONTROLLER_IMAGE + make function-image-builder FUNCTION_IMAGE_BUILDER=$BUILDER_IMAGE make kafka-controller-image KAFKA_CONTROLLER_IMAGE=$KAFKA_CONTROLLER_IMAGE make all-yaml sed -i.bak 's/'":latest"'/'":${CONTROLLER_TAG}"'/g' kubeless.yaml @@ -116,6 +119,7 @@ script: GKE) docker login -u="$DOCKER_USERNAME" -p="$DOCKER_PASSWORD" docker push $CONTROLLER_IMAGE + docker push $BUILDER_IMAGE docker push $KAFKA_CONTROLLER_IMAGE echo "Waiting for the GKE cluster to be ready" tail -f $TRAVIS_BUILD_DIR/gke-start.log & diff --git a/Makefile b/Makefile index bc92ce412..2cb9c041f 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,7 @@ VERSION = dev-$(shell date +%FT%T%z) KUBECFG = kubecfg DOCKER = docker CONTROLLER_IMAGE = kubeless-controller-manager:latest +FUNCTION_IMAGE_BUILDER = kubeless-function-image-builder:latest KAFKA_CONTROLLER_IMAGE = kafka-trigger-controller:latest OS = linux ARCH = amd64 @@ -57,6 +58,15 @@ controller-build: controller-image: docker/controller-manager $(DOCKER) build -t $(CONTROLLER_IMAGE) $< +docker/function-image-builder: function-image-builder-build + cp $(BUNDLES)/kubeless_$(OS)-$(ARCH)/imbuilder $@ + +function-image-builder-build: + ./script/binary-controller -os=$(OS) -arch=$(ARCH) imbuilder github.com/kubeless/kubeless/pkg/function-image-builder + +function-image-builder: docker/function-image-builder + $(DOCKER) build -t $(FUNCTION_IMAGE_BUILDER) $< + docker/kafka-controller: kafka-controller-build cp $(BUNDLES)/kubeless_$(OS)-$(ARCH)/kafka-controller $@ diff --git a/docker/controller-manager/Dockerfile b/docker/controller-manager/Dockerfile index 777ebeac9..b03db618b 100644 --- a/docker/controller-manager/Dockerfile +++ b/docker/controller-manager/Dockerfile @@ -1,5 +1,7 @@ FROM bitnami/minideb:jessie +RUN install_packages ca-certificates + ADD kubeless-controller-manager /kubeless-controller-manager ENTRYPOINT ["/kubeless-controller-manager"] diff --git a/docker/function-image-builder/Dockerfile b/docker/function-image-builder/Dockerfile new file mode 100644 index 000000000..fae93f462 --- /dev/null +++ b/docker/function-image-builder/Dockerfile @@ -0,0 +1,8 @@ +FROM fedora:27 + +RUN dnf install -y skopeo nodejs + +ADD imbuilder / +ADD entrypoint.sh / + +ENTRYPOINT [ "/entrypoint.sh" ] diff --git a/docker/function-image-builder/entrypoint.sh b/docker/function-image-builder/entrypoint.sh new file mode 100755 index 000000000..203a60055 --- /dev/null +++ b/docker/function-image-builder/entrypoint.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +# Copyright (c) 2016-2017 Bitnami +# +# 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. + +set -e + +# Kubernetes ImagePullSecrets uses .dockerconfigjson as the file name +# for storing credentials but skopeo requires it to be named config.json +if [ -f $DOCKER_CONFIG_FOLDER/.dockerconfigjson ]; then + echo "Creating $HOME/.docker/config.json" + mkdir -p $HOME/.docker + ln -s $DOCKER_CONFIG_FOLDER/.dockerconfigjson $HOME/.docker/config.json +fi + +"${@}" diff --git a/docs/building-functions.md b/docs/building-functions.md new file mode 100644 index 000000000..f5c652562 --- /dev/null +++ b/docs/building-functions.md @@ -0,0 +1,64 @@ +# Kubeless building process for functions + +> **Warning**: This feature is still under heavy development + +Kubeless includes a way of building and storing functions as docker images. This can be used to: + + - Persistent function storage. + - Speed the process of redeploying the same function. This is specicially useful for scalling your function. + - Generate immutable function deployments. Once a function image is generated, the same image will be used every time the function is used. + +## Setup the build process + +In order to setup the build process the only steps needed are: + + 1. Generate a Kubernetes [secret](https://kubernetes.io/docs/concepts/configuration/secret) with the credentials required to push images to the docker registry and enable the build st. In order to do so, `kubectl` has an utility that allows you to create this secret in just one command: + +| **Note**: The command below will generate the correct secret only if the version of `kubectl` is 1.9+ + +```console +kubectl create secret docker-registry kubeless-registry-credentials \ + --docker-server=https://index.docker.io/v1/ \ + --docker-username=user \ + --docker-password=password \ + --docker-email=user@example.com +``` + +If the secret has been generated correctly you should see the following output: + +```console +$ kubectl get secret kubeless-registry-credentials --output="jsonpath={.data.\.dockerconfigjson}" | base64 -d + +{"auths":{"https://index.docker.io/v1/":{"username":"user","password":"password","email":"user@example.com","auth":"dGVfdDpwYZNz"}}} +``` + + 2. Enable the build step in the Kubeless configuration. If you have already deploy Kubeless you can enable it editing the configmap. You will need to set the property `enable-build-step: "false"` to `"true"`: + + ```console + kubectl edit configmaps -n kubeless kubeless-config + ``` + + 3. Once the build step is enabled you need to restart the controller in order for the changes to take effect: + + ```console + kubectl delete pod -n kubeless -l kubeless=controller + ``` + +Once the secret is available and the build step is enabled Kubeless will automatically start building function images. + +## Build process + +The following diagram represents the building process: + +![Build Process](./img/build-process.png) + +When a new function is created the Kubeless Controller generates two items: + + - A [Kubernetes job](https://kubernetes.io/docs/concepts/workloads/controllers/jobs-run-to-completion/) that will use the registry credentials to push a new image under the `user` repository. It will use the checksum (SHA256) of the function specification as tag so any change in the function will generate a different image. + - A Pod to run the function. This pod will wait until the previus job finishes in order to pull the function image. + +## Known limitations + + - It is only possible to use a single registry to pull images and push them so if the build system is used with a registry different than https://index.docker.io/v1/ (the official one) the images present in the Kubeless ConfigMap should be copied to the new registry. + - Base images are not currently cached, that means that every time a new build is triggered it will download the base image. + \ No newline at end of file diff --git a/docs/img/build-process.png b/docs/img/build-process.png new file mode 100644 index 000000000..0475b3a3d Binary files /dev/null and b/docs/img/build-process.png differ diff --git a/examples/Makefile b/examples/Makefile index fa6bbaad5..3a6645592 100644 --- a/examples/Makefile +++ b/examples/Makefile @@ -34,6 +34,8 @@ get-python-deps-update: get-python-deps-update-verify: $(eval pod := $(shell kubectl get pod -l function=get-python-deps -o go-template -o custom-columns=:metadata.name --no-headers=true)) + echo "Checking updated deps of $(pod)" + kubectl exec -it $(pod) pip freeze kubectl exec -it $(pod) pip freeze | grep -q "twitter==" get-python-34: @@ -204,13 +206,13 @@ get-dotnetcore-verify: kubeless function call get-dotnetcore |egrep hello.world custom-get-python: - kubeless function deploy --runtime-image kubeless/get-python-example@sha256:a922942597ce617adbe808b9f0cdc3cf7ff987a1277adf0233dd47be5d85082a custom-get-python + kubeless function deploy --runtime-image kubeless/get-python-example@sha256:af01f42956ca9981bc4ccec0c7df553539a44630fb87e00e562091a95e75dd21 custom-get-python custom-get-python-verify: kubeless function call custom-get-python |egrep hello.world custom-get-python-update: - kubeless function update --runtime-image kubeless/get-python-example@sha256:d2ca4ab086564afbac6d30c29614f1623ddb9163b818537742c42fd785fcf2ce custom-get-python + kubeless function update --runtime-image kubeless/get-python-example@sha256:2cbc81d6412be01596bb5d8f3541a8e3e30ae6a8634122764bd32cff400dac3d custom-get-python custom-get-python-update-verify: kubeless function call custom-get-python |egrep hello.world.updated diff --git a/examples/python/Dockerfile b/examples/python/Dockerfile index 3b19d6451..a73ce420c 100644 --- a/examples/python/Dockerfile +++ b/examples/python/Dockerfile @@ -3,4 +3,5 @@ FROM kubeless/python@sha256:ba948a6783b93d75037b7b1806a3925d441401ae6fba18282f71 ENV FUNC_HANDLER=foo \ MOD_NAME=helloget ADD helloget.py / +RUN mkdir -p /kubeless/ ENTRYPOINT [ "bash", "-c", "mv /helloget.py /kubeless/ && python /kubeless.py"] diff --git a/kubeless-rbac.jsonnet b/kubeless-rbac.jsonnet index 338b35786..5325fdb6e 100644 --- a/kubeless-rbac.jsonnet +++ b/kubeless-rbac.jsonnet @@ -21,6 +21,12 @@ local controller_roles = [ resources: ["pods"], verbs: ["list", "delete"], }, + { + apiGroups: [""], + resources: ["secrets"], + resourceNames: ["kubeless-registry-credentials"], + verbs: ["get"], + }, { apiGroups: ["kubeless.io"], resources: ["functions", "kafkatriggers", "httptriggers", "cronjobtriggers"], @@ -28,8 +34,8 @@ local controller_roles = [ }, { apiGroups: ["batch"], - resources: ["cronjobs"], - verbs: ["create", "get", "delete", "list", "update", "patch"], + resources: ["cronjobs", "jobs"], + verbs: ["create", "get", "delete", "deletecollection", "list", "update", "patch"], }, { apiGroups: ["autoscaling"], diff --git a/kubeless.jsonnet b/kubeless.jsonnet index 7fdbe8f47..22a5da12e 100644 --- a/kubeless.jsonnet +++ b/kubeless.jsonnet @@ -156,7 +156,9 @@ local kubelessConfig = configMap.default("kubeless-config", namespace) + configMap.data({"ingress-enabled": "false"}) + configMap.data({"service-type": "ClusterIP"})+ configMap.data({"deployment": std.toString(deploymentConfig)})+ - configMap.data({"runtime-images": std.toString(runtime_images)}); + configMap.data({"runtime-images": std.toString(runtime_images)})+ + configMap.data({"enable-build-step": "false"})+ + configMap.data({"builder-image": "andresmgot/kubeless-function-image-builder:latest"}); { controllerAccount: k.util.prune(controllerAccount), diff --git a/pkg/controller/function_controller.go b/pkg/controller/function_controller.go index f3ca0d89b..8f64be785 100644 --- a/pkg/controller/function_controller.go +++ b/pkg/controller/function_controller.go @@ -17,7 +17,9 @@ limitations under the License. package controller import ( + "crypto/sha256" "fmt" + "net/url" "time" monitoringv1alpha1 "github.com/coreos/prometheus-operator/pkg/client/monitoring/v1alpha1" @@ -39,6 +41,7 @@ import ( "github.com/kubeless/kubeless/pkg/client/clientset/versioned" kv1beta1 "github.com/kubeless/kubeless/pkg/client/informers/externalversions/kubeless/v1beta1" "github.com/kubeless/kubeless/pkg/langruntime" + "github.com/kubeless/kubeless/pkg/registry" "github.com/kubeless/kubeless/pkg/utils" ) @@ -251,6 +254,42 @@ func (c *FunctionController) processItem(key string) error { return nil } +// startImageBuildJob creates (if necessary) a job that will build an image for the given function +// returns the name of the image, a boolean indicating if the build job has been created and an error +func (c *FunctionController) startImageBuildJob(funcObj *kubelessApi.Function, or []metav1.OwnerReference) (string, bool, error) { + imagePullSecret, err := c.clientset.CoreV1().Secrets(funcObj.ObjectMeta.Namespace).Get("kubeless-registry-credentials", metav1.GetOptions{}) + if err != nil { + return "", false, fmt.Errorf("Unable to locate registry credentials to build function image: %v", err) + } + reg, err := registry.New(*imagePullSecret) + if err != nil { + return "", false, fmt.Errorf("Unable to retrieve registry information: %v", err) + } + // Use function content and deps as tag (digested) + tag := fmt.Sprintf("%x", sha256.Sum256([]byte(fmt.Sprintf("%v%v", funcObj.Spec.Function, funcObj.Spec.Deps)))) + imageName := fmt.Sprintf("%s/%s", reg.Creds.Username, funcObj.ObjectMeta.Name) + // Check if image already exists + exists, err := reg.ImageExists(imageName, tag) + if err != nil { + return "", false, fmt.Errorf("Unable to check is target image exists: %v", err) + } + image := fmt.Sprintf("%s:%s", imageName, tag) + if !exists { + regURL, err := url.Parse(reg.Endpoint) + if err != nil { + return "", false, fmt.Errorf("Unable to parse registry URL: %v", err) + } + err = utils.EnsureFuncImage(c.clientset, funcObj, c.langRuntime, or, imageName, tag, c.config.Data["builder-image"], regURL.Host, imagePullSecret.Name) + if err != nil { + return "", false, fmt.Errorf("Unable to create image build job: %v", err) + } + } else { + // Image already exists + return image, false, nil + } + return image, true, nil +} + // ensureK8sResources creates/updates k8s objects (deploy, svc, configmap) for the function func (c *FunctionController) ensureK8sResources(funcObj *kubelessApi.Function) error { if len(funcObj.ObjectMeta.Labels) == 0 { @@ -287,7 +326,30 @@ func (c *FunctionController) ensureK8sResources(funcObj *kubelessApi.Function) e return err } - err = utils.EnsureFuncDeployment(c.clientset, funcObj, or, c.langRuntime) + prebuiltImage := "" + if len(funcObj.Spec.Deployment.Spec.Template.Spec.Containers) > 0 && funcObj.Spec.Deployment.Spec.Template.Spec.Containers[0].Image != "" { + prebuiltImage = funcObj.Spec.Deployment.Spec.Template.Spec.Containers[0].Image + } + // Skip image build step if using a custom runtime + if prebuiltImage == "" { + if c.config.Data["enable-build-step"] == "true" { + var isBuilding bool + prebuiltImage, isBuilding, err = c.startImageBuildJob(funcObj, or) + if err != nil { + logrus.Errorf("Unable to build function: %v", err) + } else { + if isBuilding { + logrus.Infof("Started build process for function %s", funcObj.ObjectMeta.Name) + } else { + logrus.Infof("Found existing image %s", prebuiltImage) + } + } + } + } else { + logrus.Infof("Skipping image-build step for %s", funcObj.ObjectMeta.Name) + } + + err = utils.EnsureFuncDeployment(c.clientset, funcObj, or, c.langRuntime, prebuiltImage) if err != nil { return err } @@ -358,6 +420,14 @@ func (c *FunctionController) deleteK8sResources(ns, name string) error { return err } + // delete build job + err = c.clientset.BatchV1().Jobs(ns).DeleteCollection(&metav1.DeleteOptions{}, metav1.ListOptions{ + LabelSelector: fmt.Sprintf("created-by=kubeless,function=%s", name), + }) + if err != nil && !k8sErrors.IsNotFound(err) { + return err + } + return nil } diff --git a/pkg/function-image-builder/image_builder.go b/pkg/function-image-builder/image_builder.go new file mode 100644 index 000000000..53d67139f --- /dev/null +++ b/pkg/function-image-builder/image_builder.go @@ -0,0 +1,164 @@ +/* +Copyright (c) 2016-2017 Bitnami + +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 ( + "bufio" + "fmt" + "io/ioutil" + "log" + "os" + "os/exec" + + lbuilder "github.com/kubeless/kubeless/pkg/function-image-builder/layer-builder" + "github.com/spf13/cobra" +) + +var globalUsage = `` //TODO: add explanation + +func init() { + layerCmd.Flags().StringP("src", "", "", "Source image reference. F.e. dir://path/to/image") + layerCmd.Flags().StringP("src-creds", "", "", "Source image credentials in case it is a private registry. F.e. user:my_pass") + layerCmd.Flags().StringP("dst", "", "", "Destination image reference. F.e. docker://user/image") + layerCmd.Flags().StringP("dst-creds", "", "", "Destination credentials in case it is a docker registry. F.e. user:my_pass") + layerCmd.Flags().StringP("cwd", "", "", "Working directory") +} + +func runCommand(command string, args []string) error { + cmd := exec.Command(command, args...) + + stdout, _ := cmd.StdoutPipe() + stderr, _ := cmd.StderrPipe() + cmd.Start() + + scannerStdout := bufio.NewScanner(stdout) + scannerStdout.Split(bufio.ScanLines) + for scannerStdout.Scan() { + m := scannerStdout.Text() + fmt.Fprintln(os.Stdout, m) + } + scannerStderr := bufio.NewScanner(stderr) + scannerStderr.Split(bufio.ScanLines) + for scannerStderr.Scan() { + m := scannerStderr.Text() + fmt.Fprintln(os.Stderr, m) + } + + return cmd.Wait() +} + +func skopeoCopy(src, dst, srcCreds, dstCreds string) error { + command := "skopeo" + args := []string{"copy"} + if srcCreds != "" { + args = append(args, "--src-creds", srcCreds) + } + if dstCreds != "" { + args = append(args, "--dst-creds", dstCreds) + } + args = append(args, src, dst) + return runCommand(command, args) +} + +var layerCmd = &cobra.Command{ + Use: "add-layer FLAG", + Short: "Add tar as a image layer", + Long: ``, + Run: func(cmd *cobra.Command, args []string) { + if len(args) != 1 { + log.Fatal("Need exactly one argument - layer tar") + } + + layerTar := args[0] + + srcImage, err := cmd.Flags().GetString("src") + if err != nil { + log.Fatal(err) + } + if srcImage == "" { + log.Fatal("Need specify the source image using the flag --src") + } + + dstImage, err := cmd.Flags().GetString("dst") + if err != nil { + log.Fatal(err) + } + if dstImage == "" { + log.Fatal("Need specify the destination image using the flag --dst") + } + + srcCreds, err := cmd.Flags().GetString("src-creds") + if err != nil { + log.Fatal(err) + } + + dstCreds, err := cmd.Flags().GetString("dst-creds") + if err != nil { + log.Fatal(err) + } + + workDir, err := cmd.Flags().GetString("cwd") + if err != nil { + log.Fatal(err) + } + if workDir == "" { + workDir, err = ioutil.TempDir("", "build") + if err != nil { + log.Fatal(err) + } + } + + // Store src image + err = skopeoCopy(srcImage, fmt.Sprintf("dir://%s", workDir), srcCreds, dstCreds) + if err != nil { + log.Fatal(err) + } + log.Println("Succesfully stored base image ", srcImage, " at ", workDir) + + // Add layer + err = lbuilder.AddTarToLayer(workDir, layerTar) + if err != nil { + log.Fatal(err) + } + log.Println("Added layer ", layerTar, " in ", workDir) + + // Publish new image + err = skopeoCopy(fmt.Sprintf("dir://%s", workDir), dstImage, srcCreds, dstCreds) + if err != nil { + log.Fatal(err) + } + log.Println("Succesfully stored final image at ", dstImage) + }, +} + +func newRootCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "imbuilder", + Short: "Pulls an image and push a new one including a tar file as a new layer", + Long: globalUsage, + } + + cmd.AddCommand(layerCmd) + return cmd +} + +func main() { + cmd := newRootCmd() + if err := cmd.Execute(); err != nil { + os.Exit(1) + } +} diff --git a/pkg/function-image-builder/layer-builder/description.go b/pkg/function-image-builder/layer-builder/description.go new file mode 100644 index 000000000..003d6298f --- /dev/null +++ b/pkg/function-image-builder/layer-builder/description.go @@ -0,0 +1,121 @@ +/* +Copyright (c) 2016-2017 Bitnami + +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 layerbuilder + +import ( + "crypto/sha256" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "time" +) + +// Config represents a container configuration +type Config struct { + Hostname string + Domainname string + User string + AttachStdin bool + AttachStdout bool + AttachStderr bool + Tty bool + OpenStdin bool + StdinOnce bool + Env []string + Cmd []string + ArgsEscaped bool + Image string + Volumes interface{} + WorkingDir string + Entrypoint interface{} + OnBuild interface{} + Labels interface{} +} + +// HistoryEntry represents a layer creation info +type HistoryEntry struct { + Created string `json:"created"` + CreatedBy string `json:"created_by,omitifempty"` + Comment string `json:"comment,omitifempty"` + EmptyLayer bool `json:"empty_layer,omitifempty"` +} + +// Rootfs represents the root filesystem of an image +type Rootfs struct { + Type string `json:"type"` + DiffIds []string `json:"diff_ids"` +} + +// Description represents the specification of a Docker image +type Description struct { + Arch string `json:"architecture"` + Config Config `json:"config"` + Container string `json:"container"` + ContainerConfig Config `json:"container_config"` + Created string `json:"created"` + DockerVersion string `json:"docker_version"` + History []HistoryEntry `json:"history"` + OS string `json:"os"` + Rootfs Rootfs `json:"rootfs"` +} + +// New generates a Description object based on the description file +func (d *Description) New(descriptionFile io.Reader) error { + descriptionContent, err := ioutil.ReadAll(descriptionFile) + if err != nil { + return err + } + return json.Unmarshal(descriptionContent, d) +} + +// AddLayer adds a new Layer to the image Description +func (d *Description) AddLayer(newLayer *Layer) { + // Delete some properties that doesn't apply anymore + d.Config.Hostname = "" + d.Config.Image = "" + d.Container = "" + d.ContainerConfig.Hostname = "" + d.ContainerConfig.Image = "" + // Update new properties + d.Created = time.Now().UTC().Format(time.RFC3339) + d.History = append(d.History, HistoryEntry{ + Created: time.Now().UTC().Format(time.RFC3339), + Comment: "Created by Kubeless", + }) + d.Rootfs.DiffIds = append(d.Rootfs.DiffIds, fmt.Sprintf("sha256:%s", newLayer.Sha256)) +} + +// Content returns the description content +func (d *Description) Content() ([]byte, error) { + return json.Marshal(*d) +} + +// ToLayer returns the Description as a Layer +func (d *Description) ToLayer() (*Layer, error) { + content, err := d.Content() + if err != nil { + return nil, err + } + descriptionNewSize := int64(len(content)) + descriptionNewSha := fmt.Sprintf("%x", sha256.Sum256(content)) + + return &Layer{ + Size: descriptionNewSize, + Sha256: descriptionNewSha, + }, nil +} diff --git a/pkg/function-image-builder/layer-builder/description_test.go b/pkg/function-image-builder/layer-builder/description_test.go new file mode 100644 index 000000000..57f302eba --- /dev/null +++ b/pkg/function-image-builder/layer-builder/description_test.go @@ -0,0 +1,69 @@ +/* +Copyright (c) 2016-2017 Bitnami + +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 layerbuilder + +import ( + "strings" + "testing" +) + +func TestNewDescription(t *testing.T) { + descFile := strings.NewReader(`{"architecture":"amd64","config":{"Hostname":"","Domainname":"","User":"","AttachStdin":false,"AttachStdout":false,"AttachStderr":false,"Tty":false,"OpenStdin":false,"StdinOnce":false,"Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],"Cmd":["sh"],"ArgsEscaped":true,"Image":"sha256:8cae5980d887cc55ba2f978ae99c662007ee06d79881678d57f33f0473fe0736","Volumes":null,"WorkingDir":"","Entrypoint":null,"OnBuild":null,"Labels":null},"container":"8d2c840a1a9b2544fe713c2e24b6757d52328f09bdfc9c2ef6219afbf7ae6b59","container_config":{"Hostname":"8d2c840a1a9b","Domainname":"","User":"","AttachStdin":false,"AttachStdout":false,"AttachStderr":false,"Tty":false,"OpenStdin":false,"StdinOnce":false,"Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],"Cmd":["/bin/sh","-c","#(nop) "],"ArgsEscaped":true,"Image":"sha256:8cae5980d887cc55ba2f978ae99c662007ee06d79881678d57f33f0473fe0736","Volumes":null,"WorkingDir":"","Entrypoint":null,"OnBuild":null,"Labels":{}},"created":"2018-02-28T22:14:49.023807051Z","docker_version":"17.06.2-ce","history":[{"created":"2018-02-28T22:14:48.759033366Z","created_by":"/bin/sh -c #(nop) ADD file:327f69fc1ac9a7b6e56e9032f7b8fbd7741dd0b22920761909c6c8e5fa9c5815 in / "},{"created":"2018-02-28T22:14:49.023807051Z","created_by":"/bin/sh -c #(nop) ","empty_layer":true}],"os":"linux","rootfs":{"type":"layers","diff_ids":["sha256:c5183829c43c4698634093dc38f9bee26d1b931dedeba71dbee984f42fe1270d"]}}`) + d := Description{} + err := d.New(descFile) + if err != nil { + t.Errorf("Unexpected error %v", err) + } +} + +func TestAddLayerDescription(t *testing.T) { + descFile := strings.NewReader(`{"architecture":"amd64","config":{"Hostname":"","Domainname":"","User":"","AttachStdin":false,"AttachStdout":false,"AttachStderr":false,"Tty":false,"OpenStdin":false,"StdinOnce":false,"Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],"Cmd":["sh"],"ArgsEscaped":true,"Image":"sha256:8cae5980d887cc55ba2f978ae99c662007ee06d79881678d57f33f0473fe0736","Volumes":null,"WorkingDir":"","Entrypoint":null,"OnBuild":null,"Labels":null},"container":"8d2c840a1a9b2544fe713c2e24b6757d52328f09bdfc9c2ef6219afbf7ae6b59","container_config":{"Hostname":"8d2c840a1a9b","Domainname":"","User":"","AttachStdin":false,"AttachStdout":false,"AttachStderr":false,"Tty":false,"OpenStdin":false,"StdinOnce":false,"Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],"Cmd":["/bin/sh","-c","#(nop) "],"ArgsEscaped":true,"Image":"sha256:8cae5980d887cc55ba2f978ae99c662007ee06d79881678d57f33f0473fe0736","Volumes":null,"WorkingDir":"","Entrypoint":null,"OnBuild":null,"Labels":{}},"created":"2018-02-28T22:14:49.023807051Z","docker_version":"17.06.2-ce","history":[{"created":"2018-02-28T22:14:48.759033366Z","created_by":"/bin/sh -c #(nop) ADD file:327f69fc1ac9a7b6e56e9032f7b8fbd7741dd0b22920761909c6c8e5fa9c5815 in / "},{"created":"2018-02-28T22:14:49.023807051Z","created_by":"/bin/sh -c #(nop) ","empty_layer":true}],"os":"linux","rootfs":{"type":"layers","diff_ids":["sha256:c5183829c43c4698634093dc38f9bee26d1b931dedeba71dbee984f42fe1270d"]}}`) + d := Description{} + err := d.New(descFile) + if err != nil { + t.Errorf("Unexpected error %v", err) + } + newLayer := Layer{ + Size: 10, + Sha256: "abc123", + } + d.AddLayer(&newLayer) + // Last history entry should be the new layer + if d.History[len(d.History)-1].Comment != "Created by Kubeless" { + t.Errorf("Failed to include new layer: %v", d.History) + } + // Last rootfs.diff_id should be the new layer + if d.Rootfs.DiffIds[len(d.Rootfs.DiffIds)-1] == "abc123" { + t.Error("Failed to include new layer") + } +} + +func TestDescriptionToLayer(t *testing.T) { + emptyDesc := Description{} + res, err := emptyDesc.ToLayer() + if err != nil { + t.Fatalf("Unexpected error %v", err) + } + expectedSize := int64(721) + expectedSha := "17263670d4f12e26a270c7ec0a443c3ba8354da1d42f43f8e421634c5965bb6b" + if res.Sha256 != expectedSha { + t.Errorf("Unexpected sha256 %s", res.Sha256) + } + if res.Size != expectedSize { + t.Errorf("Unexpected size %d", res.Size) + } +} diff --git a/pkg/function-image-builder/layer-builder/layer.go b/pkg/function-image-builder/layer-builder/layer.go new file mode 100644 index 000000000..6f6175dd2 --- /dev/null +++ b/pkg/function-image-builder/layer-builder/layer.go @@ -0,0 +1,48 @@ +/* +Copyright (c) 2016-2017 Bitnami + +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 layerbuilder + +import ( + "crypto/sha256" + "fmt" + "io/ioutil" + "os" +) + +// Layer represent the size and checksum of a image layer +type Layer struct { + Size int64 + Sha256 string +} + +// New returns a Layer based on its file +func (f *Layer) New(layerFile *os.File) error { + // Calculate sha256 + fContent, err := ioutil.ReadAll(layerFile) + if err != nil { + return err + } + f.Sha256 = fmt.Sprintf("%x", sha256.Sum256(fContent)) + + // Calculate size + fstat, err := layerFile.Stat() + if err != nil { + return err + } + f.Size = fstat.Size() + return nil +} diff --git a/pkg/function-image-builder/layer-builder/layer_builder.go b/pkg/function-image-builder/layer-builder/layer_builder.go new file mode 100644 index 000000000..3b1a84f92 --- /dev/null +++ b/pkg/function-image-builder/layer-builder/layer_builder.go @@ -0,0 +1,149 @@ +/* +Copyright (c) 2016-2017 Bitnami + +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 layerbuilder + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "os" + "path" + "strings" +) + +func copyReader(src io.Reader, dst string) error { + dstFile, err := os.Create(dst) + if err != nil { + return err + } + defer dstFile.Close() + + _, err = io.Copy(dstFile, src) + if err != nil { + return err + } + err = dstFile.Sync() + if err != nil { + return err + } + return nil +} + +func copyFile(src, dst string) error { + srcFile, err := os.Open(src) + if err != nil { + return err + } + defer srcFile.Close() + + return copyReader(srcFile, dst) +} + +func getLayer(file string) (*Layer, error) { + layerFile, err := os.Open(file) + if err != nil { + return nil, err + } + defer layerFile.Close() + layer := Layer{} + err = layer.New(layerFile) + if err != nil { + return nil, err + } + return &layer, nil +} + +func saveNewDescription(content []byte, dir, contentChecksum string) error { + dLayerFile := path.Join(dir, fmt.Sprintf("%s.tar", contentChecksum)) + return copyReader(bytes.NewReader(content), dLayerFile) +} + +func updateDescription(descriptionDir string, descriptionFile *os.File, newLayer *Layer) (*Description, error) { + d := Description{} + err := d.New(descriptionFile) + if err != nil { + return nil, fmt.Errorf("Unable to parse image description: %v", err) + } + d.AddLayer(newLayer) + if err != nil { + return nil, fmt.Errorf("Unable to update image description: %v", err) + } + return &d, nil +} + +// AddTarToLayer copies a tar file into a image directory and update its metadata +func AddTarToLayer(imageDir, tarFile string) error { + tarLayer, err := getLayer(tarFile) + if err != nil { + return err + } + err = copyFile(tarFile, path.Join(imageDir, fmt.Sprintf("%s.tar", tarLayer.Sha256))) + if err != nil { + return fmt.Errorf("Failed to copy tar file: %v", err) + } + + // Parse manifest + manifestPath := path.Join(imageDir, "manifest.json") + manifestFile, err := os.Open(manifestPath) + if err != nil { + return err + } + m := Manifest{} + err = m.New(manifestFile) + if err != nil { + return fmt.Errorf("Failed to parse image manifest: %v", err) + } + + // Update description + descriptionPath := path.Join(imageDir, fmt.Sprintf("%s.tar", strings.Replace(m.Config.Digest, "sha256:", "", -1))) + descriptionFile, err := os.Open(descriptionPath) + if err != nil { + return err + } + description, err := updateDescription(imageDir, descriptionFile, tarLayer) + if err != nil { + return err + } + descriptionLayer, err := description.ToLayer() + if err != nil { + return fmt.Errorf("Unable to generate layer from description: %v", err) + } + descriptionContent, err := description.Content() + if err != nil { + return err + } + err = saveNewDescription(descriptionContent, imageDir, descriptionLayer.Sha256) + if err != nil { + return err + } + + // Update manifest + m.UpdateConfig(descriptionLayer) + m.AddLayer(tarLayer) + mBytes, err := json.Marshal(m) + if err != nil { + return err + } + err = ioutil.WriteFile(manifestPath, mBytes, 0644) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/function-image-builder/layer-builder/layer_test.go b/pkg/function-image-builder/layer-builder/layer_test.go new file mode 100644 index 000000000..ef7a7f93b --- /dev/null +++ b/pkg/function-image-builder/layer-builder/layer_test.go @@ -0,0 +1,43 @@ +/* +Copyright (c) 2016-2017 Bitnami + +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 layerbuilder + +import ( + "io/ioutil" + "os" + "testing" +) + +func TestNewLayer(t *testing.T) { + f, err := ioutil.TempFile("", "") + if err != nil { + t.Fatal(err) + } + defer os.Remove(f.Name()) + f.WriteString("test content") + layer := Layer{} + err = layer.New(f) + if err != nil { + t.Fatalf("Unexpected error %v", err) + } + if layer.Sha256 != "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" { + t.Errorf("Wrong sha, expecting patata, received %s", layer.Sha256) + } + if layer.Size != 12 { + t.Errorf("Wrong size, expecting patata, received %d", layer.Size) + } +} diff --git a/pkg/function-image-builder/layer-builder/manifest.go b/pkg/function-image-builder/layer-builder/manifest.go new file mode 100644 index 000000000..3eb8a56b8 --- /dev/null +++ b/pkg/function-image-builder/layer-builder/manifest.go @@ -0,0 +1,66 @@ +/* +Copyright (c) 2016-2017 Bitnami + +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 layerbuilder + +import ( + "encoding/json" + "fmt" + "io" + "io/ioutil" +) + +type layer struct { + MediaType string `json:"mediaType"` + Size int64 `json:"size"` + Digest string `json:"digest"` +} + +// Manifest represent the manifest.json of an image +type Manifest struct { + SchemaVersion int `json:"schemaVersion"` + MediaType string `json:"mediaType"` + Config layer `json:"config"` + Layers []layer `json:"layers"` +} + +// New parses an io.Reader into a Manifest +func (m *Manifest) New(manifestFile io.Reader) error { + manifestContent, err := ioutil.ReadAll(manifestFile) + if err != nil { + return err + } + err = json.Unmarshal(manifestContent, m) + if err != nil { + return nil + } + return nil +} + +// UpdateConfig overrides the Config information of the manifest with a new Layer +func (m *Manifest) UpdateConfig(newConfig *Layer) { + m.Config.Size = int64(newConfig.Size) + m.Config.Digest = fmt.Sprintf("sha256:%s", newConfig.Sha256) +} + +// AddLayer adds a new layer to the list in the Manifest +func (m *Manifest) AddLayer(newLayer *Layer) { + m.Layers = append(m.Layers, layer{ + MediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip", + Size: newLayer.Size, + Digest: fmt.Sprintf("sha256:%s", newLayer.Sha256), + }) +} diff --git a/pkg/function-image-builder/layer-builder/manifest_test.go b/pkg/function-image-builder/layer-builder/manifest_test.go new file mode 100644 index 000000000..57cb54875 --- /dev/null +++ b/pkg/function-image-builder/layer-builder/manifest_test.go @@ -0,0 +1,75 @@ +/* +Copyright (c) 2016-2017 Bitnami + +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 layerbuilder + +import ( + "strings" + "testing" +) + +func TestNewManifest(t *testing.T) { + manifestFile := strings.NewReader(`{"schemaVersion":2,"mediaType":"application/vnd.docker.distribution.manifest.v2+json","config":{"mediaType":"application/vnd.docker.container.image.v1+json","size":1489,"digest":"sha256:c7fc094ddbf9f9335543421b34d8c6f3becd3bb05c9f9a5ca0f0e6065871072d"},"layers":[{"mediaType":"application/vnd.docker.image.rootfs.diff.tar.gzip","size":723113,"digest":"sha256:d070b8ef96fc4f2d92ff520a4fe55594e362b4e1076a32bbfeb261dc03322910"}]}`) + m := Manifest{} + err := m.New(manifestFile) + if err != nil { + t.Errorf("Unexpected error %v", err) + } + if m.Config.Size != 1489 { + t.Errorf("Unexpected size %d", m.Config.Size) + } + if m.Config.Digest != "sha256:c7fc094ddbf9f9335543421b34d8c6f3becd3bb05c9f9a5ca0f0e6065871072d" { + t.Errorf("Unexpected digest %s", m.Config.Digest) + } + if len(m.Layers) != 1 { + t.Errorf("Unexpected layers length %d", len(m.Layers)) + } +} + +func TestAddNewLayer(t *testing.T) { + manifestFile := strings.NewReader(`{"schemaVersion":2,"mediaType":"application/vnd.docker.distribution.manifest.v2+json","config":{"mediaType":"application/vnd.docker.container.image.v1+json","size":1489,"digest":"sha256:c7fc094ddbf9f9335543421b34d8c6f3becd3bb05c9f9a5ca0f0e6065871072d"},"layers":[{"mediaType":"application/vnd.docker.image.rootfs.diff.tar.gzip","size":723113,"digest":"sha256:d070b8ef96fc4f2d92ff520a4fe55594e362b4e1076a32bbfeb261dc03322910"}]}`) + m := Manifest{} + err := m.New(manifestFile) + if err != nil { + t.Errorf("Unexpected error %v", err) + } + m.AddLayer(&Layer{ + Size: 10, + Sha256: "Test", + }) + if len(m.Layers) != 2 { + t.Errorf("Unexpected layers length %d", len(m.Layers)) + } + if m.Layers[1].Size != 10 && m.Layers[1].Digest != "Test" { + t.Errorf("Unexpected layer %v", m.Layers[1]) + } +} + +func TestUpdateConfig(t *testing.T) { + manifestFile := strings.NewReader(`{"schemaVersion":2,"mediaType":"application/vnd.docker.distribution.manifest.v2+json","config":{"mediaType":"application/vnd.docker.container.image.v1+json","size":1489,"digest":"sha256:c7fc094ddbf9f9335543421b34d8c6f3becd3bb05c9f9a5ca0f0e6065871072d"},"layers":[{"mediaType":"application/vnd.docker.image.rootfs.diff.tar.gzip","size":723113,"digest":"sha256:d070b8ef96fc4f2d92ff520a4fe55594e362b4e1076a32bbfeb261dc03322910"}]}`) + m := Manifest{} + err := m.New(manifestFile) + if err != nil { + t.Errorf("Unexpected error %v", err) + } + m.UpdateConfig(&Layer{ + Size: 10, + Sha256: "Test", + }) + if m.Config.Size != 10 && m.Config.Digest != "Test" { + t.Errorf("Unexpected layer %v", m.Config) + } +} diff --git a/pkg/registry/registry.go b/pkg/registry/registry.go new file mode 100644 index 000000000..709e4c74a --- /dev/null +++ b/pkg/registry/registry.go @@ -0,0 +1,239 @@ +/* +Copyright (c) 2016-2017 Bitnami + +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 registry + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "reflect" + "regexp" + "time" + + "k8s.io/api/core/v1" +) + +// Credentials represent the required credentials to authenticate against a Docker registry +type Credentials struct { + Username string `json:"username"` + Password string `json:"password"` + Email string `json:"email,omitifempty"` + Auth string `json:"auth,omitifempty"` +} + +// Registry struct represents a Docker Registry +type Registry struct { + Endpoint string + Version string + Creds Credentials +} + +type tagv1 struct { + Layer string `json:"layer"` + Name string `json:"name"` +} + +type tagListV2 struct { + Name string `json:"name"` + Tags []string `json:"tags"` +} + +type dockerCfg struct { + Auths map[string]Credentials `json:"auths"` +} + +// New returns a Registry struct parsing its URL and storing the required credentials +func New(config v1.Secret) (*Registry, error) { + // Parse secret + cfg := dockerCfg{} + err := json.Unmarshal(config.Data[".dockerconfigjson"], &cfg) + if err != nil { + return nil, err + } + regs := reflect.ValueOf(cfg.Auths).MapKeys() + if len(regs) > 1 { + return nil, fmt.Errorf("Found several registries: %q, unable to decide which one to use", regs) + } + registryURL := regs[0].String() + re := regexp.MustCompile("(https?://.*)/(v[0-9]+)/?") + parsedURL := re.FindStringSubmatch(registryURL) + if len(parsedURL) == 0 { + return nil, fmt.Errorf("Unable to parse registry URL %s", registryURL) + } + reg := Registry{ + Endpoint: parsedURL[1], + Version: parsedURL[2], + Creds: cfg.Auths[registryURL], + } + return ®, err +} + +// getTags return the list of tags from an HTTP response to the tag/list API endpoint +func (r *Registry) getTags(body []byte) ([]string, error) { + switch r.Version { + case "v1": + response := []tagv1{} + err := json.Unmarshal(body, &response) + if err != nil { + return nil, err + } + tags := []string{} + for _, tag := range response { + tags = append(tags, tag.Name) + } + return tags, nil + case "v2": + response := tagListV2{} + err := json.Unmarshal(body, &response) + if err != nil { + return nil, err + } + return response.Tags, nil + default: + return nil, fmt.Errorf("API version %s not supported", r.Version) + } +} + +// tagURL return the URL of the endpoint for listing existing tags +func (r *Registry) tagURL(img string) (string, error) { + switch r.Version { + case "v1": + return fmt.Sprintf("%s/%s/repositories/%s/tags", r.Endpoint, r.Version, img), nil + case "v2": + return fmt.Sprintf("%s/%s/%s/tags/list", r.Endpoint, r.Version, img), nil + default: + return "", fmt.Errorf("API version %s not supported", r.Version) + } +} + +// findProperty returns the value of a property from a list witht the format 'foo="bar",bar="foo"' +func findProperty(src, property string) (string, error) { + re := regexp.MustCompile(fmt.Sprintf("%s=\"([^\"]*)\"", property)) + res := re.FindStringSubmatch(src) + if len(res) != 2 { + return "", fmt.Errorf("Unable to find the property %s in %s", property, src) + } + return res[1], nil +} + +type authResponse struct { + Token string `json:"token"` +} + +// doRequestWithAuth does an HTTP GET agains the given url parsing the authInfo given +func doRequestWithAuth(authInfo, url string, client *http.Client) ([]byte, error) { + bearer, err := findProperty(authInfo, "Bearer realm") + if err != nil { + return nil, fmt.Errorf("Unable to extract auth info: %v", err) + } + service, err := findProperty(authInfo, "service") + if err != nil { + return nil, fmt.Errorf("Unable to extract auth info: %v", err) + } + scope, err := findProperty(authInfo, "scope") + if err != nil { + return nil, fmt.Errorf("Unable to extract auth info: %v", err) + } + authResp, err := client.Get(fmt.Sprintf("%s?service=%s&scope=%s", bearer, service, scope)) + if err != nil { + return nil, fmt.Errorf("Unable to obtain auth token: %v", err) + } + defer authResp.Body.Close() + authb, err := ioutil.ReadAll(authResp.Body) + if err != nil { + return nil, err + } + authr := authResponse{} + err = json.Unmarshal(authb, &authr) + if err != nil { + return nil, fmt.Errorf("Unable to parse auth token: %v", err) + } + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", authr.Token)) + respWithAuth, err := client.Do(req) + if err != nil { + return nil, err + } + defer respWithAuth.Body.Close() + body, err := ioutil.ReadAll(respWithAuth.Body) + if err != nil { + return nil, err + } + return body, nil +} + +func (r *Registry) doRequest(url string) ([]byte, error) { + tr := &http.Transport{ + MaxIdleConns: 10, + IdleConnTimeout: 30 * time.Second, + DisableCompression: true, + } + client := &http.Client{ + Transport: tr, + } + resp, err := client.Get(url) + if err != nil { + return nil, err + } + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + // Handle auth if needed + if resp.StatusCode == 401 { + // Get auth info from headers + authInfo := resp.Header.Get("Www-Authenticate") + if authInfo == "" { + return nil, fmt.Errorf("Failed to authenticate: unknown authentication format: %v", body) + } + body, err = doRequestWithAuth(authInfo, url, client) + if err != nil { + return nil, err + } + } + return body, nil +} + +// ImageExists checks if a certain image:tag exists in the registry +func (r *Registry) ImageExists(id, tag string) (bool, error) { + url, err := r.tagURL(id) + if err != nil { + return false, err + } + body, err := r.doRequest(url) + if err != nil { + return false, err + } + if match, _ := regexp.MatchString("Resource not found", string(body)); match { + // There is no image with that ID yet + return false, nil + } + tags, err := r.getTags(body) + if err != nil { + return false, err + } + for _, t := range tags { + if t == tag { + return true, nil + } + } + return false, nil +} diff --git a/pkg/registry/registry_test.go b/pkg/registry/registry_test.go new file mode 100644 index 000000000..0b553521f --- /dev/null +++ b/pkg/registry/registry_test.go @@ -0,0 +1,92 @@ +package registry + +import ( + "reflect" + "testing" + + "k8s.io/api/core/v1" +) + +func TestNew(t *testing.T) { + s := v1.Secret{ + Data: map[string][]byte{ + ".dockerconfigjson": []byte("{\"auths\":{\"https://index.docker.io/v1/\":{\"username\":\"test\",\"password\":\"pass\"}}}"), + }, + } + r, err := New(s) + if err != nil { + t.Error(err) + } + if r.Endpoint != "https://index.docker.io" { + t.Errorf("Unexpected endpoint %s, expecting https://index.docker.io", r.Endpoint) + } + if r.Version != "v1" { + t.Errorf("Unexpected version %s, expecting v1", r.Version) + } + if r.Creds.Username != "test" { + t.Errorf("Unexpected username %s, expecting test", r.Creds.Username) + } + if r.Creds.Password != "pass" { + t.Errorf("Unexpected password %s, expecting pass", r.Creds.Password) + } +} + +func TestTagURLV1(t *testing.T) { + r := Registry{ + Endpoint: "https://registry-1.docker.io", + Version: "v1", + } + url, err := r.tagURL("test/image") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if url != "https://registry-1.docker.io/v1/repositories/test/image/tags" { + t.Errorf("Unexpected URL %s", url) + } +} + +func TestTagURLV2(t *testing.T) { + r := Registry{ + Endpoint: "https://registry-1.docker.io", + Version: "v2", + } + url, err := r.tagURL("test/image") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if url != "https://registry-1.docker.io/v2/test/image/tags/list" { + t.Errorf("Unexpected URL %s", url) + } +} + +func TestGetTagsV1(t *testing.T) { + r := Registry{ + Endpoint: "https://registry-1.docker.io", + Version: "v1", + } + body := []byte("[{\"later\": \"\", \"name\": \"latest\"}]") + tags, err := r.getTags(body) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + expectedTags := []string{"latest"} + if !reflect.DeepEqual(tags, expectedTags) { + t.Errorf("Unexpected tags: %v", tags) + } +} + +func TestGetTagsV2(t *testing.T) { + r := Registry{ + Endpoint: "https://registry-1.docker.io", + Version: "v2", + } + body := []byte("{\"name\": \"test\", \"tags\":[\"latest\"]}") + tags, err := r.getTags(body) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + expectedTags := []string{"latest"} + if !reflect.DeepEqual(tags, expectedTags) { + t.Errorf("Unexpected tags: %v", tags) + } +} diff --git a/pkg/utils/k8sutil.go b/pkg/utils/k8sutil.go index 0df0a5e87..854b9fc4e 100644 --- a/pkg/utils/k8sutil.go +++ b/pkg/utils/k8sutil.go @@ -671,6 +671,205 @@ func EnsureFuncService(client kubernetes.Interface, funcObj *kubelessApi.Functio return err } +func getRuntimeVolumeMount(name string) v1.VolumeMount { + return v1.VolumeMount{ + Name: name, + MountPath: "/kubeless", + } +} + +// populatePodSpec populates a basic Pod Spec that uses init containers to populate +// the runtime container with the function content and its dependencies. +// The caller should define the runtime container(s). +// It accepts a prepopulated podSpec with default information and volume that the +// runtime container should mount +func populatePodSpec(funcObj *kubelessApi.Function, lr *langruntime.Langruntimes, podSpec *v1.PodSpec, runtimeVolumeMount v1.VolumeMount) error { + depsVolumeName := funcObj.ObjectMeta.Name + "-deps" + result := podSpec + result.Volumes = []v1.Volume{ + { + Name: runtimeVolumeMount.Name, + VolumeSource: v1.VolumeSource{ + EmptyDir: &v1.EmptyDirVolumeSource{}, + }, + }, + { + Name: depsVolumeName, + VolumeSource: v1.VolumeSource{ + ConfigMap: &v1.ConfigMapVolumeSource{ + LocalObjectReference: v1.LocalObjectReference{ + Name: funcObj.ObjectMeta.Name, + }, + }, + }, + }, + } + // prepare init-containers if some function is specified + if funcObj.Spec.Function != "" { + fileName, err := getFileName(funcObj.Spec.Handler, funcObj.Spec.FunctionContentType, funcObj.Spec.Runtime, lr) + if err != nil { + return err + } + if err != nil { + return err + } + srcVolumeMount := v1.VolumeMount{ + Name: depsVolumeName, + MountPath: "/src", + } + provisionContainer, err := getProvisionContainer( + funcObj.Spec.Function, + funcObj.Spec.Checksum, + fileName, + funcObj.Spec.Handler, + funcObj.Spec.FunctionContentType, + funcObj.Spec.Runtime, + runtimeVolumeMount, + srcVolumeMount, + lr, + ) + if err != nil { + return err + } + result.InitContainers = []v1.Container{provisionContainer} + } + + // Add the imagesecrets if present to pull images from private docker registry + if funcObj.Spec.Runtime != "" { + imageSecrets, err := lr.GetImageSecrets(funcObj.Spec.Runtime) + if err != nil { + return fmt.Errorf("Unable to fetch ImagePullSecrets, %v", err) + } + result.ImagePullSecrets = imageSecrets + } + + // ensure that the runtime is supported for installing dependencies + _, err := lr.GetRuntimeInfo(funcObj.Spec.Runtime) + if funcObj.Spec.Deps != "" && err != nil { + return fmt.Errorf("Unable to install dependencies for the runtime %s", funcObj.Spec.Runtime) + } else if funcObj.Spec.Deps != "" { + envVars := []v1.EnvVar{} + if len(result.Containers) > 0 { + envVars = result.Containers[0].Env + } + depsInstallContainer, err := lr.GetBuildContainer(funcObj.Spec.Runtime, envVars, runtimeVolumeMount) + if err != nil { + return err + } + result.InitContainers = append( + result.InitContainers, + depsInstallContainer, + ) + } + return nil +} + +// EnsureFuncImage creates a Job to build a function image +func EnsureFuncImage(client kubernetes.Interface, funcObj *kubelessApi.Function, lr *langruntime.Langruntimes, or []metav1.OwnerReference, imageName, tag, builderImage, registryHost, imagePullSecretName string) error { + if len(tag) < 64 { + return fmt.Errorf("Expecting sha256 as image tag") + } + jobName := fmt.Sprintf("build-%s-%s", funcObj.ObjectMeta.Name, tag[0:10]) + _, err := client.BatchV1().Jobs(funcObj.ObjectMeta.Namespace).Get(jobName, metav1.GetOptions{}) + if err == nil { + // The job already exists + logrus.Infof("Found a previous job for building %s:%s", imageName, tag) + return nil + } + podSpec := v1.PodSpec{ + RestartPolicy: v1.RestartPolicyOnFailure, + } + runtimeVolumeMount := getRuntimeVolumeMount(funcObj.ObjectMeta.Name) + err = populatePodSpec(funcObj, lr, &podSpec, runtimeVolumeMount) + if err != nil { + return err + } + + // Add a final initContainer to create the function bundle.tar + prepareContainer := v1.Container{} + for _, c := range podSpec.InitContainers { + if c.Name == "prepare" { + prepareContainer = c + } + } + podSpec.InitContainers = append(podSpec.InitContainers, v1.Container{ + Name: "bundle", + Command: []string{"sh", "-c"}, + Args: []string{fmt.Sprintf("tar cvf %s/bundle.tar %s/*", runtimeVolumeMount.MountPath, runtimeVolumeMount.MountPath)}, + VolumeMounts: prepareContainer.VolumeMounts, + Image: unzip, + }) + + buildJob := batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: jobName, + Namespace: funcObj.ObjectMeta.Namespace, + OwnerReferences: or, + Labels: map[string]string{ + "created-by": "kubeless", + "function": funcObj.ObjectMeta.Name, + }, + }, + Spec: batchv1.JobSpec{ + Template: v1.PodTemplateSpec{ + Spec: podSpec, + }, + }, + } + + baseImage, err := lr.GetFunctionImage(funcObj.Spec.Runtime) + if err != nil { + return err + } + + // Registry volume + dockerCredsVol := imagePullSecretName + dockerCredsVolMountPath := "/docker" + registryCredsVolume := v1.Volume{ + Name: dockerCredsVol, + VolumeSource: v1.VolumeSource{ + Secret: &v1.SecretVolumeSource{ + SecretName: imagePullSecretName, + }, + }, + } + buildJob.Spec.Template.Spec.Volumes = append(buildJob.Spec.Template.Spec.Volumes, registryCredsVolume) + + // Add main container + buildJob.Spec.Template.Spec.Containers = []v1.Container{ + { + Name: "build", + Image: builderImage, + VolumeMounts: append(prepareContainer.VolumeMounts, + v1.VolumeMount{ + Name: dockerCredsVol, + MountPath: dockerCredsVolMountPath, + }, + ), + Env: []v1.EnvVar{ + { + Name: "DOCKER_CONFIG_FOLDER", + Value: dockerCredsVolMountPath, + }, + }, + Args: []string{ + "/imbuilder", + "add-layer", + "--src", fmt.Sprintf("docker://%s/%s", registryHost, baseImage), + "--dst", fmt.Sprintf("docker://%s/%s:%s", registryHost, imageName, tag), + fmt.Sprintf("%s/bundle.tar", podSpec.InitContainers[0].VolumeMounts[0].MountPath), + }, + }, + } + + // Create the job if doesn't exists yet + _, err = client.BatchV1().Jobs(funcObj.ObjectMeta.Namespace).Create(&buildJob) + if err == nil { + logrus.Infof("Started function build job %s", jobName) + } + return err +} + func svcPort(funcObj *kubelessApi.Function) int32 { if len(funcObj.Spec.ServiceSpec.Ports) == 0 { return int32(8080) @@ -679,12 +878,10 @@ func svcPort(funcObj *kubelessApi.Function) int32 { } // EnsureFuncDeployment creates/updates a function deployment -func EnsureFuncDeployment(client kubernetes.Interface, funcObj *kubelessApi.Function, or []metav1.OwnerReference, lr *langruntime.Langruntimes) error { +func EnsureFuncDeployment(client kubernetes.Interface, funcObj *kubelessApi.Function, or []metav1.OwnerReference, lr *langruntime.Langruntimes, prebuiltRuntimeImage string) error { var err error - runtimeVolumeName := funcObj.ObjectMeta.Name - depsVolumeName := funcObj.ObjectMeta.Name + "-deps" podAnnotations := map[string]string{ // Attempt to attract the attention of prometheus. // For runtimes that don't support /metrics, @@ -738,40 +935,35 @@ func EnsureFuncDeployment(client kubernetes.Interface, funcObj *kubelessApi.Func } } - dpm.Spec.Template.Spec.Volumes = append(dpm.Spec.Template.Spec.Volumes, - v1.Volume{ - Name: runtimeVolumeName, - VolumeSource: v1.VolumeSource{ - EmptyDir: &v1.EmptyDirVolumeSource{}, - }, - }, - v1.Volume{ - Name: depsVolumeName, - VolumeSource: v1.VolumeSource{ - ConfigMap: &v1.ConfigMapVolumeSource{ - LocalObjectReference: v1.LocalObjectReference{ - Name: funcObj.ObjectMeta.Name, - }, - }, - }, - }) - if len(dpm.Spec.Template.Spec.Containers) == 0 { dpm.Spec.Template.Spec.Containers = append(dpm.Spec.Template.Spec.Containers, v1.Container{}) } - if funcObj.Spec.Handler != "" { + runtimeVolumeMount := getRuntimeVolumeMount(funcObj.ObjectMeta.Name) + if funcObj.Spec.Handler != "" && funcObj.Spec.Function != "" { modName, handlerName, err := splitHandler(funcObj.Spec.Handler) if err != nil { return err } - //only resolve the image name if it has not been already set - if dpm.Spec.Template.Spec.Containers[0].Image == "" { + //only resolve the image name and build the function if it has not been built already + if dpm.Spec.Template.Spec.Containers[0].Image == "" && prebuiltRuntimeImage == "" { + err := populatePodSpec(funcObj, lr, &dpm.Spec.Template.Spec, runtimeVolumeMount) + if err != nil { + return err + } + imageName, err := lr.GetFunctionImage(funcObj.Spec.Runtime) if err != nil { return err } dpm.Spec.Template.Spec.Containers[0].Image = imageName + + dpm.Spec.Template.Spec.Containers[0].VolumeMounts = append(dpm.Spec.Template.Spec.Containers[0].VolumeMounts, runtimeVolumeMount) + + } else { + if dpm.Spec.Template.Spec.Containers[0].Image == "" { + dpm.Spec.Template.Spec.Containers[0].Image = prebuiltRuntimeImage + } } timeout := funcObj.Spec.Timeout if timeout == "" { @@ -813,68 +1005,9 @@ func EnsureFuncDeployment(client kubernetes.Interface, funcObj *kubelessApi.Func dpm.Spec.Template.Spec.Containers[0].Ports = append(dpm.Spec.Template.Spec.Containers[0].Ports, v1.ContainerPort{ ContainerPort: svcPort(funcObj), }) - runtimeVolumeMount := v1.VolumeMount{ - Name: runtimeVolumeName, - MountPath: "/kubeless", - } - - dpm.Spec.Template.Spec.Containers[0].VolumeMounts = append(dpm.Spec.Template.Spec.Containers[0].VolumeMounts, runtimeVolumeMount) - // prepare init-containers if some function is specified - if funcObj.Spec.Function != "" { - fileName, err := getFileName(funcObj.Spec.Handler, funcObj.Spec.FunctionContentType, funcObj.Spec.Runtime, lr) - if err != nil { - return err - } - if err != nil { - return err - } - srcVolumeMount := v1.VolumeMount{ - Name: depsVolumeName, - MountPath: "/src", - } - provisionContainer, err := getProvisionContainer( - funcObj.Spec.Function, - funcObj.Spec.Checksum, - fileName, - funcObj.Spec.Handler, - funcObj.Spec.FunctionContentType, - funcObj.Spec.Runtime, - runtimeVolumeMount, - srcVolumeMount, - lr, - ) - if err != nil { - return err - } - dpm.Spec.Template.Spec.InitContainers = []v1.Container{provisionContainer} - } - - // Add the imagesecrets if present to pull images from private docker registry - if funcObj.Spec.Runtime != "" { - imageSecrets, err := lr.GetImageSecrets(funcObj.Spec.Runtime) - if err != nil { - return fmt.Errorf("Unable to fetch ImagePullSecrets, %v", err) - } - dpm.Spec.Template.Spec.ImagePullSecrets = imageSecrets - } - - // ensure that the runtime is supported for installing dependencies - _, err = lr.GetRuntimeInfo(funcObj.Spec.Runtime) - if funcObj.Spec.Deps != "" && err != nil { - return fmt.Errorf("Unable to install dependencies for the runtime %s", funcObj.Spec.Runtime) - } else if funcObj.Spec.Deps != "" { - buildContainer, err := lr.GetBuildContainer(funcObj.Spec.Runtime, dpm.Spec.Template.Spec.Containers[0].Env, runtimeVolumeMount) - if err != nil { - return err - } - dpm.Spec.Template.Spec.InitContainers = append( - dpm.Spec.Template.Spec.InitContainers, - buildContainer, - ) - // update deployment for loading dependencies - lr.UpdateDeployment(dpm, runtimeVolumeMount.MountPath, funcObj.Spec.Runtime) - } + // update deployment for loading dependencies + lr.UpdateDeployment(dpm, runtimeVolumeMount.MountPath, funcObj.Spec.Runtime) livenessProbe := &v1.Probe{ InitialDelaySeconds: int32(3), diff --git a/pkg/utils/k8sutil_test.go b/pkg/utils/k8sutil_test.go index 7b0e5bcb2..fc9902e79 100644 --- a/pkg/utils/k8sutil_test.go +++ b/pkg/utils/k8sutil_test.go @@ -246,48 +246,85 @@ func TestEnsureService(t *testing.T) { } } -func TestEnsureDeployment(t *testing.T) { +func TestEnsureImage(t *testing.T) { clientset := fake.NewSimpleClientset() + langruntime.AddFakeConfig(clientset) + lr := langruntime.SetupLangRuntime(clientset) + lr.ReadConfigMap() + ns := "default" + f1Name := "f1" or := []metav1.OwnerReference{ { Kind: "Function", APIVersion: "kubeless.io/v1beta1", }, } - ns := "default" - funcLabels := map[string]string{ - "foo": "bar", + f1 := &kubelessApi.Function{ + ObjectMeta: metav1.ObjectMeta{ + Name: f1Name, + Namespace: ns, + }, + Spec: kubelessApi.FunctionSpec{ + Function: "function", + Deps: "deps", + Handler: "foo.bar", + Runtime: "python2.7", + }, } - funcAnno := map[string]string{ - "bar": "foo", + // Testing happy path + err := EnsureFuncImage(clientset, f1, lr, or, "user/image", "4840d87600137157493ba43a24f0b4bb6cf524ebbf095ce96c79f85bf5a3ff5a", "kubeless/builder", "registry.docker.io", "registry-creds") + if err != nil { + t.Errorf("Unexpected error: %s", err) } + jobs, err := clientset.BatchV1().Jobs(ns).List(metav1.ListOptions{}) + if err != nil { + t.Errorf("Unexpected error: %s", err) + } + if len(jobs.Items) != 1 { + t.Errorf("It should have created the build job") + } + buildContainer := jobs.Items[0].Spec.Template.Spec.Containers[0] + if buildContainer.Image != "kubeless/builder" { + t.Errorf("Image %s of build job is not recognised", jobs.Items[0].Spec.Template.Spec.Containers[0].Image) + } + dockerConfigFolder := "" + for _, envvar := range buildContainer.Env { + if envvar.Name == "DOCKER_CONFIG_FOLDER" { + dockerConfigFolder = envvar.Value + } + } + if dockerConfigFolder == "" { + t.Error("Builder image relies on the env var DOCKER_CONFIG_FOLDER to authenticate") + } +} - langruntime.AddFakeConfig(clientset) - lr := langruntime.SetupLangRuntime(clientset) - lr.ReadConfigMap() - - f1Name := "f1" - f1Port := int32(8080) - f1 := &kubelessApi.Function{ +func getDefaultFunc(name, ns string) *kubelessApi.Function { + fPort := int32(8080) + f := kubelessApi.Function{ ObjectMeta: metav1.ObjectMeta{ - Name: f1Name, + Name: name, Namespace: ns, - Labels: funcLabels, }, Spec: kubelessApi.FunctionSpec{ Function: "function", Deps: "deps", Handler: "foo.bar", Runtime: "python2.7", - Deployment: v1beta1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Annotations: funcAnno, + ServiceSpec: v1.ServiceSpec{ + Ports: []v1.ServicePort{ + { + Name: "http-function-port", + Port: fPort, + TargetPort: intstr.FromInt(int(fPort)), + NodePort: 0, + Protocol: v1.ProtocolTCP, + }, }, + Type: v1.ServiceTypeClusterIP, + }, + Deployment: v1beta1.Deployment{ Spec: v1beta1.DeploymentSpec{ Template: v1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Annotations: funcAnno, - }, Spec: v1.PodSpec{ Containers: []v1.Container{ { @@ -305,8 +342,40 @@ func TestEnsureDeployment(t *testing.T) { }, }, } + return &f +} +func TestEnsureDeployment(t *testing.T) { + clientset := fake.NewSimpleClientset() + or := []metav1.OwnerReference{ + { + Kind: "Function", + APIVersion: "k8s.io", + }, + } + ns := "default" + funcLabels := map[string]string{ + "foo": "bar", + } + funcAnno := map[string]string{ + "bar": "foo", + } + + langruntime.AddFakeConfig(clientset) + lr := langruntime.SetupLangRuntime(clientset) + lr.ReadConfigMap() + + f1Name := "f1" + f1 := getDefaultFunc(f1Name, ns) + f1Port := f1.Spec.ServiceSpec.Ports[0].Port + f1.ObjectMeta.Labels = funcLabels + f1.Spec.Deployment.ObjectMeta = metav1.ObjectMeta{ + Annotations: funcAnno, + } + f1.Spec.Deployment.Spec.Template.ObjectMeta = metav1.ObjectMeta{ + Annotations: funcAnno, + } // Testing happy path - err := EnsureFuncDeployment(clientset, f1, or, lr) + err := EnsureFuncDeployment(clientset, f1, or, lr, "") if err != nil { t.Errorf("Unexpected error: %s", err) } @@ -412,12 +481,10 @@ func TestEnsureDeployment(t *testing.T) { } // If no handler and function is given it should not fail - f2 := kubelessApi.Function{} - f2 = *f1 - f2.ObjectMeta.Name = "func2" + f2 := getDefaultFunc("func2", ns) f2.Spec.Function = "" f2.Spec.Handler = "" - err = EnsureFuncDeployment(clientset, &f2, or, lr) + err = EnsureFuncDeployment(clientset, f2, or, lr, "") if err != nil { t.Errorf("Unexpected error: %s", err) } @@ -427,11 +494,9 @@ func TestEnsureDeployment(t *testing.T) { } // If the Image has been already provided it should not resolve it - f3 := kubelessApi.Function{} - f3 = *f1 - f3.ObjectMeta.Name = "func3" + f3 := getDefaultFunc("func3", ns) f3.Spec.Deployment.Spec.Template.Spec.Containers[0].Image = "test-image" - err = EnsureFuncDeployment(clientset, &f3, or, lr) + err = EnsureFuncDeployment(clientset, f3, or, lr, "") if err != nil { t.Errorf("Unexpected error: %s", err) } @@ -444,12 +509,10 @@ func TestEnsureDeployment(t *testing.T) { } // If no function is given it should not use an init container - f4 := kubelessApi.Function{} - f4 = *f1 - f4.ObjectMeta.Name = "func4" + f4 := getDefaultFunc("func4", ns) f4.Spec.Function = "" f4.Spec.Deps = "" - err = EnsureFuncDeployment(clientset, &f4, or, lr) + err = EnsureFuncDeployment(clientset, f4, or, lr, "") if err != nil { t.Errorf("Unexpected error: %s", err) } @@ -466,7 +529,7 @@ func TestEnsureDeployment(t *testing.T) { f6 = *f1 f6.Spec.Handler = "foo.bar2" f6.Spec.Deployment.ObjectMeta.Annotations["new-key"] = "value" - err = EnsureFuncDeployment(clientset, &f6, or, lr) + err = EnsureFuncDeployment(clientset, &f6, or, lr, "") if err != nil { t.Errorf("Unexpected error: %s", err) } @@ -482,23 +545,19 @@ func TestEnsureDeployment(t *testing.T) { } // It should return an error if some dependencies are given but the runtime is not supported - f7 := kubelessApi.Function{} - f7 = *f1 - f7.ObjectMeta.Name = "func7" + f7 := getDefaultFunc("func7", ns) f7.Spec.Deps = "deps" f7.Spec.Runtime = "cobol" - err = EnsureFuncDeployment(clientset, &f7, or, lr) + err = EnsureFuncDeployment(clientset, f7, or, lr, "") if err == nil { - t.Errorf("An error should be thrown") + t.Fatal("An error should be thrown") } // If a timeout is specified it should set an environment variable FUNC_TIMEOUT - f8 := kubelessApi.Function{} - f8 = *f1 - f8.ObjectMeta.Name = "func8" + f8 := getDefaultFunc("func8", ns) f8.Spec.Timeout = "10" - err = EnsureFuncDeployment(clientset, &f8, or, lr) + err = EnsureFuncDeployment(clientset, f8, or, lr, "") if err != nil { t.Errorf("Unexpected error: %s", err) } @@ -509,6 +568,23 @@ func TestEnsureDeployment(t *testing.T) { if getEnvValueFromList("FUNC_TIMEOUT", dpm.Spec.Template.Spec.Containers[0].Env) != "10" { t.Error("Unable to set timeout") } + + // If a prebuilt image is specified it should not build the function using init containers + f9 := getDefaultFunc("func9", ns) + err = EnsureFuncDeployment(clientset, f9, or, lr, "user/image:test") + if err != nil { + t.Errorf("Unexpected error: %s", err) + } + dpm, err = clientset.ExtensionsV1beta1().Deployments(ns).Get("func9", metav1.GetOptions{}) + if err != nil { + t.Errorf("Unexpected error: %s", err) + } + if dpm.Spec.Template.Spec.Containers[0].Image != "user/image:test" { + t.Errorf("Unexpected image %s, expecting prebuilt user/image:test", dpm.Spec.Template.Spec.Containers[0].Image) + } + if len(dpm.Spec.Template.Spec.InitContainers) != 0 { + t.Error("Unexpected init containers") + } } func fakeRESTClient(f func(req *http.Request) (*http.Response, error)) *restFake.RESTClient { diff --git a/script/binary-controller b/script/binary-controller index d1047ebc4..dfc6e3862 100755 --- a/script/binary-controller +++ b/script/binary-controller @@ -29,6 +29,18 @@ else OS_ARCH_ARG=($2) fi +if [ -z "$3" ]; then + TARGET="kubeless-controller-manager" +else + TARGET=($3) +fi + +if [ -z "$4" ]; then + PKG="github.com/kubeless/kubeless/cmd/kubeless-controller-manager" +else + PKG=($4) +fi + GITCOMMIT=$(git rev-parse --short HEAD) BUILD_FLAGS=(-ldflags="-w -X version.GITCOMMIT=${GITCOMMIT}") @@ -37,6 +49,6 @@ rm -rf bundles/kubeless* # Build kubeless-controller gox "${OS_PLATFORM_ARG[@]}" "${OS_ARCH_ARG[@]}" \ - -output="bundles/kubeless_{{.OS}}-{{.Arch}}/kubeless-controller-manager" \ + -output="bundles/kubeless_{{.OS}}-{{.Arch}}/$TARGET" \ "${BUILD_FLAGS[@]}" \ - github.com/kubeless/kubeless/cmd/kubeless-controller-manager + "$PKG"