Unverified Commit d3f430fd authored by bryanl's avatar bryanl
Browse files

Add docker image resolver to `param set`



Adds an image resolve to param set. eg:

`ks param set deployment image foo/bar:latest`

uses the docker registry to find the manifest reference for `foo/bar:latest`.
It then sets this value instead. Support is at the component and environment
level.

Fixes #569
Signed-off-by: default avatarbryanl <bryanliles@gmail.com>
parent 1dc3abb8
...@@ -43,9 +43,10 @@ ks param set guestbook replicas 2 --env=dev ...@@ -43,9 +43,10 @@ ks param set guestbook replicas 2 --env=dev
### Options ### Options
``` ```
--as-string Force value to be interpreted as string --as-string Force value to be interpreted as string
--env string Specify environment to set parameters for --env string Specify environment to set parameters for
-h, --help help for set -h, --help help for set
--resolve-image Resolve Docker image tag to reference
``` ```
### Options inherited from parent commands ### Options inherited from parent commands
......
...@@ -85,6 +85,9 @@ const ( ...@@ -85,6 +85,9 @@ const (
OptionPath = "path" OptionPath = "path"
// OptionQuery is query option. // OptionQuery is query option.
OptionQuery = "query" OptionQuery = "query"
// OptionResolveImage is resolve image option. It is used to resolve docker image references
// when setting parameters.
OptionResolveImage = "resolve-image"
// OptionRootPath is path option. // OptionRootPath is path option.
OptionRootPath = "root-path" OptionRootPath = "root-path"
// OptionServer is server option. // OptionServer is server option.
......
...@@ -22,6 +22,7 @@ import ( ...@@ -22,6 +22,7 @@ import (
"github.com/ksonnet/ksonnet/pkg/app" "github.com/ksonnet/ksonnet/pkg/app"
"github.com/ksonnet/ksonnet/pkg/component" "github.com/ksonnet/ksonnet/pkg/component"
"github.com/ksonnet/ksonnet/pkg/env" "github.com/ksonnet/ksonnet/pkg/env"
"github.com/ksonnet/ksonnet/pkg/util/dockerregistry"
"github.com/ksonnet/ksonnet/pkg/util/jsonnet" "github.com/ksonnet/ksonnet/pkg/util/jsonnet"
"github.com/pkg/errors" "github.com/pkg/errors"
) )
...@@ -38,18 +39,20 @@ func RunParamSet(m map[string]interface{}) error { ...@@ -38,18 +39,20 @@ func RunParamSet(m map[string]interface{}) error {
// ParamSet sets a parameter for a component. // ParamSet sets a parameter for a component.
type ParamSet struct { type ParamSet struct {
app app.App app app.App
name string name string
rawPath string rawPath string
rawValue string rawValue string
global bool global bool
envName string envName string
asString bool asString bool
resolveImage bool
getModuleFn getModuleFn getModuleFn getModuleFn
resolvePathFn func(a app.App, path string) (component.Module, component.Component, error) resolvePathFn func(a app.App, path string) (component.Module, component.Component, error)
setEnvFn func(ksApp app.App, envName, name, pName, value string) error setEnvFn func(ksApp app.App, envName, name, pName, value string) error
setGlobalEnvFn func(ksApp app.App, envName, pName, value string) error setGlobalEnvFn func(ksApp app.App, envName, pName, value string) error
resolveImageFn func(image string) (string, error)
} }
// NewParamSet creates an instance of ParamSet. // NewParamSet creates an instance of ParamSet.
...@@ -57,18 +60,20 @@ func NewParamSet(m map[string]interface{}) (*ParamSet, error) { ...@@ -57,18 +60,20 @@ func NewParamSet(m map[string]interface{}) (*ParamSet, error) {
ol := newOptionLoader(m) ol := newOptionLoader(m)
ps := &ParamSet{ ps := &ParamSet{
app: ol.LoadApp(), app: ol.LoadApp(),
name: ol.LoadOptionalString(OptionName), name: ol.LoadOptionalString(OptionName),
rawPath: ol.LoadString(OptionPath), rawPath: ol.LoadString(OptionPath),
rawValue: ol.LoadString(OptionValue), rawValue: ol.LoadString(OptionValue),
global: ol.LoadOptionalBool(OptionGlobal), global: ol.LoadOptionalBool(OptionGlobal),
envName: ol.LoadOptionalString(OptionEnvName), envName: ol.LoadOptionalString(OptionEnvName),
asString: ol.LoadOptionalBool(OptionAsString), asString: ol.LoadOptionalBool(OptionAsString),
resolveImage: ol.LoadOptionalBool(OptionResolveImage),
getModuleFn: component.GetModule, getModuleFn: component.GetModule,
resolvePathFn: component.ResolvePath, resolvePathFn: component.ResolvePath,
setEnvFn: setEnv, setEnvFn: setEnv,
setGlobalEnvFn: setGlobalEnv, setGlobalEnvFn: setGlobalEnv,
resolveImageFn: dockerregistry.ResolveImage,
} }
if ol.err != nil { if ol.err != nil {
...@@ -97,14 +102,37 @@ func (ps *ParamSet) Run() error { ...@@ -97,14 +102,37 @@ func (ps *ParamSet) Run() error {
} }
if ps.envName != "" { if ps.envName != "" {
value := ps.rawValue
if ps.resolveImage {
digest, err := ps.resolveImageFn(value)
if err != nil {
return errors.Wrap(err, "resolving docker image reference")
}
value = digest
}
if ps.name != "" { if ps.name != "" {
return ps.setEnvFn(ps.app, ps.envName, ps.name, ps.rawPath, ps.rawValue) return ps.setEnvFn(ps.app, ps.envName, ps.name, ps.rawPath, value)
} }
return ps.setGlobalEnvFn(ps.app, ps.envName, ps.rawPath, ps.rawValue) return ps.setGlobalEnvFn(ps.app, ps.envName, ps.rawPath, value)
} }
path := strings.Split(ps.rawPath, ".") path := strings.Split(ps.rawPath, ".")
if ps.resolveImage {
s, ok := value.(string)
if !ok {
return errors.New("value is not a string, so it can't be resolved as a docker image")
}
digest, err := ps.resolveImageFn(s)
if err != nil {
return errors.Wrap(err, "resolving docker image reference")
}
value = digest
}
if ps.global { if ps.global {
return ps.setGlobal(path, value) return ps.setGlobal(path, value)
} }
......
...@@ -108,6 +108,39 @@ func TestParamSet_asString(t *testing.T) { ...@@ -108,6 +108,39 @@ func TestParamSet_asString(t *testing.T) {
}) })
} }
func TestParamSet_resolveImage(t *testing.T) {
withApp(t, func(appMock *amocks.App) {
componentName := "deployment"
path := "image"
value := "foo/bar:latest"
c := &cmocks.Component{}
c.On("SetParam", []string{"image"}, "foo/bar@sha256:abcde").Return(nil)
in := map[string]interface{}{
OptionApp: appMock,
OptionName: componentName,
OptionPath: path,
OptionValue: value,
OptionResolveImage: true,
}
a, err := NewParamSet(in)
require.NoError(t, err)
a.resolvePathFn = func(app.App, string) (component.Module, component.Component, error) {
return nil, c, nil
}
a.resolveImageFn = func(string) (string, error) {
return "foo/bar@sha256:abcde", nil
}
err = a.Run()
require.NoError(t, err)
})
}
func TestParamSet_global(t *testing.T) { func TestParamSet_global(t *testing.T) {
withApp(t, func(appMock *amocks.App) { withApp(t, func(appMock *amocks.App) {
module := "/" module := "/"
...@@ -168,6 +201,41 @@ func TestParamSet_env(t *testing.T) { ...@@ -168,6 +201,41 @@ func TestParamSet_env(t *testing.T) {
}) })
} }
func TestParamSet_env_resolveImage(t *testing.T) {
withApp(t, func(appMock *amocks.App) {
name := "deployment"
path := "image"
value := "foo/bar:latest"
in := map[string]interface{}{
OptionApp: appMock,
OptionName: name,
OptionPath: path,
OptionValue: value,
OptionEnvName: "default",
OptionResolveImage: true,
}
a, err := NewParamSet(in)
require.NoError(t, err)
envSetter := func(ksApp app.App, envName, name, pName, value string) error {
assert.Equal(t, "default", envName)
assert.Equal(t, "deployment", name)
assert.Equal(t, "image", pName)
assert.Equal(t, "foo/bar@sha256:abcde", value)
return nil
}
a.setEnvFn = envSetter
a.resolveImageFn = func(string) (string, error) {
return "foo/bar@sha256:abcde", nil
}
err = a.Run()
require.NoError(t, err)
})
}
func TestParamSet_envGlobal(t *testing.T) { func TestParamSet_envGlobal(t *testing.T) {
withApp(t, func(appMock *amocks.App) { withApp(t, func(appMock *amocks.App) {
path := "replicas" path := "replicas"
......
...@@ -35,6 +35,7 @@ const ( ...@@ -35,6 +35,7 @@ const (
flagJpath = "jpath" flagJpath = "jpath"
flagModule = "module" flagModule = "module"
flagNamespace = "namespace" flagNamespace = "namespace"
flagResolveImage = "resolve-image"
flagServer = "server" flagServer = "server"
flagSet = "set" flagSet = "set"
flagSkipDefaultRegistries = "skip-default-registries" flagSkipDefaultRegistries = "skip-default-registries"
......
...@@ -24,9 +24,11 @@ import ( ...@@ -24,9 +24,11 @@ import (
) )
var ( var (
vParamSetEnv = "param-set-env" vParamSetEnv = "param-set-env"
vParamSetAsString = "param-set-as-string" vParamSetAsString = "param-set-as-string"
paramSetLong = ` vParamSetResolveImage = "param-set-resolve-image"
paramSetLong = `
The ` + "`set`" + ` command sets component or environment parameters such as replica count The ` + "`set`" + ` command sets component or environment parameters such as replica count
or name. Parameters are set individually, one at a time. All of these changes are or name. Parameters are set individually, one at a time. All of these changes are
reflected in the ` + "`params.libsonnet`" + ` files. reflected in the ` + "`params.libsonnet`" + ` files.
...@@ -78,12 +80,13 @@ func newParamSetCmd(a app.App) *cobra.Command { ...@@ -78,12 +80,13 @@ func newParamSetCmd(a app.App) *cobra.Command {
} }
m := map[string]interface{}{ m := map[string]interface{}{
actions.OptionApp: a, actions.OptionApp: a,
actions.OptionName: name, actions.OptionName: name,
actions.OptionPath: path, actions.OptionPath: path,
actions.OptionValue: value, actions.OptionValue: value,
actions.OptionEnvName: viper.GetString(vParamSetEnv), actions.OptionEnvName: viper.GetString(vParamSetEnv),
actions.OptionAsString: viper.GetBool(vParamSetAsString), actions.OptionAsString: viper.GetBool(vParamSetAsString),
actions.OptionResolveImage: viper.GetBool(vParamSetResolveImage),
} }
return runAction(actionParamSet, m) return runAction(actionParamSet, m)
...@@ -96,5 +99,8 @@ func newParamSetCmd(a app.App) *cobra.Command { ...@@ -96,5 +99,8 @@ func newParamSetCmd(a app.App) *cobra.Command {
paramSetCmd.Flags().Bool(flagAsString, false, "Force value to be interpreted as string") paramSetCmd.Flags().Bool(flagAsString, false, "Force value to be interpreted as string")
viper.BindPFlag(vParamSetAsString, paramSetCmd.Flags().Lookup(flagAsString)) viper.BindPFlag(vParamSetAsString, paramSetCmd.Flags().Lookup(flagAsString))
paramSetCmd.Flags().Bool(flagResolveImage, false, "Resolve Docker image tag to reference")
viper.BindPFlag(vParamSetResolveImage, paramSetCmd.Flags().Lookup(flagResolveImage))
return paramSetCmd return paramSetCmd
} }
...@@ -28,12 +28,27 @@ func Test_paramSetCmd(t *testing.T) { ...@@ -28,12 +28,27 @@ func Test_paramSetCmd(t *testing.T) {
args: []string{"param", "set", "component-name", "param-name", "param-value"}, args: []string{"param", "set", "component-name", "param-name", "param-value"},
action: actionParamSet, action: actionParamSet,
expected: map[string]interface{}{ expected: map[string]interface{}{
actions.OptionApp: nil, actions.OptionApp: nil,
actions.OptionName: "component-name", actions.OptionName: "component-name",
actions.OptionPath: "param-name", actions.OptionPath: "param-name",
actions.OptionValue: "param-value", actions.OptionValue: "param-value",
actions.OptionEnvName: "", actions.OptionEnvName: "",
actions.OptionAsString: false, actions.OptionAsString: false,
actions.OptionResolveImage: false,
},
},
{
name: "resolve image",
args: []string{"param", "set", "component-name", "param-name", "param-value", "--resolve-image"},
action: actionParamSet,
expected: map[string]interface{}{
actions.OptionApp: nil,
actions.OptionName: "component-name",
actions.OptionPath: "param-name",
actions.OptionValue: "param-value",
actions.OptionEnvName: "",
actions.OptionAsString: false,
actions.OptionResolveImage: true,
}, },
}, },
...@@ -42,12 +57,13 @@ func Test_paramSetCmd(t *testing.T) { ...@@ -42,12 +57,13 @@ func Test_paramSetCmd(t *testing.T) {
args: []string{"param", "set", "param-name", "param-value", "--env", "default"}, args: []string{"param", "set", "param-name", "param-value", "--env", "default"},
action: actionParamSet, action: actionParamSet,
expected: map[string]interface{}{ expected: map[string]interface{}{
actions.OptionApp: nil, actions.OptionApp: nil,
actions.OptionName: "", actions.OptionName: "",
actions.OptionPath: "param-name", actions.OptionPath: "param-name",
actions.OptionValue: "param-value", actions.OptionValue: "param-value",
actions.OptionEnvName: "default", actions.OptionEnvName: "default",
actions.OptionAsString: false, actions.OptionAsString: false,
actions.OptionResolveImage: false,
}, },
}, },
{ {
...@@ -61,12 +77,13 @@ func Test_paramSetCmd(t *testing.T) { ...@@ -61,12 +77,13 @@ func Test_paramSetCmd(t *testing.T) {
args: []string{"param", "set", "component-name", "param-name", "param-value", "--as-string", "--env", ""}, args: []string{"param", "set", "component-name", "param-name", "param-value", "--as-string", "--env", ""},
action: actionParamSet, action: actionParamSet,
expected: map[string]interface{}{ expected: map[string]interface{}{
actions.OptionApp: nil, actions.OptionApp: nil,
actions.OptionName: "component-name", actions.OptionName: "component-name",
actions.OptionPath: "param-name", actions.OptionPath: "param-name",
actions.OptionValue: "param-value", actions.OptionValue: "param-value",
actions.OptionEnvName: "", actions.OptionEnvName: "",
actions.OptionAsString: true, actions.OptionAsString: true,
actions.OptionResolveImage: false,
}, },
}, },
} }
......
// Copyright 2017 The kubecfg authors // Copyright 2018 The ksonnet authors
// //
// //
// Licensed under the Apache License, Version 2.0 (the "License"); // Licensed under the Apache License, Version 2.0 (the "License");
...@@ -13,25 +13,32 @@ ...@@ -13,25 +13,32 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
package utils package dockerregistry
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"net/http"
"strings" "strings"
) )
const defaultRegistry = "registry-1.docker.io" const (
// defaultRegistry is the default Docker registry
defaultRegistry = "registry-1.docker.io"
)
// ImageName represents the parts of a docker image name // ImageName represents the parts of a docker image name
// eg: "myregistryhost:5000/fedora/httpd:version1.0"
type ImageName struct { type ImageName struct {
// eg: "myregistryhost:5000/fedora/httpd:version1.0" // Registry is the registry api address
Registry string // "myregistryhost:5000" Registry string
Repository string // "fedora" // Repository is the repository name
Name string // "httpd" Repository string
Tag string // "version1.0" // Name is the name of the image
Digest string Name string
// Tag is the image tag
Tag string
// Digest is the image digest
Digest string
} }
// String implements the Stringer interface // String implements the Stringer interface
...@@ -106,56 +113,3 @@ func ParseImageName(image string) (ImageName, error) { ...@@ -106,56 +113,3 @@ func ParseImageName(image string) (ImageName, error) {
return ret, nil return ret, nil
} }
// Resolver is able to resolve docker image names into more specific forms
type Resolver interface {
Resolve(image *ImageName) error
}
// NewIdentityResolver returns a resolver that does only trivial
// :latest canonicalisation
func NewIdentityResolver() Resolver {
return identityResolver{}
}
type identityResolver struct{}
func (r identityResolver) Resolve(image *ImageName) error {
return nil
}
// NewRegistryResolver returns a resolver that looks up a docker
// registry to resolve digests
func NewRegistryResolver(httpClient *http.Client) Resolver {
return &registryResolver{
Client: httpClient,
cache: make(map[string]string),
}
}
type registryResolver struct {
Client *http.Client
cache map[string]string
}
func (r *registryResolver) Resolve(n *ImageName) error {
if n.Digest != "" {
// Already has explicit digest
return nil
}
if digest, ok := r.cache[n.String()]; ok {
n.Digest = digest
return nil
}
c := NewRegistryClient(r.Client, n.RegistryURL())
digest, err := c.ManifestDigest(n.RegistryRepoName(), n.Tag)
if err != nil {
return fmt.Errorf("Unable to fetch digest for %s: %v", n, err)
}
r.cache[n.String()] = digest
n.Digest = digest
return nil
}
// Copyright 2018 The ksonnet authors
//
//
// 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 dockerregistry
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestImageName_String(t *testing.T) {
cases := []struct {
name string
expected string
}{
{
name: "foo/bar",
expected: "foo/bar:latest",
},
{
name: "foo/bar@sha256:abcde",
expected: "foo/bar@sha256:abcde",
},
{
name: "myregistry:5000/foo/bar",
expected: "myregistry:5000/foo/bar:latest",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
in, err := ParseImageName(tc.name)
require.NoError(t, err)
got := in.String()
require.Equal(t, tc.expected, got)
})
}
}
func TestImageName_RegistryRepoName(t *testing.T) {
cases := []struct {
name string
repoName string
expected string
}{
{
name: "with repo name",
repoName: "foo",
expected: "foo/bar",
},
{
name: "without repo name",
expected: "library/bar",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
in := ImageName{
Repository: tc.repoName,
Name: "bar",
}
got := in.RegistryRepoName()
require.Equal(t, tc.expected, got)