|
| 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