Skip to content
Snippets Groups Projects
Unverified Commit 54405b18 authored by Angus Lees's avatar Angus Lees
Browse files

Add new `resolveImage` native function

Add a new native function that resolves docker image names into more
specific forms.  In particular, it can look up a docker registry and
convert image:tag to image@digest at jsonnet-eval time.

Limitations: Does not currently support private docker
registries (that require authentication).

Controlled via two new command line flags:
- `--resolve-images` Change implementation of resolveImage native
  function. One of: noop, registry (default "noop")
- `--resolve-images-error` Action when resolveImage fails. One of
  ignore,warn,error (default "warn")

Note in particular that the defaults will *not* do remote registry
lookups, and will only add an explicit ":latest" tag where no tag was
given.

Fixes #13
parent f24c1feb
No related branches found
No related tags found
No related merge requests found
......@@ -5,6 +5,7 @@ import (
"encoding/json"
goflag "flag"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
......@@ -22,8 +23,10 @@ import (
)
const (
flagJpath = "jpath"
flagExtVar = "extVar"
flagJpath = "jpath"
flagExtVar = "extVar"
flagResolver = "resolve-images"
flagResolvFail = "resolve-images-error"
)
var clientConfig clientcmd.ClientConfig
......@@ -31,6 +34,8 @@ var clientConfig clientcmd.ClientConfig
func init() {
RootCmd.PersistentFlags().StringP(flagJpath, "J", "", "Additional jsonnet library search path")
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
loadingRules := clientcmd.NewDefaultClientConfigLoadingRules()
......@@ -91,11 +96,69 @@ func JsonnetVM(cmd *cobra.Command) (*jsonnet.VM, error) {
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
}
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) {
vm, err := JsonnetVM(cmd)
if err != nil {
......
......@@ -8,4 +8,9 @@
// YAML document will still be returned as an array with one
// element.
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
");
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
{
apiVersion: "test",
......
......@@ -9,7 +9,21 @@ import (
"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) {
err = json.Unmarshal(data, &res)
return
......@@ -30,4 +44,8 @@ func RegisterNativeFuncs(vm *jsonnet.VM) {
}
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) {
func TestParseJson(t *testing.T) {
vm := jsonnet.Make()
defer vm.Destroy()
RegisterNativeFuncs(vm)
RegisterNativeFuncs(vm, NewIdentityResolver())
_, err := vm.EvaluateSnippet("failtest", `std.native("parseJson")("barf{")`)
if err == nil {
......@@ -37,7 +37,7 @@ func TestParseJson(t *testing.T) {
func TestParseYaml(t *testing.T) {
vm := jsonnet.Make()
defer vm.Destroy()
RegisterNativeFuncs(vm)
RegisterNativeFuncs(vm, NewIdentityResolver())
_, err := vm.EvaluateSnippet("failtest", `std.native("parseYaml")("[barf")`)
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