sourcegraph/dev/sg/msp/helpers.go

329 lines
12 KiB
Go

package msp
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"github.com/urfave/cli/v2"
"golang.org/x/exp/maps"
"github.com/sourcegraph/sourcegraph/dev/managedservicesplatform"
"github.com/sourcegraph/sourcegraph/dev/managedservicesplatform/clouddeploy"
"github.com/sourcegraph/sourcegraph/dev/managedservicesplatform/operationdocs/terraform"
"github.com/sourcegraph/sourcegraph/dev/managedservicesplatform/spec"
"github.com/sourcegraph/sourcegraph/dev/managedservicesplatform/stacks/cloudrun"
"github.com/sourcegraph/sourcegraph/dev/managedservicesplatform/terraformcloud"
"github.com/sourcegraph/sourcegraph/dev/sg/internal/std"
msprepo "github.com/sourcegraph/sourcegraph/dev/sg/msp/repo"
"github.com/sourcegraph/sourcegraph/lib/errors"
"github.com/sourcegraph/sourcegraph/lib/output"
"github.com/sourcegraph/sourcegraph/lib/pointers"
)
// useServiceArgument retrieves the service spec corresponding to the first
// argument.
//
// 'exact' indicates that no additional arguments are expected.
func useServiceArgument(c *cli.Context, exact bool) (*spec.Spec, error) {
// If we can successfully load the list of services, provide the
// list as feedback for the user
allServices, _ := msprepo.ListServices()
serviceID := c.Args().First()
if serviceID == "" {
if len(allServices) > 0 {
return nil, errors.Newf("argument service is required, available services: [%s]",
strings.Join(allServices, ", "))
}
return nil, errors.New("argument service is required")
}
serviceSpecPath := msprepo.ServiceYAMLPath(serviceID)
s, err := spec.Open(serviceSpecPath)
if err != nil {
if errors.Is(err, spec.ErrServiceDoesNotExist) {
if len(allServices) > 0 {
return nil, errors.Newf("service %q does not exist, available services: [%s]",
serviceID, strings.Join(allServices, ", "))
}
return nil, errors.Newf("service %q does not exist", serviceID)
}
return nil, errors.Wrapf(err, "load service %q", serviceID)
}
// Arg 0 is service, arg 1 is environment - any additional arguments are
// unexpected if we are getting exact arguments.
if exact && c.Args().Get(1) != "" {
return s, errors.Newf("got unexpected additional arguments %q - note that flags must be placed BEFORE arguments, i.e. '<flags> <arguments>'",
strings.Join(c.Args().Slice()[1:], " "))
}
return s, nil
}
// useServiceAndEnvironmentArguments retrieves the service and environment specs
// corresponding to the first and second arguments respectively. It should only
// be used if both arguments are required.
func useServiceAndEnvironmentArguments(c *cli.Context, exact bool) (*spec.Spec, *spec.EnvironmentSpec, error) {
svc, err := useServiceArgument(c, false)
if err != nil {
return nil, nil, err
}
environmentID := c.Args().Get(1)
if environmentID == "" {
return svc, nil, errors.Newf("second argument <environment ID> is required, available environments for service %q: [%s]",
svc.Service.ID, strings.Join(svc.ListEnvironmentIDs(), ", "))
}
env := svc.GetEnvironment(environmentID)
if env == nil {
return svc, nil, errors.Newf("environment %q not found in the %q service spec, available environments: [%s]",
environmentID, svc.Service.ID, strings.Join(svc.ListEnvironmentIDs(), ", "))
}
// Arg 0 is service, arg 1 is environment - any additional arguments are
// unexpected if we are getting exact arguments.
if exact && c.Args().Get(2) != "" {
return svc, env, errors.Newf("got unexpected additional arguments %q - note that flags must be placed BEFORE arguments, i.e. '<flags> <arguments>'",
strings.Join(c.Args().Slice()[2:], " "))
}
return svc, env, nil
}
func syncEnvironmentWorkspaces(c *cli.Context, tfc *terraformcloud.Client, service spec.ServiceSpec, env spec.EnvironmentSpec) error {
if c.Bool("delete") {
if !pointers.DerefZero(env.AllowDestroys) {
return errors.Newf("environments[%s].allowDestroys must be 'true' to delete workspaces", env.ID)
}
std.Out.Promptf("[%s] Deleting workspaces for environment %q - are you sure? (y/N) ",
service.ID, env.ID)
var input string
if _, err := fmt.Scan(&input); err != nil {
return err
}
if input != "y" {
return errors.New("aborting")
}
pending := std.Out.Pending(output.Styledf(output.StylePending,
"[%s] Deleting Terraform Cloud workspaces for environment %q", service.ID, env.ID))
// Destroy stacks in reverse order
stacks := managedservicesplatform.StackNames()
slices.Reverse(stacks)
if errs := tfc.DeleteWorkspaces(c.Context, service, env, stacks); len(errs) > 0 {
for _, err := range errs {
std.Out.WriteWarningf(err.Error())
}
return errors.New("some errors occurred when deleting workspaces")
}
pending.Complete(output.Styledf(output.StyleSuccess,
"[%s] Deleting Terraform Cloud workspaces for environment %q", service.ID, env.ID))
return nil // exit early for deletion, we are done
}
pending := std.Out.Pending(output.Styledf(output.StylePending,
"[%s] Synchronizing Terraform Cloud workspaces for environment %q", service.ID, env.ID))
workspaces, err := tfc.SyncWorkspaces(c.Context, service, env, managedservicesplatform.StackNames())
if err != nil {
return errors.Wrap(err, "sync Terraform Cloud workspace")
}
pending.Complete(output.Styledf(output.StyleSuccess,
"[%s] Synchronized Terraform Cloud workspaces for environment %q", service.ID, env.ID))
var summary strings.Builder
for _, ws := range workspaces {
summary.WriteString(fmt.Sprintf("- %s: %s", ws.Name, ws.URL()))
if ws.Created {
summary.WriteString(" (created)")
} else {
summary.WriteString(" (updated)")
}
summary.WriteString("\n")
}
return std.Out.WriteMarkdown(summary.String())
}
type generateTerraformOptions struct {
// targetEnv generates the specified env only, otherwise generates all
targetEnv string
// targetCategory generates the specified category only
targetCategory spec.EnvironmentCategory
// stableGenerate disables updating of any values that are evaluated at
// generation time
stableGenerate bool
}
func generateTerraform(service *spec.Spec, opts generateTerraformOptions) error {
serviceID := service.Service.ID
serviceSpecPath := msprepo.ServiceYAMLPath(serviceID)
var envs []spec.EnvironmentSpec
if opts.targetEnv != "" {
deployEnv := service.GetEnvironment(opts.targetEnv)
if deployEnv == nil {
return errors.Newf("environment %q not found in service spec", opts.targetEnv)
}
envs = append(envs, *deployEnv)
} else {
envs = service.Environments
}
for _, env := range envs {
env := env
if opts.targetCategory != "" && env.Category != opts.targetCategory {
// Quietly skip environments that don't match specified category
std.Out.WriteLine(output.StyleSuggestion.Linef(
"[%s] Skipping non-%q environment %q (category %q)",
serviceID, opts.targetCategory, env.ID, env.Category))
continue
}
pending := std.Out.Pending(output.StylePending.Linef(
"[%s] Preparing Terraform for %q environment %q", serviceID, env.Category, env.ID))
renderer := managedservicesplatform.Renderer{
OutputDir: filepath.Join(filepath.Dir(serviceSpecPath), "terraform", env.ID),
StableGenerate: opts.stableGenerate,
}
// CDKTF needs the output dir to exist ahead of time, even for
// rendering. If it doesn't exist yet, create it
if f, err := os.Lstat(renderer.OutputDir); err != nil {
if !os.IsNotExist(err) {
return errors.Wrap(err, "check output directory")
}
if err := os.MkdirAll(renderer.OutputDir, 0755); err != nil {
return errors.Wrap(err, "prepare output directory")
}
} else if !f.IsDir() {
return errors.Newf("output directory %q is not a directory", renderer.OutputDir)
}
// Render environment
cdktf, err := renderer.RenderEnvironment(*service, env)
if err != nil {
return err
}
pending.Updatef("[%s] Generating Terraform assets in %q for %q environment %q...",
serviceID, renderer.OutputDir, env.Category, env.ID)
if err := cdktf.Synthesize(); err != nil {
return err
}
if rollout := service.BuildRolloutPipelineConfiguration(env); rollout != nil {
pending.Updatef("[%s] Building rollout pipeline configurations for environment %q...", serviceID, env.ID)
// First, we generate the Cloud Deploy configuration file with
// additional configuration for the rollout pipeline that we can't
// yet provide with Terraform. In the future, we can hopefully
// replace this with a pure-Terraform version.
region := cloudrun.GCPRegion // region is currently fixed
deploySpec, err := clouddeploy.RenderSpec(
service.Service,
service.Build,
*rollout,
region)
if err != nil {
return errors.Wrap(err, "render Cloud Deploy configuration file")
}
deploySpecFilename := fmt.Sprintf("rollout-%s.clouddeploy.yaml", region)
comment := generateCloudDeployDocstring(env.ProjectID, serviceID, region, deploySpecFilename)
if err := os.WriteFile(
filepath.Join(filepath.Dir(serviceSpecPath), deploySpecFilename),
append([]byte(comment), deploySpec.Bytes()...),
0644,
); err != nil {
return errors.Wrap(err, "write Cloud Deploy configuration file")
}
// Next, we generate skaffold.yaml archive for upload to GCS. See
// cloudrun.ScaffoldSourceFile docstring for more on why we need
// to generate this separately. This step will likely always be
// rquired.
skaffoldObject, err := clouddeploy.NewCloudRunCustomTargetSkaffoldAssetsArchive()
if err != nil {
return errors.Wrap(err, "create Cloud Deploy custom target skaffold YAML archive")
}
skaffoldObjectPath := filepath.Join(renderer.OutputDir, "stacks/cloudrun", cloudrun.ScaffoldSourceFile)
if err := os.WriteFile(skaffoldObjectPath, skaffoldObject.Bytes(), 0644); err != nil {
return errors.Wrap(err, "write Cloud Run custom target skaffold YAML archive")
}
}
pending.Complete(output.Styledf(output.StyleSuccess,
"[%s] Infrastructure assets generated in %q!", serviceID, renderer.OutputDir))
}
return nil
}
func isHandbookRepo(relPath string) error {
path, err := filepath.Abs(relPath)
if err != nil {
return errors.Wrapf(err, "unable to infer absolute path of %q", relPath)
}
// https://sourcegraph.com/github.com/sourcegraph/handbook/-/blob/package.json?L2=
const handbookPackageName = "@sourcegraph/handbook.sourcegraph.com"
packageJSONData, err := os.ReadFile(filepath.Join(path, "package.json"))
if err != nil {
return errors.Wrap(err, "expected package.json")
}
var packageJSON struct {
Name string `json:"name"`
}
if err := json.Unmarshal(packageJSONData, &packageJSON); err != nil {
return errors.Wrap(err, "parse package.json")
}
if packageJSON.Name == handbookPackageName {
return nil
}
return errors.Newf("unexpected package %q", packageJSON.Name)
}
func generateCloudDeployDocstring(projectID, serviceID, gcpRegion, cloudDeployFilename string) string {
return fmt.Sprintf(`# DO NOT EDIT; generated by 'sg msp generate'
#
# This file defines additional Cloud Deploy configuration that is not yet available in Terraform.
# Apply this using the following command:
#
# gcloud deploy apply --project=%[1]s --region=%[3]s --file=%[4]s
#
# Releases can be created using the following command, which can be added to CI pipelines:
#
# gcloud deploy releases create $RELEASE_NAME --labels="commit=$COMMIT,author=$AUTHOR" --deploy-parameters="customTarget/tag=$TAG" --project=%[1]s --region=%[3]s --delivery-pipeline=%[2]s-%[3]s-rollout --source='gs://%[1]s-cloudrun-skaffold/source.tar.gz'
#
# The secret 'cloud_deploy_releaser_service_account_id' provides the ID of a service account
# that can be used to provision workload auth, for example https://sourcegraph.sourcegraph.com/github.com/sourcegraph/infrastructure/-/blob/managed-services/continuous-deployment-pipeline/main.tf?L5-20
`, // TODO improve the releases DX
projectID, serviceID, gcpRegion, cloudDeployFilename)
}
func CollectAlertPolicies(svc *spec.Spec) (map[string]terraform.AlertPolicy, error) {
// Deduplicate alerts across environments into a single map
collectedAlerts := make(map[string]terraform.AlertPolicy)
for _, env := range svc.ListEnvironmentIDs() {
// Parse the generated alert policies to create alerting docs
monitoringPath := msprepo.ServiceStackCDKTFPath(svc.Service.ID, env, "monitoring")
monitoring, err := terraform.ParseMonitoringCDKTF(monitoringPath)
if err != nil {
return nil, err
}
maps.Copy(collectedAlerts, monitoring.ResourceType.GoogleMonitoringAlertPolicy)
}
return collectedAlerts, nil
}