mirror of
https://github.com/sourcegraph/sourcegraph.git
synced 2026-02-06 15:51:43 +00:00
Part of https://github.com/sourcegraph/managed-services/issues/599 See https://github.com/sourcegraph/managed-services/pull/1288 for how this mechanism will be used. ## Test plan ```sh sg msp generate -all -category=test ``` 
329 lines
12 KiB
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
|
|
}
|