Skip to content
77 changes: 76 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ const (
defaultGHTokenEnvironmentVariable = "GH_TOKEN"
// defaultPRNumberEnvironmentVariable is the default environment variable that indicates the PR number
defaultPRNumberEnvironmentVariable = "PR_NUMBER"
// default environment variables used by OCI Registry
defaultOciDNS = "OCI_DNS"
defaultOciUser = "OCI_USER"
defaultOciPassword = "OCI_PASS"
// defaultSkipEnvironmentVariable is the default environment variable that indicates whether to skip execution
defaultSkipEnvironmentVariable = "SKIP"
// softErrorsEnvironmentVariable is the default environment variable that indicates if soft error mode is enabled
Expand Down Expand Up @@ -99,6 +103,8 @@ var (
LocalMode bool
// RemoteMode indicates that only remote validation should be run
RemoteMode bool
// DebugMode indicates debug mode
DebugMode bool
// CacheMode indicates that caching should be used on all remotely pulled resources
CacheMode = false
// ForkURL represents the fork URL configured as a remote in your local git repository
Expand All @@ -111,6 +117,12 @@ var (
PullRequest = ""
// GithubToken represents the Github Auth token
GithubToken string
// OciDNS represents the DNS of the OCI Registry
OciDNS string
// OciUser represents the user of the OCI Registry
OciUser string
// OciPassword represents the password of the OCI Registry
OciPassword string
// Skip indicates whether to skip execution
Skip = false
// SoftErrorMode indicates if certain non-fatal errors will be turned into warnings
Expand Down Expand Up @@ -174,7 +186,12 @@ func main() {
app.Name = "charts-build-scripts"
app.Version = fmt.Sprintf("%s (%s)", Version, GitCommit)
app.Usage = "Build scripts used to maintain patches on Helm charts forked from other repositories"
// Flags
debugFlag := cli.BoolFlag{
Name: "debug,d",
Usage: "Debug mode",
Required: false,
Destination: &DebugMode,
}
configFlag := cli.StringFlag{
Name: "config",
Usage: "A configuration file with additional options for allowing this branch to interact with other branches",
Expand Down Expand Up @@ -260,6 +277,33 @@ func main() {
Destination: &ChartVersion,
EnvVar: defaultChartVersionEnvironmentVariable,
}
ociDNS := cli.StringFlag{
Name: "oci-dns",
Usage: `Usage:
Provided OCI registry DNS.
`,
Required: true,
Destination: &OciDNS,
EnvVar: defaultOciDNS,
}
ociUser := cli.StringFlag{
Name: "oci-user",
Usage: `Usage:
Provided OCI registry User.
`,
Required: true,
Destination: &OciUser,
EnvVar: defaultOciUser,
}
ociPass := cli.StringFlag{
Name: "oci-pass",
Usage: `Usage:
Provided OCI registry Password.
`,
Required: true,
Destination: &OciPassword,
EnvVar: defaultOciPassword,
}
branchFlag := cli.StringFlag{
Name: "branch,b",
Usage: `Usage:
Expand Down Expand Up @@ -562,6 +606,16 @@ func main() {
Before: setupCache,
Flags: []cli.Flag{packageFlag, branchFlag, overrideVersionFlag, multiRCFlag, newChartFlag},
},

{
Name: "update-oci-registry",
Usage: `Update the oci-registry with the given assets or push all assets.
`,
Action: updateOCIRegistry,
Flags: []cli.Flag{
debugFlag, ociDNS, ociUser, ociPass,
},
},
}

if err := app.Run(os.Args); err != nil {
Expand Down Expand Up @@ -1172,3 +1226,24 @@ func chartBump(c *cli.Context) {
logger.Fatal(ctx, fmt.Errorf("failed to bump: %w", err).Error())
}
}

func updateOCIRegistry(c *cli.Context) {
ctx := context.Background()

emptyUser := OciUser == ""
emptyPass := OciPassword == ""
emptyDNS := OciDNS == ""

if emptyUser || emptyPass || emptyDNS {
logger.Log(ctx, slog.LevelError, "missing credential", slog.Bool("OCI User Empty", emptyUser))
logger.Log(ctx, slog.LevelError, "missing credential", slog.Bool("OCI Password Empty", emptyPass))
logger.Log(ctx, slog.LevelError, "missing credential", slog.Bool("OCI DNS Empty", emptyDNS))
logger.Fatal(ctx, errors.New("no credentials provided for pushing helm chart to OCI registry").Error())
}

getRepoRoot()
rootFs := filesystem.GetFilesystem(RepoRoot)
if err := auto.UpdateOCI(ctx, rootFs, OciDNS, OciUser, OciPassword, DebugMode); err != nil {
logger.Fatal(ctx, err.Error())
}
}
275 changes: 275 additions & 0 deletions pkg/auto/oci.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
package auto

import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"strings"

"github.com/go-git/go-billy/v5"
"github.com/rancher/charts-build-scripts/pkg/logger"
"github.com/rancher/charts-build-scripts/pkg/options"
"github.com/rancher/charts-build-scripts/pkg/path"

"helm.sh/helm/v3/pkg/action"
"helm.sh/helm/v3/pkg/cli"
"helm.sh/helm/v3/pkg/registry"
)

type loadAssetFunc func(chart, asset string) ([]byte, error)
type checkAssetFunc func(ctx context.Context, regClient *registry.Client, ociDNS, chart, version string) (bool, error)
type pushFunc func(helmClient *registry.Client, data []byte, url string) error

type oci struct {
DNS string
user string
password string
helmClient *registry.Client
loadAsset loadAssetFunc
checkAsset checkAssetFunc
push pushFunc
}

// UpdateOCI pushes Helm charts to an OCI registry
func UpdateOCI(ctx context.Context, rootFs billy.Filesystem, ociDNS, ociUser, ociPass string, debug bool) error {
release, err := options.LoadReleaseOptionsFromFile(ctx, rootFs, path.RepositoryReleaseYaml)
if err != nil {
return err
}

oci, err := setupOCI(ctx, ociDNS, ociUser, ociPass, debug)
if err != nil {
return err
}

pushedAssets, err := oci.update(ctx, &release)
if err != nil {
return err
}

logger.Log(ctx, slog.LevelInfo, "pushed", slog.Any("assets", pushedAssets))
return nil
}

func setupOCI(ctx context.Context, ociDNS, ociUser, ociPass string, debug bool) (*oci, error) {
var err error
o := &oci{
DNS: ociDNS,
user: ociUser,
password: ociPass,
}

o.helmClient, err = setupHelm(ctx, o.DNS, o.user, o.password, debug)
if err != nil {
return nil, err
}

o.loadAsset = loadAsset
o.checkAsset = checkAsset
o.push = push

return o, nil
}

func setupHelm(ctx context.Context, ociDNS, ociUser, ociPass string, debug bool) (*registry.Client, error) {
settings := cli.New()
actionConfig := new(action.Configuration)
if err := actionConfig.Init(settings.RESTClientGetter(), settings.Namespace(), os.Getenv("HELM_DRIVER"), func(format string, v ...interface{}) {
fmt.Sprintf(format, v...)
}); err != nil {
return nil, err
}

var regClient *registry.Client
var err error

registryHost := extractRegistryHost(ociDNS)
isLocalHost := strings.HasPrefix(registryHost, "localhost:")

switch {
// Debug Mode but pointing to a server with custom-certificates
case debug && !isLocalHost:
logger.Log(ctx, slog.LevelDebug, "debug mode", slog.Bool("localhost", isLocalHost))
caFile := "/etc/docker/certs.d/" + registryHost + "/ca.crt"
regClient, err = registry.NewRegistryClientWithTLS(os.Stdout, "", "", caFile, false, "", true)
if err != nil {
logger.Log(ctx, slog.LevelError, "failed to create registry client with TLS")
return nil, err
}
if err = regClient.Login(
registryHost,
registry.LoginOptInsecure(false),
registry.LoginOptTLSClientConfig("", "", caFile),
registry.LoginOptBasicAuth(ociUser, ociPass),
); err != nil {
logger.Log(ctx, slog.LevelError, "failed to login to registry with TLS", slog.Group(ociDNS, ociUser, ociPass))
return nil, err
}

// Debug Mode at localhost without TLS
case debug && isLocalHost:
logger.Log(ctx, slog.LevelDebug, "debug mode", slog.Bool("localhost", isLocalHost))
regClient, err = registry.NewClient(
registry.ClientOptDebug(true),
registry.ClientOptPlainHTTP(),
)
if err != nil {
logger.Log(ctx, slog.LevelError, "failed to create registry client")
return nil, err
}
if err = regClient.Login(registryHost,
registry.LoginOptInsecure(true), // true for localhost, false for production
registry.LoginOptBasicAuth(ociUser, ociPass)); err != nil {
logger.Log(ctx, slog.LevelError, "failed to login to registry", slog.Group(ociDNS, ociUser, ociPass))
return nil, err
}

// Production code with Secure Mode and authentication
default:
regClient, err = registry.NewClient(registry.ClientOptDebug(false))
if err != nil {
return nil, err
}
if err = regClient.Login(registryHost,
registry.LoginOptInsecure(false),
registry.LoginOptBasicAuth(ociUser, ociPass)); err != nil {
return nil, err
}
}

return regClient, nil
}

// extractRegistryHost will extract the DNS for login
func extractRegistryHost(ociDNS string) string {
if idx := strings.Index(ociDNS, "/"); idx != -1 {
return ociDNS[:idx]
}
return ociDNS
}

// update will attempt to update a helm chart to an OCI registry.
// 2 phases:
// - 1: Pre-Flight validations (check the current chart + check if it already exists)
// - 2: Push
func (o *oci) update(ctx context.Context, release *options.ReleaseOptions) ([]string, error) {
var pushedAssets []string

// List of assets to process
type assetInfo struct {
chart string
version string
asset string
data []byte
}
var assetsToProcess []assetInfo

// Phase 1: Pre-Flight Validations
logger.Log(ctx, slog.LevelDebug, "Phase 1: Pre-Flight Validations")
for chart, versions := range *release {
for _, version := range versions {
asset := chart + "-" + version + ".tgz"
assetData, err := o.loadAsset(chart, asset)
if err != nil {
logger.Log(ctx, slog.LevelError, "failed to load asset", slog.String("asset", asset))
return pushedAssets, err
}

// Check if the asset version already exists in the OCI registry
// Never overwrite a previously released chart!
exists, err := o.checkAsset(ctx, o.helmClient, o.DNS, chart, version)
if err != nil {
logger.Log(ctx, slog.LevelError, "failed to check registry for asset", slog.String("asset", asset))
return pushedAssets, err
}
if exists {
// Skip existing charts instead of failing
logger.Log(ctx, slog.LevelWarn, "chart already exists in registry, will skip",
slog.String("asset", asset))
continue
}

logger.Log(ctx, slog.LevelDebug, "asset valid and doesn't exist in the registry already", slog.String("asset", asset))
assetsToProcess = append(assetsToProcess, assetInfo{
chart: chart,
version: version,
asset: asset,
data: assetData,
})
}
}

// check if there is anything to push
if len(assetsToProcess) == 0 {
logger.Log(ctx, slog.LevelInfo, "no new charts to push - all charts already exist in registry")
return pushedAssets, nil
}

// Phase 2
var pushErrors []error
logger.Log(ctx, slog.LevelInfo, "Phase 2: Push")
for _, info := range assetsToProcess {
logger.Log(ctx, slog.LevelDebug, "pushing", slog.String("asset", info.asset))

if err := o.push(o.helmClient, info.data, buildPushURL(o.DNS, info.chart, info.version)); err != nil {
logger.Log(ctx, slog.LevelError, "failed to push asset", slog.String("asset", info.asset))
pushErrors = append(pushErrors, errors.New("asset: "+info.asset+" error: "+err.Error()))
continue
}
pushedAssets = append(pushedAssets, info.asset)
logger.Log(ctx, slog.LevelInfo, "pushed", slog.String("asset", info.asset))
}

if len(pushErrors) > 0 {
logger.Log(ctx, slog.LevelError, "push phase completed with errors",
slog.Int("successful", len(pushedAssets)),
slog.Int("failed", len(pushErrors)))
for _, err := range pushErrors {
logger.Err(err)
}
return pushedAssets, errors.New("some assets failed, please fix and retry only these assets")
}

return pushedAssets, nil
}

func push(helmClient *registry.Client, data []byte, url string) error {
if _, err := helmClient.Push(data, url, registry.PushOptStrictMode(true)); err != nil {
return err
}
return nil
}

func loadAsset(chart, asset string) ([]byte, error) {
return os.ReadFile(path.RepositoryAssetsDir + "/" + chart + "/" + asset)
}

// oci://<oci-dns>/<chart(repository)>:<version>
func buildPushURL(ociDNS, chart, version string) string {
return ociDNS + "/" + chart + ":" + version
}

// checkAsset checks if a specific asset version exists in the OCI registry
func checkAsset(ctx context.Context, helmClient *registry.Client, ociDNS, chart, version string) (bool, error) {
// Once issue is resolved: https://github.com/helm/helm/issues/13368
// Replace by: helmClient.Tags(ociDNS + "/" + chart + ":" + version)
existingVersions, err := helmClient.Tags(ociDNS + "/" + chart)
if err != nil {
if strings.Contains(err.Error(), "unexpected status code 404: name unknown: repository name not known to registry") {
logger.Log(ctx, slog.LevelDebug, "asset does not exist at registry", slog.String("chart", chart))
return false, nil
}
logger.Err(err)
return false, err
}

for _, existingVersion := range existingVersions {
if existingVersion == version {
return true, nil
}
}

return false, nil
}
Loading
Loading