Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add oci pull capabilities #5075

Merged
merged 8 commits into from
Oct 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ require (
github.com/mesosphere/toml-merge v0.2.0
github.com/mitchellh/go-homedir v1.1.0
github.com/olekukonko/tablewriter v0.0.5
github.com/opencontainers/image-spec v1.1.0
github.com/opencontainers/runtime-spec v1.2.0
github.com/otiai10/copy v1.14.0
github.com/pelletier/go-toml v1.9.5
Expand Down Expand Up @@ -59,6 +60,7 @@ require (
golang.org/x/tools v0.25.0
google.golang.org/grpc v1.67.1
helm.sh/helm/v3 v3.16.1
oras.land/oras-go/v2 v2.5.0
)

// Kubernetes
Expand Down Expand Up @@ -208,7 +210,6 @@ require (
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.0 // indirect
github.com/opencontainers/runc v1.1.14 // indirect
github.com/opencontainers/selinux v1.11.0 // indirect
github.com/peterbourgon/diskv v2.0.1+incompatible // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -882,6 +882,8 @@ k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 h1:pUdcCO1Lk/tbT5ztQWOBi5HBgbBP1
k8s.io/utils v0.0.0-20240711033017-18e509b52bc8/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
oras.land/oras-go v1.2.5 h1:XpYuAwAb0DfQsunIyMfeET92emK8km3W4yEzZvUbsTo=
oras.land/oras-go v1.2.5/go.mod h1:PuAwRShRZCsZb7g8Ar3jKKQR/2A/qN+pkYxIOd/FAoo=
oras.land/oras-go/v2 v2.5.0 h1:o8Me9kLY74Vp5uw07QXPiitjsw7qNXi8Twd+19Zf02c=
oras.land/oras-go/v2 v2.5.0/go.mod h1:z4eisnLP530vwIOUOJeBIj0aGI0L1C3d53atvCBqZHg=
sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.30.3 h1:2770sDpzrjjsAtVhSeUFseziht227YAWYHLGNM8QPwY=
sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.30.3/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw=
sigs.k8s.io/controller-runtime v0.19.0 h1:nWVM7aq+Il2ABxwiCizrVDSlmDcshi9llbaFbC0ji/Q=
Expand Down
210 changes: 210 additions & 0 deletions internal/oci/oci.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
/*
Copyright 2024 k0s 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 oci

import (
"context"
"crypto/tls"
"fmt"
"io"
"net/http"
"os"

ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"oras.land/oras-go/v2/content"
"oras.land/oras-go/v2/content/file"
"oras.land/oras-go/v2/registry"
"oras.land/oras-go/v2/registry/remote"
"oras.land/oras-go/v2/registry/remote/auth"
"oras.land/oras-go/v2/registry/remote/credentials"
)

// Download downloads the OCI artifact present at the given registry URL.
// Usage example:
//
// artifact := "docker.io/company/k0s:latest"
// fp, _ := os.CreateTemp("", "k0s-oci-artifact-*")
// err := oci.Download(ctx, artifact, fp)
//
// This function expects at least one artifact to be present, if none is found
// this returns an error. The artifact name can be specified using the
// WithArtifactName option.
func Download(ctx context.Context, url string, target io.Writer, options ...DownloadOption) (err error) {
opts := downloadOptions{}
for _, opt := range options {
opt(&opts)
}

creds, err := opts.auth.CredentialStore(ctx)
if err != nil {
return fmt.Errorf("failed to create credential store: %w", err)
}

imgref, err := registry.ParseReference(url)
if err != nil {
return fmt.Errorf("failed to parse artifact reference: %w", err)
}

tmpdir, err := os.MkdirTemp("", "k0s-oci-artifact-*")
if err != nil {
return fmt.Errorf("failed to create temp dir: %w", err)
}
defer func() { _ = os.RemoveAll(tmpdir) }()

repo, err := remote.NewRepository(url)
if err != nil {
return fmt.Errorf("failed to create repository: %w", err)
}

if opts.plainHTTP {
repo.PlainHTTP = true
}

fs, err := file.New(tmpdir)
if err != nil {
return fmt.Errorf("failed to create file store: %w", err)
}
defer fs.Close()

transp := http.DefaultTransport.(*http.Transport).Clone()
if opts.insecureSkipTLSVerify {
transp.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
}

repo.Client = &auth.Client{
Client: &http.Client{Transport: transp},
Credential: creds.Get,
}

tag := imgref.Reference
desc, data, err := repo.Manifests().FetchReference(ctx, tag)
if err != nil {
return fmt.Errorf("failed to fetch manifest: %w", err)
}
defer data.Close()

successors, err := content.Successors(ctx, repo, desc)
if err != nil {
return fmt.Errorf("failed to fetch successors: %w", err)
}

source, err := findArtifactDescriptor(successors, opts)
if err != nil {
return fmt.Errorf("failed to find artifact: %w", err)
}

// get a reader to the blob and copies it to the target.
reader, err := repo.Blobs().Fetch(ctx, source)
if err != nil {
return fmt.Errorf("failed to fetch blob: %w", err)
}
defer reader.Close()

if _, err := io.Copy(target, reader); err != nil {
return fmt.Errorf("failed to copy blob: %w", err)
}

return nil
}

// findArtifactDescriptor filters, out of the provided list of descriptors, the
// one that matches the given options. If no artifact name is provided, it
// returns the first descriptor.
func findArtifactDescriptor(all []ocispec.Descriptor, opts downloadOptions) (ocispec.Descriptor, error) {
for _, desc := range all {
if desc.MediaType == ocispec.MediaTypeEmptyJSON {
continue
}
// if no artifact name is specified, we use the first one.
fname := opts.artifactName
if fname == "" || fname == desc.Annotations[ocispec.AnnotationTitle] {
return desc, nil
}
}
if opts.artifactName == "" {
return ocispec.Descriptor{}, fmt.Errorf("no artifacts found")
}
return ocispec.Descriptor{}, fmt.Errorf("artifact %q not found", opts.artifactName)
}
Comment on lines +127 to +142
Copy link
Member

@twz123 twz123 Oct 11, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When having a look at our implementation, I realize that the ADR just says: "We'll download via OCI", but not at all what's meant by this. As far as I understand, there's no common understanding of what it means to download an artifact via OCI, i.e. it is very much implementation dependent.

I think we should augment the ADR with a spec what k0s means/expects when it tries to download files from OCI registries. WDYT?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh yes, this is what we mention in the ADR:

Implement support in Autopilot for pulling artifacts, such as k0s binaries and image bundles

So we at least know what we expect to find in the registry. We should at least write down what we expect when it comes to file names and formats.


// downloadOptions holds the options used when downloading OCI artifacts.
type downloadOptions struct {
insecureSkipTLSVerify bool
auth DockerConfig
artifactName string
plainHTTP bool
}

// DownloadOption is a function that sets an option for the OCI download.
type DownloadOption func(*downloadOptions)

// WithInsecureSkipTLSVerify sets the insecureSkipTLSVerify option to true.
func WithInsecureSkipTLSVerify() DownloadOption {
return func(opts *downloadOptions) {
opts.insecureSkipTLSVerify = true
}
}

// WithPlainHTTP sets the client to reach the remote registry using plain HTTP
// instead of HTTPS.
func WithPlainHTTP() DownloadOption {
return func(opts *downloadOptions) {
opts.plainHTTP = true
}
}

// WithDockerAuth sets the Docker config to be used when authenticating to
// the registry.
func WithDockerAuth(auth DockerConfig) DownloadOption {
return func(opts *downloadOptions) {
opts.auth = auth
}
}

// WithArtifactName sets the name of the artifact to be downloaded. This is
// used to filter out the artifacts present in the manifest.
func WithArtifactName(name string) DownloadOption {
return func(opts *downloadOptions) {
opts.artifactName = name
}
}

// DockerConfigEntry holds an entry in the '.dockerconfigjson' file.
type DockerConfigEntry struct {
Username string `json:"username"`
Password string `json:"password"`
}

// DockerConfig represents the content of the '.dockerconfigjson' file.
type DockerConfig struct {
Auths map[string]DockerConfigEntry `json:"auths"`
}

// CredentialStore turns the Docker configuration into a credential store and
// returns it.
func (d DockerConfig) CredentialStore(ctx context.Context) (credentials.Store, error) {
creds := credentials.NewMemoryStore()
for addr, entry := range d.Auths {
if err := creds.Put(ctx, addr, auth.Credential{
Username: entry.Username,
Password: entry.Password,
}); err != nil {
return nil, fmt.Errorf("failed to add credential: %w", err)
}
}
return creds, nil
}
Loading
Loading