jx/internal/resource/file.go
Matthew Rich cb923b96c9
Some checks failed
Lint / golangci-lint (push) Successful in 11m0s
Declarative Tests / test (push) Failing after 35s
fix closing the output writer; test tar output
2024-10-04 00:30:49 +00:00

811 lines
20 KiB
Go

// Copyright 2024 Matthew Rich <matthewrich.conf@gmail.com>. All rights reserved.
package resource
import (
"context"
"errors"
"fmt"
"log/slog"
"gopkg.in/yaml.v3"
"encoding/json"
"io"
"io/fs"
"net/url"
"os"
"os/user"
"path/filepath"
"strconv"
"syscall"
"time"
"crypto/sha256"
"gitea.rosskeen.house/rosskeen.house/machine"
"decl/internal/codec"
"decl/internal/iofilter"
"decl/internal/data"
"decl/internal/folio"
"decl/internal/transport"
"strings"
"embed"
"compress/gzip"
)
const (
FileTypeName TypeName = "file"
)
// Describes the type of file the resource represents
type FileType string
// Supported file types
const (
RegularFile FileType = "regular"
DirectoryFile FileType = "directory"
BlockDeviceFile FileType = "block"
CharacterDeviceFile FileType = "char"
NamedPipeFile FileType = "pipe"
SymbolicLinkFile FileType = "symlink"
SocketFile FileType = "socket"
)
var ErrInvalidFileInfo error = errors.New("Invalid FileInfo")
var ErrInvalidFileMode error = errors.New("Invalid Mode")
var ErrInvalidFileOwner error = errors.New("Unknown User")
var ErrInvalidFileGroup error = errors.New("Unknown Group")
type FileMode string
func init() {
folio.DocumentRegistry.ResourceTypes.Register([]string{"file"}, func(u *url.URL) data.Resource {
f := NewFile()
f.parsedURI = u
//f.Uri.SetURL(u)
f.Path = filepath.Join(u.Hostname(), u.Path)
f.exttype, f.fileext = f.Uri.Extension()
slog.Info("folio.DocumentRegistry.ResourceTypes.Register()()", "url", u, "file", f)
/*
if absPath, err := filepath.Abs(f.Path); err == nil {
f.Filesystem = os.DirFS(filepath.Dir(absPath))
}
*/
return f
})
}
/*
Manage the state of file system objects
The file content may be serialized directly in the `Content` field
or the `ContentSourceRef/sourceref` may be used to refer to the source
of the content from which to stream the content.
The `SerializeContent` the flag allows forcing the content to be serialized in the output.
*/
type File struct {
Uri folio.URI `json:"uri,omitempty" yaml:"uri,omitempty"`
parsedURI *url.URL `json:"-" yaml:"-"`
Filesystem fs.FS `json:"-" yaml:"-"`
exttype string `json:"-" yaml:"-"`
fileext string `json:"-" yaml:"-"`
stater machine.Stater `json:"-" yaml:"-"`
normalizePath bool `json:"-" yaml:"-"`
absPath string `json:"-" yaml:"-"`
basePath int `json:"-" yaml:"-"`
Path string `json:"path" yaml:"path"`
Owner string `json:"owner" yaml:"owner"`
Group string `json:"group" yaml:"group"`
Mode FileMode `json:"mode" yaml:"mode"`
Atime time.Time `json:"atime,omitempty" yaml:"atime,omitempty"`
Ctime time.Time `json:"ctime,omitempty" yaml:"ctime,omitempty"`
Mtime time.Time `json:"mtime,omitempty" yaml:"mtime,omitempty"`
Content string `json:"content,omitempty" yaml:"content,omitempty"`
ContentSourceRef folio.ResourceReference `json:"sourceref,omitempty" yaml:"sourceref,omitempty"`
Sha256 string `json:"sha256,omitempty" yaml:"sha256,omitempty"`
Size int64 `json:"size,omitempty" yaml:"size,omitempty"`
Target string `json:"target,omitempty" yaml:"target,omitempty"`
FileType FileType `json:"filetype" yaml:"filetype"`
State string `json:"state,omitempty" yaml:"state,omitempty"`
SerializeContent bool `json:"serializecontent,omitempty" yaml:"serializecontent,omitempty"`
GzipContent bool `json:"gzipcontent,omitempty" yaml:"gzipcontent,omitempty"`
config data.ConfigurationValueGetter
Resources data.ResourceMapper `json:"-" yaml:"-"`
}
type ResourceFileInfo struct {
resource *File
}
func NewFile() *File {
currentUser, _ := user.Current()
group, _ := user.LookupGroupId(currentUser.Gid)
f := &File{ normalizePath: false, Owner: currentUser.Username, Group: group.Name, Mode: "0644", FileType: RegularFile, SerializeContent: false }
slog.Info("NewFile()", "file", f)
return f
}
func NewNormalizedFile() *File {
f := NewFile()
f.normalizePath = true
return f
}
func (f *File) ContentType() string {
if f.parsedURI.Scheme != "file" {
return f.parsedURI.Scheme
}
return f.exttype
}
func (f *File) SetResourceMapper(resources data.ResourceMapper) {
f.Resources = resources
}
func (f *File) Clone() data.Resource {
return &File {
Uri: f.Uri,
parsedURI: f.parsedURI,
exttype: f.exttype,
fileext: f.fileext,
normalizePath: f.normalizePath,
absPath: f.absPath,
Path: f.Path,
Owner: f.Owner,
Group: f.Group,
Mode: f.Mode,
Atime: f.Atime,
Ctime: f.Ctime,
Mtime: f.Mtime,
Content: f.Content,
Sha256: f.Sha256,
Size: f.Size,
Target: f.Target,
FileType: f.FileType,
State: f.State,
}
}
func (f *File) StateMachine() machine.Stater {
if f.stater == nil {
f.stater = StorageMachine(f)
}
return f.stater
}
func (f *File) Notify(m *machine.EventMessage) {
ctx := context.Background()
slog.Info("Notify()", "file", f, "m", m)
switch m.On {
case machine.ENTERSTATEEVENT:
switch m.Dest {
case "start_stat":
if statErr := f.ReadStat(); statErr == nil {
if triggerErr := f.StateMachine().Trigger("exists"); triggerErr == nil {
return
}
} else {
if triggerErr := f.StateMachine().Trigger("notexists"); triggerErr == nil {
return
}
}
case "start_read":
if _,readErr := f.Read(ctx); readErr == nil {
if triggerErr := f.StateMachine().Trigger("state_read"); triggerErr == nil {
return
} else {
f.State = "absent"
panic(triggerErr)
}
} else {
f.State = "absent"
if ! errors.Is(readErr, ErrResourceStateAbsent) {
panic(readErr)
}
}
case "start_create":
if e := f.Create(ctx); e == nil {
if triggerErr := f.StateMachine().Trigger("created"); triggerErr == nil {
return
}
} else {
f.State = "absent"
panic(e)
}
case "start_delete":
if deleteErr := f.Delete(ctx); deleteErr == nil {
if triggerErr := f.StateMachine().Trigger("deleted"); triggerErr == nil {
return
} else {
f.State = "present"
panic(triggerErr)
}
} else {
f.State = "present"
panic(deleteErr)
}
case "absent":
f.State = "absent"
case "present", "created", "read":
f.State = "present"
}
case machine.EXITSTATEEVENT:
}
}
func (f *File) SetGzipContent(flag bool) {
f.GzipContent = flag
}
func (f *File) PathNormalization(flag bool) {
f.normalizePath = flag
}
func (f *File) FilePath() string {
return f.Path
}
func (f *File) SetFS(fsys fs.FS) {
f.Filesystem = fsys
}
func (f *File) URI() string {
return fmt.Sprintf("file://%s", f.Path)
}
func (f *File) RelativePath() string {
return f.Path[f.basePath:]
}
func (f *File) SetBasePath(index int) {
if index < len(f.Path) {
f.basePath = index
}
}
func (f *File) SetURI(uri string) (err error) {
slog.Info("File.SetURI()", "uri", uri, "file", f, "parsed", f.parsedURI)
f.SetURIFromString(uri)
err = f.SetParsedURI(f.Uri.Parse())
return
}
func (f *File) DetectGzip() bool {
return (f.parsedURI.Query().Get("gzip") == "true" || f.fileext == "gz" || f.exttype == "tgz" || f.exttype == "gz" || f.fileext == "tgz" )
}
func (f *File) SetURIFromString(uri string) {
f.Uri = folio.URI(uri)
f.exttype, f.fileext = f.Uri.Extension()
}
func (f *File) SetParsedURI(u *url.URL) (err error) {
if u != nil {
if u.Scheme == "" {
u.Scheme = "file"
f.Uri = ""
}
if f.Uri.IsEmpty() {
f.SetURIFromString(u.String())
}
slog.Info("File.SetParsedURI()", "parsed", u, "path", f.Path)
f.parsedURI = u
if f.parsedURI.Scheme == "file" {
f.Path = filepath.Join(f.parsedURI.Hostname(), f.parsedURI.Path)
slog.Info("File.SetParsedURI()", "path", f.Path)
if err = f.NormalizePath(); err != nil {
return
}
return
}
}
err = fmt.Errorf("%w: %s is not a file", ErrInvalidResourceURI, f.Uri)
return
}
func (f *File) UseConfig(config data.ConfigurationValueGetter) {
f.config = config
}
func (f *File) JSON() ([]byte, error) {
return json.Marshal(f)
}
func (f *File) Validate() (err error) {
var fileJson []byte
if fileJson, err = f.JSON(); err == nil {
s := NewSchema(f.Type())
err = s.Validate(string(fileJson))
}
return err
}
func (f *File) Apply() error {
ctx := context.Background()
switch f.State {
case "absent":
return f.Delete(ctx)
case "present":
return f.Create(ctx)
}
return nil
}
func (f *File) Load(docData []byte, format codec.Format) (err error) {
err = format.StringDecoder(string(docData)).Decode(f)
if err == nil {
f.UpdateContentAttributes()
}
return
}
func (f *File) LoadReader(r io.ReadCloser, format codec.Format) (err error) {
err = format.Decoder(r).Decode(f)
if err == nil {
f.UpdateContentAttributes()
}
return
}
func (f *File) LoadString(docData string, format codec.Format) (err error) {
err = format.StringDecoder(docData).Decode(f)
if err == nil {
f.UpdateContentAttributes()
}
return
}
func (f *File) LoadDecl(yamlResourceDeclaration string) (err error) {
return f.LoadString(yamlResourceDeclaration, codec.FormatYaml)
}
func (f *File) ResolveId(ctx context.Context) string {
if e := f.NormalizePath(); e != nil {
panic(e)
}
return f.Path
}
func (f *File) NormalizePath() (err error) {
if f.config != nil {
if prefixPath, configErr := f.config.GetValue("prefix"); configErr == nil {
f.Path = filepath.Join(prefixPath.(string), f.Path)
}
}
if f.absPath, err = filepath.Abs(f.Path); err == nil && f.normalizePath {
f.Path = f.absPath
}
return
}
func (f *File) GetContentSourceRef() string {
return string(f.ContentSourceRef)
}
func (f *File) SetContentSourceRef(uri string) {
f.Size = 0
f.ContentSourceRef = folio.ResourceReference(uri)
}
func (f *File) Stat() (info fs.FileInfo, err error) {
if _, ok := f.Filesystem.(embed.FS); ok {
info, err = fs.Stat(f.Filesystem, f.Path)
} else {
info, err = os.Lstat(f.absPath)
}
return
}
func (f *File) FileInfo() fs.FileInfo {
return &ResourceFileInfo{ resource: f }
}
func (f *ResourceFileInfo) Name() string {
// return filepath.Base(f.resource.Path)
return f.resource.RelativePath()
}
func (f *ResourceFileInfo) Size() int64 {
return f.resource.Size
}
func (f *ResourceFileInfo) Mode() (mode os.FileMode) {
if fileMode, fileModeErr := f.resource.Mode.GetMode(); fileModeErr == nil {
mode |= fileMode
} else {
panic(fileModeErr)
}
mode |= f.resource.FileType.GetMode()
return
}
func (f *ResourceFileInfo) ModTime() time.Time {
return f.resource.Mtime
}
func (f *ResourceFileInfo) IsDir() bool {
return f.resource.FileType == DirectoryFile
}
func (f *ResourceFileInfo) Sys() any {
return nil
}
func (f *File) Create(ctx context.Context) error {
slog.Info("File.Create()", "file", f)
uid, uidErr := LookupUID(f.Owner)
if uidErr != nil {
return fmt.Errorf("%w: unkwnon user %d", ErrInvalidFileOwner, uid)
}
gid, gidErr := LookupGID(f.Group)
if gidErr != nil {
return fmt.Errorf("%w: unkwnon group %d", ErrInvalidFileGroup, gid)
}
mode, modeErr := f.Mode.GetMode()
if modeErr != nil {
return modeErr
}
switch f.FileType {
case SymbolicLinkFile:
linkErr := os.Symlink(f.Target, f.Path)
if linkErr != nil {
return linkErr
}
case DirectoryFile:
if mkdirErr := os.MkdirAll(f.Path, mode); mkdirErr != nil {
return mkdirErr
}
default:
fallthrough
case RegularFile:
copyBuffer := make([]byte, 32 * 1024)
hash := sha256.New()
f.Size = 0
var contentReader io.ReadCloser
if len(f.Content) == 0 && len(f.ContentSourceRef) != 0 {
if refReader, err := f.ContentSourceRef.Lookup(nil).ContentReaderStream(); err == nil {
contentReader = refReader
} else {
return err
}
} else {
contentReader = io.NopCloser(strings.NewReader(f.Content))
}
sumReadData := iofilter.NewReader(contentReader, func(p []byte, readn int, readerr error) (n int, err error) {
hash.Write(p[:readn])
f.Size += int64(readn)
return readn, readerr
})
var createdFileWriter io.WriteCloser
createdFile, fileErr := os.Create(f.Path)
if fileErr != nil {
return fileErr
}
if f.GzipContent && f.DetectGzip() {
createdFileWriter = gzip.NewWriter(createdFile)
defer createdFileWriter.Close()
} else {
createdFileWriter = createdFile
}
defer createdFile.Close()
if chmodErr := createdFile.Chmod(mode); chmodErr != nil {
return chmodErr
}
_, writeErr := io.CopyBuffer(createdFileWriter, sumReadData, copyBuffer)
if writeErr != nil {
return fmt.Errorf("File.Create(): CopyBuffer failed %v %v: %w", createdFileWriter, contentReader, writeErr)
}
f.Sha256 = fmt.Sprintf("%x", hash.Sum(nil))
if !f.Mtime.IsZero() && !f.Atime.IsZero() {
if chtimesErr := os.Chtimes(f.Path, f.Atime, f.Mtime); chtimesErr != nil {
return chtimesErr
}
}
}
if chownErr := os.Chown(f.Path, uid, gid); chownErr != nil {
return chownErr
}
f.State = "present"
return nil
}
func (f *File) Update(ctx context.Context) error {
return f.Create(ctx)
}
func (f *File) Delete(ctx context.Context) error {
return os.Remove(f.Path)
}
func (f *File) UpdateContentAttributes() {
f.Size = int64(len(f.Content))
f.Sha256 = fmt.Sprintf("%x", sha256.Sum256([]byte(f.Content)))
}
func (f *File) SetFileInfo(info os.FileInfo) error {
return f.UpdateAttributesFromFileInfo(info)
}
func (f *File) UpdateAttributesFromFileInfo(info os.FileInfo) error {
if info != nil {
f.Mtime = info.ModTime()
if stat, ok := info.Sys().(*syscall.Stat_t); ok {
f.Atime = time.Unix(int64(stat.Atim.Sec), int64(stat.Atim.Nsec))
f.Ctime = time.Unix(int64(stat.Ctim.Sec), int64(stat.Ctim.Nsec))
userId := strconv.Itoa(int(stat.Uid))
groupId := strconv.Itoa(int(stat.Gid))
fileUser, userErr := user.LookupId(userId)
if userErr != nil { //UnknownUserIdError
//panic(userErr)
f.Owner = userId
} else {
f.Owner = fileUser.Username
}
fileGroup, groupErr := user.LookupGroupId(groupId)
if groupErr != nil {
//panic(groupErr)
f.Group = groupId
} else {
f.Group = fileGroup.Name
}
}
f.Size = info.Size()
f.Mode = FileMode(fmt.Sprintf("%04o", info.Mode().Perm()))
f.FileType.SetMode(info.Mode())
return nil
}
return ErrInvalidFileInfo
}
func (f *File) ContentSourceRefStat() (info fs.FileInfo) {
if len(f.ContentSourceRef) > 0 {
rs, _ := f.ContentReaderStream()
info, _ = rs.Stat()
rs.Close()
}
return
}
func (f *File) ReadStat() (err error) {
var info fs.FileInfo
slog.Info("ReadStat()", "filesystem", f.Filesystem, "path", f.Path)
info, err = f.Stat()
slog.Info("ReadStat()", "filesystem", f.Filesystem, "path", f.Path, "info", info, "error", err)
if err == nil {
_ = f.SetFileInfo(info)
} else {
if refStat := f.ContentSourceRefStat(); refStat != nil {
_ = f.SetFileInfo(refStat)
//f.Size = refStat.Size()
err = nil
}
// XXX compare the mod times and set state to outdated
}
slog.Info("ReadStat()", "stat", info, "path", f.Path)
if err != nil {
f.State = "absent"
return
}
return
}
func (f *File) open() (file io.ReadCloser, err error) {
slog.Info("open()", "file", f.Path, "fs", f.Filesystem)
if _, ok := f.Filesystem.(embed.FS); ok {
file, err = f.Filesystem.Open(f.Path)
} else {
file, err = os.Open(f.Path)
}
if f.GzipContent && f.DetectGzip() {
file, err = gzip.NewReader(file)
}
slog.Info("open()", "file", f.Path, "error", err)
return
}
func (f *File) Read(ctx context.Context) ([]byte, error) {
if normalizePathErr := f.NormalizePath(); normalizePathErr != nil {
return nil, normalizePathErr
}
statErr := f.ReadStat()
if statErr != nil {
return nil, fmt.Errorf("%w - %w", ErrResourceStateAbsent, statErr)
}
switch f.FileType {
case RegularFile:
if len(f.ContentSourceRef) == 0 || f.SerializeContent {
//file, fileErr := os.Open(f.Path)
file, fileErr := f.open()
if fileErr != nil {
panic(fileErr)
}
fileContent, ioErr := io.ReadAll(file)
if ioErr != nil {
panic(ioErr)
}
f.Content = string(fileContent)
f.Sha256 = fmt.Sprintf("%x", sha256.Sum256(fileContent))
}
case SymbolicLinkFile:
linkTarget, pathErr := os.Readlink(f.Path)
if pathErr != nil {
return nil, pathErr
}
f.Target = linkTarget
}
f.State = "present"
return yaml.Marshal(f)
}
// set up reader for source content
func (f *File) readThru() (contentReader io.ReadCloser, err error) {
if len(f.ContentSourceRef) != 0 {
contentReader, err = f.ContentSourceRef.Lookup(nil).ContentReaderStream()
if f.GzipContent {
contentReader.(*transport.Reader).DetectGzip()
} else {
contentReader.(*transport.Reader).SetGzip(false)
}
slog.Info("File.readThru()", "reader", contentReader)
} else {
if len(f.Content) != 0 {
contentReader = io.NopCloser(strings.NewReader(f.Content))
} else {
//contentReader, err = os.Open(f.Path)
contentReader, err = f.open()
}
}
contentReader = f.UpdateContentAttributesFromReader(contentReader)
return
}
func (f *File) UpdateContentAttributesFromReader(reader io.ReadCloser) io.ReadCloser {
var content strings.Builder
hash := sha256.New()
f.Size = 0
f.Content = ""
f.Sha256 = ""
return iofilter.NewReader(reader, func(p []byte, readn int, readerr error) (n int, err error) {
hash.Write(p[:readn])
f.Sha256 = fmt.Sprintf("%x", hash.Sum(nil))
f.Size += int64(readn)
if len(f.ContentSourceRef) == 0 || f.SerializeContent {
content.Write(p[:readn])
f.Content = content.String()
}
return readn, readerr
})
}
func (f *File) SetContent(r io.Reader) error {
fileContent, ioErr := io.ReadAll(r)
f.Content = string(fileContent)
f.UpdateContentAttributes()
return ioErr
}
func (f *File) GetContent(w io.Writer) (contentReader io.ReadCloser, err error) {
slog.Info("File.GetContent()", "content", len(f.Content), "sourceref", f.ContentSourceRef)
switch f.FileType {
case RegularFile:
contentReader, err = f.readThru()
if w != nil {
copyBuffer := make([]byte, 32 * 1024)
_, writeErr := io.CopyBuffer(w, contentReader, copyBuffer)
if writeErr != nil {
return nil, fmt.Errorf("File.GetContent(): CopyBuffer failed %v %v: %w", w, contentReader, writeErr)
}
return nil, nil
}
}
return
}
func (f *File) ContentReaderStream() (*transport.Reader, error) {
if len(f.Content) == 0 && len(f.ContentSourceRef) != 0 {
return f.ContentSourceRef.Lookup(nil).ContentReaderStream()
}
return nil, fmt.Errorf("Cannot provide transport reader for string content")
}
// ContentWriterStream() would not provide a mechanism to keep the in-memory state up-to-date
func (f *File) ContentWriterStream() (*transport.Writer, error) {
if len(f.Content) == 0 && len(f.ContentSourceRef) != 0 {
return f.ContentSourceRef.Lookup(nil).ContentWriterStream()
}
return nil, fmt.Errorf("Cannot provide transport writer for string content")
}
func (f *File) GetTarget() string { return f.Target }
func (f *File) Type() string { return "file" }
func (f *FileType) UnmarshalYAML(value *yaml.Node) error {
var s string
if err := value.Decode(&s); err != nil {
return err
}
switch s {
case string(RegularFile), string(DirectoryFile), string(BlockDeviceFile), string(CharacterDeviceFile), string(NamedPipeFile), string(SymbolicLinkFile), string(SocketFile):
*f = FileType(s)
return nil
default:
return errors.New("invalid FileType value")
}
}
func (f *FileType) SetMode(mode os.FileMode) {
switch true {
case mode.IsRegular():
*f = RegularFile
case mode.IsDir():
*f = DirectoryFile
case mode&os.ModeSymlink != 0:
*f = SymbolicLinkFile
case mode&os.ModeNamedPipe != 0:
*f = NamedPipeFile
case mode&os.ModeSocket != 0:
*f = SocketFile
case mode&os.ModeCharDevice != 0:
*f = CharacterDeviceFile
case mode&os.ModeDevice != 0:
*f = BlockDeviceFile
}
}
func (f *FileType) GetMode() (mode os.FileMode) {
switch *f {
case RegularFile:
case DirectoryFile:
mode |= os.ModeDir
case SymbolicLinkFile:
mode |= os.ModeSymlink
case NamedPipeFile:
mode |= os.ModeNamedPipe
case SocketFile:
mode |= os.ModeSocket
case CharacterDeviceFile:
mode |= os.ModeCharDevice
case BlockDeviceFile:
mode |= os.ModeDevice
}
return
}
func (f *FileMode) GetMode() (os.FileMode, error) {
if mode, modeErr := strconv.ParseInt(string(*f), 8, 64); modeErr != nil {
return os.FileMode(mode), fmt.Errorf("%w: %s invalid mode %d - %w", ErrInvalidFileMode, *f, mode, modeErr)
} else {
return os.FileMode(mode), nil
}
}