pkg/modulewriter/modulewriter.go (347 lines of code) (raw):
/**
* Copyright 2022 Google LLC
*
* 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 modulewriter writes modules to a deployment directory
package modulewriter
import (
"crypto/md5"
"embed"
"encoding/hex"
"errors"
"fmt"
"hpc-toolkit/pkg/config"
"hpc-toolkit/pkg/deploymentio"
"hpc-toolkit/pkg/logging"
"hpc-toolkit/pkg/sourcereader"
"io"
"os"
"path"
"path/filepath"
"github.com/hashicorp/go-getter"
"github.com/otiai10/copy"
)
// strings that get re-used throughout this package and others
const (
HiddenGhpcDirName = ".ghpc"
ArtifactsDirName = "artifacts"
ExpandedBlueprintName = "expanded_blueprint.yaml"
prevGroupDirName = "previous_deployment_groups"
gitignoreTemplate = "deployment.gitignore.tmpl"
artifactsWarningFilename = "DO_NOT_MODIFY_THIS_DIRECTORY"
)
func HiddenGhpcDir(deplDir string) string {
return filepath.Join(filepath.Clean(deplDir), HiddenGhpcDirName)
}
func ArtifactsDir(deplDir string) string {
return filepath.Join(HiddenGhpcDir(deplDir), ArtifactsDirName)
}
// ModuleWriter interface for writing modules to a deployment
type ModuleWriter interface {
writeGroup(
bp config.Blueprint,
grpIdx int,
groupPath string,
instructionsFile io.Writer,
) error
restoreState(deploymentDir string) error
kind() config.ModuleKind
}
var kinds = map[config.ModuleKind]ModuleWriter{
config.TerraformKind: new(TFWriter),
config.PackerKind: new(PackerWriter),
}
//go:embed *.tmpl
var templatesFS embed.FS
// WriteDeployment writes a deployment directory using modules defined the environment blueprint.
func WriteDeployment(bp config.Blueprint, deploymentDir string) error {
expanded := bp.Clone() // clone to avoid modifying the original blueprint
// TODO: probably not a right place to do "materialize". Consider bubbling it up.
if err := bp.Materialize(); err != nil {
return err
}
if err := prepDepDir(deploymentDir); err != nil {
return err
}
if err := stageFiles(bp, deploymentDir); err != nil {
return err
}
instructions, err := os.Create(InstructionsPath(deploymentDir))
if err != nil {
return err
}
defer instructions.Close()
fmt.Fprintln(instructions, "Advanced Deployment Instructions")
fmt.Fprintln(instructions, "================================")
for ig := range bp.Groups {
if err := writeGroup(deploymentDir, bp, ig, instructions); err != nil {
return err
}
}
writeDestroyInstructions(instructions, bp, deploymentDir)
if err := writeExpandedBlueprint(deploymentDir, expanded); err != nil {
return err
}
for _, writer := range kinds {
if err := writer.restoreState(deploymentDir); err != nil {
return fmt.Errorf("error trying to restore terraform state: %w", err)
}
}
return nil
}
func stageFiles(bp config.Blueprint, deplPath string) error {
staged := bp.StagedFiles()
if len(staged) == 0 {
return nil
}
// create staging directory
if err := os.MkdirAll(filepath.Join(deplPath, config.StagingDir), 0700); err != nil {
return err
}
errs := config.Errors{}
for _, f := range staged {
// TODO: attribute error to the position in the blueprint
errs.Add(stageFile(deplPath, f))
}
return errs.OrNil()
}
func doesExists(path string) (bool, error) {
_, err := os.Lstat(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return false, nil
}
return false, err
}
return true, nil
}
func stageFile(deplPath string, f config.StagedFile) error {
// RelDst is relative to group folders ("../.ghpc/staged"),
// prepend any_group_dir to be "eaten" by ".."
dst := filepath.Join(deplPath, "any_group_dir", f.RelDst)
dstExists, err := doesExists(dst)
if err != nil {
return err
}
srcExists, err := doesExists(f.AbsSrc)
if err != nil {
return err
}
if !srcExists && !dstExists {
return fmt.Errorf("file for staging %s does not exists", f.AbsSrc)
}
if !srcExists && dstExists {
// We implement this relaxation for cases where user does not have access to the original blueprint,
// and does re-creation using expanded blueprint.
logging.Error("WARNING: file %s does not exists, proceeding by using previously staged copy", f.AbsSrc)
return nil
}
return copy.Copy(f.AbsSrc, dst)
}
func writeGroup(deplPath string, bp config.Blueprint, gIdx int, instructions io.Writer) error {
g := bp.Groups[gIdx]
gPath, err := createGroupDir(deplPath, g)
if err != nil {
return err
}
if err := copyGroupSources(gPath, g); err != nil {
return err
}
writer, ok := kinds[g.Kind()]
if !ok {
return fmt.Errorf("invalid kind in deployment group %q, got %q", g.Name, g.Kind())
}
if err := writer.writeGroup(bp, gIdx, gPath, instructions); err != nil {
return fmt.Errorf("error writing deployment group %s: %w", g.Name, err)
}
return nil
}
// InstructionsPath returns the path to the instructions file for a deployment
func InstructionsPath(deploymentDir string) string {
return filepath.Join(deploymentDir, "instructions.txt")
}
func createGroupDir(deplPath string, g config.Group) (string, error) {
gPath := filepath.Join(deplPath, string(g.Name))
// Create the deployment group directory if not already created.
if _, err := os.Stat(gPath); errors.Is(err, os.ErrNotExist) {
if err := os.Mkdir(gPath, 0755); err != nil {
return "", fmt.Errorf("failed to create directory at %s for deployment group %s: err=%w", gPath, g.Name, err)
}
}
return gPath, nil
}
// DeploymentSource returns module source within deployment group
// Rules are following:
// - remote source
// = terraform => <mod.Source>
// = packer => <mod.ID>/<package_subdir>
// - packer
// => <mod.ID>
// - embedded (source starts with "modules" or "community/modules")
// => ./modules/embedded/<mod.Source>
// - other
// => ./modules/<basename(mod.Source)>-<hash(abs(mod.Source))>
func DeploymentSource(mod config.Module) (string, error) {
switch mod.Kind {
case config.TerraformKind:
return tfDeploymentSource(mod)
case config.PackerKind:
return packerDeploymentSource(mod), nil
default:
return "", fmt.Errorf("unexpected module kind %#v", mod.Kind)
}
}
// TODO: attribute error to Blueprint position
func tfDeploymentSource(mod config.Module) (string, error) {
switch {
case sourcereader.IsEmbeddedPath(mod.Source):
return "./modules/" + filepath.Join("embedded", mod.Source), nil
case sourcereader.IsLocalPath(mod.Source):
abs, err := filepath.Abs(mod.Source)
if err != nil {
return "", fmt.Errorf("failed to get absolute path for %#v: %v", mod.Source, err)
}
base := filepath.Base(mod.Source)
return fmt.Sprintf("./modules/%s-%s", base, shortHash(abs)), nil
default:
return mod.Source, nil
}
}
func packerDeploymentSource(mod config.Module) string {
if sourcereader.IsRemotePath(mod.Source) {
_, subDir := getter.SourceDirSubdir(mod.Source)
return filepath.Join(string(mod.ID), subDir)
}
return string(mod.ID)
}
// Returns first 4 characters of md5 sum in hex form
func shortHash(s string) string {
h := md5.Sum([]byte(s))
return hex.EncodeToString(h[:])[:4]
}
func copyEmbeddedModules(base string) error {
r := sourcereader.EmbeddedSourceReader{}
for _, src := range []string{"modules", "community/modules"} {
dst := filepath.Join(base, "modules/embedded", src)
if err := os.MkdirAll(dst, 0755); err != nil {
return err
}
if err := r.CopyDir(src, dst); err != nil {
return err
}
}
return nil
}
func copyGroupSources(gPath string, g config.Group) error {
var copyEmbedded = false
for iMod := range g.Modules {
mod := &g.Modules[iMod]
deplSource, err := DeploymentSource(*mod)
if err != nil {
return err
}
if mod.Kind == config.TerraformKind {
// some terraform modules do not require copying
if sourcereader.IsEmbeddedPath(mod.Source) {
copyEmbedded = true
continue // all embedded terraform modules fill be copied at once
}
if sourcereader.IsRemotePath(mod.Source) {
continue // will be downloaded by terraform
}
}
/* Copy source files */
var src, dst string
if sourcereader.IsRemotePath(mod.Source) && mod.Kind == config.PackerKind {
src, _ = getter.SourceDirSubdir(mod.Source)
dst = filepath.Join(gPath, string(mod.ID))
} else {
src = mod.Source
dst = filepath.Join(gPath, deplSource)
}
if _, err := os.Stat(dst); err == nil {
continue
}
reader := sourcereader.Factory(src)
if err := reader.GetModule(src, dst); err != nil {
return fmt.Errorf("failed to get module from %s to %s: %w", src, dst, err)
}
// remove .git directory if one exists; we do not want submodule
// git history in deployment directory
if err := os.RemoveAll(filepath.Join(dst, ".git")); err != nil {
return err
}
}
if copyEmbedded {
if err := copyEmbeddedModules(gPath); err != nil {
return fmt.Errorf("failed to copy embedded modules: %w", err)
}
}
return nil
}
// Prepares a deployment directory to be written to.
func prepDepDir(depDir string) error {
deploymentio := deploymentio.GetDeploymentioLocal()
ghpcDir := HiddenGhpcDir(depDir)
// create deployment directory
if err := deploymentio.CreateDirectory(depDir); err != nil {
// Confirm we have a previously written deployment dir before overwriting.
if _, err := os.Stat(ghpcDir); os.IsNotExist(err) {
return fmt.Errorf("while trying to update the deployment directory at %s, the '.ghpc/' dir could not be found", depDir)
}
} else {
if err := deploymentio.CreateDirectory(ghpcDir); err != nil {
return fmt.Errorf("failed to create directory at %s: err=%w", ghpcDir, err)
}
gitignoreFile := filepath.Join(depDir, ".gitignore")
if err := deploymentio.CopyFromFS(templatesFS, gitignoreTemplate, gitignoreFile); err != nil {
return fmt.Errorf("failed to copy template.gitignore file to %s: err=%w", gitignoreFile, err)
}
}
if err := prepArtifactsDir(ArtifactsDir(depDir)); err != nil {
return err
}
// remove any existing backups of deployment group
prevGroupDir := filepath.Join(ghpcDir, prevGroupDirName)
os.RemoveAll(prevGroupDir)
if err := os.MkdirAll(prevGroupDir, 0755); err != nil {
return fmt.Errorf("failed to create directory to save previous deployment groups at %s: %w", prevGroupDir, err)
}
// create new backup of deployment group directory
files, err := os.ReadDir(depDir)
if err != nil {
return fmt.Errorf("error trying to read directories in %s, %w", depDir, err)
}
for _, f := range files {
if !f.IsDir() || f.Name() == HiddenGhpcDirName {
continue
}
src := filepath.Join(depDir, f.Name())
dest := filepath.Join(prevGroupDir, f.Name())
if err := os.Rename(src, dest); err != nil {
return fmt.Errorf("error while moving previous deployment groups: failed on %s: %w", f.Name(), err)
}
}
return nil
}
func prepArtifactsDir(artifactsDir string) error {
// cleanup previous artifacts on every write
if err := os.RemoveAll(artifactsDir); err != nil {
return fmt.Errorf(
"error while removing the artifacts directory at %s; %s", artifactsDir, err.Error())
}
if err := os.MkdirAll(artifactsDir, 0700); err != nil {
return err
}
artifactsWarningFile := path.Join(artifactsDir, artifactsWarningFilename)
f, err := os.Create(artifactsWarningFile)
if err != nil {
return err
}
defer f.Close()
_, err = f.WriteString(artifactsWarning)
return err
}
func writeExpandedBlueprint(depDir string, bp config.Blueprint) error {
return bp.Export(filepath.Join(ArtifactsDir(depDir), ExpandedBlueprintName))
}
func writeDestroyInstructions(w io.Writer, bp config.Blueprint, deploymentDir string) {
packerManifests := []string{}
fmt.Fprintln(w)
fmt.Fprintln(w, "Destroying infrastructure when no longer needed")
fmt.Fprintln(w, "===============================================")
fmt.Fprintln(w)
fmt.Fprintln(w, "Automated")
fmt.Fprintln(w, "---------")
fmt.Fprintln(w)
fmt.Fprintf(w, "gcluster destroy %s\n", deploymentDir)
fmt.Fprintln(w)
fmt.Fprintln(w, "Advanced / Manual")
fmt.Fprintln(w, "-----------------")
fmt.Fprintln(w, "Infrastructure should be destroyed in reverse order of creation:")
fmt.Fprintln(w)
for grpIdx := len(bp.Groups) - 1; grpIdx >= 0; grpIdx-- {
grp := bp.Groups[grpIdx]
grpPath := filepath.Join(deploymentDir, string(grp.Name))
if grp.Kind() == config.TerraformKind {
fmt.Fprintf(w, "terraform -chdir=%s destroy\n", grpPath)
}
if grp.Kind() == config.PackerKind {
packerManifests = append(packerManifests, filepath.Join(grpPath, string(grp.Modules[0].ID), "packer-manifest.json"))
}
}
WritePackerDestroyInstructions(w, packerManifests)
}
// WritePackerDestroyInstructions prints our best effort guidance to the user on
// deleting images produced by Packer; must improve definition of Packer outputs
func WritePackerDestroyInstructions(w io.Writer, manifests []string) {
if len(manifests) == 0 {
return
}
fmt.Fprintln(w)
fmt.Fprintf(w, "Please browse to the Cloud Console to remove VM images produced by Packer.\n")
fmt.Fprintln(w, "If this file is present, the names of images can be read from it:")
fmt.Fprintln(w)
for _, manifest := range manifests {
fmt.Fprintln(w, manifest)
}
fmt.Fprintln(w)
fmt.Fprintln(w, "https://console.cloud.google.com/compute/images")
}