Files
pulumi-docker-build/provider/internal/cache.go
2025-04-11 21:19:58 +00:00

770 lines
23 KiB
Go

// Copyright 2024, Pulumi Corporation.
//
// 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 internal
import (
"errors"
"fmt"
"strings"
controllerapi "github.com/docker/buildx/controller/pb"
"github.com/docker/buildx/util/buildflags"
"github.com/pulumi/pulumi-go-provider/infer"
)
var (
_ fmt.Stringer = (*CacheFrom)(nil)
_ fmt.Stringer = (*CacheFromAzureBlob)(nil)
_ fmt.Stringer = (*CacheFromGitHubActions)(nil)
_ fmt.Stringer = (*CacheFromLocal)(nil)
_ fmt.Stringer = (*CacheFromRegistry)(nil)
_ fmt.Stringer = (*CacheFromS3)(nil)
_ fmt.Stringer = (*CacheTo)(nil)
_ fmt.Stringer = (*CacheToAzureBlob)(nil)
_ fmt.Stringer = (*CacheToGitHubActions)(nil)
_ fmt.Stringer = (*CacheToInline)(nil)
_ fmt.Stringer = (*CacheToLocal)(nil)
_ fmt.Stringer = (*CacheToRegistry)(nil)
_ fmt.Stringer = (*CacheToS3)(nil)
_ fmt.Stringer = CacheWithCompression{}
_ fmt.Stringer = CacheWithIgnoreError{}
_ fmt.Stringer = CacheWithMode{}
_ fmt.Stringer = CacheWithOCI{}
_ infer.Annotated = (*CacheFrom)(nil)
_ infer.Annotated = (*CacheFromAzureBlob)(nil)
_ infer.Annotated = (*CacheFromGitHubActions)(nil)
_ infer.Annotated = (*CacheFromLocal)(nil)
_ infer.Annotated = (*CacheFromRegistry)(nil)
_ infer.Annotated = (*CacheFromS3)(nil)
_ infer.Annotated = (*CacheTo)(nil)
_ infer.Annotated = (*CacheToInline)(nil)
_ infer.Annotated = (*CacheToLocal)(nil)
_ infer.Annotated = (*CacheWithCompression)(nil)
_ infer.Annotated = (*CacheWithIgnoreError)(nil)
_ infer.Annotated = (*CacheWithMode)(nil)
_ infer.Annotated = (*CacheWithOCI)(nil)
_ infer.Enum[CacheMode] = (*CacheMode)(nil)
_ infer.Enum[CompressionType] = (*CompressionType)(nil)
)
// CacheFromLocal pulls cache manifests from a local directory.
type CacheFromLocal struct {
Src string `pulumi:"src"`
Digest string `pulumi:"digest,optional"`
}
// String returns the CLI-encoded value of these cache options, or an empty
// string if the receiver is nil.
func (c *CacheFromLocal) String() string {
if c == nil {
return ""
}
parts := []string{"type=local"}
if c.Src != "" {
parts = append(parts, "src="+c.Src)
}
if c.Digest != "" {
parts = append(parts, "digest="+c.Digest)
}
return strings.Join(parts, ",")
}
// Annotate sets docstrings on CacheFromLocal.
func (c *CacheFromLocal) Annotate(a infer.Annotator) {
a.Describe(&c.Src, "Path of the local directory where cache gets imported from.")
a.Describe(&c.Digest, "Digest of manifest to import.")
}
// CacheFromRegistry pulls cache manifests from a registry ref.
type CacheFromRegistry struct {
Ref string `pulumi:"ref"`
}
// Annotate sets docstrings on CacheFromRegistry.
func (c *CacheFromRegistry) Annotate(a infer.Annotator) {
a.Describe(&c.Ref, "Fully qualified name of the cache image to import.")
}
// String returns the CLI-encoded value of these cache options, or an empty
// string if the receiver is nil.
func (c *CacheFromRegistry) String() string {
if c == nil {
return ""
}
return "type=registry,ref=" + c.Ref
}
// CacheWithOCI exposes OCI media type options.
type CacheWithOCI struct {
OCI *bool `pulumi:"ociMediaTypes,optional"`
ImageManifest *bool `pulumi:"imageManifest,optional"`
}
// Annotate sets docstrings on CacheWithOCI.
func (c *CacheWithOCI) Annotate(a infer.Annotator) {
a.Describe(&c.OCI, dedent(`
Whether to use OCI media types in exported manifests. Defaults to
"true".
`))
a.Describe(&c.ImageManifest, dedent(`
Export cache manifest as an OCI-compatible image manifest instead of a
manifest list. Requires "ociMediaTypes" to also be "true".
Some registries like AWS ECR will not work with caching if this is
"false".
Defaults to "false" to match Docker's default behavior.
`))
a.SetDefault(&c.OCI, true)
a.SetDefault(&c.ImageManifest, false)
}
// String returns the CLI-encoded value of these cache options, or an empty
// string if unknown.
func (c CacheWithOCI) String() string {
if c.OCI == nil {
return ""
}
parts := []string{fmt.Sprintf("oci-mediatypes=%t", *c.OCI)}
if c.ImageManifest != nil {
parts = append(parts, fmt.Sprintf("image-manifest=%t", *c.ImageManifest))
}
return strings.Join(parts, ",")
}
// CacheFromGitHubActions pulls cache manifests from the GitHub actions cache.
type CacheFromGitHubActions struct {
URL string `pulumi:"url,optional"`
Token string `pulumi:"token,optional" provider:"secret"`
Scope string `pulumi:"scope,optional"`
}
// Annotate sets docstrings on CacheFromGitHubActions.
func (c *CacheFromGitHubActions) Annotate(a infer.Annotator) {
a.SetDefault(&c.URL, "", "ACTIONS_CACHE_URL")
a.SetDefault(&c.Token, "", "ACTIONS_RUNTIME_TOKEN")
a.SetDefault(&c.Scope, "buildkit")
a.Describe(&c.URL, dedent(`
The cache server URL to use for artifacts.
Defaults to "$ACTIONS_CACHE_URL", although a separate action like
"crazy-max/ghaction-github-runtime" is recommended to expose this
environment variable to your jobs.
`))
a.Describe(&c.Token, dedent(`
The GitHub Actions token to use. This is not a personal access tokens
and is typically generated automatically as part of each job.
Defaults to "$ACTIONS_RUNTIME_TOKEN", although a separate action like
"crazy-max/ghaction-github-runtime" is recommended to expose this
environment variable to your jobs.
`))
a.Describe(&c.Scope, dedent(`
The scope to use for cache keys. Defaults to "buildkit".
This should be set if building and caching multiple images in one
workflow, otherwise caches will overwrite each other.
`))
}
func (c *CacheFromGitHubActions) String() string {
if c == nil {
return ""
}
parts := []string{"type=gha"}
if c.Scope != "" {
parts = append(parts, "scope="+c.Scope)
}
if c.Token != "" {
parts = append(parts, "token="+c.Token)
}
if c.URL != "" {
parts = append(parts, "url="+c.URL)
}
return strings.Join(parts, ",")
}
// CacheFromAzureBlob pulls cache manifests from Azure
// blob storage.
type CacheFromAzureBlob struct {
Name string `pulumi:"name"`
AccountURL string `pulumi:"accountUrl,optional"`
SecretAccessKey string `pulumi:"secretAccessKey,optional" provider:"secret"`
}
// String returns the CLI-encoded value of these cache options, or an empty
// string if the receiver is nil.
func (c *CacheFromAzureBlob) String() string {
if c == nil {
return ""
}
parts := []string{"type=azblob"}
if c.Name != "" {
parts = append(parts, "name="+c.Name)
}
if c.AccountURL != "" {
parts = append(parts, "account_url="+c.AccountURL)
}
if c.SecretAccessKey != "" {
parts = append(parts, "secret_access_key="+c.SecretAccessKey)
}
return strings.Join(parts, ",")
}
// Annotate sets docstrings on CacheFromAzureBlob.
func (c *CacheFromAzureBlob) Annotate(a infer.Annotator) {
a.Describe(&c.Name, "The name of the cache image.")
a.Describe(&c.AccountURL, "Base URL of the storage account.")
a.Describe(&c.SecretAccessKey, "Blob storage account key.")
}
// CacheToAzureBlob pushes cache manifests to Azure blob storage.
type CacheToAzureBlob struct {
CacheWithMode
CacheWithIgnoreError
CacheFromAzureBlob
}
func (c *CacheToAzureBlob) String() string {
if c == nil {
return ""
}
return join(&c.CacheFromAzureBlob, c.CacheWithMode, c.CacheWithIgnoreError)
}
// CacheFromS3 pulls cache manifests from S3-compatible APIs.
type CacheFromS3 struct {
Region string `pulumi:"region"`
Bucket string `pulumi:"bucket"`
Name string `pulumi:"name,optional"`
EndpointURL string `pulumi:"endpointUrl,optional"`
BlobsPrefix string `pulumi:"blobsPrefix,optional"`
ManifestsPrefix string `pulumi:"manifestsPrefix,optional"`
UsePathStyle *bool `pulumi:"usePathStyle,optional"`
AccessKeyID string `pulumi:"accessKeyId,optional"`
SecretAccessKey string `pulumi:"secretAccessKey,optional" provider:"secret"`
SessionToken string `pulumi:"sessionToken,optional" provider:"secret"`
}
// Annotate sets docstrings and defaults on CacheFromS3.
func (c *CacheFromS3) Annotate(a infer.Annotator) {
a.SetDefault(&c.Region, "", "AWS_REGION")
a.SetDefault(&c.AccessKeyID, "", "AWS_ACCESS_KEY_ID")
a.SetDefault(&c.SecretAccessKey, "", "AWS_SECRET_ACCESS_KEY")
a.SetDefault(&c.SessionToken, "", "AWS_SESSION_TOKEN")
a.Describe(&c.Bucket, dedent(`
Name of the S3 bucket.
`))
a.Describe(&c.Region, dedent(`
The geographic location of the bucket. Defaults to "$AWS_REGION".
`))
a.Describe(&c.AccessKeyID, dedent(`
Defaults to "$AWS_ACCESS_KEY_ID".
`))
a.Describe(&c.SecretAccessKey, dedent(`
Defaults to "$AWS_SECRET_ACCESS_KEY".
`))
a.Describe(&c.SessionToken, dedent(`
Defaults to "$AWS_SESSION_TOKEN".
`))
a.Describe(&c.BlobsPrefix, dedent(`
Prefix to prepend to blob filenames.
`))
a.Describe(&c.EndpointURL, dedent(`
Endpoint of the S3 bucket.
`))
a.Describe(&c.ManifestsPrefix, dedent(`
Prefix to prepend on manifest filenames.
`))
a.Describe(&c.Name, dedent(`
Name of the cache image.
`))
a.Describe(&c.UsePathStyle, dedent(`
Uses "bucket" in the URL instead of hostname when "true".
`))
}
// String returns the CLI-encoded value of these cache options, or an empty
// string if the receiver is nil.
func (c *CacheFromS3) String() string {
if c == nil {
return ""
}
parts := []string{"type=s3"}
if c.Bucket != "" {
parts = append(parts, "bucket="+c.Bucket)
}
if c.Name != "" {
parts = append(parts, "name="+c.Name)
}
if c.EndpointURL != "" {
parts = append(parts, "endpoint_url="+c.EndpointURL)
}
if c.BlobsPrefix != "" {
parts = append(parts, "blobs_prefix="+c.BlobsPrefix)
}
if c.ManifestsPrefix != "" {
parts = append(parts, "manifests_prefix="+c.ManifestsPrefix)
}
if c.UsePathStyle != nil {
parts = append(parts, fmt.Sprintf("use_path_type=%t", *c.UsePathStyle))
}
if c.AccessKeyID != "" {
parts = append(parts, "access_key_id="+c.AccessKeyID)
}
if c.SecretAccessKey != "" {
parts = append(parts, "secret_access_key="+c.SecretAccessKey)
}
if c.SessionToken != "" {
parts = append(parts, "session_token="+c.SessionToken)
}
return strings.Join(parts, ",")
}
// CacheWithMode is a cache that can configure its mode.
type CacheWithMode struct {
Mode *CacheMode `pulumi:"mode,optional"`
}
// Annotate sets docstrings and defaults on CacheWithMode.
func (c *CacheWithMode) Annotate(a infer.Annotator) {
a.SetDefault(&c.Mode, Min)
a.Describe(&c.Mode, dedent(`
The cache mode to use. Defaults to "min".
`))
}
func (c CacheWithMode) String() string {
if c.Mode == nil {
return ""
}
return fmt.Sprintf("mode=%s", *c.Mode)
}
// CacheWithIgnoreError exposes an option to ignore errors during caching.
type CacheWithIgnoreError struct {
IgnoreError *bool `pulumi:"ignoreError,optional"`
}
// Annotate sets docstrings and defaults on CacheWithIgnoreError.
func (c *CacheWithIgnoreError) Annotate(a infer.Annotator) {
a.SetDefault(&c.IgnoreError, false)
a.Describe(&c.IgnoreError, "Ignore errors caused by failed cache exports.")
}
func (c CacheWithIgnoreError) String() string {
if c.IgnoreError == nil {
return ""
}
return fmt.Sprintf("ignore-error=%t", *c.IgnoreError)
}
// CacheToS3 pushes cache manifests to an S3-compatible API.
type CacheToS3 struct {
CacheWithMode
CacheWithIgnoreError
CacheFromS3
}
// String returns the CLI-encoded value of these cache options, or an empty
// string if the receiver is nil.
func (c *CacheToS3) String() string {
if c == nil {
return ""
}
return join(&c.CacheFromS3, c.CacheWithMode, c.CacheWithIgnoreError)
}
// Raw is a CLI-encoded cache entry appropriate for passing directly to the
// CLI. Useful if the Docker backend supports cache types not captured by our
// API, or if the user just prefers "type=..." inputs.
type Raw string
// String return the raw string as-is. This can be empty during previews where
// the user has provided a value but it is unknown.
func (c Raw) String() string {
return string(c)
}
// CacheFrom is a "union" type for all of our available `--cache-from` options.
type CacheFrom struct {
Local *CacheFromLocal `pulumi:"local,optional"`
Registry *CacheFromRegistry `pulumi:"registry,optional"`
GHA *CacheFromGitHubActions `pulumi:"gha,optional"`
AZBlob *CacheFromAzureBlob `pulumi:"azblob,optional"`
S3 *CacheFromS3 `pulumi:"s3,optional"`
Raw Raw `pulumi:"raw,optional"`
Disabled bool `pulumi:"disabled,optional"`
}
// Annotate sets docstrings and defaults on CacheFrom.
func (c *CacheFrom) Annotate(a infer.Annotator) {
a.Describe(&c.Local, dedent(`
A simple backend which caches images on your local filesystem.
`))
a.Describe(&c.Registry, dedent(`
Upload build caches to remote registries.
`))
a.Describe(&c.GHA, dedent(`
Recommended for use with GitHub Actions workflows.
An action like "crazy-max/ghaction-github-runtime" is recommended to
expose appropriate credentials to your GitHub workflow.
`))
a.Describe(&c.AZBlob, dedent(`
Upload build caches to Azure's blob storage service.
`))
a.Describe(&c.S3, dedent(`
Upload build caches to AWS S3 or an S3-compatible services such as
MinIO.
`))
a.Describe(&c.Raw, dedent(`
A raw string as you would provide it to the Docker CLI (e.g.,
"type=inline").
`))
a.Describe(&c.Disabled, dedent(`
When "true" this entry will be excluded. Defaults to "false".
`))
}
// String returns a CLI-encoded value for this `--cache-from` entry, or an
// empty string if disabled. `validate` should be called to ensure only one
// entry was set.
func (c CacheFrom) String() string {
if c.Disabled {
return ""
}
return join(c.Local, c.Registry, c.GHA, c.AZBlob, c.S3, c.Raw)
}
func (c CacheFrom) validate(preview bool) (*controllerapi.CacheOptionsEntry, error) {
if strings.Count(c.String(), "type=") > 1 {
return nil, errors.New("cacheFrom should only specify one cache type")
}
parsed, err := buildflags.ParseCacheEntry([]string{c.String()})
if err != nil {
return nil, err
}
if len(parsed) == 0 {
// This can happen for example if we have a GHA cache configuration but no GitHub
// environment variables set. Ignore the cacheFrom entry in this case.
return nil, nil
}
pb := parsed[0].ToPB()
if !isActive(pb) {
pb = nil
}
return pb, nil
}
// isActive checks if the GitHub token is set in the cache entry.
// If it is not a GHA cache entry, it will return true.
// This is to maintain backwards compatibility with the old buildx behaviour.
func isActive(ci *controllerapi.CacheOptionsEntry) bool {
if ci.Type != "gha" {
return true
}
return ci.Attrs["token"] != "" && (ci.Attrs["url"] != "" || ci.Attrs["url_v2"] != "")
}
// CacheToInline embeds cache information directly into an image.
type CacheToInline struct{}
// String returns the CLI-encoded value of these cache options, or an empty
// string if unknown.
func (c *CacheToInline) String() string {
if c == nil {
return ""
}
return "type=inline"
}
// Annotate sets docstrings on CacheToInline.
func (c *CacheToInline) Annotate(a infer.Annotator) {
a.Describe(&c, dedent(`
Include an inline cache with the exported image.
`))
}
// CacheToLocal writes cache manifests to a local directory.
type CacheToLocal struct {
CacheWithCompression
CacheWithIgnoreError
CacheWithMode
Dest string `pulumi:"dest"`
}
// Annotate sets docstrings on CacheToLocal.
func (c *CacheToLocal) Annotate(a infer.Annotator) {
a.Describe(&c.Dest, dedent(`
Path of the local directory to export the cache.
`))
}
// String returns the CLI-encoded value of these cache options, or an empty
// string if the receiver is nil.
func (c *CacheToLocal) String() string {
if c == nil {
return ""
}
return join(
Raw("type=local,dest="+c.Dest),
c.CacheWithCompression,
c.CacheWithIgnoreError,
)
}
// CacheToRegistry pushes cache manifests to a remote registry.
type CacheToRegistry struct {
CacheWithMode
CacheWithIgnoreError
CacheWithOCI
CacheWithCompression
CacheFromRegistry
}
// String returns the CLI-encoded value of these cache options, or an empty
// string if the receiver is nil.
func (c *CacheToRegistry) String() string {
if c == nil {
return ""
}
return join(
&c.CacheFromRegistry,
c.CacheWithMode,
c.CacheWithIgnoreError,
c.CacheWithOCI,
c.CacheWithCompression,
)
}
// CacheWithCompression is a cache with options to configure compression
// settings.
type CacheWithCompression struct {
Compression *CompressionType `pulumi:"compression,optional"`
CompressionLevel int `pulumi:"compressionLevel,optional"`
ForceCompression *bool `pulumi:"forceCompression,optional"`
}
// Annotate sets docstrings and defaults on CacheWithCompression.
func (c *CacheWithCompression) Annotate(a infer.Annotator) {
a.SetDefault(&c.Compression, Gzip)
a.SetDefault(&c.CompressionLevel, 0)
a.SetDefault(&c.ForceCompression, false)
a.Describe(&c.Compression, "The compression type to use.")
a.Describe(&c.CompressionLevel, "Compression level from 0 to 22.")
a.Describe(&c.ForceCompression, "Forcefully apply compression.")
}
// String returns the CLI-encoded value of these cache options, or an empty
// string if the receiver is nil.
func (c CacheWithCompression) String() string {
if c.CompressionLevel == 0 {
return ""
}
parts := []string{}
if c.Compression != nil {
parts = append(parts, fmt.Sprintf("compression=%s", *c.Compression))
}
if c.CompressionLevel > 0 {
cl := c.CompressionLevel
if cl > 22 {
cl = 22
}
parts = append(parts, fmt.Sprintf("compression-level=%d", cl))
}
if c.ForceCompression != nil {
parts = append(parts, fmt.Sprintf("force-compression=%t", *c.ForceCompression))
}
return strings.Join(parts, ",")
}
// CacheToGitHubActions pushes cache manifests to the GitHub Actions cache
// backend.
type CacheToGitHubActions struct {
CacheWithMode
CacheWithIgnoreError
CacheFromGitHubActions
}
// String returns the CLI-encoded value of these cache options, or an empty
// string if the receiver is nil.
func (c *CacheToGitHubActions) String() string {
if c == nil {
return ""
}
return join(&c.CacheFromGitHubActions, c.CacheWithMode, c.CacheWithIgnoreError)
}
// CacheTo is a "union" type for all of our available `--cache-to` options.
type CacheTo struct {
Inline *CacheToInline `pulumi:"inline,optional"`
Local *CacheToLocal `pulumi:"local,optional"`
Registry *CacheToRegistry `pulumi:"registry,optional"`
GHA *CacheToGitHubActions `pulumi:"gha,optional"`
AZBlob *CacheToAzureBlob `pulumi:"azblob,optional"`
S3 *CacheToS3 `pulumi:"s3,optional"`
Raw Raw `pulumi:"raw,optional"`
Disabled bool `pulumi:"disabled,optional"`
}
// Annotate sets docstrings and defaults on CacheTo.
func (c *CacheTo) Annotate(a infer.Annotator) {
a.Describe(&c.Inline, dedent(`
The inline cache storage backend is the simplest implementation to get
started with, but it does not handle multi-stage builds. Consider the
"registry" cache backend instead.
`))
a.Describe(&c.Local, dedent(`
A simple backend which caches imagines on your local filesystem.
`))
a.Describe(&c.Registry, dedent(`
Push caches to remote registries. Incompatible with the "docker" build
driver.
`))
a.Describe(&c.GHA, dedent(`
Recommended for use with GitHub Actions workflows.
An action like "crazy-max/ghaction-github-runtime" is recommended to
expose appropriate credentials to your GitHub workflow.
`))
a.Describe(&c.AZBlob, dedent(`
Push cache to Azure's blob storage service.
`))
a.Describe(&c.S3, dedent(`
Push cache to AWS S3 or S3-compatible services such as MinIO.
`))
a.Describe(&c.Raw, dedent(`
A raw string as you would provide it to the Docker CLI (e.g.,
"type=inline")`,
))
a.Describe(&c.Disabled, dedent(`
When "true" this entry will be excluded. Defaults to "false".
`))
}
// String returns a CLI-encoded value for this `--cache-to` entry, or an
// empty string if disabled. `validate` should be called to ensure only one
// entry was set.
func (c CacheTo) String() string {
if c.Disabled {
return ""
}
return join(c.Inline, c.Local, c.Registry, c.GHA, c.AZBlob, c.S3, c.Raw)
}
func (c CacheTo) validate(preview bool) (*controllerapi.CacheOptionsEntry, error) {
if strings.Count(c.String(), "type=") > 1 {
return nil, errors.New("cacheTo should only specify one cache type")
}
parsed, err := buildflags.ParseCacheEntry([]string{c.String()})
if err != nil {
return nil, err
}
if len(parsed) == 0 {
// This can happen for example if we have a GHA cache configuration but no GitHub
// environment variables set. Ignore the cacheTo entry in this case.
return nil, nil
}
pb := parsed[0].ToPB()
if !isActive(pb) {
pb = nil
}
return pb, nil
}
// CacheMode controls the complexity of exported cache manifests.
type CacheMode string
const (
// Min cache mode.
Min CacheMode = "min"
// Max cache mode.
Max CacheMode = "max"
)
// Values returns all valid CacheMode values for SDK generation.
func (CacheMode) Values() []infer.EnumValue[CacheMode] {
return []infer.EnumValue[CacheMode]{
{
Value: Min,
Description: "Only layers that are exported into the resulting image are cached.",
},
{
Value: Max,
Description: "All layers are cached, even those of intermediate steps.",
},
}
}
// CompressionType is the algorithm used for compressing blobs.
type CompressionType string
const (
// Gzip compression.
Gzip CompressionType = "gzip"
// Estargz compression.
Estargz CompressionType = "estargz"
// Zstd compression.
Zstd CompressionType = "zstd"
)
// Values returns all valid CompressionType values for SDK generation.
func (CompressionType) Values() []infer.EnumValue[CompressionType] {
return []infer.EnumValue[CompressionType]{
{Value: Gzip, Description: "Use `gzip` for compression."},
{Value: Estargz, Description: "Use `estargz` for compression."},
{Value: Zstd, Description: "Use `zstd` for compression."},
}
}
type joiner struct{ sep string }
func (j joiner) join(ss ...fmt.Stringer) string {
parts := []string{}
for _, s := range ss {
p := s.String()
if p == "" {
continue
}
parts = append(parts, p)
}
return strings.Join(parts, j.sep)
}
func join(ss ...fmt.Stringer) string {
return joiner{","}.join(ss...)
}