package manifest import ( "fmt" "io/fs" "os" "path/filepath" "github.com/spf13/afero" "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/yaml" "edge-infra.dev/pkg/k8s/decoder" "edge-infra.dev/pkg/k8s/unstructured" ) type Manifest struct { fs afero.Fs path string content runtime.Object mode os.FileMode } // New returns a new Manifest object. The content argument should one of: // // 1. Empty runtime.Object (e.g. v1.Pod{}) that represents the type of data you expect the Manifest // to contain. This is useful if you intend on loading or reading content at a later point. // // 2. Complete runtime.Object (e.g. v1.Pod{Name:xxx,...}) that represents the object that you would // like to start with. This is useful if you already know the content of a manifest that you would // like to write. // // The mode provided can be 0 only if the intention is to respect the original mode of the current // manifest (if one exists), or accept the default of 0600. func New(fs afero.Fs, path string, content runtime.Object, mode os.FileMode) Manifest { return Manifest{ fs: fs, path: path, content: content, mode: mode, } } // WithCreate wraps the given function with functionality to create a manifest. This // will overwrite the original if one was already in place. The provided function // should be passed the target object and can modify it as needed. If no mode is // set on the Manifest, 0600 will be used as default. func (m *Manifest) WithCreate(fn func(obj runtime.Object) error) error { if err := fn(m.content); err != nil { return err } if m.mode == 0 { m.mode = 0600 } return m.Write() } // WithUpdate wraps the given function with functionality to update a manifest. This // will read from the old manifest and the current structure will be passed in to the // provided function to modify as required. If a mode is not set on the Manifest, then // the mode of the original file will be respected. func (m *Manifest) WithUpdate(fn func(obj runtime.Object) error) error { syncMode := (m.mode == 0) if err := m.Read(syncMode); err != nil { return fmt.Errorf("failed to read etcd manifest data: %w", err) } if err := fn(m.content); err != nil { return err } return m.Write() } // Read reads a Kubernetes manifest file and decodes it into the type provided to // the Manifest. In the case of files that contain multiple manifests, Read will only // consider the first found manifest to decode into the object. // // The syncMode argument can be passed to tell the method whether you want to sync // the mode from the original file into the Manifest. func (m *Manifest) Read(syncMode bool) error { manifestBytes, err := afero.ReadFile(m.fs, m.path) if err != nil { return err } if err := m.Load(manifestBytes); err != nil { return err } if syncMode { return m.syncFileMode() } return nil } // syncFileMode ensures that the file mode is correct for the file at the file // path by replacing the current mode with the actual value. func (m *Manifest) syncFileMode() error { fileInfo, err := m.Stat() if err != nil { return fmt.Errorf("failed to stat file (%s): %w", m.path, err) } m.mode = fileInfo.Mode() return nil } // Stat will return file information about the manifest at the provided // filepath. func (m *Manifest) Stat() (fs.FileInfo, error) { file, err := m.fs.Open(m.path) if err != nil { return nil, fmt.Errorf("failed to open file: %w", err) } return file.Stat() } // Load will take the provided bytes (which should represent a manifest file) // and decode them into the Manifest object. In the case of files that contain // multiple manifests, Read will only consider the first found manifest to decode // into the object. func (m *Manifest) Load(manifestBytes []byte) error { manifests, err := decoder.DecodeYAML(manifestBytes) if err != nil { return fmt.Errorf("failed to decode YAML: %w", err) } if len(manifests) == 0 { return fmt.Errorf("no manifests found in file") } if err := runtime.DefaultUnstructuredConverter.FromUnstructured(manifests[0].UnstructuredContent(), m.content); err != nil { return fmt.Errorf("failed to convert: %w", err) } return nil } // Write encodes the given pod object into the Kubernetes manifest file // at the defined path. This process is atomic and will write a temporary // file for the object before moving it over the original. func (m *Manifest) Write() error { if m.content == nil { return fmt.Errorf("unable to write nil content") } bytes, err := m.Bytes() if err != nil { return err } tmpFilename := "." + filepath.Base(m.path) + ".tmp" tmpPath := filepath.Join(filepath.Dir(m.path), tmpFilename) if err := afero.WriteFile(m.fs, tmpPath, bytes, m.mode); err != nil { return err } return m.fs.Rename(tmpPath, m.path) } // Content will return the current object loaded into the Manifest. func (m *Manifest) Content() runtime.Object { return m.content } // Bytes will return the current object loaded into the Manifest in []byte form. func (m *Manifest) Bytes() ([]byte, error) { uobj := unstructured.Unstructured{} obj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(m.content) if err != nil { return nil, err } uobj.Object = obj json, err := uobj.MarshalJSON() if err != nil { return nil, err } return yaml.JSONToYAML(json) }