123 lines
4.8 KiB
Go
123 lines
4.8 KiB
Go
package artifacts
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
|
)
|
|
|
|
// ImmutableRunObjectKey identifies one clean object beneath a session's
|
|
// immutable run namespace.
|
|
type ImmutableRunObjectKey struct {
|
|
RunID string
|
|
RelativePath string
|
|
}
|
|
|
|
// PublishedOutputObjectKey identifies a published destination beneath one
|
|
// immutable run in a session.
|
|
type PublishedOutputObjectKey struct {
|
|
RunID string
|
|
Destination string
|
|
}
|
|
|
|
// ParseImmutableRunObjectKey proves that key belongs to one immutable run in
|
|
// the expected campaign/session namespace and returns its run and relative path.
|
|
func ParseImmutableRunObjectKey(sessionPrefix, key string) (ImmutableRunObjectKey, error) {
|
|
if err := validateRemoteSessionPrefix(sessionPrefix); err != nil {
|
|
return ImmutableRunObjectKey{}, err
|
|
}
|
|
if err := validateRemoteObjectKey(key); err != nil {
|
|
return ImmutableRunObjectKey{}, fmt.Errorf("immutable object key %q: %w", key, err)
|
|
}
|
|
runsPrefix := sessionPrefix + config.S3RunsSegment + "/"
|
|
remainder, ok := strings.CutPrefix(key, runsPrefix)
|
|
if !ok {
|
|
return ImmutableRunObjectKey{}, fmt.Errorf("immutable object key %q is outside session run prefix %q", key, runsPrefix)
|
|
}
|
|
runID, relativePath, ok := strings.Cut(remainder, "/")
|
|
if !ok || runID == "" {
|
|
return ImmutableRunObjectKey{}, fmt.Errorf("immutable object key %q has an empty run identity", key)
|
|
}
|
|
if err := ValidateRunIdentity(runID); err != nil {
|
|
return ImmutableRunObjectKey{}, fmt.Errorf("immutable object key %q: %w", key, err)
|
|
}
|
|
if err := validateRemoteObjectKey(relativePath); err != nil {
|
|
return ImmutableRunObjectKey{}, fmt.Errorf("immutable object key %q has invalid run-relative path: %w", key, err)
|
|
}
|
|
return ImmutableRunObjectKey{RunID: runID, RelativePath: relativePath}, nil
|
|
}
|
|
|
|
// ParsePublishedOutputObjectKey proves that key is in the published-output
|
|
// namespace of one immutable run in the expected session.
|
|
func ParsePublishedOutputObjectKey(sessionPrefix, key string) (PublishedOutputObjectKey, error) {
|
|
identity, err := ParseImmutableRunObjectKey(sessionPrefix, key)
|
|
if err != nil {
|
|
return PublishedOutputObjectKey{}, err
|
|
}
|
|
destination, ok := strings.CutPrefix(identity.RelativePath, "published/")
|
|
if !ok || destination == "" {
|
|
return PublishedOutputObjectKey{}, fmt.Errorf("immutable object key %q is outside its immutable run published-output prefix", key)
|
|
}
|
|
if err := validateRemoteObjectKey(destination); err != nil {
|
|
return PublishedOutputObjectKey{}, fmt.Errorf("immutable object key %q has invalid published destination: %w", key, err)
|
|
}
|
|
return PublishedOutputObjectKey{RunID: identity.RunID, Destination: destination}, nil
|
|
}
|
|
|
|
// ValidateRemoteSessionPrefixIdentity proves that a canonical remote session
|
|
// prefix names the expected campaign and session.
|
|
func ValidateRemoteSessionPrefixIdentity(sessionPrefix, campaign, sessionID string) error {
|
|
if err := validateRemoteSessionPrefix(sessionPrefix); err != nil {
|
|
return err
|
|
}
|
|
if err := ValidateSessionIdentity(campaign, sessionID); err != nil {
|
|
return err
|
|
}
|
|
|
|
parts := strings.Split(strings.TrimSuffix(sessionPrefix, "/"), "/")
|
|
prefixCampaign := parts[len(parts)-3]
|
|
prefixSessionID := parts[len(parts)-1]
|
|
if prefixCampaign != campaign || prefixSessionID != sessionID {
|
|
return fmt.Errorf(
|
|
"session prefix %q identifies campaign/session %q/%q, want %q/%q",
|
|
sessionPrefix,
|
|
prefixCampaign,
|
|
prefixSessionID,
|
|
campaign,
|
|
sessionID,
|
|
)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateCanonicalPublishSource(source string) error {
|
|
classified, err := artifactpolicy.ClassifySource(source)
|
|
if err != nil || classified.Kind == artifactpolicy.SourceKindPreviousArtifact {
|
|
return fmt.Errorf("source %q is not a supported publish source", source)
|
|
}
|
|
if classified.ID != source {
|
|
return fmt.Errorf("source %q is not canonical", source)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateRemoteSessionPrefix(sessionPrefix string) error {
|
|
if sessionPrefix == "" || strings.TrimSpace(sessionPrefix) != sessionPrefix || strings.Contains(sessionPrefix, `\`) || !strings.HasSuffix(sessionPrefix, "/") {
|
|
return fmt.Errorf("session prefix %q must be a canonical relative object prefix", sessionPrefix)
|
|
}
|
|
withoutSlash := strings.TrimSuffix(sessionPrefix, "/")
|
|
if err := validateRemoteObjectKey(withoutSlash); err != nil {
|
|
return fmt.Errorf("session prefix %q: %w", sessionPrefix, err)
|
|
}
|
|
parts := strings.Split(withoutSlash, "/")
|
|
if len(parts) < 4 || parts[len(parts)-4] != config.S3CampaignsSegment || parts[len(parts)-2] != config.S3SessionsSegment {
|
|
return fmt.Errorf("session prefix %q does not identify a campaign session", sessionPrefix)
|
|
}
|
|
if err := ValidateSessionIdentity(parts[len(parts)-3], parts[len(parts)-1]); err != nil {
|
|
return fmt.Errorf("session prefix %q: %w", sessionPrefix, err)
|
|
}
|
|
return nil
|
|
}
|