file.go (216 lines of code) (raw):
// Licensed to Elasticsearch B.V. under one or more contributor
// license agreements. See the NOTICE file distributed with
// this work for additional information regarding copyright
// ownership. Elasticsearch B.V. licenses this file to you under
// the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
package resource
import (
"bytes"
"context"
"crypto/md5"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"runtime"
)
const (
defaultFileProviderName = "file"
)
// FileProvider is a provider of files. It can be configured with the prefix
// path where files should be managed.
type FileProvider struct {
Prefix string
}
// File is a resource that manages a file.
type File struct {
// Provider is the name of the provider to use, defaults to "file".
Provider string
// Path is the path of the file.
Path string
// Absent is set to true to indicate that the file should not exist. If it
// exists, the file is removed.
Absent bool
// Mode is the file mode and permissions of the file. If not set, defaults to 0644
// for files and 0755 for directories.
Mode *fs.FileMode
// Directory is set to true to indicate that the file is a directory.
Directory bool
// CreateParent is set to true if parent path should be created too.
CreateParent bool
// Force forces destructive operations, such as removing a file to replace it
// with a directory, or the other way around. These operations will fail if
// force is not set.
Force bool
// Content is the content for the file.
// TODO: Support directory contents.
Content FileContent
// KeepExistingContent keeps content of file if it exists.
KeepExistingContent bool
// MD5 is the expected md5 sum of the content of the file. If the current content
// of the file matches this checksum, the file is not updated.
MD5 string
}
func (f *File) String() string {
return fmt.Sprintf("[File:%s:%s]", f.Provider, f.Path)
}
func (f *File) provider(scope Scope) *FileProvider {
name := f.Provider
if name == "" {
name = defaultFileProviderName
}
var provider *FileProvider
ok := scope.Provider(name, &provider)
if !ok {
return &FileProvider{}
}
return provider
}
func (f *File) mode() fs.FileMode {
switch {
case f.Mode != nil:
return *f.Mode
case f.Directory:
return 0755
default:
return 0644
}
}
func (f *File) Get(ctx context.Context, scope Scope) (current ResourceState, err error) {
provider := f.provider(scope)
path := filepath.Join(provider.Prefix, f.Path)
info, err := os.Stat(path)
if errors.Is(err, fs.ErrNotExist) {
return &FileState{expected: !f.Absent}, nil
} else if err != nil {
return nil, err
}
return &FileState{
info: info,
expected: !f.Absent,
scope: scope,
content: func() (io.ReadCloser, error) {
return os.Open(path)
},
}, nil
}
func (f *File) Create(ctx context.Context, scope Scope) error {
err := f.createFile(scope)
if err != nil {
return err
}
err = f.writeContent(ctx, scope)
if err != nil {
return err
}
err = f.ensureMode(scope)
if err != nil {
return err
}
return nil
}
func (f *File) createFile(scope Scope) error {
provider := f.provider(scope)
path := filepath.Join(provider.Prefix, f.Path)
if f.CreateParent {
err := os.MkdirAll(filepath.Dir(path), f.mode()|0111)
if err != nil {
return fmt.Errorf("failed to create parent directory: %w", err)
}
}
if f.Directory {
return os.Mkdir(path, f.mode())
}
created, err := os.OpenFile(path, os.O_CREATE, 0644)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
created.Close()
return nil
}
func (f *File) writeContent(ctx context.Context, scope Scope) error {
if f.Content == nil {
return nil
}
provider := f.provider(scope)
path := filepath.Join(provider.Prefix, f.Path)
return safeWriteContent(ctx, scope, path, f.Content, f.MD5)
}
func (f *File) ensureMode(scope Scope) error {
provider := f.provider(scope)
path := filepath.Join(provider.Prefix, f.Path)
if err := os.Chmod(path, f.mode()); err != nil {
return fmt.Errorf("failed to set mode: %w", err)
}
return nil
}
// safeWriteContent writes the content to a tmp file before overwriting the original file.
// If md5sum is not empty, it checks that the md5 is correct before writing the final file.
func safeWriteContent(ctx context.Context, scope Scope, path string, content FileContent, md5Sum string) error {
tmpFile, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path))
if err != nil {
return err
}
defer os.Remove(tmpFile.Name())
checksum := md5.New()
w := io.MultiWriter(tmpFile, checksum)
err = content(ctx, scope, w)
tmpFile.Close()
if err != nil {
return err
}
if md5Sum != "" && md5Sum != string(checksum.Sum(nil)) {
return errors.New("md5 checksum of content differs")
}
err = os.Remove(path)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("cannot replace file %s", path)
}
return os.Rename(tmpFile.Name(), path)
}
func (f *File) Update(ctx context.Context, scope Scope) error {
provider := f.provider(scope)
path := filepath.Join(provider.Prefix, f.Path)
if f.Absent {
return os.Remove(path)
}
if f.Force {
info, err := os.Stat(path)
if err == nil && info != nil && f.Directory != info.IsDir() {
err := os.RemoveAll(path)
if err != nil {
return err
}
}
return f.Create(ctx, scope)
}
if !f.KeepExistingContent {
err := f.writeContent(ctx, scope)
if err != nil {
return err
}
}
err := f.ensureMode(scope)
if err != nil {
return err
}
return nil
}
type FileState struct {
info fs.FileInfo
expected bool
scope Scope
content func() (io.ReadCloser, error)
}
func (f *FileState) Found(context.Context) bool {
return f.info != nil || !f.expected
}
func (f *FileState) NeedsUpdate(ctx context.Context, resource Resource) (bool, error) {
file := resource.(*File)
if file.Absent && f.info != nil {
return true, nil
}
if f.info != nil && file.Directory != f.info.IsDir() {
return true, nil
}
// TODO: Implement file permissions support based on ACLs in Windows.
if f.info != nil && runtime.GOOS != "windows" && file.mode().Perm() != f.info.Mode().Perm() {
return true, nil
}
if file.Content != nil && !file.KeepExistingContent {
current, err := f.content()
if err != nil {
return true, err
}
defer current.Close()
currentCheckSum := md5.New()
io.Copy(currentCheckSum, current)
if file.MD5 != "" && file.MD5 == string(currentCheckSum.Sum(nil)) {
return false, nil
}
expectedCheckSum := md5.New()
file.Content(ctx, f.scope, expectedCheckSum)
if !bytes.Equal(currentCheckSum.Sum(nil), expectedCheckSum.Sum(nil)) {
return true, nil
}
}
return false, nil
}
// FileMode is a helper function to create a *fs.FileMode inline.
func FileMode(mode fs.FileMode) *fs.FileMode {
return &mode
}