diff --git a/Dockerfile b/Dockerfile index 3e4f45a..adc478f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,15 +12,16 @@ RUN go mod download # Copy the go source COPY . /workspace -# Build with make to apply all build logic difined in Mekefile +# Build with make to apply all build logic defined in Makefile RUN make build +# Build host-local cni +RUN git clone https://github.com/containernetworking/plugins.git ; cd plugins ; git checkout v1.2.0 -b v1.2.0 +RUN cd plugins ; go build -o plugins/bin/host-local ./plugins/ipam/host-local # Use distroless as minimal base image to package the manager binary # Refer to https://github.com/GoogleContainerTools/distroless for more details -FROM gcr.io/distroless/static:nonroot +FROM gcr.io/distroless/base-debian11:latest WORKDIR / COPY --from=builder /workspace/build/ipam-controller . COPY --from=builder /workspace/build/ipam-node . -USER 65532:65532 - -ENTRYPOINT ["/ipam-controller"] +COPY --from=builder /workspace/plugins/plugins/bin/host-local . diff --git a/cmd/ipam-node/main.go b/cmd/ipam-node/main.go index 61a0cf9..b187e6f 100644 --- a/cmd/ipam-node/main.go +++ b/cmd/ipam-node/main.go @@ -13,7 +13,312 @@ package main +import ( + "bytes" + "crypto/sha256" + b64 "encoding/base64" + "fmt" + "io" + "log" + "os" + "text/template" + "time" + + "github.com/spf13/pflag" + + "github.com/Mellanox/nvidia-k8s-ipam/pkg/cmdutils" +) + +// Options stores command line options +type Options struct { + NvIpamCNIDataDir string + NvIpamCNIDataDirHost string + CNIConfDir string + HostLocalBinFile string // may be hidden or remove? + SkipHostLocalBinaryCopy bool + NvIpamKubeConfigFileHost string + NvIpamLogLevel string + NvIpamLogFile string + SkipTLSVerify bool +} + +const ( + serviceAccountTokenFile = "/var/run/secrets/kubernetes.io/serviceaccount/token" //nolint:golint,gosec + serviceAccountCAFile = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt" //nolint:golint,gosec +) + +func (o *Options) addFlags() { + // suppress error message for help + pflag.ErrHelp = nil //nolint:golint,reassign + fs := pflag.CommandLine + fs.StringVar(&o.NvIpamCNIDataDir, + "nv-ipam-cni-data-dir", "/host/var/lib/cni/nv-ipam", "nv-ipam CNI data directory") + fs.StringVar(&o.NvIpamCNIDataDirHost, + "nv-ipam-cni-data-dir-host", "/var/lib/cni/nv-ipam", "nv-ipam CNI data directory on host") + fs.StringVar(&o.CNIConfDir, + "cni-conf-dir", "/host/etc/cni/net.d", "CNI config directory") + fs.StringVar(&o.HostLocalBinFile, + "host-local-bin-file", "/host-local", "host-local binary file path") + fs.BoolVar(&o.SkipHostLocalBinaryCopy, + "skip-host-local-binary-copy", false, "skip host-loca binary file copy") + fs.StringVar(&o.NvIpamKubeConfigFileHost, + "nv-ipam-kubeconfig-file-host", "/etc/cni/net.d/nv-ipam.d/nv-ipam.kubeconfig", "kubeconfig for nv-ipam") + fs.StringVar(&o.NvIpamLogLevel, + "nv-ipam-log-level", "info", "nv-ipam log level") + fs.StringVar(&o.NvIpamLogFile, + "nv-ipam-log-file", "/var/log/nv-ipam-cni.log", "nv-ipam log file") + fs.BoolVar(&o.SkipTLSVerify, + "skip-tls-verify", false, "skip TLS verify") + fs.MarkHidden("skip-tls-verify") //nolint:golint,errcheck +} + +func (o *Options) verifyFileExists() error { + // CNIConfDir + if _, err := os.Stat(o.CNIConfDir); err != nil { + return fmt.Errorf("cni-conf-dir is not found: %v", err) + } + + if _, err := os.Stat(fmt.Sprintf("%s/bin", o.NvIpamCNIDataDir)); err != nil { + return fmt.Errorf("nv-ipam-cni-data-bin-dir is not found: %v", err) + } + + if _, err := os.Stat(fmt.Sprintf("%s/state/host-local", o.NvIpamCNIDataDir)); err != nil { + return fmt.Errorf("nv-ipam-cni-data-state-dir is not found: %v", err) + } + + // HostLocalBinFile + if _, err := os.Stat(o.HostLocalBinFile); err != nil { + return fmt.Errorf("host-local-bin-file is not found: %v", err) + } + return nil +} + +const kubeConfigTemplate = `# Kubeconfig file for nv-ipam CNI plugin. +apiVersion: v1 +kind: Config +clusters: +- name: local + cluster: + server: {{ .KubeConfigHost }} + {{ .KubeServerTLS }} +users: +- name: nv-ipam-node + user: + token: "{{ .KubeServiceAccountToken }}" +contexts: +- name: nv-ipam-node-context + context: + cluster: local + user: nv-ipam-node +current-context: nv-ipam-node-context +` + +const nvIpamConfigTemplate = `{ + "kubeconfig": "{{ .KubeConfigFile }}", + "dataDir": "{{ .NvIpamDataDir }}", + "logFile": "{{ .NvIpamLogFile }}", + "logLevel": "{{ .NvIpamLogLevel }}" +} +` + +func (o *Options) createKubeConfig(currentFileHash []byte) ([]byte, error) { + // check file exists + if _, err := os.Stat(serviceAccountTokenFile); err != nil { + return nil, fmt.Errorf("service account token is not found: %v", err) + } + if _, err := os.Stat(serviceAccountCAFile); err != nil { + return nil, fmt.Errorf("service account ca is not found: %v", err) + } + + // create nv-ipam.d directory + if err := os.MkdirAll(fmt.Sprintf("%s/nv-ipam.d", o.CNIConfDir), 0755); err != nil { + return nil, fmt.Errorf("cannot create nv-ipam.d directory: %v", err) + } + + // get Kubernetes service protocol/host/port + kubeProtocol := os.Getenv("KUBERNETES_SERVICE_PROTOCOL") + if kubeProtocol == "" { + kubeProtocol = "https" + } + kubeHost := os.Getenv("KUBERNETES_SERVICE_HOST") + kubePort := os.Getenv("KUBERNETES_SERVICE_PORT") + + // check tlsConfig + var tlsConfig string + if o.SkipTLSVerify { + tlsConfig = "insecure-skip-tls-verify: true" + } else { + // create tlsConfig by service account CA file + caFileByte, err := os.ReadFile(serviceAccountCAFile) + if err != nil { + return nil, fmt.Errorf("cannot read service account ca file: %v", err) + } + caFileB64 := bytes.ReplaceAll([]byte(b64.StdEncoding.EncodeToString(caFileByte)), []byte("\n"), []byte("")) + tlsConfig = fmt.Sprintf("certificate-authority-data: %s", string(caFileB64)) + } + + saTokenByte, err := os.ReadFile(serviceAccountTokenFile) + if err != nil { + return nil, fmt.Errorf("cannot read service account token file: %v", err) + } + + // create kubeconfig by template and replace it by atomic + tempKubeConfigFile := fmt.Sprintf("%s/nv-ipam.d/nv-ipam.kubeconfig.new", o.CNIConfDir) + nvIPAMKubeConfig := fmt.Sprintf("%s/nv-ipam.d/nv-ipam.kubeconfig", o.CNIConfDir) + fp, err := os.OpenFile(tempKubeConfigFile, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return nil, fmt.Errorf("cannot create kubeconfig temp file: %v", err) + } + + templateKubeconfig, err := template.New("kubeconfig").Parse(kubeConfigTemplate) + if err != nil { + return nil, fmt.Errorf("template parse error: %v", err) + } + templateData := map[string]string{ + "KubeConfigHost": fmt.Sprintf("%s://[%s]:%s", kubeProtocol, kubeHost, kubePort), + "KubeServerTLS": tlsConfig, + "KubeServiceAccountToken": string(saTokenByte), + } + + // Prepare + hash := sha256.New() + writer := io.MultiWriter(hash, fp) + + // genearate kubeconfig from template + if err = templateKubeconfig.Execute(writer, templateData); err != nil { + return nil, fmt.Errorf("cannot create kubeconfig: %v", err) + } + + if err := fp.Sync(); err != nil { + os.Remove(fp.Name()) + return nil, fmt.Errorf("cannot flush kubeconfig temp file: %v", err) + } + if err := fp.Close(); err != nil { + os.Remove(fp.Name()) + return nil, fmt.Errorf("cannot close kubeconfig temp file: %v", err) + } + + newFileHash := hash.Sum(nil) + if currentFileHash != nil && bytes.Equal(newFileHash, currentFileHash) { + log.Printf("kubeconfig is same, not copy\n") + os.Remove(fp.Name()) + return currentFileHash, nil + } + + // replace file with tempfile + if err := os.Rename(tempKubeConfigFile, nvIPAMKubeConfig); err != nil { + return nil, fmt.Errorf("cannot replace %q with temp file %q: %v", nvIPAMKubeConfig, tempKubeConfigFile, err) + } + + log.Printf("kubeconfig is created in %s\n", nvIPAMKubeConfig) + return newFileHash, nil +} +func (o *Options) createNvIpamConfig(currentFileHash []byte) ([]byte, error) { + // create kubeconfig by template and replace it by atomic + tempNvIpamConfigFile := fmt.Sprintf("%s/nv-ipam.d/nv-ipam.conf.new", o.CNIConfDir) + nvIpamConfigFile := fmt.Sprintf("%s/nv-ipam.d/nv-ipam.conf", o.CNIConfDir) + fp, err := os.OpenFile(tempNvIpamConfigFile, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return nil, fmt.Errorf("cannot create nv-ipam.conf temp file: %v", err) + } + + templateNvIpamConfig, err := template.New("nv-ipam-config").Parse(nvIpamConfigTemplate) + if err != nil { + return nil, fmt.Errorf("template parse error: %v", err) + } + + templateData := map[string]string{ + "KubeConfigFile": o.NvIpamKubeConfigFileHost, + "NvIpamDataDir": o.NvIpamCNIDataDirHost, + "NvIpamLogFile": o.NvIpamLogFile, + "NvIpamLogLevel": o.NvIpamLogLevel, + } + + // Prepare + hash := sha256.New() + writer := io.MultiWriter(hash, fp) + + // genearate nv-ipam-config from template + if err = templateNvIpamConfig.Execute(writer, templateData); err != nil { + return nil, fmt.Errorf("cannot create nv-ipam-config: %v", err) + } + + if err := fp.Sync(); err != nil { + os.Remove(fp.Name()) + return nil, fmt.Errorf("cannot flush nv-ipam-config temp file: %v", err) + } + if err := fp.Close(); err != nil { + os.Remove(fp.Name()) + return nil, fmt.Errorf("cannot close nv-ipam-config temp file: %v", err) + } + + newFileHash := hash.Sum(nil) + if currentFileHash != nil && bytes.Equal(newFileHash, currentFileHash) { + log.Printf("nv-ipam-config is same, not copy\n") + os.Remove(fp.Name()) + return currentFileHash, nil + } + + // replace file with tempfile + if err := os.Rename(tempNvIpamConfigFile, nvIpamConfigFile); err != nil { + return nil, fmt.Errorf("cannot replace %q with temp file %q: %v", nvIpamConfigFile, tempNvIpamConfigFile, err) + } + + log.Printf("nv-ipam-config is created in %s\n", nvIpamConfigFile) + return newFileHash, nil +} + func main() { - //nolint:forbidigo - println("IPAM node") + opt := Options{} + opt.addFlags() + helpFlag := pflag.BoolP("help", "h", false, "show help message and quit") + + pflag.Parse() + if *helpFlag { + pflag.PrintDefaults() + os.Exit(1) + } + + err := opt.verifyFileExists() + if err != nil { + log.Printf("%v\n", err) + return + } + + // copy host-local binary + if !opt.SkipHostLocalBinaryCopy { + // Copy + hostLocalCNIBinDir := fmt.Sprintf("%s/bin", opt.NvIpamCNIDataDir) + if err = cmdutils.CopyFileAtomic(opt.HostLocalBinFile, hostLocalCNIBinDir, "_host-local", "host-local"); err != nil { + log.Printf("failed at host-local copy: %v\n", err) + return + } + } + + _, err = opt.createKubeConfig(nil) + if err != nil { + log.Printf("failed to create nv-ipam kubeconfig: %v\n", err) + return + } + log.Printf("kubeconfig file is created.\n") + + _, err = opt.createNvIpamConfig(nil) + if err != nil { + log.Printf("failed to create nv-ipam config file: %v\n", err) + return + } + log.Printf("nv-ipam config file is created.\n") + + nodeName := os.Getenv("NODE_NAME") + err = os.WriteFile(fmt.Sprintf("%s/nv-ipam.d/k8s-node-name", opt.CNIConfDir), []byte(nodeName), 0600) + if err != nil { + log.Printf("failed to create nv-ipam k8s-node-name: %v\n", err) + return + } + log.Printf("k8s-node-name file is created.\n") + + // sleep infinitely + for { + time.Sleep(time.Duration(1<<63 - 1)) + } } diff --git a/deploy/nv-ipam-node.yaml b/deploy/nv-ipam-node.yaml new file mode 100644 index 0000000..194ceb4 --- /dev/null +++ b/deploy/nv-ipam-node.yaml @@ -0,0 +1,101 @@ +--- +kind: ClusterRole +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: nv-ipam-node +rules: + - apiGroups: + - "" + resources: + - nodes + verbs: + - get +--- +kind: ClusterRoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: nv-ipam-node +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: nv-ipam-node +subjects: +- kind: ServiceAccount + name: nv-ipam-node + namespace: kube-system +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: nv-ipam-node + namespace: kube-system +--- +apiVersion: apps/v1 +kind: DaemonSet +metadata: + name: kube-nv-ipam-node-ds + namespace: kube-system + labels: + tier: node + app: nv-ipam-node + name: nv-ipam-node +spec: + selector: + matchLabels: + name: nv-ipam-node + updateStrategy: + type: RollingUpdate + template: + metadata: + labels: + tier: node + app: nv-ipam-node + name: nv-ipam-node + spec: + hostNetwork: true + tolerations: + - operator: Exists + effect: NoSchedule + - operator: Exists + effect: NoExecute + serviceAccountName: nv-ipam-node + containers: + - name: kube-nv-ipam-node + image: ghcr.io/mellanox/nvidia-k8s-ipam:latest + imagePullPolicy: IfNotPresent + env: + - name: NODE_NAME + valueFrom: + fieldRef: + fieldPath: spec.nodeName + command: ["/ipam-node"] + resources: + requests: + cpu: "100m" + memory: "50Mi" + limits: + cpu: "100m" + memory: "50Mi" + securityContext: + privileged: true + volumeMounts: + - name: cni + mountPath: /host/etc/cni/net.d + - name: hostlocalcnibin + mountPath: /host/var/lib/cni/nv-ipam/bin + - name: hostlocalcnistate + mountPath: /host/var/lib/cni/nv-ipam/state/host-local + terminationGracePeriodSeconds: 10 + volumes: + - name: cni + hostPath: + path: /etc/cni/net.d + type: DirectoryOrCreate + - name: hostlocalcnibin + hostPath: + path: /var/lib/cni/nv-ipam/bin + type: DirectoryOrCreate + - name: hostlocalcnistate + hostPath: + path: /var/lib/cni/nv-ipam/state/host-local + type: DirectoryOrCreate diff --git a/go.mod b/go.mod index 48fb13d..73f8c83 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,10 @@ go 1.20 require ( github.com/go-logr/logr v1.2.4 + github.com/onsi/ginkgo/v2 v2.6.0 + github.com/onsi/gomega v1.24.1 github.com/spf13/cobra v1.7.0 + github.com/spf13/pflag v1.0.5 k8s.io/api v0.26.4 k8s.io/apimachinery v0.26.4 k8s.io/client-go v0.26.4 @@ -48,7 +51,6 @@ require ( github.com/prometheus/client_model v0.3.0 // indirect github.com/prometheus/common v0.37.0 // indirect github.com/prometheus/procfs v0.8.0 // indirect - github.com/spf13/pflag v1.0.5 // indirect go.uber.org/atomic v1.7.0 // indirect go.uber.org/multierr v1.6.0 // indirect go.uber.org/zap v1.24.0 // indirect diff --git a/go.sum b/go.sum index 04bd6f8..f63d54b 100644 --- a/go.sum +++ b/go.sum @@ -218,7 +218,9 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8m github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/onsi/ginkgo/v2 v2.6.0 h1:9t9b9vRUbFq3C4qKFCGkVuq/fIHji802N1nrtkh1mNc= +github.com/onsi/ginkgo/v2 v2.6.0/go.mod h1:63DOGlLAH8+REH8jUGdL3YpCpu7JODesutUjdENfUAc= github.com/onsi/gomega v1.24.1 h1:KORJXNNTzJXzu4ScJWssJfJMnJ+2QJqhoQSRwNlze9E= +github.com/onsi/gomega v1.24.1/go.mod h1:3AOiACssS3/MajrniINInwbfOOtfZvplPzuRSmvt1jM= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= diff --git a/pkg/cmdutils/cmdutils_suite_test.go b/pkg/cmdutils/cmdutils_suite_test.go new file mode 100644 index 0000000..f901f8b --- /dev/null +++ b/pkg/cmdutils/cmdutils_suite_test.go @@ -0,0 +1,27 @@ +/* + Copyright 2023, NVIDIA CORPORATION & AFFILIATES + Licensed 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 cmdutils is the package that contains utilities for nv-ipam command +package cmdutils + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "testing" +) + +func TestServer(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "cmdutils") +} diff --git a/pkg/cmdutils/utils.go b/pkg/cmdutils/utils.go new file mode 100644 index 0000000..ddb79ca --- /dev/null +++ b/pkg/cmdutils/utils.go @@ -0,0 +1,83 @@ +/* + Copyright 2023, NVIDIA CORPORATION & AFFILIATES + Licensed 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 cmdutils is the package that contains utilities for multus command +package cmdutils + +import ( + "fmt" + "io" + "os" + "path/filepath" +) + +// CopyFileAtomic does file copy atomically +func CopyFileAtomic(srcFilePath, destDir, tempFileName, destFileName string) error { + tempFilePath := filepath.Join(destDir, tempFileName) + // check temp filepath and remove old file if exists + if _, err := os.Stat(tempFilePath); err == nil { + err = os.Remove(tempFilePath) + if err != nil { + return fmt.Errorf("cannot remove old temp file %q: %v", tempFilePath, err) + } + } + + // create temp file + f, err := os.CreateTemp(destDir, tempFileName) + defer f.Close() //nolint:golint,staticcheck + if err != nil { + return fmt.Errorf("cannot create temp file %q in %q: %v", tempFileName, destDir, err) + } + + srcFile, err := os.Open(srcFilePath) + if err != nil { + return fmt.Errorf("cannot open file %q: %v", srcFilePath, err) + } + defer srcFile.Close() + + // Copy file to tempfile + _, err = io.Copy(f, srcFile) + if err != nil { + f.Close() + os.Remove(tempFilePath) + return fmt.Errorf("cannot write data to temp file %q: %v", tempFilePath, err) + } + if err := f.Sync(); err != nil { + return fmt.Errorf("cannot flush temp file %q: %v", tempFilePath, err) + } + if err := f.Close(); err != nil { + return fmt.Errorf("cannot close temp file %q: %v", tempFilePath, err) + } + + // change file mode if different + destFilePath := filepath.Join(destDir, destFileName) + _, err = os.Stat(destFilePath) + if err != nil && !os.IsNotExist(err) { + return err + } + srcFileStat, err := os.Stat(srcFilePath) + if err != nil { + return err + } + + if err := os.Chmod(f.Name(), srcFileStat.Mode()); err != nil { + return fmt.Errorf("cannot set stat on temp file %q: %v", f.Name(), err) + } + + // replace file with tempfile + if err := os.Rename(f.Name(), destFilePath); err != nil { + return fmt.Errorf("cannot replace %q with temp file %q: %v", destFilePath, tempFilePath, err) + } + + return nil +} diff --git a/pkg/cmdutils/utils_test.go b/pkg/cmdutils/utils_test.go new file mode 100644 index 0000000..143e29c --- /dev/null +++ b/pkg/cmdutils/utils_test.go @@ -0,0 +1,69 @@ +/* + Copyright 2023, NVIDIA CORPORATION & AFFILIATES + Licensed 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 cmdutils is the package that contains utilities for multus command +package cmdutils + +import ( + "fmt" + "os" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("thin entrypoint testing", func() { + It("Run CopyFileAtomic()", func() { + // create directory and files + tmpDir, err := os.MkdirTemp("", "multus_thin_entrypoint_tmp") + Expect(err).NotTo(HaveOccurred()) + + // create source directory + srcDir := fmt.Sprintf("%s/src", tmpDir) + err = os.Mkdir(srcDir, 0755) + Expect(err).NotTo(HaveOccurred()) + + // create destination directory + destDir := fmt.Sprintf("%s/dest", tmpDir) + err = os.Mkdir(destDir, 0755) + Expect(err).NotTo(HaveOccurred()) + + // sample source file + srcFilePath := fmt.Sprintf("%s/sampleInput", srcDir) + err = os.WriteFile(srcFilePath, []byte("sampleInputABC"), 0744) + Expect(err).NotTo(HaveOccurred()) + + // old files in dest + destFileName := "sampleInputDest" + destFilePath := fmt.Sprintf("%s/%s", destDir, destFileName) + err = os.WriteFile(destFilePath, []byte("inputOldXYZ"), 0611) + Expect(err).NotTo(HaveOccurred()) + + tempFileName := "temp_file" + err = CopyFileAtomic(srcFilePath, destDir, tempFileName, destFileName) + Expect(err).NotTo(HaveOccurred()) + + // check file mode + stat, err := os.Stat(destFilePath) + Expect(err).NotTo(HaveOccurred()) + Expect(stat.Mode()).To(Equal(os.FileMode(0744))) + + // check file contents + destFileByte, err := os.ReadFile(destFilePath) + Expect(err).NotTo(HaveOccurred()) + Expect(destFileByte).To(Equal([]byte("sampleInputABC"))) + + err = os.RemoveAll(tmpDir) + Expect(err).NotTo(HaveOccurred()) + }) +})