Skip to content
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
52 changes: 41 additions & 11 deletions internal/packagecmd/internal/builder/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ const (
flagRegistryPassword execute.Arg = "--password"
// flagDevBuild enables development build mode.
flagDevBuild execute.Arg = "--dev"
// flagInsecureRegistry allows plain HTTP requests to the registry.
flagInsecureRegistry execute.Arg = "--insecure-registry"

// envPackageVersion is consumed by werf templates as the package version.
envPackageVersion = "PACKAGE_TAG"
Expand All @@ -68,6 +70,10 @@ const (
envWerfSignCert = "WERF_SIGN_CERT"
// envWerfSignKey is the private key used for signing (file path, Base64-encoded value, or Vault URL, e.g. hashivault://dh-2025-aug-ec).
envWerfSignKey = "WERF_SIGN_KEY"
// envInsecureRegistry allows plain HTTP requests to the registry.
envInsecureRegistry = "WERF_INSECURE_REGISTRY"
// envSkipTLSVerifyRegistry skips TLS certificate verification of the registry.
envSkipTLSVerifyRegistry = "WERF_SKIP_TLS_VERIFY_REGISTRY"
// envSignIntermediates holds intermediate certificates used in the signing chain (file path or Base64-encoded value).
envSignIntermediates = "WERF_SIGN_INTERMEDIATES"

Expand All @@ -87,6 +93,8 @@ type Options struct {
Force bool
// Debug keeps generated build files in the package root after build.
Debug bool
// Insecure allows plain HTTP registries and skips TLS certificate verification.
Insecure bool
// Sign configures image signing during the build.
Sign SignOptions
}
Expand Down Expand Up @@ -144,6 +152,11 @@ func Build(ctx context.Context, version string, opts Options, logger *logs.Logge
return fmt.Errorf("invalid semantic version '%s': %w", version, err)
}

var regOpts []registry.Option
if opts.Insecure {
regOpts = append(regOpts, registry.WithInsecure())
}

// Construct full repository path with package name
repo := opts.RepositoryCredentials.Repository
if len(repo) > 0 {
Expand All @@ -153,7 +166,7 @@ func Build(ctx context.Context, version string, opts Options, logger *logs.Logge
if len(opts.RepositoryCredentials.Username) > 0 && len(opts.RepositoryCredentials.Token) > 0 {
logger.Info("✨ Login registry '%s'", repo)

if err = login(ctx, repo, opts.RepositoryCredentials.Username, opts.RepositoryCredentials.Token); err != nil {
if err = login(ctx, repo, opts.RepositoryCredentials.Username, opts.RepositoryCredentials.Token, opts.Insecure); err != nil {
return fmt.Errorf("login registry: %w", err)
}
}
Expand All @@ -166,7 +179,7 @@ func Build(ctx context.Context, version string, opts Options, logger *logs.Logge
if len(opts.FinalRepositoryCredentials.Username) > 0 && len(opts.FinalRepositoryCredentials.Token) > 0 {
logger.Info("✨ Login final registry '%s'", finalRepo)

if err = login(ctx, finalRepo, opts.FinalRepositoryCredentials.Username, opts.FinalRepositoryCredentials.Token); err != nil {
if err = login(ctx, finalRepo, opts.FinalRepositoryCredentials.Username, opts.FinalRepositoryCredentials.Token, opts.Insecure); err != nil {
return fmt.Errorf("login final registry: %w", err)
}
}
Expand All @@ -181,7 +194,7 @@ func Build(ctx context.Context, version string, opts Options, logger *logs.Logge
}

if repoLog != "local" {
if err = registry.Exists(ctx, fmt.Sprintf("%s:%s", repoLog, version)); !opts.Force && err == nil {
if err = registry.Exists(ctx, fmt.Sprintf("%s:%s", repoLog, version), regOpts...); !opts.Force && err == nil {
logger.Info("✅ Version '%s' already exists in the registry", version)
return nil
}
Expand Down Expand Up @@ -210,21 +223,21 @@ func Build(ctx context.Context, version string, opts Options, logger *logs.Logge

logger.Info("✨ Build and push images to '%s'...", repoLog)

if err = build(ctx, finalRepo, repo, path, version, opts.Sign); err != nil {
if err = build(ctx, finalRepo, repo, path, version, opts.Sign, opts.Insecure); err != nil {
return fmt.Errorf("failed to build package: %w", err)
}

logger.Info("✅ Images built and pushed")

// Skip publishing steps for local builds
if repoLog != "local" {
if err = registry.PushPackageIndex(ctx, repoLog); err != nil {
if err = registry.PushPackageIndex(ctx, repoLog, regOpts...); err != nil {
return fmt.Errorf("failed to register index: %w", err)
}

logger.Info("✨ Publish version '%s'...", version)

if err = publishVersionImage(ctx, repoLog, version, path); err != nil {
if err = publishVersionImage(ctx, repoLog, version, path, regOpts...); err != nil {
return fmt.Errorf("failed to publish version: %w", err)
}
}
Expand All @@ -235,7 +248,7 @@ func Build(ctx context.Context, version string, opts Options, logger *logs.Logge
}

// login authenticates with the container registry using the d8 delivery-kit CLI.
func login(ctx context.Context, registry, username, token string) error {
func login(ctx context.Context, registry, username, token string, insecure bool) error {
args := []execute.Arg{
argDeliveryPlugin,
argContainerRegistry,
Expand All @@ -250,12 +263,19 @@ func login(ctx context.Context, registry, username, token string) error {
execute.Arg(token),
}

// The flag marks the registry insecure in the docker sense: HTTPS without
// certificate verification first, plain HTTP as a fallback. Self-signed
// HTTPS registries therefore work here too.
if insecure {
args = append(args, flagInsecureRegistry)
}

return commandCli.Execute(ctx, execute.WithArgs(args...))
}

// build executes the package build process using d8 delivery-kit.
// For local builds, it skips the image-spec-stage; for registry builds, it sets WERF_REPO.
func build(ctx context.Context, finalRegistry, registry, packageDir, version string, signOpts SignOptions) error {
func build(ctx context.Context, finalRegistry, registry, packageDir, version string, signOpts SignOptions, insecure bool) error {
args := []execute.Arg{
argDeliveryPlugin,
argBuild,
Expand All @@ -269,6 +289,16 @@ func build(ctx context.Context, finalRegistry, registry, packageDir, version str
execute.NewEnv(envPackageVersion, version),
}

// delivery-kit reads both settings as flag defaults, so the environment is
// enough to configure them.
if insecure {
env = append(
env,
execute.NewEnv(envInsecureRegistry, "1"),
execute.NewEnv(envSkipTLSVerifyRegistry, "1"),
)
}

if signOpts.Enabled {
env = append(
env,
Expand Down Expand Up @@ -300,7 +330,7 @@ func build(ctx context.Context, finalRegistry, registry, packageDir, version str

// publishVersionImage reads the build report and copies bundle and release images to their final destinations.
// Bundle image is tagged as repo:version, release image as repo/version:version.
func publishVersionImage(ctx context.Context, repo, version, path string) error {
func publishVersionImage(ctx context.Context, repo, version, path string, opts ...registry.Option) error {
raw, err := os.ReadFile(filepath.Join(path, reportFile))
if err != nil {
return fmt.Errorf("failed to read report file: %w", err)
Expand All @@ -320,7 +350,7 @@ func publishVersionImage(ctx context.Context, repo, version, path string) error
src := bundle.DockerImageName
dest := fmt.Sprintf("%s:%s", repo, version)

if err = registry.Copy(ctx, src, dest); err != nil {
if err = registry.Copy(ctx, src, dest, opts...); err != nil {
return fmt.Errorf("failed to copy image: %w", err)
}

Expand All @@ -333,7 +363,7 @@ func publishVersionImage(ctx context.Context, repo, version, path string) error
src = release.DockerImageName
dest = fmt.Sprintf("%s/version:%s", repo, version)

if err = registry.Copy(ctx, src, dest); err != nil {
if err = registry.Copy(ctx, src, dest, opts...); err != nil {
return fmt.Errorf("failed to copy image: %w", err)
}

Expand Down
99 changes: 71 additions & 28 deletions internal/packagecmd/internal/tools/registry/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ package registry

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

"github.com/google/go-containerregistry/pkg/authn"
Expand All @@ -18,9 +20,12 @@ import (
type options struct {
// auth authenticates the request.
auth remote.Option
// insecure allows plain HTTP registries and skips TLS verification.
insecure bool
}

// Option customizes how a registry request authenticates.
// Option customizes a registry request: authentication, transport security
// and the URL scheme of reference parsing.
type Option func(*options)

// WithBasicAuth authenticates requests with username and password instead of the
Expand All @@ -36,42 +41,85 @@ func WithBasicAuth(username, password string) Option {
}
}

// WithInsecure talks to the registry over plain HTTP and skips TLS certificate
// verification. Use only when the user explicitly opts in.
func WithInsecure() Option {
return func(o *options) {
o.insecure = true
}
}

// resolve applies opts over the defaults: the ambient Docker keychain and verified HTTPS.
func resolve(opts ...Option) options {
o := options{auth: remote.WithAuthFromKeychain(authn.DefaultKeychain)}

for _, opt := range opts {
opt(&o)
}

return o
}

// Auth resolves opts into the authentication option for a remote request, defaulting
// to the ambient Docker keychain. It is exported for packages that issue their own
// registry requests instead of going through this one, such as imagefs.
// Non-auth options such as WithInsecure are dropped here: a caller that needs
// them must consume RemoteOptions and NameOptions instead.
func Auth(opts ...Option) remote.Option {
o := options{auth: remote.WithAuthFromKeychain(authn.DefaultKeychain)}
return resolve(opts...).auth
}

for _, opt := range opts {
opt(&o)
// RemoteOptions resolves opts into the options of a remote request bound to ctx.
func RemoteOptions(ctx context.Context, opts ...Option) []remote.Option {
o := resolve(opts...)

r := []remote.Option{o.auth, remote.WithContext(ctx)}
if o.insecure {
r = append(r, remote.WithTransport(insecureTransport()))
}

return r
}

// NameOptions resolves opts into the options of reference and repository parsing.
func NameOptions(opts ...Option) []name.Option {
if resolve(opts...).insecure {
return []name.Option{name.Insecure}
}

return o.auth
return nil
}

// Copy copies a container image from srcRef to destRef using credentials from the default keychain.
func Copy(ctx context.Context, srcRef, destRef string) error {
ref, err := name.ParseReference(srcRef, name.Insecure)
// insecureTransport clones remote's default transport with TLS verification disabled.
func insecureTransport() http.RoundTripper {
t := remote.DefaultTransport.(*http.Transport).Clone()
t.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec // user-opted via --insecure

return t
}

// Copy copies a container image from srcRef to destRef. By default it authenticates
// with the ambient Docker keychain; opts can override credentials and transport.
func Copy(ctx context.Context, srcRef, destRef string, opts ...Option) error {
nameOpts := NameOptions(opts...)
remoteOpts := RemoteOptions(ctx, opts...)

ref, err := name.ParseReference(srcRef, nameOpts...)
if err != nil {
return fmt.Errorf("failed to parse reference: %w", err)
}

src, err := remote.Image(ref, remote.WithAuthFromKeychain(authn.DefaultKeychain), remote.WithContext(ctx))
src, err := remote.Image(ref, remoteOpts...)
if err != nil {
return fmt.Errorf("failed to get image: %w", err)
}

opts := []remote.Option{
remote.WithAuthFromKeychain(authn.DefaultKeychain),
remote.WithContext(ctx),
}

dest, err := name.ParseReference(destRef)
dest, err := name.ParseReference(destRef, nameOpts...)
if err != nil {
return fmt.Errorf("failed to parse reference: %w", err)
}

if err = remote.Write(dest, src, opts...); err != nil {
if err = remote.Write(dest, src, remoteOpts...); err != nil {
return fmt.Errorf("failed to write image: %w", err)
}

Expand All @@ -81,7 +129,7 @@ func Copy(ctx context.Context, srcRef, destRef string) error {
// PushPackageIndex creates an empty package index marker image for repository.
// It extracts the package name from the registry path (e.g., "registry.io/org/pkg" -> "pkg")
// and pushes an empty image tagged as "registry.io/org:pkg".
func PushPackageIndex(ctx context.Context, repository string) error {
func PushPackageIndex(ctx context.Context, repository string, opts ...Option) error {
img := empty.Image

// Match the marker image produced by crane with --new_layer "".
Expand All @@ -100,17 +148,12 @@ func PushPackageIndex(ctx context.Context, repository string) error {
base := strings.Join(splits[:len(splits)-1], "/")
index := splits[len(splits)-1]

ref, err := name.ParseReference(fmt.Sprintf("%s:%s", base, index))
ref, err := name.ParseReference(fmt.Sprintf("%s:%s", base, index), NameOptions(opts...)...)
if err != nil {
return fmt.Errorf("failed to parse reference: %w", err)
}

opts := []remote.Option{
remote.WithAuthFromKeychain(authn.DefaultKeychain),
remote.WithContext(ctx),
}

if err = remote.Write(ref, img, opts...); err != nil {
if err = remote.Write(ref, img, RemoteOptions(ctx, opts...)...); err != nil {
return fmt.Errorf("failed to write image to registry: %w", err)
}

Expand All @@ -120,12 +163,12 @@ func PushPackageIndex(ctx context.Context, repository string) error {
// Tags lists the tags of repository, which is a repository path without a tag.
// An empty result means the repository exists but carries no tags.
func Tags(ctx context.Context, repository string, opts ...Option) ([]string, error) {
repo, err := name.NewRepository(repository)
repo, err := name.NewRepository(repository, NameOptions(opts...)...)
if err != nil {
return nil, fmt.Errorf("failed to parse repository: %w", err)
}

tags, err := remote.List(repo, Auth(opts...), remote.WithContext(ctx))
tags, err := remote.List(repo, RemoteOptions(ctx, opts...)...)
if err != nil {
return nil, fmt.Errorf("failed to list tags of %q: %w", repository, err)
}
Expand All @@ -135,12 +178,12 @@ func Tags(ctx context.Context, repository string, opts ...Option) ([]string, err

// Exists verifies that ref exists by performing a HEAD request for its manifest.
func Exists(ctx context.Context, ref string, opts ...Option) error {
r, err := name.ParseReference(ref)
r, err := name.ParseReference(ref, NameOptions(opts...)...)
if err != nil {
return fmt.Errorf("failed to parse reference: %w", err)
}

if _, err = remote.Head(r, Auth(opts...), remote.WithContext(ctx)); err != nil {
if _, err = remote.Head(r, RemoteOptions(ctx, opts...)...); err != nil {
return fmt.Errorf("image %q not found in registry: %w", ref, err)
}

Expand Down
43 changes: 43 additions & 0 deletions internal/packagecmd/internal/tools/registry/registry_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package registry

import (
"context"
"net/http"
"testing"

"github.com/google/go-containerregistry/pkg/name"
"github.com/google/go-containerregistry/pkg/v1/remote"
"github.com/stretchr/testify/require"
)

func TestNameOptions(t *testing.T) {
// A private-range or localhost registry resolves to HTTP on its own, so the
// fixture uses a public host to tell the two code paths apart.
t.Run("default parses a remote registry as HTTPS", func(t *testing.T) {
ref, err := name.ParseReference("registry.example.com:5000/packages/app:v1.0.0", NameOptions()...)
require.NoError(t, err)
require.Equal(t, "https", ref.Context().Registry.Scheme())
})

t.Run("insecure parses a remote registry as HTTP", func(t *testing.T) {
ref, err := name.ParseReference("registry.example.com:5000/packages/app:v1.0.0", NameOptions(WithInsecure())...)
require.NoError(t, err)
require.Equal(t, "http", ref.Context().Registry.Scheme())
})
}

func TestRemoteOptions(t *testing.T) {
ctx := context.Background()

require.Len(t, RemoteOptions(ctx), 2, "auth and context only")
require.Len(t, RemoteOptions(ctx, WithInsecure()), 3, "auth, context and transport")
}

func TestInsecureTransport(t *testing.T) {
transport, ok := insecureTransport().(*http.Transport)
require.True(t, ok)
require.True(t, transport.TLSClientConfig.InsecureSkipVerify)

// The shared ggcr default transport must stay untouched.
require.NotSame(t, remote.DefaultTransport, transport)
}
Loading
Loading