feat(jar): add OCI registry pull for JAR artifacts with backward compat

- Add jarRef and jarPullSecret fields to FlinkJob CRD (jarUri/basicAuth deprecated)
- OCI pull via go-containerregistry with dual auth (K8s pull secret + env vars)
- Media type validation on pulled layers
- Atomic status patches with runningJarRef/runningJarDigest tracking
- NeedsUpgrade/RunningRef/RunningRefPatchData domain helpers
- README with usage guide, pushing JARs, and GitHub Actions CI/CD workflow
- CONTEXT.md domain glossary
This commit is contained in:
2026-07-24 17:37:30 +03:30
parent 91c10c89da
commit a36e72877d
15 changed files with 750 additions and 92 deletions

View File

@@ -2,31 +2,51 @@ package jar
import (
"crypto/rand"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"os"
"strings"
"flink-kube-operator/pkg"
"github.com/google/go-containerregistry/pkg/authn"
"github.com/google/go-containerregistry/pkg/name"
"github.com/google/go-containerregistry/pkg/v1/remote"
"github.com/google/go-containerregistry/pkg/v1/types"
api "github.com/logi-camp/go-flink-client"
"go.uber.org/zap"
)
type JarFile struct {
uri string
ref string
filePath string
digest string
basicAuthUsername *string
basicAuthPassword *string
auth authn.Authenticator
}
func NewJarFile(URI string, basicAuthUsername *string, basicAuthPassword *string) (*JarFile, error) {
// NewJarFile creates a JarFile from an OCI reference, pulling the artifact.
// auth is the OCI authenticator (from pull secret or env vars). Use authn.Anonymous for public registries.
func NewJarFile(ref string, auth authn.Authenticator) (*JarFile, error) {
jarFile := &JarFile{
uri: URI,
ref: ref,
auth: auth,
}
err := jarFile.Pull()
if err != nil {
return nil, err
}
return jarFile, nil
}
// NewJarFileFromURI creates a JarFile from an HTTP URI (deprecated path, kept for backward compat).
func NewJarFileFromURI(uri string, basicAuthUsername *string, basicAuthPassword *string) (*JarFile, error) {
jarFile := &JarFile{
ref: uri,
basicAuthUsername: basicAuthUsername,
basicAuthPassword: basicAuthPassword,
}
@@ -38,10 +58,10 @@ func NewJarFile(URI string, basicAuthUsername *string, basicAuthPassword *string
}
func (jarFile *JarFile) Upload(flinkClient *api.Client) (fileName string, err error) {
resp, err := flinkClient.UploadJar(jarFile.filePath)
if err != nil {
pkg.Logger.Error("[main] error uploading jar", zap.Error(err))
pkg.Logger.Error("[jar] error uploading jar", zap.Error(err))
return "", err
}
filePathParts := strings.Split(resp.FileName, "/")
fileName = filePathParts[len(filePathParts)-1]
@@ -52,6 +72,78 @@ func (jarFile *JarFile) Upload(flinkClient *api.Client) (fileName string, err er
return
}
func (jarFile *JarFile) Digest() string {
return jarFile.digest
}
func (jarFile *JarFile) Pull() error {
randBytes := make([]byte, 16)
rand.Read(randBytes)
fileName := hex.EncodeToString(randBytes)
jarFile.filePath = "/tmp/" + fileName + ".jar"
ref, err := name.ParseReference(jarFile.ref)
if err != nil {
return fmt.Errorf("parsing OCI reference %q: %w", jarFile.ref, err)
}
img, err := remote.Image(ref, remote.WithAuth(jarFile.auth))
if err != nil {
return fmt.Errorf("fetching OCI image %q: %w", jarFile.ref, err)
}
d, err := img.Digest()
if err != nil {
return fmt.Errorf("getting image digest: %w", err)
}
jarFile.digest = d.String()
layers, err := img.Layers()
if err != nil {
return fmt.Errorf("getting image layers: %w", err)
}
if len(layers) == 0 {
return fmt.Errorf("OCI image %q has no layers", jarFile.ref)
}
layer := layers[0]
mediaType, err := layer.MediaType()
if err != nil {
return fmt.Errorf("getting layer media type: %w", err)
}
if mediaType != types.OCILayer && mediaType != types.OCIUncompressedLayer && mediaType != types.DockerLayer && mediaType != types.DockerUncompressedLayer && string(mediaType) != "application/java-archive" {
pkg.Logger.Warn("[jar] unexpected layer media type, expected application/java-archive or standard OCI layer",
zap.String("got", string(mediaType)),
zap.String("ref", jarFile.ref),
)
}
blob, err := layer.Compressed()
if err != nil {
return fmt.Errorf("reading layer blob: %w", err)
}
defer blob.Close()
out, err := os.Create(jarFile.filePath)
if err != nil {
return fmt.Errorf("creating temp file: %w", err)
}
defer out.Close()
_, err = io.Copy(out, blob)
if err != nil {
jarFile.delete()
return fmt.Errorf("writing layer to disk: %w", err)
}
pkg.Logger.Info("[jar] pulled OCI artifact",
zap.String("ref", jarFile.ref),
zap.String("digest", jarFile.digest),
zap.String("path", jarFile.filePath),
)
return nil
}
func (jarFile *JarFile) Download() error {
randBytes := make([]byte, 16)
rand.Read(randBytes)
@@ -66,31 +158,15 @@ func (jarFile *JarFile) Download() error {
var resp *http.Response
if jarFile.basicAuthPassword != nil && jarFile.basicAuthUsername != nil {
basicAuth := func(username, password string) string {
auth := username + ":" + password
return base64.StdEncoding.EncodeToString([]byte(auth))
}
redirectPolicyFunc := func(req *http.Request, via []*http.Request) error {
req.Header.Add("Authorization", "Basic "+basicAuth(*jarFile.basicAuthUsername, *jarFile.basicAuthPassword))
return nil
}
client := &http.Client{
Jar: &cookiejar.Jar{},
CheckRedirect: redirectPolicyFunc,
}
req, err := http.NewRequest("GET", jarFile.uri, nil)
req, err := http.NewRequest("GET", jarFile.ref, nil)
if err != nil {
jarFile.delete()
return err
}
req.Header.Add("Authorization", "Basic "+basicAuth(*jarFile.basicAuthUsername, *jarFile.basicAuthPassword))
resp, err = client.Do(req)
req.SetBasicAuth(*jarFile.basicAuthUsername, *jarFile.basicAuthPassword)
resp, err = http.DefaultClient.Do(req)
} else {
resp, err = http.Get(jarFile.uri)
resp, err = http.Get(jarFile.ref)
}
if err != nil {
jarFile.delete()
@@ -98,9 +174,7 @@ func (jarFile *JarFile) Download() error {
return err
}
if resp.StatusCode > 299 {
respBody := []byte{}
resp.Body.Read(respBody)
err = errors.New(string(respBody) + " status:" + resp.Status)
err = fmt.Errorf("download failed: status %s", resp.Status)
pkg.Logger.Error("error in downloading jar", zap.Error(err))
return err
}