Skip to content

Commit 00ee786

Browse files
authored
Merge pull request #151 from nicholasSUSE/update-oci-registry
Push Helm Chart to OCI Registry
2 parents ab7e461 + aa4e08b commit 00ee786

File tree

3 files changed

+568
-1
lines changed

3 files changed

+568
-1
lines changed

main.go

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ const (
5656
defaultGHTokenEnvironmentVariable = "GH_TOKEN"
5757
// defaultPRNumberEnvironmentVariable is the default environment variable that indicates the PR number
5858
defaultPRNumberEnvironmentVariable = "PR_NUMBER"
59+
// default environment variables used by OCI Registry
60+
defaultOciDNS = "OCI_DNS"
61+
defaultOciUser = "OCI_USER"
62+
defaultOciPassword = "OCI_PASS"
5963
// defaultSkipEnvironmentVariable is the default environment variable that indicates whether to skip execution
6064
defaultSkipEnvironmentVariable = "SKIP"
6165
// softErrorsEnvironmentVariable is the default environment variable that indicates if soft error mode is enabled
@@ -99,6 +103,8 @@ var (
99103
LocalMode bool
100104
// RemoteMode indicates that only remote validation should be run
101105
RemoteMode bool
106+
// DebugMode indicates debug mode
107+
DebugMode bool
102108
// CacheMode indicates that caching should be used on all remotely pulled resources
103109
CacheMode = false
104110
// ForkURL represents the fork URL configured as a remote in your local git repository
@@ -111,6 +117,12 @@ var (
111117
PullRequest = ""
112118
// GithubToken represents the Github Auth token
113119
GithubToken string
120+
// OciDNS represents the DNS of the OCI Registry
121+
OciDNS string
122+
// OciUser represents the user of the OCI Registry
123+
OciUser string
124+
// OciPassword represents the password of the OCI Registry
125+
OciPassword string
114126
// Skip indicates whether to skip execution
115127
Skip = false
116128
// SoftErrorMode indicates if certain non-fatal errors will be turned into warnings
@@ -174,7 +186,12 @@ func main() {
174186
app.Name = "charts-build-scripts"
175187
app.Version = fmt.Sprintf("%s (%s)", Version, GitCommit)
176188
app.Usage = "Build scripts used to maintain patches on Helm charts forked from other repositories"
177-
// Flags
189+
debugFlag := cli.BoolFlag{
190+
Name: "debug,d",
191+
Usage: "Debug mode",
192+
Required: false,
193+
Destination: &DebugMode,
194+
}
178195
configFlag := cli.StringFlag{
179196
Name: "config",
180197
Usage: "A configuration file with additional options for allowing this branch to interact with other branches",
@@ -260,6 +277,33 @@ func main() {
260277
Destination: &ChartVersion,
261278
EnvVar: defaultChartVersionEnvironmentVariable,
262279
}
280+
ociDNS := cli.StringFlag{
281+
Name: "oci-dns",
282+
Usage: `Usage:
283+
Provided OCI registry DNS.
284+
`,
285+
Required: true,
286+
Destination: &OciDNS,
287+
EnvVar: defaultOciDNS,
288+
}
289+
ociUser := cli.StringFlag{
290+
Name: "oci-user",
291+
Usage: `Usage:
292+
Provided OCI registry User.
293+
`,
294+
Required: true,
295+
Destination: &OciUser,
296+
EnvVar: defaultOciUser,
297+
}
298+
ociPass := cli.StringFlag{
299+
Name: "oci-pass",
300+
Usage: `Usage:
301+
Provided OCI registry Password.
302+
`,
303+
Required: true,
304+
Destination: &OciPassword,
305+
EnvVar: defaultOciPassword,
306+
}
263307
branchFlag := cli.StringFlag{
264308
Name: "branch,b",
265309
Usage: `Usage:
@@ -562,6 +606,16 @@ func main() {
562606
Before: setupCache,
563607
Flags: []cli.Flag{packageFlag, branchFlag, overrideVersionFlag, multiRCFlag, newChartFlag},
564608
},
609+
610+
{
611+
Name: "update-oci-registry",
612+
Usage: `Update the oci-registry with the given assets or push all assets.
613+
`,
614+
Action: updateOCIRegistry,
615+
Flags: []cli.Flag{
616+
debugFlag, ociDNS, ociUser, ociPass,
617+
},
618+
},
565619
}
566620

567621
if err := app.Run(os.Args); err != nil {
@@ -1172,3 +1226,24 @@ func chartBump(c *cli.Context) {
11721226
logger.Fatal(ctx, fmt.Errorf("failed to bump: %w", err).Error())
11731227
}
11741228
}
1229+
1230+
func updateOCIRegistry(c *cli.Context) {
1231+
ctx := context.Background()
1232+
1233+
emptyUser := OciUser == ""
1234+
emptyPass := OciPassword == ""
1235+
emptyDNS := OciDNS == ""
1236+
1237+
if emptyUser || emptyPass || emptyDNS {
1238+
logger.Log(ctx, slog.LevelError, "missing credential", slog.Bool("OCI User Empty", emptyUser))
1239+
logger.Log(ctx, slog.LevelError, "missing credential", slog.Bool("OCI Password Empty", emptyPass))
1240+
logger.Log(ctx, slog.LevelError, "missing credential", slog.Bool("OCI DNS Empty", emptyDNS))
1241+
logger.Fatal(ctx, errors.New("no credentials provided for pushing helm chart to OCI registry").Error())
1242+
}
1243+
1244+
getRepoRoot()
1245+
rootFs := filesystem.GetFilesystem(RepoRoot)
1246+
if err := auto.UpdateOCI(ctx, rootFs, OciDNS, OciUser, OciPassword, DebugMode); err != nil {
1247+
logger.Fatal(ctx, err.Error())
1248+
}
1249+
}

pkg/auto/oci.go

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
package auto
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"log/slog"
8+
"os"
9+
"strings"
10+
11+
"github.com/go-git/go-billy/v5"
12+
"github.com/rancher/charts-build-scripts/pkg/logger"
13+
"github.com/rancher/charts-build-scripts/pkg/options"
14+
"github.com/rancher/charts-build-scripts/pkg/path"
15+
16+
"helm.sh/helm/v3/pkg/action"
17+
"helm.sh/helm/v3/pkg/cli"
18+
"helm.sh/helm/v3/pkg/registry"
19+
)
20+
21+
type loadAssetFunc func(chart, asset string) ([]byte, error)
22+
type checkAssetFunc func(ctx context.Context, regClient *registry.Client, ociDNS, chart, version string) (bool, error)
23+
type pushFunc func(helmClient *registry.Client, data []byte, url string) error
24+
25+
type oci struct {
26+
DNS string
27+
user string
28+
password string
29+
helmClient *registry.Client
30+
loadAsset loadAssetFunc
31+
checkAsset checkAssetFunc
32+
push pushFunc
33+
}
34+
35+
// UpdateOCI pushes Helm charts to an OCI registry
36+
func UpdateOCI(ctx context.Context, rootFs billy.Filesystem, ociDNS, ociUser, ociPass string, debug bool) error {
37+
release, err := options.LoadReleaseOptionsFromFile(ctx, rootFs, path.RepositoryReleaseYaml)
38+
if err != nil {
39+
return err
40+
}
41+
42+
oci, err := setupOCI(ctx, ociDNS, ociUser, ociPass, debug)
43+
if err != nil {
44+
return err
45+
}
46+
47+
pushedAssets, err := oci.update(ctx, &release)
48+
if err != nil {
49+
return err
50+
}
51+
52+
logger.Log(ctx, slog.LevelInfo, "pushed", slog.Any("assets", pushedAssets))
53+
return nil
54+
}
55+
56+
func setupOCI(ctx context.Context, ociDNS, ociUser, ociPass string, debug bool) (*oci, error) {
57+
var err error
58+
o := &oci{
59+
DNS: ociDNS,
60+
user: ociUser,
61+
password: ociPass,
62+
}
63+
64+
o.helmClient, err = setupHelm(ctx, o.DNS, o.user, o.password, debug)
65+
if err != nil {
66+
return nil, err
67+
}
68+
69+
o.loadAsset = loadAsset
70+
o.checkAsset = checkAsset
71+
o.push = push
72+
73+
return o, nil
74+
}
75+
76+
func setupHelm(ctx context.Context, ociDNS, ociUser, ociPass string, debug bool) (*registry.Client, error) {
77+
settings := cli.New()
78+
actionConfig := new(action.Configuration)
79+
if err := actionConfig.Init(settings.RESTClientGetter(), settings.Namespace(), os.Getenv("HELM_DRIVER"), func(format string, v ...interface{}) {
80+
fmt.Sprintf(format, v...)
81+
}); err != nil {
82+
return nil, err
83+
}
84+
85+
var regClient *registry.Client
86+
var err error
87+
88+
registryHost := extractRegistryHost(ociDNS)
89+
isLocalHost := strings.HasPrefix(registryHost, "localhost:")
90+
91+
switch {
92+
// Debug Mode but pointing to a server with custom-certificates
93+
case debug && !isLocalHost:
94+
logger.Log(ctx, slog.LevelDebug, "debug mode", slog.Bool("localhost", isLocalHost))
95+
caFile := "/etc/docker/certs.d/" + registryHost + "/ca.crt"
96+
regClient, err = registry.NewRegistryClientWithTLS(os.Stdout, "", "", caFile, false, "", true)
97+
if err != nil {
98+
logger.Log(ctx, slog.LevelError, "failed to create registry client with TLS")
99+
return nil, err
100+
}
101+
if err = regClient.Login(
102+
registryHost,
103+
registry.LoginOptInsecure(false),
104+
registry.LoginOptTLSClientConfig("", "", caFile),
105+
registry.LoginOptBasicAuth(ociUser, ociPass),
106+
); err != nil {
107+
logger.Log(ctx, slog.LevelError, "failed to login to registry with TLS", slog.Group(ociDNS, ociUser, ociPass))
108+
return nil, err
109+
}
110+
111+
// Debug Mode at localhost without TLS
112+
case debug && isLocalHost:
113+
logger.Log(ctx, slog.LevelDebug, "debug mode", slog.Bool("localhost", isLocalHost))
114+
regClient, err = registry.NewClient(
115+
registry.ClientOptDebug(true),
116+
registry.ClientOptPlainHTTP(),
117+
)
118+
if err != nil {
119+
logger.Log(ctx, slog.LevelError, "failed to create registry client")
120+
return nil, err
121+
}
122+
if err = regClient.Login(registryHost,
123+
registry.LoginOptInsecure(true), // true for localhost, false for production
124+
registry.LoginOptBasicAuth(ociUser, ociPass)); err != nil {
125+
logger.Log(ctx, slog.LevelError, "failed to login to registry", slog.Group(ociDNS, ociUser, ociPass))
126+
return nil, err
127+
}
128+
129+
// Production code with Secure Mode and authentication
130+
default:
131+
regClient, err = registry.NewClient(registry.ClientOptDebug(false))
132+
if err != nil {
133+
return nil, err
134+
}
135+
if err = regClient.Login(registryHost,
136+
registry.LoginOptInsecure(false),
137+
registry.LoginOptBasicAuth(ociUser, ociPass)); err != nil {
138+
return nil, err
139+
}
140+
}
141+
142+
return regClient, nil
143+
}
144+
145+
// extractRegistryHost will extract the DNS for login
146+
func extractRegistryHost(ociDNS string) string {
147+
if idx := strings.Index(ociDNS, "/"); idx != -1 {
148+
return ociDNS[:idx]
149+
}
150+
return ociDNS
151+
}
152+
153+
// update will attempt to update a helm chart to an OCI registry.
154+
// 2 phases:
155+
// - 1: Pre-Flight validations (check the current chart + check if it already exists)
156+
// - 2: Push
157+
func (o *oci) update(ctx context.Context, release *options.ReleaseOptions) ([]string, error) {
158+
var pushedAssets []string
159+
160+
// List of assets to process
161+
type assetInfo struct {
162+
chart string
163+
version string
164+
asset string
165+
data []byte
166+
}
167+
var assetsToProcess []assetInfo
168+
169+
// Phase 1: Pre-Flight Validations
170+
logger.Log(ctx, slog.LevelDebug, "Phase 1: Pre-Flight Validations")
171+
for chart, versions := range *release {
172+
for _, version := range versions {
173+
asset := chart + "-" + version + ".tgz"
174+
assetData, err := o.loadAsset(chart, asset)
175+
if err != nil {
176+
logger.Log(ctx, slog.LevelError, "failed to load asset", slog.String("asset", asset))
177+
return pushedAssets, err
178+
}
179+
180+
// Check if the asset version already exists in the OCI registry
181+
// Never overwrite a previously released chart!
182+
exists, err := o.checkAsset(ctx, o.helmClient, o.DNS, chart, version)
183+
if err != nil {
184+
logger.Log(ctx, slog.LevelError, "failed to check registry for asset", slog.String("asset", asset))
185+
return pushedAssets, err
186+
}
187+
if exists {
188+
// Skip existing charts instead of failing
189+
logger.Log(ctx, slog.LevelWarn, "chart already exists in registry, will skip",
190+
slog.String("asset", asset))
191+
continue
192+
}
193+
194+
logger.Log(ctx, slog.LevelDebug, "asset valid and doesn't exist in the registry already", slog.String("asset", asset))
195+
assetsToProcess = append(assetsToProcess, assetInfo{
196+
chart: chart,
197+
version: version,
198+
asset: asset,
199+
data: assetData,
200+
})
201+
}
202+
}
203+
204+
// check if there is anything to push
205+
if len(assetsToProcess) == 0 {
206+
logger.Log(ctx, slog.LevelInfo, "no new charts to push - all charts already exist in registry")
207+
return pushedAssets, nil
208+
}
209+
210+
// Phase 2
211+
var pushErrors []error
212+
logger.Log(ctx, slog.LevelInfo, "Phase 2: Push")
213+
for _, info := range assetsToProcess {
214+
logger.Log(ctx, slog.LevelDebug, "pushing", slog.String("asset", info.asset))
215+
216+
if err := o.push(o.helmClient, info.data, buildPushURL(o.DNS, info.chart, info.version)); err != nil {
217+
logger.Log(ctx, slog.LevelError, "failed to push asset", slog.String("asset", info.asset))
218+
pushErrors = append(pushErrors, errors.New("asset: "+info.asset+" error: "+err.Error()))
219+
continue
220+
}
221+
pushedAssets = append(pushedAssets, info.asset)
222+
logger.Log(ctx, slog.LevelInfo, "pushed", slog.String("asset", info.asset))
223+
}
224+
225+
if len(pushErrors) > 0 {
226+
logger.Log(ctx, slog.LevelError, "push phase completed with errors",
227+
slog.Int("successful", len(pushedAssets)),
228+
slog.Int("failed", len(pushErrors)))
229+
for _, err := range pushErrors {
230+
logger.Err(err)
231+
}
232+
return pushedAssets, errors.New("some assets failed, please fix and retry only these assets")
233+
}
234+
235+
return pushedAssets, nil
236+
}
237+
238+
func push(helmClient *registry.Client, data []byte, url string) error {
239+
if _, err := helmClient.Push(data, url, registry.PushOptStrictMode(true)); err != nil {
240+
return err
241+
}
242+
return nil
243+
}
244+
245+
func loadAsset(chart, asset string) ([]byte, error) {
246+
return os.ReadFile(path.RepositoryAssetsDir + "/" + chart + "/" + asset)
247+
}
248+
249+
// oci://<oci-dns>/<chart(repository)>:<version>
250+
func buildPushURL(ociDNS, chart, version string) string {
251+
return ociDNS + "/" + chart + ":" + version
252+
}
253+
254+
// checkAsset checks if a specific asset version exists in the OCI registry
255+
func checkAsset(ctx context.Context, helmClient *registry.Client, ociDNS, chart, version string) (bool, error) {
256+
// Once issue is resolved: https://github.com/helm/helm/issues/13368
257+
// Replace by: helmClient.Tags(ociDNS + "/" + chart + ":" + version)
258+
existingVersions, err := helmClient.Tags(ociDNS + "/" + chart)
259+
if err != nil {
260+
if strings.Contains(err.Error(), "unexpected status code 404: name unknown: repository name not known to registry") {
261+
logger.Log(ctx, slog.LevelDebug, "asset does not exist at registry", slog.String("chart", chart))
262+
return false, nil
263+
}
264+
logger.Err(err)
265+
return false, err
266+
}
267+
268+
for _, existingVersion := range existingVersions {
269+
if existingVersion == version {
270+
return true, nil
271+
}
272+
}
273+
274+
return false, nil
275+
}

0 commit comments

Comments
 (0)