diff --git a/.gitignore b/.gitignore index bee8a64..27ad5f5 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ +/oci-discovery __pycache__ diff --git a/Makefile b/Makefile index 34b1562..7d68b43 100644 --- a/Makefile +++ b/Makefile @@ -12,6 +12,12 @@ # See the License for the specific language governing permissions and # limitations under the License. +all: oci-discovery + +.PHONY: oci-discovery +oci-discovery: + go build -o oci-discovery ./tools/cmd + test: test-go test-python test-debug: test-go-debug test-python-debug @@ -27,3 +33,6 @@ test-python: test-python-debug: DEBUG=1 python3 -m unittest discover -v + +clean: + rm -f oci-discovery diff --git a/tools/README.md b/tools/README.md index 77cac50..658e781 100644 --- a/tools/README.md +++ b/tools/README.md @@ -1,24 +1,30 @@ # Discovery Tools -## Eaxmple: +## Example: ``` -$ go run cmd/main.go --debug discovery example.com/app#1.0 2>/tmp/log -[ - { - "mediaType": "application/vnd.oci.image.manifest.v1+json", - "digest": "sha256:e9770a03fbdccdd4632895151a93f9af58bbe2c91fdfaaf73160648d250e6ec3", - "size": 799, - "annotations": { - "org.opencontainers.image.ref.name": "1.0" - }, - "platform": { - "architecture": "ppc64le", - "os": "linux" - } - } -] +$ oci-discovery --debug resolve example.com/app#1.0 2>/tmp/log +{ + "example.com/app#1.0": [ + { + "mediaType": "application/vnd.oci.descriptor.v1+json", + "root": { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:e9770a03fbdccdd4632895151a93f9af58bbe2c91fdfaaf73160648d250e6ec3" + "size": 799, + "annotations": { + "org.opencontainers.image.ref.name": "1.0" + }, + "platform": { + "architecture": "ppc64le", + "os": "linux" + } + }, + "uri": "https://example.com/oci-index/app" + } + ] +} $ cat /tmp/log -time="2017-09-19T12:43:41-07:00" level=debug msg="requesting application/vnd.oci.ref-engines.v1+json from http://example.com/.well-known/oci-host-ref-engines" +time="2017-09-19T12:43:41-07:00" level=debug msg="requesting application/vnd.oci.ref-engines.v1+json from https://example.com/.well-known/oci-host-ref-engines" time="2017-09-19T12:43:41-07:00" level=debug msg="requesting application/vnd.oci.image.index.v1+json from https://example.com/oci-index/app" ``` diff --git a/tools/cmd/main.go b/tools/cmd/main.go index f57508e..6215398 100644 --- a/tools/cmd/main.go +++ b/tools/cmd/main.go @@ -1,3 +1,17 @@ +// Copyright 2017 oci-discovery contributors +// +// 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 ( @@ -5,31 +19,14 @@ import ( "github.com/sirupsen/logrus" "github.com/urfave/cli" - "github.com/xiekeyang/oci-discovery/tools/discovery" _ "github.com/xiekeyang/oci-discovery/tools/indextemplate" ) -var discoveryCommand = cli.Command{ - Name: "discovery", - Usage: "Resolve image names via OCI Ref-engine Discovery.", - Action: discovery.DiscoveryHandler, - Flags: []cli.Flag{ - cli.StringFlag{ - Name: "protocol", - Usage: "Protocol to use for ref-engine discovery", - }, - cli.UintFlag{ - Name: "port", - Usage: "Port to use for ref-engine discovery", - }, - }, -} - func main() { app := cli.NewApp() app.Name = "oci-discovery-tool" - app.Usage = "OCI (Open Container Initiative) image discovery tools" + app.Usage = "OCI (Open Container Initiative) image discovery tools." app.Flags = []cli.Flag{ cli.BoolFlag{ Name: "debug", @@ -43,7 +40,7 @@ func main() { return nil } app.Commands = []cli.Command{ - discoveryCommand, + resolveCommand, } if err := app.Run(os.Args); err != nil { diff --git a/tools/cmd/resolve.go b/tools/cmd/resolve.go new file mode 100644 index 0000000..2c7c9e1 --- /dev/null +++ b/tools/cmd/resolve.go @@ -0,0 +1,88 @@ +// Copyright 2017 oci-discovery contributors +// +// 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 ( + "encoding/json" + "fmt" + "os" + + "github.com/sirupsen/logrus" + "github.com/urfave/cli" + "github.com/xiekeyang/oci-discovery/tools/hostbasedimagenames" + "github.com/xiekeyang/oci-discovery/tools/refengine" + "github.com/xiekeyang/oci-discovery/tools/refenginediscovery" + "golang.org/x/net/context" +) + +// resolved is a flag for breaking discovery iteration. +var resolved = fmt.Errorf("satisfactory resolution") + +var resolveCommand = cli.Command{ + Name: "resolve", + Usage: "Resolve image names via OCI Ref-Engine Discovery.", + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "protocol", + Usage: "Protocol to use for ref-engine discovery", + }, + cli.UintFlag{ + Name: "port", + Usage: "Port to use for ref-engine discovery", + }, + }, + Action: func(c *cli.Context) error { + ctx := context.Background() + allRoots := map[string][]refengine.MerkleRoot{} + + protocols := []string{} + if c.IsSet("protocol") { + protocols = append(protocols, c.String("protocol")) + } + + for _, name := range c.Args() { + parsedName, err := hostbasedimagenames.Parse(name) + if err != nil { + logrus.Warn(err) + continue + } + + err = refenginediscovery.Discover( + ctx, protocols, parsedName["host"], + func(ctx context.Context, refEngine refengine.Engine, casEngines []refenginediscovery.ResolvedCASEngines) error { + return resolveCallback(ctx, allRoots, refEngine, casEngines, name) + }) + if err == resolved { + continue + } else if err != nil { + logrus.Warn(err) + } + } + + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", "\t") + return encoder.Encode(allRoots) + }, +} + +func resolveCallback(ctx context.Context, allRoots map[string][]refengine.MerkleRoot, refEngine refengine.Engine, casEngines []refenginediscovery.ResolvedCASEngines, name string) (err error) { + roots, err := refEngine.Get(ctx, name) + if err != nil { + logrus.Warn(err) + return nil + } + allRoots[name] = roots + return resolved +} diff --git a/tools/discovery/discovery.go b/tools/discovery/discovery.go deleted file mode 100644 index 7878799..0000000 --- a/tools/discovery/discovery.go +++ /dev/null @@ -1,129 +0,0 @@ -package discovery - -import ( - "bytes" - "crypto/tls" - "encoding/json" - "fmt" - "mime" - "net/http" - "net/url" - "os" - - "github.com/sirupsen/logrus" - "github.com/urfave/cli" - "github.com/xiekeyang/oci-discovery/tools/engine" - "github.com/xiekeyang/oci-discovery/tools/hostbasedimagenames" - v1 "github.com/xiekeyang/oci-discovery/tools/newimagespec" - "github.com/xiekeyang/oci-discovery/tools/refengine" - "github.com/xiekeyang/oci-discovery/tools/util" - "golang.org/x/net/context" -) - -var ( - defaultTrans = &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - } -) - -type RefEngines struct { - RefEngines []engine.Config `json:"refEngines,omitempty"` - CASEngines []engine.Config `json:"casEngines,omitempty"` -} - -func DiscoveryHandler(cliContext *cli.Context) error { - var ( - ctx = context.Background() - name = cliContext.Args()[0] - roots = []v1.Descriptor{} - ) - - parsedName, err := hostbasedimagenames.Parse(name) - if err != nil { - return err - } - - uri, engines, err := refEnginesFetching(parsedName) - if err != nil { - return err - } - - for _, config := range engines { - constructor, ok := refengine.Constructors[config.Protocol] - if !ok { - logrus.Debugf("unsupported ref-engine protocol %q (%v)", config.Protocol, refengine.Constructors) - continue - } - engine, err := constructor(ctx, uri, config.Data) - if err != nil { - logrus.Warnf("failed to initialize %s ref-engine with %v: %s", config.Protocol, config.Data, err) - continue - } - roots, err = engine.Get(ctx, name) - if err != nil { - logrus.Warnf("failed to resolve %q with %s ref-engine (%v): %s", name, config.Protocol, config.Data, err) - continue - } - return stdWrite(roots) - } - - return stdWrite(roots) -} - -func refEnginesFetching(parsedName map[string]string) (uri *url.URL, engines []engine.Config, err error) { - uri, err = templateRefEngines.resolve(util.StringStringToStringInterface(parsedName)) - if err != nil { - return nil, nil, err - } - - client := &http.Client{Transport: defaultTrans} - - logrus.Debugf("requesting application/vnd.oci.ref-engines.v1+json from %s", uri) - resp, err := client.Get(uri.String()) - if err != nil { - return uri, nil, err - } - defer resp.Body.Close() - - if resp.StatusCode >= 400 { - return uri, nil, fmt.Errorf("ref engine fetching error, status code = %d", resp.StatusCode) - } - - mediatype, _, err := mime.ParseMediaType(resp.Header.Get(`Content-Type`)) - if err != nil { - return uri, nil, err - } - - if mediatype != `application/vnd.oci.ref-engines.v1+json` { - return uri, nil, fmt.Errorf("Unknown Content-Type: %s", mediatype) - } - - var refEngines RefEngines - if err := json.NewDecoder(resp.Body).Decode(&refEngines); err != nil { - logrus.Errorf("ref engines object decoded failed: %s", err) - return uri, nil, err - } - - return uri, refEngines.RefEngines, nil -} - -func stdWrite(v interface{}) error { - var out bytes.Buffer - - b, err := json.Marshal(v) - if err != nil { - return err - } - - err = json.Indent(&out, b, "", "\t") - if err != nil { - return err - } - - _, err = out.WriteTo(os.Stdout) - if err != nil { - return err - } - - return nil -} diff --git a/tools/discovery/resolve.go b/tools/discovery/resolve.go deleted file mode 100644 index f318ad7..0000000 --- a/tools/discovery/resolve.go +++ /dev/null @@ -1,35 +0,0 @@ -package discovery - -import ( - "net/url" - - "github.com/jtacoma/uritemplates" - "github.com/sirupsen/logrus" -) - -const ( - templateRefEngines urlResolver = `https://{host}/.well-known/oci-host-ref-engines` -) - -type urlResolver string - -func (ur urlResolver) resolve(v map[string]interface{}) (*url.URL, error) { - t, err := uritemplates.Parse(string(ur)) - if err != nil { - return nil, err - } - - rawurl, err := t.Expand(v) - if err != nil { - logrus.Errorf("name resolving failed: %s", err) - return nil, err - } - - u, err := url.Parse(rawurl) - if err != nil { - return nil, err - } - u.Scheme = "http" - - return u, nil -} diff --git a/tools/engine/config_test.go b/tools/engine/config_test.go index 424ad6d..17a2287 100644 --- a/tools/engine/config_test.go +++ b/tools/engine/config_test.go @@ -48,12 +48,12 @@ func TestConfigGood(t *testing.T) { t.Run(testcase.JSON, func(t *testing.T) { var config Config json.Unmarshal([]byte(testcase.JSON), &config) - assert.Equal(t, config, testcase.Expected) + assert.Equal(t, testcase.Expected, config) marshaled, err := json.Marshal(config) if err != nil { t.Fatal(err) } - assert.Equal(t, string(marshaled), testcase.JSON) + assert.Equal(t, testcase.JSON, string(marshaled)) }) } } diff --git a/tools/hostbasedimagenames/hostbasedimagenames_test.go b/tools/hostbasedimagenames/hostbasedimagenames_test.go index f5c27d6..400f69a 100644 --- a/tools/hostbasedimagenames/hostbasedimagenames_test.go +++ b/tools/hostbasedimagenames/hostbasedimagenames_test.go @@ -80,7 +80,7 @@ func TestParseGood(t *testing.T) { if err != nil { t.Fatal(err) } - assert.Equal(t, parsed, testcase.Expected) + assert.Equal(t, testcase.Expected, parsed) }) } } diff --git a/tools/indextemplate/indextemplate.go b/tools/indextemplate/indextemplate.go index 85309ed..8d7a1d8 100644 --- a/tools/indextemplate/indextemplate.go +++ b/tools/indextemplate/indextemplate.go @@ -72,7 +72,7 @@ func New(ctx context.Context, baseURI *url.URL, config interface{}) (engine refe } // Get returns an array of matching references from the store. -func (engine *Engine) Get(ctx context.Context, name string) (descriptors []v1.Descriptor, err error) { +func (engine *Engine) Get(ctx context.Context, name string) (roots []refengine.MerkleRoot, err error) { parsedName, err := hostbasedimagenames.Parse(name) if err != nil { return nil, err @@ -106,11 +106,11 @@ func (engine *Engine) Get(ctx context.Context, name string) (descriptors []v1.De return nil, err } - if mediatype != `application/vnd.oci.image.index.v1+json` { - return nil, fmt.Errorf("Unknown Content-Type: %s", mediatype) + if mediatype != request.Header.Get(`Accept`) { + return nil, fmt.Errorf("requested %s from %s but got %s", request.Header.Get(`Accept`), request.URL, mediatype) } - return engine.handleIndex(response, parsedName) + return engine.handleIndex(mediatype, response, parsedName) } // Close releases resources held by the engine. @@ -133,28 +133,29 @@ func (engine *Engine) resolveURI(parsedName map[string]string) (uri *url.URL, er return uri, nil } -func (engine *Engine) handleIndex(response *http.Response, parsedName map[string]string) (descriptors []v1.Descriptor, err error) { - descriptors = make([]v1.Descriptor, 0) +func (engine *Engine) handleIndex(mediatype string, response *http.Response, parsedName map[string]string) (roots []refengine.MerkleRoot, err error) { + roots = []refengine.MerkleRoot{} var index v1.Index // FIXME: check response content type (and charset?) if err := json.NewDecoder(response.Body).Decode(&index); err != nil { - logrus.Errorf("%s claimed to return application/vnd.oci.image.index.v1+json, but the response schema did not match: %s", response.Request.URL, err) - return descriptors, err + logrus.Errorf("%s claimed to return %s, but the response schema did not match: %s", response.Request.URL, mediatype, err) + return roots, err } - if fragment, ok := parsedName["fragment"]; ok && len(fragment) > 0 { - for _, descriptor := range index.Manifests { - if fragment == descriptor.Annotations[`org.opencontainers.image.ref.name`] { - descriptors = append(descriptors, descriptor) - } + for _, descriptor := range index.Manifests { + fragment, ok := parsedName["fragment"] + if !ok || fragment == "" || fragment == descriptor.Annotations[`org.opencontainers.image.ref.name`] { + roots = append(roots, refengine.MerkleRoot{ + MediaType: `application/vnd.oci.descriptor.v1+json`, + Root: descriptor, + URI: response.Request.URL, // FIXME: get URI after any redirects + }) } - } else { - descriptors = append(descriptors, index.Manifests...) } - return descriptors, nil + return roots, nil } func init() { diff --git a/tools/indextemplate/indextemplate_test.go b/tools/indextemplate/indextemplate_test.go index 1da59fe..0e95757 100644 --- a/tools/indextemplate/indextemplate_test.go +++ b/tools/indextemplate/indextemplate_test.go @@ -28,6 +28,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/xiekeyang/oci-discovery/tools/hostbasedimagenames" v1new "github.com/xiekeyang/oci-discovery/tools/newimagespec" + "github.com/xiekeyang/oci-discovery/tools/refengine" "golang.org/x/net/context" ) @@ -104,7 +105,7 @@ func TestResolveURI(t *testing.T) { t.Fatal(err) } - assert.Equal(t, uri.String(), testcase.expected) + assert.Equal(t, testcase.expected, uri.String()) }) } } @@ -115,7 +116,16 @@ func TestHandleIndexGood(t *testing.T) { "uri": "https://example.com/index", } - engine, err := New(ctx, nil, config) + uri, err := url.Parse(config["uri"]) + if err != nil { + t.Fatal(err) + } + + request := &http.Request{ + URL: uri, + } + + engine, err := New(ctx, uri, config) if err != nil { t.Fatal(err) } @@ -228,15 +238,23 @@ func TestHandleIndexGood(t *testing.T) { } response := &http.Response{ - Body: ioutil.NopCloser(bytes.NewReader(bodyBytes)), + Request: request, + Body: ioutil.NopCloser(bytes.NewReader(bodyBytes)), } - descriptors, err := engine.(*Engine).handleIndex(response, parsedName) + roots, err := engine.(*Engine).handleIndex(response, parsedName) if err != nil { t.Fatal(err) } - assert.Equal(t, descriptors, testcase.expected) + expected := make([]refengine.MerkleRoot, len(testcase.expected)) + for i, descriptor := range testcase.expected { + expected[i].MediaType = `application/vnd.oci.descriptor.v1+json` + expected[i].Root = descriptor + expected[i].URI = uri + } + + assert.Equal(t, expected, roots) }) } } @@ -278,17 +296,17 @@ func TestHandleIndexBad(t *testing.T) { { label: "manifests is not a JSON array", response: `{"manifests": {}}`, - expected: "json: cannot unmarshal object into Go value of type []v1.Descriptor", + expected: `json: cannot unmarshal object into Go .* of type \[\]v1.Descriptor`, }, { label: "manifests contains a non-object", response: `{"manifests": [1]}`, - expected: "json: cannot unmarshal number into Go value of type v1.Descriptor", + expected: `json: cannot unmarshal number into Go .* of type v1.Descriptor`, }, { label: "at least one manifests[].annotations is not a JSON object", response: `{"manifests": [{"annotations": 1}]}`, - expected: "json: cannot unmarshal number into Go value of type map[string]string", + expected: `json: cannot unmarshal number into Go .* of type map\[string\]string`, }, } { t.Run(testcase.label, func(t *testing.T) { @@ -297,12 +315,12 @@ func TestHandleIndexBad(t *testing.T) { Body: ioutil.NopCloser(strings.NewReader(testcase.response)), } - descriptors, err := engine.(*Engine).handleIndex(response, parsedName) + roots, err := engine.(*Engine).handleIndex(response, parsedName) if err == nil { - t.Fatalf("returned %v and did not raise the expected error", descriptors) + t.Fatalf("returned %v and did not raise the expected error", roots) } - assert.Equal(t, err.Error(), testcase.expected) + assert.Regexp(t, testcase.expected, err.Error()) }) } } diff --git a/tools/refengine/interface.go b/tools/refengine/interface.go index 480f29d..e61de51 100644 --- a/tools/refengine/interface.go +++ b/tools/refengine/interface.go @@ -17,16 +17,14 @@ package refengine import ( "net/url" - //"github.com/opencontainers/image-spec/specs-go/v1" - v1 "github.com/xiekeyang/oci-discovery/tools/newimagespec" "golang.org/x/net/context" ) // Engine represents a reference engine. type Engine interface { - // Get returns an array of matching references from the store. - Get(ctx context.Context, name string) (descriptors []v1.Descriptor, err error) + // Get returns an array of potential Merkle roots from the store. + Get(ctx context.Context, name string) (roots []MerkleRoot, err error) // Close releases resources held by the engine. Subsequent engine // method calls will fail. diff --git a/tools/refengine/root.go b/tools/refengine/root.go new file mode 100644 index 0000000..ca79cc5 --- /dev/null +++ b/tools/refengine/root.go @@ -0,0 +1,88 @@ +// Copyright 2017 oci-discovery contributors +// +// 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 refengine + +import ( + "encoding/json" + "fmt" + "net/url" +) + +// MerkleRoot holds a single resolved Merkle root. +type MerkleRoot struct { + // MediaType is the media type of Root. + MediaType string `json:"mediaType,omitempty"` + + // The Merkle root object. While this may be of any type. OCI + // tools will generally use image-spec Descriptors. + Root interface{} + + // URI is the source, if any, from which Root was retrieved. It can + // be used to expand any relative reference contained within Root. + URI *url.URL +} + +func (root *MerkleRoot) UnmarshalJSON(b []byte) (err error) { + var dataInterface interface{} + if err := json.Unmarshal(b, &dataInterface); err != nil { + return err + } + + data, ok := dataInterface.(map[string]interface{}) + if !ok { + return fmt.Errorf("merkle root is not a JSON object: %v", dataInterface) + } + + mediaTypeInterface, ok := data["mediaType"] + if ok { + mediaTypeString, ok := mediaTypeInterface.(string) + if !ok { + return fmt.Errorf("merkle root mediaType is not a string: %v", mediaTypeInterface) + } + root.MediaType = mediaTypeString + } + + root.Root = data["root"] + + uriInterface, ok := data["uri"] + if !ok { + root.URI = nil + } else { + uriString, ok := uriInterface.(string) + if !ok { + return fmt.Errorf("merkle root uri is not a string: %v", uriInterface) + } + root.URI, err = url.Parse(uriString) + if err != nil { + return err + } + } + + return nil +} + +func (root MerkleRoot) MarshalJSON() ([]byte, error) { + data := map[string]interface{}{} + if root.MediaType != "" { + data["mediaType"] = root.MediaType + } + if root.Root != nil { + data["root"] = root.Root + } + if root.URI != nil { + data["uri"] = root.URI.String() + } + return json.Marshal(data) +} diff --git a/tools/refengine/root_test.go b/tools/refengine/root_test.go new file mode 100644 index 0000000..0a148a4 --- /dev/null +++ b/tools/refengine/root_test.go @@ -0,0 +1,75 @@ +// Copyright 2017 oci-discovery contributors +// +// 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 refengine + +import ( + "encoding/json" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMerkleRootGood(t *testing.T) { + for _, testcase := range []struct { + JSON string + Expected MerkleRoot + }{ + { + JSON: `{"root":"a"}`, + Expected: MerkleRoot{ + Root: "a", + }, + }, + { + JSON: `{"mediaType":"text/plain"}`, + Expected: MerkleRoot{ + MediaType: "text/plain", + }, + }, + { + JSON: `{"root":"a","uri":"https://example.com"}`, + Expected: MerkleRoot{ + MediaType: "", + Root: "a", + URI: &url.URL{ + Scheme: "https", + Host: "example.com", + }, + }, + }, + { + JSON: `{"root":[1.2,3.4],"uri":"https://example.com"}`, + Expected: MerkleRoot{ + Root: []interface{}{1.2, 3.4}, + URI: &url.URL{ + Scheme: "https", + Host: "example.com", + }, + }, + }, + } { + t.Run(testcase.JSON, func(t *testing.T) { + var root MerkleRoot + json.Unmarshal([]byte(testcase.JSON), &root) + assert.Equal(t, testcase.Expected, root) + marshaled, err := json.Marshal(root) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, testcase.JSON, string(marshaled)) + }) + } +} diff --git a/tools/refenginediscovery/refenginediscovery.go b/tools/refenginediscovery/refenginediscovery.go new file mode 100644 index 0000000..bcbb418 --- /dev/null +++ b/tools/refenginediscovery/refenginediscovery.go @@ -0,0 +1,133 @@ +// Copyright 2017 oci-discovery contributors +// +// 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 refenginediscovery + +import ( + "encoding/json" + "fmt" + "mime" + "net/http" + "net/url" + + "github.com/sirupsen/logrus" + "github.com/xiekeyang/oci-discovery/tools/refengine" + "golang.org/x/net/context" +) + +// Discover calculates ref engines using Ref-Engine Discovery and +// calls RefEngines on each one. Discover returns any errors returned +// by RefEngines and aborts further iteration. Other errors (e.g. in +// fetching a ref-engine discovery object from a particular +// protocol/host pair) generate logged warnings but are otherwise +// ignored. +func Discover(ctx context.Context, protocols []string, host string, refEngineCallback RefEngineCallback) (err error) { + if protocols == nil || len(protocols) == 0 { + protocols = []string{"https", "http"} + } + + uri, err := url.Parse("https://example.com/.well-known/oci-host-ref-engines") + if err != nil { + return err + } + for _, protocol := range protocols { + uri.Scheme = protocol + // FIXME: walk DNS ancestors + uri.Host = host + base, err := fetch(ctx, uri) + if err != nil { + logrus.Warn(err) + continue + } + err = base.RefEngines(ctx, refEngineCallback) + if err != nil { + return err + } + } + + return nil +} + +func fetch(ctx context.Context, uri *url.URL) (base *Base, err error) { + base = &Base{} + client := &http.Client{} + + request := &http.Request{ + Method: "GET", + URL: uri, + Header: map[string][]string{ + "Accept": {"application/vnd.oci.ref-engines.v1+json"}, + }, + } + request = request.WithContext(ctx) + + logrus.Debugf("requesting %s from %s", request.Header.Get(`Accept`), request.URL) + response, err := client.Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + base.URI = uri // FIXME: get URI after any redirects + + if response.StatusCode >= 400 { + return nil, fmt.Errorf("ref engine fetching error, status code = %d", response.StatusCode) + } + + mediatype, _, err := mime.ParseMediaType(response.Header.Get(`Content-Type`)) + if err != nil { + return nil, err + } + + if mediatype != request.Header.Get(`Accept`) { + return nil, fmt.Errorf("requested %s from %s but got %s", request.Header.Get(`Accept`), request.URL, mediatype) + } + + if err := json.NewDecoder(response.Body).Decode(&base.Config); err != nil { + logrus.Errorf("ref engines object decoded failed: %s", err) + return nil, err + } + + return base, nil +} + +// RefEngines constructs a ref engine for each Config.RefEngines entry +// and calls refEngineCallback on it. RefEngines returns any errors +// returned by refEngineCallback and aborts further iteration. Other +// errors (e.g. in failure to initialize a ref engine) generate logged +// warnings but are otherwise ignored. +func (base *Base) RefEngines(ctx context.Context, refEngineCallback RefEngineCallback) (err error) { + for _, config := range base.Config.RefEngines { + constructor, ok := refengine.Constructors[config.Protocol] + if !ok { + logrus.Debugf("unsupported ref-engine protocol %q (%v)", config.Protocol, refengine.Constructors) + continue + } + engine, err := constructor(ctx, base.URI, config.Data) + if err != nil { + logrus.Warnf("failed to initialize %s ref engine with %v: %s", config.Protocol, config.Data, err) + continue + } + resolvedCASEngines := make([]ResolvedCASEngines, len(base.Config.CASEngines)) + for i, config := range base.Config.CASEngines { + resolvedCASEngines[i].Config = config + resolvedCASEngines[i].URI = base.URI + } + err = refEngineCallback(ctx, engine, resolvedCASEngines) + if err != nil { + return err + } + } + + return nil +} diff --git a/tools/refenginediscovery/type.go b/tools/refenginediscovery/type.go new file mode 100644 index 0000000..a2fc917 --- /dev/null +++ b/tools/refenginediscovery/type.go @@ -0,0 +1,61 @@ +// Copyright 2017 oci-discovery contributors +// +// 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 refenginediscovery + +import ( + "net/url" + + "github.com/xiekeyang/oci-discovery/tools/engine" + "github.com/xiekeyang/oci-discovery/tools/refengine" + "golang.org/x/net/context" +) + +// Config holds application/vnd.oci.ref-engines.v1+json data. +type Config struct { + + // RefEngines is an array of ref-engine configurations. + RefEngines []engine.Config `json:"refEngines,omitempty"` + + // CASEngines is an array of CAS-engine configurations. + CASEngines []engine.Config `json:"casEngines,omitempty"` +} + +// Base holds a resolved ref-engines object. +type Base struct { + + // Config holds the application/vnd.oci.ref-engines.v1+json data. + Config Config + + // URI is the source, if any, from which Config was retrieved. It + // can be used to expand any relative reference contained within + // Config. + URI *url.URL +} + +// ResolvedCASEngine holds a CAS-engine configuration and the URI +// from which it was retrieved. +type ResolvedCASEngines struct { + + // Config the CAS-engine configuration. + Config engine.Config + + // URI is the source, if any, from which Config was retrieved. It + // can be used to expand any relative reference contained within + // Config. + URI *url.URL +} + +// RefEngineCallback templates a callback for use in RefEngines. +type RefEngineCallback func(ctx context.Context, refEngine refengine.Engine, casEngines []ResolvedCASEngines) (err error)