msp: generate projectID up-front and persist in spec (#59220)

This is a big diff, but they all tie together, so hear me out:

The only way to get the project ID right now is to query the appropriate Terraform Cloud workspace outputs. However, to do that, you need access to `sourcegraph-secrets`, to get the appropriate TFC access token.

This is awkward because as an operator, you would follow the instructions to request `mspServiceEditor` on your desired project - but now, to use various MSP tooling like `sg msp pg connect`, you must _also_ request access to `sourcegraph-secrets`, so that we can get a TFC token to find the project ID and other stuff. Because we might have a large number of services it's not feasible to manually set up Entitle bundles (they cannot be programmatically created).

The approach I want to take is to copy the MSP team TFC token from `sourcegraph-secrets` into each individual MSP environment project. Then, we can get the MSP team TFC token from the _environment_ project instead, access for which will be granted by the `mspServiceEditor` role. To do this however, we must know the project ID up front. So this PR makes the following changes:

1. Makes it so that the randomized project ID isn't managed by Terraform, but generated statically, and configured in `environments[].projectID`.
2. This requires changes to `sg msp init` to create a project ID the same way we create it in-Terraform today, but in addition to service initialization, we must now also have tooling to start configuration a new environment as well, so that we can generate a project ID for the operator. This is done via a new command, `sg msp init-env`, which inserts a new environment into a service spec.
   - MSP service specs are intended to be operator-written and hopefully include lots of docstrings on configuration, so we take special care to preserve formatting and comments by manipulating `yaml.Node` directly.
4. In order to use `yaml.Node`, however, we must switch over to `gopkg.in/yaml.v3` - previously, we used the K8S YAML library, mostly as a carry-over from what is used in Cloud. In order to use `gopkg.in/yaml.v3`, we need to:
   - Replace all `json` struct tags with `yaml`, as the YAML library does not support JSON tags
   - Upgrade `github.com/invopop/jsonschema` so that we can point the JSON schema generator to use the `yaml` tags as well
5. Now that we have `projectID` statically available, we can remove code that queries TFC workspaces for the project ID and replace them with references to the spec instead.

## Test plan

1. Unit tests on `sg msp init`'s generated output
2. Unit tests on inserting environment
3. Unit tests on project ID generator
4. https://github.com/sourcegraph/managed-services/pull/295
This commit is contained in:
Robert Lin 2023-12-22 17:25:40 -08:00 committed by GitHub
parent 09536d25d3
commit 4a640b5e96
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 563 additions and 204 deletions

View File

@ -3406,13 +3406,6 @@ def go_dependencies():
sum = "h1:JR7eDj8HD6eXrc5fWLbSUnfcQFL06PYvCc0DKQnWfaU=",
version = "v1.0.0",
)
go_repository(
name = "com_github_iancoleman_orderedmap",
build_file_proto_mode = "disable_global",
importpath = "github.com/iancoleman/orderedmap",
sum = "h1:sq1N/TFpYH++aViPcaKjys3bDClUEU7s5B+z6jq8pNA=",
version = "v0.2.0",
)
go_repository(
name = "com_github_iancoleman_strcase",
build_file_proto_mode = "disable_global",
@ -3459,8 +3452,8 @@ def go_dependencies():
name = "com_github_invopop_jsonschema",
build_file_proto_mode = "disable_global",
importpath = "github.com/invopop/jsonschema",
sum = "h1:2vgQcBz1n256N+FpX3Jq7Y17AjYt46Ig3zIWyy770So=",
version = "v0.7.0",
sum = "h1:6ovsNSuvn9wEQVOyc72aycBMVQFKz7cPdMJn10CvzRI=",
version = "v0.12.0",
)
go_repository(
name = "com_github_ionos_cloud_sdk_go_v6",
@ -6151,8 +6144,8 @@ def go_dependencies():
name = "com_github_wk8_go_ordered_map_v2",
build_file_proto_mode = "disable_global",
importpath = "github.com/wk8/go-ordered-map/v2",
sum = "h1:jLbYIFyWQMUwHLO20cImlCRBoNc5lp0nmE2dvwcxc7k=",
version = "v2.1.5",
sum = "h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=",
version = "v2.1.8",
)
go_repository(
name = "com_github_wsxiaoys_terminal",

View File

@ -6,13 +6,11 @@ go_library(
importpath = "github.com/sourcegraph/sourcegraph/dev/managedservicesplatform/internal/stack/project",
visibility = ["//dev/managedservicesplatform:__subpackages__"],
deps = [
"//dev/managedservicesplatform/internal/resource/random",
"//dev/managedservicesplatform/internal/resourceid",
"//dev/managedservicesplatform/internal/stack",
"//dev/managedservicesplatform/internal/stack/options/googleprovider",
"//dev/managedservicesplatform/internal/stack/options/randomprovider",
"//dev/managedservicesplatform/spec",
"//lib/errors",
"//lib/pointers",
"@com_github_aws_jsii_runtime_go//:jsii-runtime-go",
"@com_github_grafana_regexp//:regexp",

View File

@ -9,13 +9,11 @@ import (
"github.com/sourcegraph/managed-services-platform-cdktf/gen/google/project"
"github.com/sourcegraph/managed-services-platform-cdktf/gen/google/projectservice"
"github.com/sourcegraph/sourcegraph/dev/managedservicesplatform/internal/resource/random"
"github.com/sourcegraph/sourcegraph/dev/managedservicesplatform/internal/resourceid"
"github.com/sourcegraph/sourcegraph/dev/managedservicesplatform/internal/stack"
"github.com/sourcegraph/sourcegraph/dev/managedservicesplatform/internal/stack/options/googleprovider"
"github.com/sourcegraph/sourcegraph/dev/managedservicesplatform/internal/stack/options/randomprovider"
"github.com/sourcegraph/sourcegraph/dev/managedservicesplatform/spec"
"github.com/sourcegraph/sourcegraph/lib/errors"
"github.com/sourcegraph/sourcegraph/lib/pointers"
)
@ -65,13 +63,9 @@ type CrossStackOutput struct {
}
type Variables struct {
// ProjectIDPrefix is the prefix for a project ID. A suffix of the format
// '-${randomizedSuffix}' will be added, as project IDs must be unique.
ProjectIDPrefix string
// ProjectIDSuffixLength is the length of the randomized suffix added to
// to the project.
ProjectIDSuffixLength *int
// ProjectIDPrefix is the generated project ID. A suffix of the format
// '-${randomizedSuffix}' should be added, as project IDs must be unique.
ProjectID string
// DisplayName is a display name for the project. It does not need to be unique.
DisplayName string
@ -92,46 +86,25 @@ type Variables struct {
const StackName = "project"
const (
// https://cloud.google.com/resource-manager/reference/rest/v1/projects
projectIDMaxLength = 30
projectIDRandomizedSuffixByteLength = 2 // real length 4
projectIDMinRandomizedSuffixLength = 2
)
// NewStack creates a stack that provisions a GCP project.
func NewStack(stacks *stack.Set, vars Variables) (*CrossStackOutput, error) {
stack, locals, err := stacks.New(StackName,
randomprovider.With(),
// ID is not known ahead of time, we can omit it
// The project we want might not exist yet, so omit it when initializing
// the provider.
googleprovider.With(""))
if err != nil {
return nil, err
}
// Name all stack resources after the desired project ID
id := resourceid.New(vars.ProjectIDPrefix)
// The project ID must leave room for a randomized suffix and a separator.
suffixByteLength := projectIDRandomizedSuffixByteLength
if vars.ProjectIDSuffixLength != nil {
suffixByteLength = *vars.ProjectIDSuffixLength / 2
}
realSuffixLength := suffixByteLength * 2 // after converting to hex
if afterSuffixLength := len(vars.ProjectIDPrefix) + 1 + realSuffixLength; afterSuffixLength > projectIDMaxLength {
return nil, errors.Newf("project ID prefix %q is too long after adding random suffix (%d characters) - got %d characters, but maximum is %d characters",
vars.ProjectIDPrefix, projectIDRandomizedSuffixByteLength, afterSuffixLength, projectIDMaxLength)
}
projectID := random.New(stack, id, random.Config{
ByteLength: suffixByteLength,
Prefix: vars.ProjectIDPrefix,
})
id := resourceid.New(vars.ProjectID)
project := project.NewProject(stack,
id.TerraformID("project"),
&project.ProjectConfig{
Name: pointers.Ptr(vars.DisplayName),
ProjectId: &projectID.HexValue,
ProjectId: &vars.ProjectID,
AutoCreateNetwork: false,
BillingAccount: pointers.Ptr(BillingAccountID),
FolderId: func() *string {

View File

@ -79,17 +79,11 @@ func (r *Renderer) RenderEnvironment(
},
}))
}
var (
projectIDPrefix = fmt.Sprintf("%s-%s", svc.ID, env.ID)
stacks = stack.NewSet(r.OutputDir, stackSetOptions...)
)
stacks := stack.NewSet(r.OutputDir, stackSetOptions...)
// Render all required CDKTF stacks for this environment
projectOutput, err := project.NewStack(stacks, project.Variables{
ProjectIDPrefix: projectIDPrefix,
ProjectIDSuffixLength: svc.ProjectIDSuffixLength,
ProjectID: env.ProjectID,
DisplayName: fmt.Sprintf("%s - %s",
pointers.Deref(svc.Name, svc.ID), env.ID),

View File

@ -7,6 +7,7 @@ go_library(
"build.go",
"environment.go",
"monitoring.go",
"projectid.go",
"service.go",
"spec.go",
],
@ -17,7 +18,7 @@ go_library(
"//lib/errors",
"//lib/pointers",
"@com_github_grafana_regexp//:regexp",
"@io_k8s_sigs_yaml//:yaml",
"@in_gopkg_yaml_v3//:yaml_v3",
],
)
@ -25,6 +26,7 @@ go_test(
name = "spec_test",
srcs = [
"environment_test.go",
"projectid_test.go",
"service_test.go",
],
embed = [":spec"],
@ -32,5 +34,6 @@ go_test(
"//lib/pointers",
"@com_github_hexops_autogold_v2//:autogold",
"@com_github_stretchr_testify//assert",
"@com_github_stretchr_testify//require",
],
)

View File

@ -1,8 +1,8 @@
package spec
type BuildSpec struct {
Image string `json:"image"`
Source BuildSourceSpec `json:"source"`
Image string `yaml:"image"`
Source BuildSourceSpec `yaml:"source"`
}
func (s BuildSpec) Validate() []error {
@ -12,6 +12,6 @@ func (s BuildSpec) Validate() []error {
}
type BuildSourceSpec struct {
Repo string `json:"repo"`
Dir string `json:"dir"`
Repo string `yaml:"repo"`
Dir string `yaml:"dir"`
}

View File

@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/sourcegraph/sourcegraph/dev/managedservicesplatform/internal/imageupdater"
"github.com/sourcegraph/sourcegraph/lib/errors"
@ -14,28 +15,39 @@ import (
type EnvironmentSpec struct {
// ID is an all-lowercase alphanumeric identifier for the deployment
// environment, e.g. "prod" or "dev".
ID string `json:"id"`
ID string `yaml:"id"`
// ProjectID is the generated Google Project ID for this environment,
// provided by either 'sg msp init' or 'sg msp init -env'.
//
// The format is:
//
// $SERVICE_ID-$ENVIRONMENT_ID-$RANDOM_SUFFIX
//
// ❗ This value cannot be changed after your environment has been
// initialized!
ProjectID string `yaml:"projectID"`
// Category is either "test", "internal", or "external".
Category *EnvironmentCategory `json:"category,omitempty"`
Category *EnvironmentCategory `yaml:"category,omitempty"`
// Deploy specifies how to deploy revisions.
Deploy EnvironmentDeploySpec `json:"deploy"`
Deploy EnvironmentDeploySpec `yaml:"deploy"`
// EnvironmentServiceSpec carries service-specific configuration.
*EnvironmentServiceSpec `json:",inline"`
*EnvironmentServiceSpec `yaml:",inline"`
// EnvironmentJobSpec carries job-specific configuration.
*EnvironmentJobSpec `json:",inline"`
*EnvironmentJobSpec `yaml:",inline"`
// Instances describes how machines running the service are deployed.
Instances EnvironmentInstancesSpec `json:"instances"`
Instances EnvironmentInstancesSpec `yaml:"instances"`
// Env are key-value pairs of environment variables to set on the service.
//
// Values can be subsituted with supported runtime values with gotemplate, e.g., "{{ .ProjectID }}"
// - ProjectID: The project ID of the service.
// - ServiceDnsName: The DNS name of the service.
Env map[string]string `json:"env,omitempty"`
Env map[string]string `yaml:"env,omitempty"`
// SecretEnv are key-value pairs of environment variables sourced from
// secrets set on the service, where the value is the name of the secret
@ -44,14 +56,26 @@ type EnvironmentSpec struct {
// To point to a secret in another project, use the format
// 'projects/{project}/secrets/{secretName}' in the value. Access to the
// target project will be automatically granted.
SecretEnv map[string]string `json:"secretEnv,omitempty"`
SecretEnv map[string]string `yaml:"secretEnv,omitempty"`
// Resources configures additional resources that a service may depend on.
Resources *EnvironmentResourcesSpec `json:"resources,omitempty"`
Resources *EnvironmentResourcesSpec `yaml:"resources,omitempty"`
}
func (s EnvironmentSpec) Validate() []error {
var errs []error
if s.ProjectID == "" {
errs = append(errs, errors.New("projectID is required"))
}
if len(s.ProjectID) > 30 {
errs = append(errs, errors.New("projectID must be less than 30 characters"))
}
if !strings.Contains(s.ProjectID, fmt.Sprintf("-%s-", s.ID)) {
errs = append(errs, errors.Newf("projectID %q must contain environment ID: expecting format '$SERVICE_ID-$ENVIRONMENT_ID-$RANDOM_SUFFIX'",
s.ProjectID))
}
errs = append(errs, s.Deploy.Validate()...)
errs = append(errs, s.Resources.Validate()...)
return errs
@ -70,9 +94,9 @@ const (
)
type EnvironmentDeploySpec struct {
Type EnvironmentDeployType `json:"type"`
Manual *EnvironmentDeployManualSpec `json:"manual,omitempty"`
Subscription *EnvironmentDeployTypeSubscriptionSpec `json:"subscription,omitempty"`
Type EnvironmentDeployType `yaml:"type"`
Manual *EnvironmentDeployManualSpec `yaml:"manual,omitempty"`
Subscription *EnvironmentDeployTypeSubscriptionSpec `yaml:"subscription,omitempty"`
}
func (s EnvironmentDeploySpec) Validate() []error {
@ -123,12 +147,12 @@ func (d EnvironmentDeploySpec) ResolveTag(repo string) (string, error) {
type EnvironmentDeployManualSpec struct {
// Tag is the tag to deploy. If empty, defaults to "insiders".
Tag string `json:"tag,omitempty"`
Tag string `yaml:"tag,omitempty"`
}
type EnvironmentDeployTypeSubscriptionSpec struct {
// Tag is the tag to subscribe to.
Tag string `json:"tag,omitempty"`
Tag string `yaml:"tag,omitempty"`
// TODO: In the future, we may support subscribing by semver constraints.
}
@ -136,18 +160,18 @@ type EnvironmentServiceSpec struct {
// Domain configures where the resource is externally accessible.
//
// Only supported for services of 'kind: service'.
Domain *EnvironmentServiceDomainSpec `json:"domain,omitempty"`
Domain *EnvironmentServiceDomainSpec `yaml:"domain,omitempty"`
// StatupProbe is provisioned by default. It can be disabled with the
// 'disabled' field. Probes are made to the MSP-standard '/-/healthz'
// endpoint.
//
// Only supported for services of 'kind: service'.
StatupProbe *EnvironmentServiceStartupProbeSpec `json:"startupProbe,omitempty"`
StatupProbe *EnvironmentServiceStartupProbeSpec `yaml:"startupProbe,omitempty"`
// LivenessProbe is only provisioned if this field is set. Probes are made
// to the MSP-standard '/-/healthz' endpoint.
//
// Only supported for services of 'kind: service'.
LivenessProbe *EnvironmentServiceLivenessProbeSpec `json:"livenessProbe,omitempty"`
LivenessProbe *EnvironmentServiceLivenessProbeSpec `yaml:"livenessProbe,omitempty"`
// Authentication configures access to the service. By default, the service
// is publically available, and the service should handle any required
// authentication by itself. Set this field to an empty value to not
@ -161,13 +185,13 @@ type EnvironmentServiceSpec struct {
// https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/cloud_run_v2_service_iam
//
// Only supported for services of 'kind: service'.
Authentication *EnvironmentServiceAuthenticationSpec `json:"authentication,omitempty"`
Authentication *EnvironmentServiceAuthenticationSpec `yaml:"authentication,omitempty"`
}
type EnvironmentServiceDomainSpec struct {
// Type is one of 'none' or 'cloudflare'. If empty, defaults to 'none'.
Type EnvironmentDomainType `json:"type"`
Cloudflare *EnvironmentDomainCloudflareSpec `json:"cloudflare,omitempty"`
Type EnvironmentDomainType `yaml:"type"`
Cloudflare *EnvironmentDomainCloudflareSpec `yaml:"cloudflare,omitempty"`
}
// GetDNSName generates the DNS name for the environment. If nil or not configured,
@ -190,29 +214,29 @@ const (
)
type EnvironmentDomainCloudflareSpec struct {
Subdomain string `json:"subdomain"`
Zone string `json:"zone"`
Subdomain string `yaml:"subdomain"`
Zone string `yaml:"zone"`
// Proxied configures whether Cloudflare should proxy all traffic to get
// WAF protection instead of only DNS resolution.
Proxied bool `json:"proxied,omitempty"`
Proxied bool `yaml:"proxied,omitempty"`
// Required configures whether traffic can only be allowed through Cloudflare.
// TODO: Unimplemented.
Required bool `json:"required,omitempty"`
Required bool `yaml:"required,omitempty"`
}
type EnvironmentInstancesSpec struct {
Resources EnvironmentInstancesResourcesSpec `json:"resources"`
Resources EnvironmentInstancesResourcesSpec `yaml:"resources"`
// Scaling specifies the scaling behavior of the service.
//
// Currently only used for services of 'kind: service'.
Scaling *EnvironmentInstancesScalingSpec `json:"scaling,omitempty"`
Scaling *EnvironmentInstancesScalingSpec `yaml:"scaling,omitempty"`
}
type EnvironmentInstancesResourcesSpec struct {
CPU int `json:"cpu"`
Memory string `json:"memory"`
CPU int `yaml:"cpu"`
Memory string `yaml:"memory"`
}
type EnvironmentInstancesScalingSpec struct {
@ -221,15 +245,15 @@ type EnvironmentInstancesScalingSpec struct {
// Cloud Run will begin scaling up additional instances, up to MaxCount.
//
// If not provided, the defualt is defaultMaxConcurrentRequests
MaxRequestConcurrency *int `json:"maxRequestConcurrency,omitempty"`
MaxRequestConcurrency *int `yaml:"maxRequestConcurrency,omitempty"`
// MinCount is the minimum number of instances that will be running at all
// times. Set this to >0 to avoid service warm-up delays.
MinCount int `json:"minCount"`
MinCount int `yaml:"minCount"`
// MaxCount is the maximum number of instances that Cloud Run is allowed to
// scale up to.
//
// If not provided, the default is 5.
MaxCount *int `json:"maxCount,omitempty"`
MaxCount *int `yaml:"maxCount,omitempty"`
}
type EnvironmentServiceLivenessProbeSpec struct {
@ -237,18 +261,18 @@ type EnvironmentServiceLivenessProbeSpec struct {
// in seconds.
//
// Defaults to 1 second.
Timeout *int `json:"timeout,omitempty"`
Timeout *int `yaml:"timeout,omitempty"`
// Interval configures the interval, in seconds, at which to
// probe the deployed service.
//
// Defaults to 1 second.
Interval *int `json:"interval,omitempty"`
Interval *int `yaml:"interval,omitempty"`
}
type EnvironmentServiceAuthenticationSpec struct {
// Sourcegraph enables access to everyone in the sourcegraph.com GSuite
// domain.
Sourcegraph *bool `json:"sourcegraph,omitempty"`
Sourcegraph *bool `yaml:"sourcegraph,omitempty"`
}
type EnvironmentServiceStartupProbeSpec struct {
@ -258,32 +282,32 @@ type EnvironmentServiceStartupProbeSpec struct {
//
// This prevents the first Terraform apply from failing if your healthcheck
// is comprehensive.
Disabled *bool `json:"disabled,omitempty"`
Disabled *bool `yaml:"disabled,omitempty"`
// Timeout configures the period of time after which the probe times out,
// in seconds.
//
// Defaults to 1 second.
Timeout *int `json:"timeout,omitempty"`
Timeout *int `yaml:"timeout,omitempty"`
// Interval configures the interval, in seconds, at which to
// probe the deployed service.
//
// Defaults to 1 second.
Interval *int `json:"interval,omitempty"`
Interval *int `yaml:"interval,omitempty"`
}
type EnvironmentJobSpec struct {
// Schedule configures a cron schedule for the service.
//
// Only supported for services of 'kind: job'.
Schedule *EnvironmentJobScheduleSpec `json:"schedule,omitempty"`
Schedule *EnvironmentJobScheduleSpec `yaml:"schedule,omitempty"`
}
type EnvironmentJobScheduleSpec struct {
// Cron is a cron schedule in the form of "* * * * *".
Cron string `json:"cron"`
Cron string `yaml:"cron"`
// Deadline of each attempt, in seconds.
Deadline *int `json:"deadline,omitempty"`
Deadline *int `yaml:"deadline,omitempty"`
}
type EnvironmentResourcesSpec struct {
@ -295,13 +319,13 @@ type EnvironmentResourcesSpec struct {
//
// Sourcegraph Redis libraries (i.e. internal/redispool) will automatically
// use the given configuration.
Redis *EnvironmentResourceRedisSpec `json:"redis,omitempty"`
Redis *EnvironmentResourceRedisSpec `yaml:"redis,omitempty"`
// PostgreSQL, if provided, provisions a PostgreSQL database instance backed
// by Cloud SQL.
//
// To connect to the database, use
// (lib/managedservicesplatform/service.Contract).GetPostgreSQLDB().
PostgreSQL *EnvironmentResourcePostgreSQLSpec `json:"postgreSQL,omitempty"`
PostgreSQL *EnvironmentResourcePostgreSQLSpec `yaml:"postgreSQL,omitempty"`
// BigQueryDataset, if provided, provisions a dataset for the service to write
// to. Details for writing to the dataset are automatically provided in
// environment variables:
@ -311,7 +335,7 @@ type EnvironmentResourcesSpec struct {
//
// Only one dataset can be provisioned using MSP per MSP service, but the
// dataset may contain more than one table.
BigQueryDataset *EnvironmentResourceBigQueryDatasetSpec `json:"bigQueryDataset,omitempty"`
BigQueryDataset *EnvironmentResourceBigQueryDatasetSpec `yaml:"bigQueryDataset,omitempty"`
}
func (s *EnvironmentResourcesSpec) Validate() []error {
@ -326,19 +350,19 @@ func (s *EnvironmentResourcesSpec) Validate() []error {
type EnvironmentResourceRedisSpec struct {
// Defaults to STANDARD_HA.
Tier *string `json:"tier,omitempty"`
Tier *string `yaml:"tier,omitempty"`
// Defaults to 1.
MemoryGB *int `json:"memoryGB,omitempty"`
MemoryGB *int `yaml:"memoryGB,omitempty"`
}
type EnvironmentResourcePostgreSQLSpec struct {
// Databases to provision - required.
Databases []string `json:"databases"`
Databases []string `yaml:"databases"`
// Defaults to 1. Must be 1, or an even number between 2 and 96.
CPU *int `json:"cpu,omitempty"`
CPU *int `yaml:"cpu,omitempty"`
// Defaults to 4 (to meet CloudSQL minimum). You must request 0.9 to 6.5 GB
// per vCPU.
MemoryGB *int `json:"memoryGB,omitempty"`
MemoryGB *int `yaml:"memoryGB,omitempty"`
}
func (s *EnvironmentResourcePostgreSQLSpec) Validate() []error {
@ -380,7 +404,7 @@ type EnvironmentResourceBigQueryDatasetSpec struct {
// service specification file, in `${tableID}.bigquerytable.json`. Learn
// more about BigQuery table schemas here:
// https://cloud.google.com/bigquery/docs/schemas#specifying_a_json_schema_file
Tables []string `json:"tables"`
Tables []string `yaml:"tables"`
// rawSchemaFiles are the `${tableID}.bigquerytable.json` files adjacent
// to the service specification.
// Loaded by (EnvironmentResourceBigQueryTableSpec).LoadSchemas().
@ -390,14 +414,14 @@ type EnvironmentResourceBigQueryDatasetSpec struct {
// into. By default, we use the service ID as the dataset ID.
//
// Dataset IDs must be alphanumeric (plus underscores).
DatasetID *string `json:"datasetID,omitempty"`
DatasetID *string `yaml:"datasetID,omitempty"`
// ProjectID can be used to specify a separate project ID from the service's
// project for BigQuery resources. If not provided, resources are created
// within the service's project.
ProjectID *string `json:"projectID,omitempty"`
ProjectID *string `yaml:"projectID,omitempty"`
// Location defaults to "US". Do not configure unless you know what you are
// doing, as BigQuery locations are not the same as standard GCP regions.
Location *string `json:"region,omitempty"`
Location *string `yaml:"region,omitempty"`
}
func (s *EnvironmentResourceBigQueryDatasetSpec) Validate() []error {

View File

@ -12,7 +12,7 @@ var codeClassPattern = regexp.MustCompile(`\dx+`)
type MonitoringSpec struct {
// Alerts is a list of alert configurations for the deployment
Alerts MonitoringAlertsSpec `json:"alerts"`
Alerts MonitoringAlertsSpec `yaml:"alerts"`
}
func (s *MonitoringSpec) Validate() []error {
@ -25,18 +25,18 @@ func (s *MonitoringSpec) Validate() []error {
}
type MonitoringAlertsSpec struct {
ResponseCodeRatios []ResponseCodeRatioSpec `json:"responseCodeRatios"`
ResponseCodeRatios []ResponseCodeRatioSpec `yaml:"responseCodeRatios"`
}
type ResponseCodeRatioSpec struct {
ID string `json:"id"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
Code *int `json:"code,omitempty"`
CodeClass *string `json:"codeClass,omitempty"`
ExcludeCodes []string `json:"excludeCodes,omitempty"`
Duration *string `json:"duration,omitempty"`
Ratio float64 `json:"ratio"`
ID string `yaml:"id"`
Name string `yaml:"name"`
Description *string `yaml:"description,omitempty"`
Code *int `yaml:"code,omitempty"`
CodeClass *string `yaml:"codeClass,omitempty"`
ExcludeCodes []string `yaml:"excludeCodes,omitempty"`
Duration *string `yaml:"duration,omitempty"`
Ratio float64 `yaml:"ratio"`
}
func (s *MonitoringAlertsSpec) Validate() []error {

View File

@ -0,0 +1,41 @@
package spec
import (
"crypto/rand"
"encoding/hex"
"fmt"
"github.com/sourcegraph/sourcegraph/lib/errors"
)
const DefaultSuffixLength = 4
// NewProjectID generates a MSP-standard project ID for a service environment.
func NewProjectID(serviceID, envID string, suffixLength int) (string, error) {
if suffixLength < 2 {
return "", errors.New("suffix length must be at least 2 characters long")
}
suffix, err := newRandomAlphabeticalString(suffixLength)
if err != nil {
return "", errors.Wrap(err, "generate suffix")
}
projectID := fmt.Sprintf("%s-%s-%s",
serviceID, envID, suffix)
// https://cloud.google.com/resource-manager/reference/rest/v1/projects
if len(projectID) > 30 {
return "", errors.Newf("project ID %q must be no longer than 30 characters, try a shorter service ID or environment ID")
}
return projectID, nil
}
func newRandomAlphabeticalString(length int) (string, error) {
buf := make([]byte, length)
if _, err := rand.Read(buf); err != nil {
return "", errors.Wrap(err, "generate random string")
}
// Base 64 can be longer than len
return hex.EncodeToString(buf)[:length], nil
}

View File

@ -0,0 +1,29 @@
package spec
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewProjectID(t *testing.T) {
seenID := make(map[string]struct{})
const (
serviceID = "msp-test"
envID = "dev"
)
for i := 0; i < 100; i++ {
id, err := NewProjectID(serviceID, envID, DefaultSuffixLength)
require.NoError(t, err)
assert.Contains(t, id, serviceID)
assert.Contains(t, id, envID)
_, seenBefore := seenID[id]
assert.False(t, seenBefore, id)
seenID[id] = struct{}{}
}
}

View File

@ -10,29 +10,25 @@ import (
type ServiceSpec struct {
// ID is an all-lowercase, hyphen-delimited identifier for the service,
// e.g. "cody-gateway". It MUST be at most 20 characters long.
ID string `json:"id"`
ID string `yaml:"id"`
// Name is an optional human-readable display name for the service,
// e.g. "Cody Gateway".
Name *string `json:"name"`
Name *string `yaml:"name"`
// Owners denotes the teams or individuals primarily responsible for the
// service.
Owners []string `json:"owners"`
Owners []string `yaml:"owners"`
// Kind is the type of the service, either 'service' or 'job'. Defaults to
// 'service'.
Kind *ServiceKind `json:"kind,omitempty"`
Kind *ServiceKind `yaml:"kind,omitempty"`
// Protocol is a protocol other than HTTP that the service communicates
// with. If empty, the service uses HTTP. To use gRPC, configure 'h2c':
// https://cloud.google.com/run/docs/configuring/http2
Protocol *ServiceProtocol `json:"protocol,omitempty"`
// ProjectIDSuffixLength can be configured to truncate the length of the
// service's generated project IDs.
ProjectIDSuffixLength *int `json:"projectIDSuffixLength,omitempty"`
Protocol *ServiceProtocol `yaml:"protocol,omitempty"`
// IAM is an optional IAM configuration for the service account on the
// service's GCP project.
IAM *ServiceIAMSpec `json:"iam,omitempty"`
IAM *ServiceIAMSpec `yaml:"iam,omitempty"`
}
func (s ServiceSpec) Validate() []error {
@ -42,15 +38,10 @@ func (s ServiceSpec) Validate() []error {
errs = append(errs, errors.New("id must be at most 20 characters"))
}
if s.ProjectIDSuffixLength != nil && *s.ProjectIDSuffixLength < 4 {
errs = append(errs, errors.New("projectIDSuffixLength must be >= 4"))
}
if s.IAM != nil {
errs = append(errs, s.IAM.Validate()...)
}
// TODO: Add validation
return errs
}
@ -72,15 +63,15 @@ func (s *ServiceKind) Is(kind ServiceKind) bool {
type ServiceIAMSpec struct {
// Services is a list of GCP services to enable in the service's project.
Services []string `json:"services,omitempty"`
Services []string `yaml:"services,omitempty"`
// Roles is a list of IAM roles to grant to the service account.
Roles []string `json:"roles,omitempty"`
Roles []string `yaml:"roles,omitempty"`
// Permissions is a list of IAM permissions to grant to the service account.
//
// MSP will create a custom role with these permissions and grant it to the
// service account.
Permissions []string `json:"permissions,omitempty"`
Permissions []string `yaml:"permissions,omitempty"`
}
func (s ServiceIAMSpec) Validate() []error {

View File

@ -1,13 +1,13 @@
package spec
import (
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
// We intentionally use sigs.k8s.io/yaml because it has some convenience features,
// and nicer formatting. We use this in Sourcegraph Cloud as well.
"sigs.k8s.io/yaml"
"gopkg.in/yaml.v3"
"github.com/sourcegraph/sourcegraph/lib/errors"
"github.com/sourcegraph/sourcegraph/lib/pointers"
@ -24,10 +24,10 @@ import (
// Package dev/managedservicesplatform handles generating Terraform manifests
// from a given spec.
type Spec struct {
Service ServiceSpec `json:"service"`
Build BuildSpec `json:"build"`
Environments []EnvironmentSpec `json:"environments"`
Monitoring *MonitoringSpec `json:"monitoring,omitempty"`
Service ServiceSpec `yaml:"service"`
Build BuildSpec `yaml:"build"`
Environments []EnvironmentSpec `yaml:"environments"`
Monitoring *MonitoringSpec `yaml:"monitoring,omitempty"`
}
// Open a specification file, validate it, unmarshal the data as a MSP spec,
@ -55,6 +55,47 @@ func Open(specPath string) (*Spec, error) {
return spec, nil
}
// AppendEnvironment attaches environmentSpec, expressed as a map *yaml.Node,
// to the spec's "environments" list. It returns the updated spec data. The
// update preserves all formatting and docstrings.
func AppendEnvironment(specData []byte, environmentSpec *yaml.Node) ([]byte, error) {
if environmentSpec.Kind != yaml.ScalarNode && environmentSpec.Tag != "!!map" {
return nil, errors.Newf("environment spec must be a YAML map node, got kind: %v, tag: %q",
environmentSpec.Kind, environmentSpec.Tag)
}
var doc yaml.Node
if err := yaml.Unmarshal(specData, &doc); err != nil {
return nil, errors.Wrap(err, "parse spec YAML")
}
var added bool
root := doc.Content[0]
for i, n := range root.Content {
if n.Value == "environments" {
envList := root.Content[i+1]
envList.Content = append(envList.Content, environmentSpec)
added = true
break
}
}
if !added {
return nil, errors.New("spec 'environments' field not found")
}
// This is the only place we marshal a spec, other than the hand-written
// templates in dev/sg/msp/example. We need to set up an encoder to align
// with our preferences.
var update bytes.Buffer
enc := yaml.NewEncoder(&update)
enc.SetIndent(2)
if err := enc.Encode(&doc); err != nil {
return nil, errors.Wrap(err, "render updated spec to YAML")
}
return update.Bytes(), nil
}
// parse validates and unmarshals data as a MSP spec.
func parse(data []byte) (*Spec, error) {
var s Spec
@ -85,15 +126,20 @@ func (s Spec) Validate() []error {
}
}
for _, env := range s.ListEnvironmentIDs() {
projectName := fmt.Sprintf("%s - %s",
pointers.Deref(s.Service.Name, s.Service.ID), env)
if len(projectName) > 30 {
for _, env := range s.Environments {
projectDisplayName := fmt.Sprintf("%s - %s",
pointers.Deref(s.Service.Name, s.Service.ID), env.ID)
if len(projectDisplayName) > 30 {
errs = append(errs, errors.Newf(
"full environment name %q exceeds 30 characters limit - try a shorter service name or environment ID",
projectName,
projectDisplayName,
))
}
if !strings.HasPrefix(env.ProjectID, fmt.Sprintf("%s-", s.Service.ID)) {
errs = append(errs, errors.Newf("environment %q projectID %q must contain service ID: expecting format '$SERVICE_ID-$ENVIRONMENT_ID-$RANDOM_SUFFIX'",
env.ID, env.ProjectID))
}
}
errs = append(errs, s.Service.Validate()...)

View File

@ -21,6 +21,7 @@ go_library(
"//dev/sg/msp/example",
"//dev/sg/msp/repo",
"//dev/sg/msp/schema",
"//lib/cliutil/completions",
"//lib/errors",
"//lib/output",
"@com_github_urfave_cli_v2//:cli",

View File

@ -7,11 +7,14 @@ go_library(
embedsrcs = [
"job.template.yaml",
"service.template.yaml",
"environment.template.yaml",
],
importpath = "github.com/sourcegraph/sourcegraph/dev/sg/msp/example",
visibility = ["//visibility:public"],
deps = [
"//dev/managedservicesplatform/spec",
"//lib/errors",
"@in_gopkg_yaml_v3//:yaml_v3",
"@org_golang_x_text//cases",
"@org_golang_x_text//language",
],
@ -20,6 +23,7 @@ go_library(
go_test(
name = "example_test",
srcs = ["example_test.go"],
data = glob(["testdata/**"]),
embed = [":example"],
deps = [
"//dev/managedservicesplatform/spec",

View File

@ -0,0 +1,7 @@
id: {{ .EnvironmentID }}
projectID: {{ newProjectID .ServiceID .EnvironmentID .ProjectIDSuffixLength }}
# TODO: We initially provision in 'test' to make it easy to access the project
# during setup. Once done, you should change this to 'external' or 'internal'.
category: test
# TODO: Fill out the rest of your configuration here!
# ...

View File

@ -8,10 +8,18 @@ import (
"golang.org/x/text/cases"
"golang.org/x/text/language"
"gopkg.in/yaml.v3"
"github.com/sourcegraph/sourcegraph/dev/managedservicesplatform/spec"
"github.com/sourcegraph/sourcegraph/lib/errors"
)
const newProjectIDFuncKey = "newProjectID"
var templateFuncs = template.FuncMap{
newProjectIDFuncKey: spec.NewProjectID,
}
type Template struct {
// ID is spec.service.id - required.
ID string
@ -21,6 +29,9 @@ type Template struct {
Owner string
// Dev indicates if this template should render a dev environment.
Dev bool
// ProjectIDSuffixLength specifies the length of the generated project ID's
// random suffix.
ProjectIDSuffixLength int
}
func (t *Template) setDefaults() {
@ -33,7 +44,13 @@ func (t *Template) setDefaults() {
var (
//go:embed service.template.yaml
serviceTemplateYAML string
serviceTemplate = template.Must(template.New("service").Parse(serviceTemplateYAML))
serviceTemplate = func() *template.Template {
return template.Must(
template.New("service").
Funcs(templateFuncs).
Parse(serviceTemplateYAML),
)
}
)
// NewService provides a simple MSP service specification.
@ -41,7 +58,7 @@ func NewService(t Template) ([]byte, error) {
t.setDefaults()
var b bytes.Buffer
if err := serviceTemplate.Execute(&b, t); err != nil {
if err := serviceTemplate().Execute(&b, t); err != nil {
return nil, errors.Wrap(err, "executing template")
}
return b.Bytes(), nil
@ -50,7 +67,13 @@ func NewService(t Template) ([]byte, error) {
var (
//go:embed job.template.yaml
jobTemplateYAML string
jobTemplate = template.Must(template.New("job").Parse(jobTemplateYAML))
jobTemplate = func() *template.Template {
return template.Must(
template.New("job").
Funcs(templateFuncs).
Parse(jobTemplateYAML),
)
}
)
// NewJob provides a simple MSP job specification.
@ -58,8 +81,43 @@ func NewJob(t Template) ([]byte, error) {
t.setDefaults()
var b bytes.Buffer
if err := jobTemplate.Execute(&b, t); err != nil {
if err := jobTemplate().Execute(&b, t); err != nil {
return nil, errors.Wrap(err, "executing template")
}
return b.Bytes(), nil
}
var (
//go:embed environment.template.yaml
environmentTemplateYAML string
environmentTemplate = func() *template.Template {
return template.Must(
template.New("environment").
Funcs(templateFuncs).
Parse(environmentTemplateYAML),
)
}
)
type EnvironmentTemplate struct {
ServiceID string
EnvironmentID string
// ProjectIDSuffixLength is the length of the random suffix appended to
// the generated project ID.
ProjectIDSuffixLength int
}
func NewEnvironment(t EnvironmentTemplate) (*yaml.Node, error) {
var b bytes.Buffer
if err := environmentTemplate().Execute(&b, t); err != nil {
return nil, errors.Wrap(err, "executing template")
}
var doc yaml.Node
if err := yaml.Unmarshal(b.Bytes(), &doc); err != nil {
return nil, errors.Wrap(err, "unmarshal template as YAML")
}
root := doc.Content[0]
return root, nil
}

View File

@ -1,6 +1,8 @@
package example
import (
"errors"
"fmt"
"testing"
"github.com/hexops/autogold/v2"
@ -11,20 +13,34 @@ import (
"github.com/sourcegraph/sourcegraph/dev/managedservicesplatform/spec"
)
func mockNewProjectID(t *testing.T) {
templateFuncs[newProjectIDFuncKey] = func(s, e string, l int) (string, error) {
if len(s) == 0 {
return "", errors.New("service ID is required")
}
if len(e) == 0 {
return "", errors.New("environment ID is required")
}
if l == 0 {
return "", errors.New("expected length > 0")
}
return fmt.Sprintf("%s-%s-%s", s, e, t.Name()), nil
}
t.Cleanup(func() { templateFuncs[newProjectIDFuncKey] = spec.NewProjectID })
}
func TestNewService(t *testing.T) {
mockNewProjectID(t)
f, err := NewService(Template{
ID: "msp-example",
Dev: true,
Owner: "core-services",
ProjectIDSuffixLength: 4,
})
require.NoError(t, err)
t.Run("is valid", func(t *testing.T) {
var s spec.Spec
require.NoError(t, yaml.Unmarshal(f, &s))
assert.Empty(t, s.Validate())
})
autogold.Expect(`service:
id: msp-example
name: Msp Example
@ -43,6 +59,7 @@ build:
environments:
- id: dev
projectID: msp-example-dev-TestNewService
# TODO: We initially provision in 'test' to make it easy to access the project
# during setup. Once done, you should change this to 'external' or 'internal'.
category: test
@ -73,15 +90,6 @@ environments:
# Only enable if your service implements MSP /-/healthz conventions.
disabled: true
`).Equal(t, string(f))
}
func TestNewJob(t *testing.T) {
f, err := NewJob(Template{
ID: "msp-example",
Dev: true,
Owner: "core-services",
})
require.NoError(t, err)
t.Run("is valid", func(t *testing.T) {
var s spec.Spec
@ -89,6 +97,21 @@ func TestNewJob(t *testing.T) {
assert.Empty(t, s.Validate())
})
testInsertProdEnvironment(t, "msp-example", f)
}
func TestNewJob(t *testing.T) {
mockNewProjectID(t)
f, err := NewJob(Template{
ID: "msp-example",
Dev: true,
Owner: "core-services",
ProjectIDSuffixLength: 4,
})
require.NoError(t, err)
autogold.Expect(`service:
kind: job
id: msp-example
@ -108,6 +131,7 @@ build:
environments:
- id: dev
projectID: msp-example-dev-TestNewJob
# TODO: We initially provision in 'test' to make it easy to access the project
# during setup. Once done, you should change this to 'external' or 'internal'.
category: test
@ -130,4 +154,28 @@ environments:
cpu: 1
memory: 1Gi
`).Equal(t, string(f))
t.Run("is valid", func(t *testing.T) {
var s spec.Spec
require.NoError(t, yaml.Unmarshal(f, &s))
assert.Empty(t, s.Validate())
})
testInsertProdEnvironment(t, "msp-example", f)
}
func testInsertProdEnvironment(t *testing.T, serviceID string, specData []byte) {
t.Run("testInsertProdEnvironment", func(t *testing.T) {
e, err := NewEnvironment(EnvironmentTemplate{
ServiceID: serviceID,
EnvironmentID: "prod",
ProjectIDSuffixLength: 4,
})
require.NoError(t, err)
updatedSpecData, err := spec.AppendEnvironment(specData, e)
require.NoError(t, err)
autogold.ExpectFile(t, autogold.Raw(string(updatedSpecData)))
})
}

View File

@ -17,6 +17,7 @@ build:
environments:
- id: {{ if .Dev }}dev{{ else }}prod{{ end }}
projectID: {{ if .Dev }}{{ newProjectID .ID "dev" .ProjectIDSuffixLength }}{{ else }}{{ newProjectID .ID "dev" .ProjectIDSuffixLength }}{{ end }}
# TODO: We initially provision in 'test' to make it easy to access the project
# during setup. Once done, you should change this to 'external' or 'internal'.
category: test

View File

@ -16,6 +16,7 @@ build:
environments:
- id: {{ if .Dev }}dev{{ else }}prod{{ end }}
projectID: {{ if .Dev }}{{ newProjectID .ID "dev" .ProjectIDSuffixLength }}{{ else }}{{ newProjectID .ID "dev" .ProjectIDSuffixLength }}{{ end }}
# TODO: We initially provision in 'test' to make it easy to access the project
# during setup. Once done, you should change this to 'external' or 'internal'.
category: test

View File

@ -0,0 +1,46 @@
service:
kind: job
id: msp-example
name: Msp Example
owners:
- core-services
build:
# TODO: Configure the correct image for your job here. If you use a private
# registry like us.gcr.io or Artifact Registry, access will automatically be
# granted for your job to pull the correct image.
image: us.gcr.io/sourcegraph-dev/msp-example
# TODO: Configure where the source code for your job lives here.
source:
repo: github.com/sourcegraph/sourcegraph
dir: cmd/msp-example
environments:
- id: dev
projectID: msp-example-dev-TestNewJob
# TODO: We initially provision in 'test' to make it easy to access the project
# during setup. Once done, you should change this to 'external' or 'internal'.
category: test
# Specify a strategy for updating the image.
deploy:
type: manual
manual:
tag: insiders
# Specify the schedule at which to run your job.
schedule:
cron: 0 * * * *
deadline: 600 # 10 minutes
# Specify environment configuration your service needs to operate.
env:
SRC_LOG_LEVEL: info
SRC_LOG_FORMAT: json_gcp
# Specify the resources your job gets.
instances:
resources:
cpu: 1
memory: 1Gi
- id: prod
projectID: msp-example-prod-TestNewJob
# TODO: We initially provision in 'test' to make it easy to access the project
# during setup. Once done, you should change this to 'external' or 'internal'.
category: test
# TODO: Fill out the rest of your configuration here!
# ...

View File

@ -0,0 +1,53 @@
service:
id: msp-example
name: Msp Example
owners:
- core-services
build:
# TODO: Configure the correct image for your service here. If you use a private
# registry like us.gcr.io or Artifact Registry, access will automatically be
# granted for your service to pull the correct image.
image: us.gcr.io/sourcegraph-dev/msp-example
# TODO: Configure where the source code for your service lives here.
source:
repo: github.com/sourcegraph/sourcegraph
dir: cmd/msp-example
environments:
- id: dev
projectID: msp-example-dev-TestNewService
# TODO: We initially provision in 'test' to make it easy to access the project
# during setup. Once done, you should change this to 'external' or 'internal'.
category: test
# Specify a deployment strategy for upgrades.
deploy:
type: manual
manual:
tag: insiders
# Specify an externally facing domain.
domain:
type: cloudflare
cloudflare:
subdomain: msp-example
zone: sgdev.org
# Specify environment configuration your service needs to operate.
env:
SRC_LOG_LEVEL: info
SRC_LOG_FORMAT: json_gcp
# Specify how your service should scale.
instances:
resources:
cpu: 1
memory: 1Gi
scaling:
maxCount: 3
minCount: 1
startupProbe:
# Only enable if your service implements MSP /-/healthz conventions.
disabled: true
- id: prod
projectID: msp-example-prod-TestNewService
# TODO: We initially provision in 'test' to make it easy to access the project
# during setup. Once done, you should change this to 'external' or 'internal'.
category: test
# TODO: Fill out the rest of your configuration here!
# ...

View File

@ -24,7 +24,9 @@ func Render() ([]byte, error) {
return nil, errors.Wrap(err, "must be in sourcegraph/sourcegraph repository")
}
var r jsonschema.Reflector
r := jsonschema.Reflector{
FieldNameTag: "yaml",
}
if err := r.AddGoComments(
"github.com/sourcegraph/sourcegraph",
"./dev/managedservicesplatform/spec",

View File

@ -12,6 +12,7 @@ import (
"github.com/sourcegraph/sourcegraph/dev/managedservicesplatform"
"github.com/sourcegraph/sourcegraph/dev/managedservicesplatform/googlesecretsmanager"
"github.com/sourcegraph/sourcegraph/dev/managedservicesplatform/spec"
"github.com/sourcegraph/sourcegraph/dev/managedservicesplatform/terraformcloud"
"github.com/sourcegraph/sourcegraph/dev/sg/cloudsqlproxy"
"github.com/sourcegraph/sourcegraph/dev/sg/internal/category"
@ -21,6 +22,7 @@ import (
"github.com/sourcegraph/sourcegraph/dev/sg/msp/example"
msprepo "github.com/sourcegraph/sourcegraph/dev/sg/msp/repo"
"github.com/sourcegraph/sourcegraph/dev/sg/msp/schema"
"github.com/sourcegraph/sourcegraph/lib/cliutil/completions"
"github.com/sourcegraph/sourcegraph/lib/errors"
)
@ -58,9 +60,8 @@ sg msp init -owner core-services -name "MSP Example Service" msp-example
Value: "service",
},
&cli.StringFlag{
Name: "owner",
Usage: "Name of team owning this new service",
Required: true,
Name: "owner",
Usage: "Name of team owning this new service",
},
&cli.StringFlag{
Name: "name",
@ -68,7 +69,12 @@ sg msp init -owner core-services -name "MSP Example Service" msp-example
},
&cli.BoolFlag{
Name: "dev",
Usage: "Generate a dev environment as the initial environment",
Usage: "Generate a dev environment",
},
&cli.IntFlag{
Name: "project-id-suffix-length",
Usage: "Length of random suffix appended to generated project IDs",
Value: spec.DefaultSuffixLength,
},
},
Before: msprepo.UseManagedServicesRepo,
@ -82,6 +88,8 @@ sg msp init -owner core-services -name "MSP Example Service" msp-example
Name: c.String("name"),
Owner: c.String("owner"),
Dev: c.Bool("dev"),
ProjectIDSuffixLength: c.Int("project-id-suffix-length"),
}
var exampleSpec []byte
@ -114,6 +122,64 @@ sg msp init -owner core-services -name "MSP Example Service" msp-example
return nil
},
},
{
Name: "init-env",
ArgsUsage: "<service ID> <env ID>",
Usage: "Add an environment to an existing Managed Services Platform service",
Flags: []cli.Flag{
&cli.IntFlag{
Name: "project-id-suffix-length",
Usage: "Length of random suffix appended to generated project IDs",
Value: spec.DefaultSuffixLength,
},
},
Before: msprepo.UseManagedServicesRepo,
BashComplete: completions.CompleteArgs(func() (options []string) {
ss, _ := msprepo.ListServices()
return ss
}),
Action: func(c *cli.Context) error {
svc, err := useServiceArgument(c)
if err != nil {
return err
}
envID := c.Args().Get(0)
if envID == "" {
return errors.New("second argument <environment ID> is required")
}
if existing := svc.GetEnvironment(envID); existing != nil {
return errors.Newf("environment %q already exists", envID)
}
envNode, err := example.NewEnvironment(example.EnvironmentTemplate{
ServiceID: svc.Service.ID,
EnvironmentID: envID,
ProjectIDSuffixLength: c.Int("project-id-suffix-length"),
})
if err != nil {
return errors.Wrap(err, "example.NewEnvironment")
}
specPath := msprepo.ServiceYAMLPath(msprepo.ServiceYAMLPath(svc.Service.ID))
specData, err := os.ReadFile(specPath)
if err != nil {
return errors.Wrap(err, "ReadFile")
}
specData, err = spec.AppendEnvironment(specData, envNode)
if err != nil {
return errors.Wrap(err, "spec.AppendEnvironment")
}
if err := os.WriteFile(specPath, specData, 0o644); err != nil {
return err
}
std.Out.WriteSuccessf("Initialized environment %q in %s",
envID, specPath)
return nil
},
},
{
Name: "generate",
Aliases: []string{"gen"},
@ -208,31 +274,16 @@ sg msp generate -all <service>
},
BashComplete: msprepo.ServicesAndEnvironmentsCompletion(),
Action: func(c *cli.Context) error {
service, env, err := useServiceAndEnvironmentArguments(c)
_, env, err := useServiceAndEnvironmentArguments(c)
if err != nil {
return err
}
tfcClient, err := getTFCRunsClient(c)
if err != nil {
return err
}
projectOutputs, err := tfcClient.GetOutputs(c.Context,
terraformcloud.WorkspaceName(service.Service, *env,
managedservicesplatform.StackNameProject))
if err != nil {
return errors.Wrap(err, "get IAM outputs")
}
projectID, err := projectOutputs.Find("project_id")
if err != nil {
return errors.Wrap(err, "get project ID")
}
switch component := c.String("component"); component {
case "service":
std.Out.WriteNoticef("Opening link to service logs in browser...")
return open.URL((&gcplogurl.Explorer{
ProjectID: projectID.Value.(string),
ProjectID: env.ProjectID,
Query: gcplogurl.Query(`resource.type = "cloud_run_revision" jsonPayload.InstrumentationScope != ""`),
SummaryFields: &gcplogurl.SummaryFields{
Fields: []string{

5
go.mod
View File

@ -266,7 +266,7 @@ require (
github.com/hashicorp/cronexpr v1.1.1
github.com/hashicorp/go-tfe v1.32.1
github.com/hashicorp/terraform-cdk-go/cdktf v0.17.3
github.com/invopop/jsonschema v0.7.0
github.com/invopop/jsonschema v0.12.0
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa
github.com/mroth/weightedrand/v2 v2.0.1
github.com/oschwald/maxminddb-golang v1.12.0
@ -336,7 +336,6 @@ require (
github.com/hashicorp/golang-lru v0.5.4 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/hashicorp/jsonapi v0.0.0-20210826224640-ee7dae0fb22d // indirect
github.com/iancoleman/orderedmap v0.2.0 // indirect
github.com/iancoleman/strcase v0.3.0 // indirect
github.com/jackc/pgproto3/v2 v2.3.2 // indirect
github.com/jackc/pgx/v5 v5.5.0 // indirect
@ -575,7 +574,7 @@ require (
github.com/tidwall/pretty v1.2.0 // indirect
github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.5
github.com/wk8/go-ordered-map/v2 v2.1.8
github.com/xanzy/go-gitlab v0.86.0
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect

12
go.sum
View File

@ -1065,9 +1065,6 @@ github.com/honeycombio/libhoney-go v1.15.8/go.mod h1:+tnL2etFnJmVx30yqmoUkVyQjp7
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/huandu/xstrings v1.4.0 h1:D17IlohoQq4UcpqD7fDk80P7l+lwAmlFaBHgOipl2FU=
github.com/huandu/xstrings v1.4.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/iancoleman/orderedmap v0.0.0-20190318233801-ac98e3ecb4b0/go.mod h1:N0Wam8K1arqPXNWjMo21EXnBPOPp36vB07FNRdD2geA=
github.com/iancoleman/orderedmap v0.2.0 h1:sq1N/TFpYH++aViPcaKjys3bDClUEU7s5B+z6jq8pNA=
github.com/iancoleman/orderedmap v0.2.0/go.mod h1:N0Wam8K1arqPXNWjMo21EXnBPOPp36vB07FNRdD2geA=
github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI=
github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
@ -1081,8 +1078,8 @@ github.com/inconshreveable/log15 v0.0.0-20201112154412-8562bdadbbac/go.mod h1:cO
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/invopop/jsonschema v0.7.0 h1:2vgQcBz1n256N+FpX3Jq7Y17AjYt46Ig3zIWyy770So=
github.com/invopop/jsonschema v0.7.0/go.mod h1:O9uiLokuu0+MGFlyiaqtWxwqJm41/+8Nj0lD7A36YH0=
github.com/invopop/jsonschema v0.12.0 h1:6ovsNSuvn9wEQVOyc72aycBMVQFKz7cPdMJn10CvzRI=
github.com/invopop/jsonschema v0.12.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
github.com/itchyny/gojq v0.12.11 h1:YhLueoHhHiN4mkfM+3AyJV6EPcCxKZsOnYf+aVSwaQw=
github.com/itchyny/gojq v0.12.11/go.mod h1:o3FT8Gkbg/geT4pLI0tF3hvip5F3Y/uskjRz9OYa38g=
github.com/itchyny/timefmt-go v0.1.5 h1:G0INE2la8S6ru/ZI5JecgyzbbJNs5lG1RcBqa7Jm6GE=
@ -1701,7 +1698,6 @@ github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.3.1-0.20190311161405-34c6fa2dc709/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@ -1762,8 +1758,8 @@ github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAh
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
github.com/vvakame/gcplogurl v0.2.0 h1:dH55ru2OQOIAKjZi5wwXjNnSfN0oXLFYkMQy908s+tU=
github.com/vvakame/gcplogurl v0.2.0/go.mod h1:CFjKFlur6M+/2DoGZL67O1FqZxB42jiqCyl4cXAmjOU=
github.com/wk8/go-ordered-map/v2 v2.1.5 h1:jLbYIFyWQMUwHLO20cImlCRBoNc5lp0nmE2dvwcxc7k=
github.com/wk8/go-ordered-map/v2 v2.1.5/go.mod h1:9Xvgm2mV2kSq2SAm0Y608tBmu8akTzI7c2bz7/G7ZN4=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/wsxiaoys/terminal v0.0.0-20160513160801-0940f3fc43a0/go.mod h1:IXCdmsXIht47RaVFLEdVnh1t+pgYtTAhQGj73kz+2DM=
github.com/xanzy/go-gitlab v0.86.0 h1:jR8V9cK9jXRQDb46KOB20NCF3ksY09luaG0IfXE6p7w=
github.com/xanzy/go-gitlab v0.86.0/go.mod h1:5ryv+MnpZStBH8I/77HuQBsMbBGANtVpLWC15qOjWAw=