diff --git a/internal/packagecmd/internal/builder/build.go b/internal/packagecmd/internal/builder/build.go index 969af4b8..e3e87db3 100644 --- a/internal/packagecmd/internal/builder/build.go +++ b/internal/packagecmd/internal/builder/build.go @@ -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" @@ -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" @@ -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 } @@ -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 { @@ -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) } } @@ -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) } } @@ -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 } @@ -210,7 +223,7 @@ 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) } @@ -218,13 +231,13 @@ func Build(ctx context.Context, version string, opts Options, logger *logs.Logge // 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) } } @@ -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, @@ -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, @@ -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, @@ -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) @@ -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) } @@ -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) } diff --git a/internal/packagecmd/internal/tools/registry/registry.go b/internal/packagecmd/internal/tools/registry/registry.go index a096b304..bb05f235 100644 --- a/internal/packagecmd/internal/tools/registry/registry.go +++ b/internal/packagecmd/internal/tools/registry/registry.go @@ -2,8 +2,10 @@ package registry import ( "context" + "crypto/tls" "fmt" "io" + "net/http" "strings" "github.com/google/go-containerregistry/pkg/authn" @@ -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 @@ -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) } @@ -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 "". @@ -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) } @@ -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) } @@ -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) } diff --git a/internal/packagecmd/internal/tools/registry/registry_test.go b/internal/packagecmd/internal/tools/registry/registry_test.go new file mode 100644 index 00000000..ab7f3c36 --- /dev/null +++ b/internal/packagecmd/internal/tools/registry/registry_test.go @@ -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) +} diff --git a/internal/packagecmd/pkg/cmd/build/build.go b/internal/packagecmd/pkg/cmd/build/build.go index ed17f192..fdfc4ad9 100644 --- a/internal/packagecmd/pkg/cmd/build/build.go +++ b/internal/packagecmd/pkg/cmd/build/build.go @@ -3,6 +3,7 @@ package build import ( "fmt" "os" + "strconv" "github.com/spf13/cobra" @@ -29,6 +30,8 @@ var ( force bool // debug enables verbose build output and keeps rendered Werf templates. debug bool + // insecure allows plain HTTP registries and skips TLS certificate verification. + insecure bool // sign controls whether the built package is signed. sign bool // signCert stores a signing certificate path or base64-encoded certificate. @@ -58,6 +61,7 @@ Environment Variables: PACKAGE_BUILD_FINAL_REPOSITORY Final registry URL for the published artifact PACKAGE_BUILD_FINAL_REPOSITORY_USER Final registry username for authentication PACKAGE_BUILD_FINAL_REPOSITORY_TOKEN Final registry token for authentication + PACKAGE_BUILD_INSECURE Allow plain HTTP and skip TLS verification `, Example: ` # Build with explicit registry @@ -74,7 +78,10 @@ Environment Variables: package build --version=v1.0.0 --force # Build with debug mode (keeps rendered werf templates) - package build --version=v1.0.0 --debug`, + package build --version=v1.0.0 --debug + + # Build against a registry without valid TLS + package build -r 10.0.0.5:5000/packages --version=v1.0.0 --insecure`, Args: cobra.ExactArgs(0), SilenceUsage: true, RunE: build, @@ -89,6 +96,7 @@ Environment Variables: cmd.Flags().StringVarP(&packageVersion, "version", "v", "", "Package version") cmd.Flags().BoolVarP(&force, "force", "f", false, "force update version in registry") cmd.Flags().BoolVar(&debug, "debug", false, "enable debug logging") + cmd.Flags().BoolVar(&insecure, "insecure", false, "Allow plain HTTP and skip TLS verification for every registry used by the build, including the final repository and base-image pulls (env: PACKAGE_BUILD_INSECURE)") cmd.Flags().StringVar(&signCert, "sign-cert", "", "sign certificate path or base64 string (env: PACKAGE_BUILD_SIGN_CERT)") cmd.Flags().StringVar(&signKey, "sign-key", "", "sign key path or base64 string or vault url (env: PACKAGE_BUILD_SIGN_KEY)") @@ -138,6 +146,12 @@ func build(cmd *cobra.Command, _ []string) error { signKey = os.Getenv("PACKAGE_BUILD_SIGN_KEY") } + // The env var is a fallback for an unset flag only: an explicit + // --insecure=false must win over PACKAGE_BUILD_INSECURE=true. + if !cmd.Flags().Changed("insecure") { + insecure, _ = strconv.ParseBool(os.Getenv("PACKAGE_BUILD_INSECURE")) + } + if sign { if signCert == "" || signKey == "" { return fmt.Errorf("--sign-cert and --sign-key are required with --sign") @@ -149,8 +163,9 @@ func build(cmd *cobra.Command, _ []string) error { } opts := builder.Options{ - Force: force, - Debug: debug, + Force: force, + Debug: debug, + Insecure: insecure, RepositoryCredentials: builder.Credentials{ Repository: repository, Username: repositoryUser,