Skip to content
Snippets Groups Projects
Commit e064c442 authored by Angus Lees's avatar Angus Lees Committed by GitHub
Browse files

Merge pull request #30 from anguslees/resolve

Add new `resolveImage` native function
parents f24c1feb 54405b18
No related branches found
No related tags found
No related merge requests found
...@@ -5,6 +5,7 @@ import ( ...@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
goflag "flag" goflag "flag"
"fmt" "fmt"
"net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
...@@ -22,8 +23,10 @@ import ( ...@@ -22,8 +23,10 @@ import (
) )
const ( const (
flagJpath = "jpath" flagJpath = "jpath"
flagExtVar = "extVar" flagExtVar = "extVar"
flagResolver = "resolve-images"
flagResolvFail = "resolve-images-error"
) )
var clientConfig clientcmd.ClientConfig var clientConfig clientcmd.ClientConfig
...@@ -31,6 +34,8 @@ var clientConfig clientcmd.ClientConfig ...@@ -31,6 +34,8 @@ var clientConfig clientcmd.ClientConfig
func init() { func init() {
RootCmd.PersistentFlags().StringP(flagJpath, "J", "", "Additional jsonnet library search path") RootCmd.PersistentFlags().StringP(flagJpath, "J", "", "Additional jsonnet library search path")
RootCmd.PersistentFlags().StringSliceP(flagExtVar, "V", nil, "Values of external variables") RootCmd.PersistentFlags().StringSliceP(flagExtVar, "V", nil, "Values of external variables")
RootCmd.PersistentFlags().String(flagResolver, "noop", "Change implementation of resolveImage native function. One of: noop, registry")
RootCmd.PersistentFlags().String(flagResolvFail, "warn", "Action when resolveImage fails. One of ignore,warn,error")
// The "usual" clientcmd/kubectl flags // The "usual" clientcmd/kubectl flags
loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() loadingRules := clientcmd.NewDefaultClientConfigLoadingRules()
...@@ -91,11 +96,69 @@ func JsonnetVM(cmd *cobra.Command) (*jsonnet.VM, error) { ...@@ -91,11 +96,69 @@ func JsonnetVM(cmd *cobra.Command) (*jsonnet.VM, error) {
vm.ExtVar(kv[0], kv[1]) vm.ExtVar(kv[0], kv[1])
} }
utils.RegisterNativeFuncs(vm) resolver, err := buildResolver(cmd)
if err != nil {
return nil, err
}
utils.RegisterNativeFuncs(vm, resolver)
return vm, nil return vm, nil
} }
func buildResolver(cmd *cobra.Command) (utils.Resolver, error) {
flags := cmd.Flags()
resolver, err := flags.GetString(flagResolver)
if err != nil {
return nil, err
}
failAction, err := flags.GetString(flagResolvFail)
if err != nil {
return nil, err
}
ret := resolverErrorWrapper{}
switch failAction {
case "ignore":
ret.OnErr = func(error) error { return nil }
case "warn":
ret.OnErr = func(err error) error {
glog.Warning(err.Error())
return nil
}
case "error":
ret.OnErr = func(err error) error { return err }
default:
return nil, fmt.Errorf("Bad value for --%s: %s", flagResolvFail, failAction)
}
switch resolver {
case "noop":
ret.Inner = utils.NewIdentityResolver()
case "registry":
ret.Inner = utils.NewRegistryResolver(&http.Client{
Transport: utils.NewAuthTransport(http.DefaultTransport),
})
default:
return nil, fmt.Errorf("Bad value for --%s: %s", flagResolver, resolver)
}
return &ret, nil
}
type resolverErrorWrapper struct {
Inner utils.Resolver
OnErr func(error) error
}
func (r *resolverErrorWrapper) Resolve(image *utils.ImageName) error {
err := r.Inner.Resolve(image)
if err != nil {
err = r.OnErr(err)
}
return err
}
func readObjs(cmd *cobra.Command, paths []string) ([]*runtime.Unstructured, error) { func readObjs(cmd *cobra.Command, paths []string) ([]*runtime.Unstructured, error) {
vm, err := JsonnetVM(cmd) vm, err := JsonnetVM(cmd)
if err != nil { if err != nil {
......
...@@ -8,4 +8,9 @@ ...@@ -8,4 +8,9 @@
// YAML document will still be returned as an array with one // YAML document will still be returned as an array with one
// element. // element.
parseYaml:: std.native("parseYaml"), parseYaml:: std.native("parseYaml"),
// resolveImage(image): convert the docker image string from
// image:tag into a more specific image@digest, depending on kubecfg
// command line flags.
resolveImage:: std.native("resolveImage")
} }
...@@ -12,6 +12,9 @@ baz: xyzzy ...@@ -12,6 +12,9 @@ baz: xyzzy
"); ");
assert x == [[3, 4], {foo: "bar", baz: "xyzzy"}] : "got " + x; assert x == [[3, 4], {foo: "bar", baz: "xyzzy"}] : "got " + x;
local i = kubecfg.resolveImage("busybox");
assert i == "busybox:latest" : "got " + i;
// Kubecfg wants to see something that looks like a k8s object // Kubecfg wants to see something that looks like a k8s object
{ {
apiVersion: "test", apiVersion: "test",
......
...@@ -9,7 +9,21 @@ import ( ...@@ -9,7 +9,21 @@ import (
"k8s.io/client-go/pkg/util/yaml" "k8s.io/client-go/pkg/util/yaml"
) )
func RegisterNativeFuncs(vm *jsonnet.VM) { func resolveImage(resolver Resolver, image string) (string, error) {
n, err := ParseImageName(image)
if err != nil {
return "", err
}
if err := resolver.Resolve(&n); err != nil {
return "", err
}
return n.String(), nil
}
// RegisterNativeFuncs adds kubecfg's native jsonnet functions to provided VM
func RegisterNativeFuncs(vm *jsonnet.VM, resolver Resolver) {
vm.NativeCallback("parseJson", []string{"json"}, func(data []byte) (res interface{}, err error) { vm.NativeCallback("parseJson", []string{"json"}, func(data []byte) (res interface{}, err error) {
err = json.Unmarshal(data, &res) err = json.Unmarshal(data, &res)
return return
...@@ -30,4 +44,8 @@ func RegisterNativeFuncs(vm *jsonnet.VM) { ...@@ -30,4 +44,8 @@ func RegisterNativeFuncs(vm *jsonnet.VM) {
} }
return ret, nil return ret, nil
}) })
vm.NativeCallback("resolveImage", []string{"image"}, func(image string) (string, error) {
return resolveImage(resolver, image)
})
} }
...@@ -18,7 +18,7 @@ func check(t *testing.T, err error, actual, expected string) { ...@@ -18,7 +18,7 @@ func check(t *testing.T, err error, actual, expected string) {
func TestParseJson(t *testing.T) { func TestParseJson(t *testing.T) {
vm := jsonnet.Make() vm := jsonnet.Make()
defer vm.Destroy() defer vm.Destroy()
RegisterNativeFuncs(vm) RegisterNativeFuncs(vm, NewIdentityResolver())
_, err := vm.EvaluateSnippet("failtest", `std.native("parseJson")("barf{")`) _, err := vm.EvaluateSnippet("failtest", `std.native("parseJson")("barf{")`)
if err == nil { if err == nil {
...@@ -37,7 +37,7 @@ func TestParseJson(t *testing.T) { ...@@ -37,7 +37,7 @@ func TestParseJson(t *testing.T) {
func TestParseYaml(t *testing.T) { func TestParseYaml(t *testing.T) {
vm := jsonnet.Make() vm := jsonnet.Make()
defer vm.Destroy() defer vm.Destroy()
RegisterNativeFuncs(vm) RegisterNativeFuncs(vm, NewIdentityResolver())
_, err := vm.EvaluateSnippet("failtest", `std.native("parseYaml")("[barf")`) _, err := vm.EvaluateSnippet("failtest", `std.native("parseYaml")("[barf")`)
if err == nil { if err == nil {
......
package utils
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"regexp"
"strings"
"github.com/golang/glog"
)
var (
commaRegexp = regexp.MustCompile(", *")
)
// Registry is a *crazy limited* Docker registry client.
type Registry struct {
URL string
Client *http.Client
}
// NewRegistryClient creates a new Registry client using the given
// http client and base URL.
func NewRegistryClient(client *http.Client, url string) *Registry {
return &Registry{
URL: strings.TrimSuffix(url, "/"),
Client: client,
}
}
// ManifestDigest fetches the manifest digest for a given reponame and tag.
func (r *Registry) ManifestDigest(reponame, tag string) (string, error) {
url := fmt.Sprintf("%s/v2/%s/manifests/%s", r.URL, reponame, tag)
glog.V(1).Infof("HEAD %s", url)
req, err := http.NewRequest(http.MethodHead, url, nil)
if err != nil {
return "", err
}
req.Header.Add("Accept", "application/vnd.docker.distribution.manifest.v2+json")
resp, err := r.Client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("Request failed: %s", resp.Status)
}
digest := resp.Header.Get("Docker-Content-Digest")
if digest == "" {
return "", errors.New("No digest in response")
}
glog.V(1).Infof("Found digest %s", digest)
return digest, nil
}
// stolen from golang 1.8
func stripPort(hostport string) string {
colon := strings.IndexByte(hostport, ':')
if colon == -1 {
return hostport
}
if i := strings.IndexByte(hostport, ']'); i != -1 {
return strings.TrimPrefix(hostport[:i], "[")
}
return hostport[:colon]
}
func matchesDomain(url *url.URL, domain string) bool {
host := stripPort(url.Host)
return strings.HasSuffix(host, domain)
}
type authChallenge struct {
Scheme string
Params map[string]string
}
func parseAuthHeader(header http.Header) []*authChallenge {
authHeaders := header[http.CanonicalHeaderKey("WWW-Authenticate")]
ret := make([]*authChallenge, 0, len(authHeaders))
for _, h := range authHeaders {
var scheme string
params := map[string]string{}
parts := strings.SplitN(h, " ", 2)
if len(parts) < 1 || parts[0] == "" {
continue
}
scheme = strings.ToLower(parts[0])
if len(parts) == 2 {
args := commaRegexp.Split(parts[1], -1)
for _, arg := range args {
if parts := strings.SplitN(arg, "=", 2); len(parts) == 2 {
params[parts[0]] = strings.Trim(parts[1], `"`)
} else if len(parts) == 1 {
params[parts[0]] = ""
}
}
}
auth := authChallenge{
Scheme: scheme,
Params: params,
}
ret = append(ret, &auth)
}
return ret
}
// NewAuthTransport returns a roundtripper that does bearer/etc authentication
func NewAuthTransport(inner http.RoundTripper) http.RoundTripper {
return &authTransport{
Transport: inner,
Client: &http.Client{Transport: inner},
tokenCache: map[string]string{},
}
}
type authTransport struct {
Client *http.Client
Transport http.RoundTripper
tokenCache map[string]string
HostDomain string
Username string
Password string
}
// RoundTrip is required for the http.RoundTripper interface
func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) {
glog.V(1).Infof("=> %v", req)
resp, err := t.Transport.RoundTrip(req)
glog.V(1).Infof("<= err=%v resp=%v", err, resp)
if err == nil && resp.StatusCode == http.StatusUnauthorized && matchesDomain(req.URL, t.HostDomain) {
schemes := parseAuthHeader(resp.Header)
for _, scheme := range schemes {
if scheme.Scheme == "basic" {
glog.V(2).Infof("Retrying with basic auth")
req.SetBasicAuth(t.Username, t.Password)
glog.V(1).Infof("=> %v", req)
return t.Transport.RoundTrip(req)
}
if scheme.Scheme == "bearer" {
token, err := t.bearerAuth(scheme.Params["realm"], scheme.Params["service"], scheme.Params["scope"])
if err != nil {
return resp, err
}
glog.V(2).Infof("Retrying with bearer auth")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
glog.V(1).Infof("=> %v", req)
return t.Transport.RoundTrip(req)
}
}
// No recognised auth schemes, return 401 failure
}
return resp, err
}
func (t *authTransport) bearerAuth(realm, service, scope string) (string, error) {
cacheKey := fmt.Sprintf("%s!%s!%s", realm, service, scope)
if token := t.tokenCache[cacheKey]; token != "" {
return token, nil
}
url, err := url.Parse(realm)
if err != nil {
return "", err
}
q := url.Query()
q.Set("service", service)
if scope != "" {
q.Set("scope", scope)
}
url.RawQuery = q.Encode()
req, err := http.NewRequest(http.MethodGet, url.String(), nil)
if t.Username != "" || t.Password != "" {
req.SetBasicAuth(t.Username, t.Password)
}
glog.V(3).Infof("Performing oauth request to %s", req.URL)
resp, err := t.Client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("Auth request returned %s", resp.Status)
}
type authToken struct {
Token string `json:"token"`
}
var token authToken
if err := json.NewDecoder(resp.Body).Decode(&token); err != nil {
return "", err
}
glog.V(4).Infof("Got oauth token %q", token.Token)
t.tokenCache[cacheKey] = token.Token
return token.Token, err
}
package utils
import (
"net/http"
"net/url"
"reflect"
"testing"
)
var _ http.RoundTripper = &authTransport{}
func TestStripPort(t *testing.T) {
cases := []struct {
input string
output string
}{
{input: "foo:80", output: "foo"},
{input: "foo", output: "foo"},
{input: "[ip:v6]:80", output: "ip:v6"},
{input: "[ip:v6]", output: "ip:v6"},
}
for _, c := range cases {
if x := stripPort(c.input); x != c.output {
t.Errorf("Got %q from %q, expected %q", x, c.input, c.output)
}
}
}
func TestMatchesDomain(t *testing.T) {
cases := []struct {
url string
domain string
result bool
}{
{url: "http://foo.bar.baz:80", domain: "baz", result: true},
{url: "http://foo.bar.baz:80", domain: "com", result: false},
{url: "http://foo.bar.baz:80", domain: "bar.baz", result: true},
{url: "http://foo.bar.baz:80", domain: "bar.com", result: false},
{url: "http://foo.bar.baz:80", domain: "foo.bar.baz", result: true},
}
for _, c := range cases {
url, err := url.Parse(c.url)
if err != nil {
t.Fatalf("Failed to parse url %s: %s", c.url, err)
}
if x := matchesDomain(url, c.domain); x != c.result {
t.Errorf("Wrong result: matchesDomain(%s, %s) => %v", url, c.domain, x)
}
}
}
func TestParseAuthHeader(t *testing.T) {
h := http.Header{}
h.Add("WWW-Authenticate", `Basic`)
h.Add("WWW-Authenticate", `Basic realm="User Visible Realm"`)
h.Add("WWW-Authenticate", `Bearer realm="https://auth.docker.io/token",service="registry.docker.io"`)
h.Add("WWW-Authenticate", ``)
expected := []*authChallenge{
&authChallenge{
Scheme: "basic",
Params: map[string]string{},
},
&authChallenge{
Scheme: "basic",
Params: map[string]string{
"realm": "User Visible Realm",
},
},
&authChallenge{
Scheme: "bearer",
Params: map[string]string{
"realm": "https://auth.docker.io/token",
"service": "registry.docker.io",
},
},
}
auths := parseAuthHeader(h)
if len(auths) != len(expected) {
t.Errorf("Unexpected number of results: %d != %d", len(auths), len(expected))
}
for i := range auths {
if expected[i].Scheme != auths[i].Scheme {
t.Errorf("%d: Unexpected scheme: %q", i, auths[i].Scheme)
}
if !reflect.DeepEqual(expected[i].Params, auths[i].Params) {
t.Errorf("%d: Unexpected params: %v", i, auths[i].Params)
}
}
}
package utils
import (
"bytes"
"fmt"
"net/http"
"strings"
)
const defaultRegistry = "registry-1.docker.io"
// ImageName represents the parts of a docker image name
type ImageName struct {
// eg: "myregistryhost:5000/fedora/httpd:version1.0"
Registry string // "myregistryhost:5000"
Repository string // "fedora"
Name string // "httpd"
Tag string // "version1.0"
Digest string
}
// String implements the Stringer interface
func (n ImageName) String() string {
buf := bytes.Buffer{}
if n.Registry != "" {
buf.WriteString(n.Registry)
buf.WriteString("/")
}
if n.Repository != "" {
buf.WriteString(n.Repository)
buf.WriteString("/")
}
buf.WriteString(n.Name)
if n.Digest != "" {
buf.WriteString("@")
buf.WriteString(n.Digest)
} else {
buf.WriteString(":")
buf.WriteString(n.Tag)
}
return buf.String()
}
// RegistryRepoName returns the "repository" as used in the registry URL
func (n ImageName) RegistryRepoName() string {
repo := n.Repository
if repo == "" {
repo = "library"
}
return fmt.Sprintf("%s/%s", repo, n.Name)
}
// RegistryURL returns the deduced base URL of the registry for this image
func (n ImageName) RegistryURL() string {
reg := n.Registry
if reg == "" {
reg = defaultRegistry
}
return fmt.Sprintf("https://%s", reg)
}
// ParseImageName parses a docker image into an ImageName struct
func ParseImageName(image string) (ImageName, error) {
ret := ImageName{}
if parts := strings.Split(image, "/"); len(parts) == 1 {
ret.Name = parts[0]
} else if len(parts) == 2 {
ret.Repository = parts[0]
ret.Name = parts[1]
} else if len(parts) == 3 {
ret.Registry = parts[0]
ret.Repository = parts[1]
ret.Name = parts[2]
} else {
return ret, fmt.Errorf("Malformed docker image name: %s", image)
}
if parts := strings.Split(ret.Name, "@"); len(parts) == 2 {
ret.Name = parts[0]
ret.Digest = parts[1]
} else if parts := strings.Split(ret.Name, ":"); len(parts) == 2 {
ret.Name = parts[0]
ret.Tag = parts[1]
} else if len(parts) == 1 {
ret.Name = parts[0]
ret.Tag = "latest"
} else {
return ret, fmt.Errorf("Malformed docker image name/tag: %s", image)
}
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
}
package utils
import (
"crypto/tls"
"net/http"
"net/http/httptest"
"net/url"
"testing"
)
func TestParseImageName(t *testing.T) {
// Local image
n, err := ParseImageName("foo")
if err != nil {
t.Errorf("failed to parse local image name: %v", err)
}
if n.Registry != "" {
t.Errorf("Incorrect local registry: %v", n.Registry)
}
if n.Repository != "" {
t.Errorf("Incorrect local repository: %v", n.Repository)
}
if n.Name != "foo" {
t.Errorf("Incorrect local name: %v", n.Name)
}
if n.Tag != "latest" {
t.Errorf("Incorrect local tag: %v", n.Tag)
}
if n.String() != "foo:latest" {
t.Errorf("Incorrect local image string: %v", n.String())
}
// Full non-dockerhub image
n, err = ParseImageName("myregistryhost:5000/fedora/httpd:version1.0")
if err != nil {
t.Errorf("failed to parse remote image name: %v", err)
}
if n.Registry != "myregistryhost:5000" {
t.Errorf("Incorrect remote registry: %v", n.Registry)
}
if n.Repository != "fedora" {
t.Errorf("Incorrect remote repository: %v", n.Repository)
}
if n.Name != "httpd" {
t.Errorf("Incorrect remote name: %v", n.Name)
}
if n.Tag != "version1.0" {
t.Errorf("Incorrect remote tag: %v", n.Tag)
}
if n.String() != "myregistryhost:5000/fedora/httpd:version1.0" {
t.Errorf("Incorrect remote image string: %v", n.String())
}
n.Digest = "sha256:xxxxx"
if n.String() != "myregistryhost:5000/fedora/httpd@sha256:xxxxx" {
t.Errorf("Incorrect remote image string w/ digest: %v", n.String())
}
// Image with digest
n, err = ParseImageName("myregistryhost:5000/fedora/httpd@sha256:12345")
if err != nil {
t.Errorf("failed to parse remote image name: %v", err)
}
if n.Registry != "myregistryhost:5000" {
t.Errorf("Incorrect remote registry: %v", n.Registry)
}
if n.Repository != "fedora" {
t.Errorf("Incorrect remote repository: %v", n.Repository)
}
if n.Name != "httpd" {
t.Errorf("Incorrect remote name: %v", n.Name)
}
if n.Digest != "sha256:12345" {
t.Errorf("Incorrect remote tag: %v", n.Tag)
}
if n.String() != "myregistryhost:5000/fedora/httpd@sha256:12345" {
t.Errorf("Incorrect remote image string: %v", n.String())
}
n.Digest = "sha256:xxxxx"
if n.String() != "myregistryhost:5000/fedora/httpd@sha256:xxxxx" {
t.Errorf("Incorrect remote image string w/ digest: %v", n.String())
}
}
func TestIdentityResolver(t *testing.T) {
r := NewIdentityResolver()
n, err := ParseImageName("myregistryhost:5000/fedora/httpd:version1.0")
if err != nil {
t.Fatalf("Failed to parse test name: %v", err)
}
nCopy := n
r.Resolve(&n)
if nCopy != n {
t.Errorf("Identity resolver altered image: %v", n)
}
}
func TestRegistryResolver(t *testing.T) {
reqCount := 0
fake := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reqCount++
if r.Method != http.MethodHead {
t.Errorf("Wrong method: %v", r.Method)
}
if r.URL.Path != "/v2/library/busybox/manifests/latest" {
t.Errorf("Wrong URL: %v", r.URL)
}
w.Header().Add("Docker-Content-Digest", "sha256:12345")
}))
defer fake.Close()
url, err := url.Parse(fake.URL)
if err != nil {
t.Fatalf("Failed to parse testserver URL: %v", err)
}
n, err := ParseImageName("busybox")
if err != nil {
t.Errorf("Failed to parse image: %v", err)
}
n.Registry = url.Host
r := NewRegistryResolver(&http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}})
if err := r.Resolve(&n); err != nil {
t.Fatalf("Error resolving image name: %v", err)
}
if reqCount != 1 {
t.Errorf("registry resolver required %d requests?", reqCount)
}
if n.Digest != "sha256:12345" {
t.Errorf("registry resolver resolved incorrect digest: %v", n.Digest)
}
// Test cache hit on repeat request
n.Digest = ""
if err := r.Resolve(&n); err != nil {
t.Fatalf("Error re-resolving image name: %v", err)
}
if reqCount > 1 {
t.Errorf("registry resolver repeated cachable request")
}
if n.Digest != "sha256:12345" {
t.Errorf("registry resolver re-resolved incorrect digest: %v", n.Digest)
}
}
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment