Introduce durable, coordinator-owned artifacts — the model the master plan requires instead of trusting worker-supplied result URIs. - migration 0003: artifacts table + artifact_kind enum, plus nullable input/result_artifact_id columns on jobs and tasks. - domain.Artifact with NewArtifact/SetContent; storage keys derive from a fresh UUID, never from a client filename (no path traversal). - BlobStore and ArtifactRepository ports. - blob.FSStore: filesystem blob storage that streams while hashing (SHA-256), fsyncs, and atomically renames into place — a failed upload leaves neither a committed artifact nor a staging file. Covered by unit tests. - ArtifactRepo (squirrel) and COORDINATOR_STORAGE_DIR config. HTTP upload/download handlers and the switch of result submission to artifact_id come in the next parts.
70 lines
2.0 KiB
Go
70 lines
2.0 KiB
Go
package domain
|
|
|
|
import (
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
type ArtifactKind string
|
|
|
|
const (
|
|
ArtifactInput ArtifactKind = "input"
|
|
ArtifactShard ArtifactKind = "shard"
|
|
ArtifactPartialResult ArtifactKind = "partial_result"
|
|
ArtifactFinalResult ArtifactKind = "final_result"
|
|
ArtifactLog ArtifactKind = "log"
|
|
)
|
|
|
|
// Artifact is a durable file the coordinator owns, described by its metadata.
|
|
// The bytes live in blob storage under StorageKey; this struct is what the
|
|
// database persists and what every other layer reasons about.
|
|
type Artifact struct {
|
|
ID uuid.UUID
|
|
JobID uuid.UUID
|
|
TaskID *uuid.UUID // nil for a job-level input
|
|
Kind ArtifactKind
|
|
Filename string
|
|
StorageKey string
|
|
ContentType string
|
|
SizeBytes int64
|
|
SHA256 string
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
// NewArtifact begins an artifact record. Size and checksum are unknown until the
|
|
// bytes have been streamed to storage, so they are filled in later by SetContent.
|
|
//
|
|
// StorageKey is derived from a fresh UUID, never from the client-supplied
|
|
// filename — that is what stops a "../../etc/passwd" filename from escaping the
|
|
// storage directory.
|
|
func NewArtifact(jobID uuid.UUID, taskID *uuid.UUID, kind ArtifactKind,
|
|
filename, contentType string, now time.Time) (*Artifact, error) {
|
|
|
|
if filename == "" || kind == "" {
|
|
return nil, ErrInvalidInput
|
|
}
|
|
if contentType == "" {
|
|
contentType = "application/octet-stream"
|
|
}
|
|
id := uuid.New()
|
|
return &Artifact{
|
|
ID: id,
|
|
JobID: jobID,
|
|
TaskID: taskID,
|
|
Kind: kind,
|
|
Filename: filename,
|
|
StorageKey: id.String(),
|
|
ContentType: contentType,
|
|
CreatedAt: now,
|
|
}, nil
|
|
}
|
|
|
|
// SetContent records the size and checksum measured while streaming the bytes
|
|
// into storage. Both are computed by the coordinator, never trusted from the
|
|
// client — the whole point of owning the artifact.
|
|
func (a *Artifact) SetContent(sha256 string, size int64) {
|
|
a.SHA256 = sha256
|
|
a.SizeBytes = size
|
|
}
|