feat: backend native executor implement
This commit is contained in:
@@ -0,0 +1,483 @@
|
||||
// Package updatepackage reads the exact repack ZIP format currently emitted by deploy.
|
||||
package updatepackage
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"yms-daemon/internal/filestore"
|
||||
)
|
||||
|
||||
const (
|
||||
manifestName = "artifact-selection.json"
|
||||
backendArtifactKind = "BACKEND"
|
||||
nativeArtifactType = "native"
|
||||
maximumManifestBytes = 1 << 20
|
||||
)
|
||||
|
||||
// BackendNativePackage is an opened repack ZIP containing one exact native backend artifact.
|
||||
type BackendNativePackage struct {
|
||||
archive *zip.ReadCloser
|
||||
artifactEntry *zip.File
|
||||
|
||||
PackagePath string
|
||||
PackageSHA256 string
|
||||
CustomerCode string
|
||||
VersionID string
|
||||
ArtifactID int64
|
||||
FileName string
|
||||
Identity filestore.Identity
|
||||
}
|
||||
|
||||
// OpenBackendNative validates the ZIP structure and selects one explicitly declared native backend artifact.
|
||||
func OpenBackendNative(packagePath string) (*BackendNativePackage, error) {
|
||||
if err := validateAbsoluteRegularFile(packagePath, "update package"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
packageDigest, err := hashFile(packagePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
archive, err := zip.OpenReader(packagePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open update package ZIP %s: %w", packagePath, err)
|
||||
}
|
||||
closeOnError := func(cause error) (*BackendNativePackage, error) {
|
||||
return nil, errors.Join(cause, archive.Close())
|
||||
}
|
||||
|
||||
entries, err := validateEntries(archive.File)
|
||||
if err != nil {
|
||||
return closeOnError(err)
|
||||
}
|
||||
manifestEntry, found := entries[manifestName]
|
||||
if !found {
|
||||
return closeOnError(errors.New("update package is missing artifact-selection.json"))
|
||||
}
|
||||
manifest, err := decodeManifest(manifestEntry)
|
||||
if err != nil {
|
||||
return closeOnError(err)
|
||||
}
|
||||
selected, err := selectNativeBackend(manifest.BackendArtifacts)
|
||||
if err != nil {
|
||||
return closeOnError(err)
|
||||
}
|
||||
if err := validateRootFileName(selected.FileName); err != nil {
|
||||
return closeOnError(fmt.Errorf("invalid backendArtifacts.fileName: %w", err))
|
||||
}
|
||||
artifactEntry, found := entries[selected.FileName]
|
||||
if !found {
|
||||
return closeOnError(fmt.Errorf("native backend artifact declared by backendArtifacts.fileName is missing: %s", selected.FileName))
|
||||
}
|
||||
if artifactEntry.FileInfo().IsDir() {
|
||||
return closeOnError(fmt.Errorf("native backend artifact is not a file: %s", selected.FileName))
|
||||
}
|
||||
identity := filestore.Identity{
|
||||
Size: int64(artifactEntry.UncompressedSize64),
|
||||
SHA256: selected.SHA256,
|
||||
}
|
||||
if err := identity.Validate(); err != nil {
|
||||
return closeOnError(fmt.Errorf("invalid backendArtifacts.sha256: %w", err))
|
||||
}
|
||||
|
||||
return &BackendNativePackage{
|
||||
archive: archive,
|
||||
artifactEntry: artifactEntry,
|
||||
PackagePath: packagePath,
|
||||
PackageSHA256: packageDigest,
|
||||
CustomerCode: manifest.CustomerCode,
|
||||
VersionID: manifest.VersionID,
|
||||
ArtifactID: selected.ID,
|
||||
FileName: selected.FileName,
|
||||
Identity: identity,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Close releases the opened ZIP file.
|
||||
func (p *BackendNativePackage) Close() error {
|
||||
if p == nil || p.archive == nil {
|
||||
return nil
|
||||
}
|
||||
archive := p.archive
|
||||
p.archive = nil
|
||||
p.artifactEntry = nil
|
||||
return archive.Close()
|
||||
}
|
||||
|
||||
// ExtractArtifact writes the selected JAR to destination and verifies size and SHA-256 before publishing it.
|
||||
func (p *BackendNativePackage) ExtractArtifact(destination string) error {
|
||||
if p == nil || p.archive == nil || p.artifactEntry == nil {
|
||||
return errors.New("native backend update package is not open")
|
||||
}
|
||||
if !filepath.IsAbs(destination) || filepath.Clean(destination) != destination {
|
||||
return errors.New("native backend extraction destination must be a clean absolute path")
|
||||
}
|
||||
parent := filepath.Dir(destination)
|
||||
if err := os.MkdirAll(parent, 0o750); err != nil {
|
||||
return fmt.Errorf("create native backend extraction directory: %w", err)
|
||||
}
|
||||
parentInfo, err := os.Lstat(parent)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect native backend extraction directory: %w", err)
|
||||
}
|
||||
if !parentInfo.IsDir() || parentInfo.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("native backend extraction parent is not a direct directory: %s", parent)
|
||||
}
|
||||
|
||||
source, err := p.artifactEntry.Open()
|
||||
if err != nil {
|
||||
return fmt.Errorf("open native backend artifact %s: %w", p.FileName, err)
|
||||
}
|
||||
temporary, err := os.CreateTemp(parent, ".backend-jar-*")
|
||||
if err != nil {
|
||||
_ = source.Close()
|
||||
return fmt.Errorf("create native backend extraction file: %w", err)
|
||||
}
|
||||
temporaryPath := temporary.Name()
|
||||
defer os.Remove(temporaryPath)
|
||||
if err := temporary.Chmod(0o640); err != nil {
|
||||
_ = source.Close()
|
||||
_ = temporary.Close()
|
||||
return fmt.Errorf("set native backend extraction permissions: %w", err)
|
||||
}
|
||||
|
||||
digest := sha256.New()
|
||||
written, copyErr := io.Copy(io.MultiWriter(temporary, digest), source)
|
||||
closeSourceErr := source.Close()
|
||||
if err := errors.Join(copyErr, closeSourceErr); err != nil {
|
||||
_ = temporary.Close()
|
||||
return fmt.Errorf("extract native backend artifact: %w", err)
|
||||
}
|
||||
if written != p.Identity.Size {
|
||||
_ = temporary.Close()
|
||||
return fmt.Errorf("native backend artifact size mismatch: got %d, want %d", written, p.Identity.Size)
|
||||
}
|
||||
actualDigest := hex.EncodeToString(digest.Sum(nil))
|
||||
if !strings.EqualFold(actualDigest, p.Identity.SHA256) {
|
||||
_ = temporary.Close()
|
||||
return fmt.Errorf("native backend artifact SHA-256 mismatch: got %s, want %s", actualDigest, p.Identity.SHA256)
|
||||
}
|
||||
if err := temporary.Sync(); err != nil {
|
||||
_ = temporary.Close()
|
||||
return fmt.Errorf("flush native backend extraction file: %w", err)
|
||||
}
|
||||
if err := temporary.Close(); err != nil {
|
||||
return fmt.Errorf("close native backend extraction file: %w", err)
|
||||
}
|
||||
if err := os.Rename(temporaryPath, destination); err != nil {
|
||||
return fmt.Errorf("publish native backend extraction file: %w", err)
|
||||
}
|
||||
return syncDirectory(parent)
|
||||
}
|
||||
|
||||
type artifactSelectionManifest struct {
|
||||
CustomerCode string `json:"customerCode"`
|
||||
CustomerDisplayName *string `json:"customerDisplayName"`
|
||||
VersionID string `json:"versionId"`
|
||||
Items []string `json:"items"`
|
||||
BackendArtifacts []manifestArtifact `json:"backendArtifacts"`
|
||||
FrontendArtifacts []manifestArtifact `json:"frontendArtifacts"`
|
||||
NodeSSRArtifacts []manifestArtifact `json:"nodeSsrArtifacts"`
|
||||
Remark *string `json:"remark"`
|
||||
}
|
||||
|
||||
type manifestArtifact struct {
|
||||
ID int64 `json:"id"`
|
||||
VersionCode *string `json:"versionCode"`
|
||||
ArtifactKind string `json:"artifactKind"`
|
||||
Type string `json:"type"`
|
||||
SelectedType string `json:"selectedType"`
|
||||
Platform *string `json:"platform"`
|
||||
FileName string `json:"fileName"`
|
||||
FilePath *string `json:"filePath"`
|
||||
SHA256 string `json:"sha256"`
|
||||
ImageRef *string `json:"imageRef"`
|
||||
}
|
||||
|
||||
func decodeManifest(entry *zip.File) (artifactSelectionManifest, error) {
|
||||
if entry.FileInfo().IsDir() {
|
||||
return artifactSelectionManifest{}, errors.New("artifact-selection.json is not a file")
|
||||
}
|
||||
if entry.UncompressedSize64 > maximumManifestBytes {
|
||||
return artifactSelectionManifest{}, errors.New("artifact-selection.json exceeds size limit")
|
||||
}
|
||||
reader, err := entry.Open()
|
||||
if err != nil {
|
||||
return artifactSelectionManifest{}, fmt.Errorf("open artifact-selection.json: %w", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
document, err := io.ReadAll(io.LimitReader(reader, maximumManifestBytes+1))
|
||||
if err != nil {
|
||||
return artifactSelectionManifest{}, fmt.Errorf("read artifact-selection.json: %w", err)
|
||||
}
|
||||
if len(document) > maximumManifestBytes {
|
||||
return artifactSelectionManifest{}, errors.New("artifact-selection.json exceeds size limit")
|
||||
}
|
||||
if err := validateExactManifestJSON(document); err != nil {
|
||||
return artifactSelectionManifest{}, fmt.Errorf("validate artifact-selection.json keys: %w", err)
|
||||
}
|
||||
|
||||
var manifest artifactSelectionManifest
|
||||
decoder := json.NewDecoder(bytes.NewReader(document))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&manifest); err != nil {
|
||||
return artifactSelectionManifest{}, fmt.Errorf("decode artifact-selection.json: %w", err)
|
||||
}
|
||||
if err := ensureJSONEnd(decoder); err != nil {
|
||||
return artifactSelectionManifest{}, err
|
||||
}
|
||||
if strings.TrimSpace(manifest.CustomerCode) == "" || strings.TrimSpace(manifest.VersionID) == "" {
|
||||
return artifactSelectionManifest{}, errors.New("artifact-selection.json customerCode and versionId are required")
|
||||
}
|
||||
return manifest, nil
|
||||
}
|
||||
|
||||
var rootManifestKeys = map[string]struct{}{
|
||||
"customerCode": {},
|
||||
"customerDisplayName": {},
|
||||
"versionId": {},
|
||||
"items": {},
|
||||
"backendArtifacts": {},
|
||||
"frontendArtifacts": {},
|
||||
"nodeSsrArtifacts": {},
|
||||
"remark": {},
|
||||
}
|
||||
|
||||
var artifactManifestKeys = map[string]struct{}{
|
||||
"id": {},
|
||||
"versionCode": {},
|
||||
"artifactKind": {},
|
||||
"type": {},
|
||||
"selectedType": {},
|
||||
"platform": {},
|
||||
"fileName": {},
|
||||
"filePath": {},
|
||||
"sha256": {},
|
||||
"imageRef": {},
|
||||
}
|
||||
|
||||
func validateExactManifestJSON(document []byte) error {
|
||||
decoder := json.NewDecoder(bytes.NewReader(document))
|
||||
decoder.UseNumber()
|
||||
if err := validateObject(decoder, "", rootManifestKeys, true); err != nil {
|
||||
return err
|
||||
}
|
||||
return ensureJSONEnd(decoder)
|
||||
}
|
||||
|
||||
func validateObject(decoder *json.Decoder, objectPath string, allowed map[string]struct{}, root bool) error {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if delimiter, ok := token.(json.Delim); !ok || delimiter != '{' {
|
||||
return fmt.Errorf("%s must be a JSON object", displayJSONPath(objectPath))
|
||||
}
|
||||
seen := make(map[string]struct{}, len(allowed))
|
||||
for decoder.More() {
|
||||
keyToken, err := decoder.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key, ok := keyToken.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s contains a non-string key", displayJSONPath(objectPath))
|
||||
}
|
||||
fieldPath := key
|
||||
if objectPath != "" {
|
||||
fieldPath = objectPath + "." + key
|
||||
}
|
||||
if _, duplicate := seen[key]; duplicate {
|
||||
return fmt.Errorf("duplicate JSON field %s", fieldPath)
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
if _, known := allowed[key]; !known {
|
||||
return fmt.Errorf("unknown JSON field %s", fieldPath)
|
||||
}
|
||||
if root && (key == "backendArtifacts" || key == "frontendArtifacts" || key == "nodeSsrArtifacts") {
|
||||
if err := validateArtifactArray(decoder, fieldPath); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := consumeJSONValue(decoder, fieldPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err = decoder.Token()
|
||||
return err
|
||||
}
|
||||
|
||||
func validateArtifactArray(decoder *json.Decoder, arrayPath string) error {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if delimiter, ok := token.(json.Delim); !ok || delimiter != '[' {
|
||||
return fmt.Errorf("%s must be a JSON array", arrayPath)
|
||||
}
|
||||
index := 0
|
||||
for decoder.More() {
|
||||
if err := validateObject(decoder, fmt.Sprintf("%s[%d]", arrayPath, index), artifactManifestKeys, false); err != nil {
|
||||
return err
|
||||
}
|
||||
index++
|
||||
}
|
||||
_, err = decoder.Token()
|
||||
return err
|
||||
}
|
||||
|
||||
func consumeJSONValue(decoder *json.Decoder, valuePath string) error {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
delimiter, composite := token.(json.Delim)
|
||||
if !composite {
|
||||
return nil
|
||||
}
|
||||
switch delimiter {
|
||||
case '[':
|
||||
for decoder.More() {
|
||||
if err := consumeJSONValue(decoder, valuePath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err = decoder.Token()
|
||||
return err
|
||||
case '{':
|
||||
seen := make(map[string]struct{})
|
||||
for decoder.More() {
|
||||
keyToken, err := decoder.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key := keyToken.(string)
|
||||
if _, duplicate := seen[key]; duplicate {
|
||||
return fmt.Errorf("duplicate JSON field %s.%s", valuePath, key)
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
if err := consumeJSONValue(decoder, valuePath+"."+key); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err = decoder.Token()
|
||||
return err
|
||||
default:
|
||||
return fmt.Errorf("unexpected JSON delimiter %q at %s", delimiter, valuePath)
|
||||
}
|
||||
}
|
||||
|
||||
func displayJSONPath(value string) string {
|
||||
if value == "" {
|
||||
return "artifact-selection.json"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func ensureJSONEnd(decoder *json.Decoder) error {
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||||
if err == nil {
|
||||
return errors.New("artifact-selection.json contains multiple JSON values")
|
||||
}
|
||||
return fmt.Errorf("decode artifact-selection.json trailing content: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func selectNativeBackend(artifacts []manifestArtifact) (manifestArtifact, error) {
|
||||
var selected manifestArtifact
|
||||
count := 0
|
||||
for _, artifact := range artifacts {
|
||||
if artifact.ArtifactKind == backendArtifactKind && artifact.Type == nativeArtifactType && artifact.SelectedType == nativeArtifactType {
|
||||
selected = artifact
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 1 {
|
||||
return manifestArtifact{}, fmt.Errorf("backendArtifacts must contain exactly one BACKEND native selection, got %d", count)
|
||||
}
|
||||
if selected.ID <= 0 || selected.FileName == "" || selected.SHA256 == "" {
|
||||
return manifestArtifact{}, errors.New("selected native backend artifact requires id, fileName and sha256")
|
||||
}
|
||||
return selected, nil
|
||||
}
|
||||
|
||||
func validateEntries(files []*zip.File) (map[string]*zip.File, error) {
|
||||
entries := make(map[string]*zip.File, len(files))
|
||||
for _, file := range files {
|
||||
name := file.Name
|
||||
if name == "" || strings.Contains(name, "\\") || path.IsAbs(name) || path.Clean(name) != name || name == "." || strings.HasPrefix(name, "../") {
|
||||
return nil, fmt.Errorf("update package contains unsafe ZIP entry: %q", name)
|
||||
}
|
||||
if file.Mode()&os.ModeSymlink != 0 {
|
||||
return nil, fmt.Errorf("update package contains symbolic link entry: %s", name)
|
||||
}
|
||||
if _, duplicate := entries[name]; duplicate {
|
||||
return nil, fmt.Errorf("update package contains duplicate ZIP entry: %s", name)
|
||||
}
|
||||
entries[name] = file
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func validateRootFileName(name string) error {
|
||||
if name == "" || strings.ContainsAny(name, "/\\") || path.Clean(name) != name || name == "." || name == ".." {
|
||||
return fmt.Errorf("expected one exact ZIP root file name, got %q", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAbsoluteRegularFile(filePath string, description string) error {
|
||||
if !filepath.IsAbs(filePath) || filepath.Clean(filePath) != filePath {
|
||||
return fmt.Errorf("%s path must be a clean absolute path", description)
|
||||
}
|
||||
info, err := os.Lstat(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect %s %s: %w", description, filePath, err)
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("%s is not a direct regular file: %s", description, filePath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hashFile(filePath string) (string, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("open update package for SHA-256: %w", err)
|
||||
}
|
||||
digest := sha256.New()
|
||||
_, copyErr := io.Copy(digest, file)
|
||||
closeErr := file.Close()
|
||||
if err := errors.Join(copyErr, closeErr); err != nil {
|
||||
return "", fmt.Errorf("hash update package: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(digest.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func syncDirectory(directory string) error {
|
||||
file, err := os.Open(directory)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open extraction directory for flush: %w", err)
|
||||
}
|
||||
syncErr := file.Sync()
|
||||
closeErr := file.Close()
|
||||
if err := errors.Join(syncErr, closeErr); err != nil {
|
||||
return fmt.Errorf("flush extraction directory: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user