diff --git a/.env.example b/.env.example index 69fb0fe..c008cf6 100644 --- a/.env.example +++ b/.env.example @@ -19,3 +19,5 @@ CF_DEFAULT_CACHING_POLICY_ID="4135ea2d-6df8-44a3-9df3-4b5a84be39ad" CF_DEFAULT_CACHE_REQUEST_POLICY_ID="216adef6-5c7f-47e4-b989-5492eafa07d3" CF_DEFAULT_PUBLIC_ORIGIN_ACCESS_REQUEST_POLICY_ID="216adef6-5c7f-47e4-b989-5492eafa07d3" CF_DEFAULT_BUCKET_ORIGIN_ACCESS_REQUEST_POLICY_ID="88a5eaf4-2fd4-4709-b370-b4c650ea3fcf" +BLOCK_CREATION="false" +# BLOCK_CREATION_ALLOW_LIST="namespace/name,another-namespace/name" diff --git a/README.md b/README.md index 192cbc7..9eebfc6 100644 --- a/README.md +++ b/README.md @@ -295,20 +295,22 @@ Access the [documentation](https://gympass.github.io/cdn-origin-controller/) to Use the following environment variables to change the controller's behavior: -| Env var key | Required | Description | Default | -|--------------------------|----------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------| -| CF_AWS_WAF | No | The Web ACL which should be associated with the distributions. Use the ID for WAF v1 and the ARN for WAF v2. | "" | -| CF_CUSTOM_TAGS | No | Comma-separated list of custom tags to be added to distributions. Example: "foo=bar,bar=foo" | "" | -| CF_DEFAULT_ORIGIN_DOMAIN | Yes | Domain of the default origin each distribution must have to route traffic to in case no custom behaviors match the request. | "" | -| CF_DESCRIPTION_TEMPLATE | No | Template of the distribution's description. Currently a single field can be accessed, `{{group}}`, which matches the CDN group under which the distribution was provisioned. | "Serve contents for {{group}} group." | -| CF_ENABLE_IPV6 | No | Whether the distribution should also expose an IPv6 address to serve requests. | "true" | -| CF_ENABLE_LOGGING | No | If set to true enables sending logs to CloudWatch; `CF_S3_BUCKET_LOG` must be set as well. | "false" | -| CF_PRICE_CLASS | Yes | The distribution price class. Possible values are: "PriceClass_All", "PriceClass_200", "PriceClass_100". [Official reference](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/PriceClass.html). | "PriceClass_All" | -| CF_S3_BUCKET_LOG | No | The domain of the S3 bucket CloudWatch logs should be sent to. Each distribution will have its own directory inside the bucket with the same as the distribution's group. For example, if the group is "foo", the logs will be stored as `foo/..gz`.

If `CF_ENABLE_LOGGING` is not set to "true" then this value is ignored. | "" | -| CF_SECURITY_POLICY | No | The TLS/SSL security policy to be used when serving requests. [Official reference](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/secure-connections-supported-viewer-protocols-ciphers.html).

Must also inform a valid `CF_CUSTOM_SSL_CERT` if set. | "" | -| DEV_MODE | No | When set to "true" logs in unstructured text instead of JSON. Also overrides LOG_LEVEL to "debug". | "false" | -| LOG_LEVEL | No | Represents log level of verbosity. Can be "debug", "info", "warn", "error", "dpanic", "panic" and "fatal" (sorted with decreasing verbosity). | "info" | -| ENABLE_DELETION | No | Represent whether CloudFront Distributions and Route53 records should be deleted based on Ingresses being deleted. Ownership TXT DNS records are also not deleted to allow for self-healing in case of accidental deletion of Kubernetes resources. | "false" | +| Env var key | Required | Description | Default | +|---------------------------|----------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------| +| CF_AWS_WAF | No | The Web ACL which should be associated with the distributions. Use the ID for WAF v1 and the ARN for WAF v2. | "" | +| CF_CUSTOM_TAGS | No | Comma-separated list of custom tags to be added to distributions. Example: "foo=bar,bar=foo" | "" | +| CF_DEFAULT_ORIGIN_DOMAIN | Yes | Domain of the default origin each distribution must have to route traffic to in case no custom behaviors match the request. | "" | +| CF_DESCRIPTION_TEMPLATE | No | Template of the distribution's description. Currently a single field can be accessed, `{{group}}`, which matches the CDN group under which the distribution was provisioned. | "Serve contents for {{group}} group." | +| CF_ENABLE_IPV6 | No | Whether the distribution should also expose an IPv6 address to serve requests. | "true" | +| CF_ENABLE_LOGGING | No | If set to true enables sending logs to CloudWatch; `CF_S3_BUCKET_LOG` must be set as well. | "false" | +| CF_PRICE_CLASS | Yes | The distribution price class. Possible values are: "PriceClass_All", "PriceClass_200", "PriceClass_100". [Official reference](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/PriceClass.html). | "PriceClass_All" | +| CF_S3_BUCKET_LOG | No | The domain of the S3 bucket CloudWatch logs should be sent to. Each distribution will have its own directory inside the bucket with the same as the distribution's group. For example, if the group is "foo", the logs will be stored as `foo/..gz`.

If `CF_ENABLE_LOGGING` is not set to "true" then this value is ignored. | "" | +| CF_SECURITY_POLICY | No | The TLS/SSL security policy to be used when serving requests. [Official reference](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/secure-connections-supported-viewer-protocols-ciphers.html).

Must also inform a valid `CF_CUSTOM_SSL_CERT` if set. | "" | +| DEV_MODE | No | When set to "true" logs in unstructured text instead of JSON. Also overrides LOG_LEVEL to "debug". | "false" | +| LOG_LEVEL | No | Represents log level of verbosity. Can be "debug", "info", "warn", "error", "dpanic", "panic" and "fatal" (sorted with decreasing verbosity). | "info" | +| ENABLE_DELETION | No | Represent whether CloudFront Distributions and Route53 records should be deleted based on Ingresses being deleted. Ownership TXT DNS records are also not deleted to allow for self-healing in case of accidental deletion of Kubernetes resources. | "false" | +| BLOCK_CREATION | No | Boolean value to configure the controller to block creation of new CloudFront Distributions. Useful when phasing out clusters or accounts, for example. | "false" | +| BLOCK_CREATION_ALLOW_LIST | No | Comma-separated list of namespaced names of Ingresses that should override BLOCK_CREATION, and be allowed to always move forward with creating a new Distribution. Ex: "namespace/name,another-namespace/another-name". | "" | ## Contributing diff --git a/internal/cloudfront/distribution_test.go b/internal/cloudfront/distribution_test.go index a0d1963..2e1d52a 100644 --- a/internal/cloudfront/distribution_test.go +++ b/internal/cloudfront/distribution_test.go @@ -91,7 +91,7 @@ func (s *DistributionTestSuite) TestDistributionBuilder_WithOrigin() { ResponseTimeout: 30, } - dist, err := cloudfront.NewDistributionBuilder(group, config.Parse()). + dist, err := cloudfront.NewDistributionBuilder(group, s.cfg). WithOrigin(origin). Build() diff --git a/internal/cloudfront/service.go b/internal/cloudfront/service.go index 29511c3..e22bc8b 100644 --- a/internal/cloudfront/service.go +++ b/internal/cloudfront/service.go @@ -66,7 +66,7 @@ func (s *Service) Reconcile(ctx context.Context, ing *networkingv1.Ingress, clas reconciling, err := k8s.NewCDNIngressFromV1(ctx, ing, class) if err != nil { - return err + return s.handleFailure(err, ing) } log, _ := logr.FromContext(ctx) @@ -79,12 +79,16 @@ func (s *Service) Reconcile(ctx context.Context, ing *networkingv1.Ingress, clas desiredIngresses, desiredDist, err := s.desiredState(ctx, reconciling) if err != nil { - return fmt.Errorf("computing desired state: %v", err) + return s.handleFailure(fmt.Errorf("computing desired state: %v", err), ing) + } + + if err := s.validateCreation(desiredDist, ing); err != nil { + return s.handleFailure(err, ing) } cdnStatus, err := s.fetchOrGenerateCDNStatus(desiredIngresses, desiredDist) if err != nil { - return err + return s.handleFailure(fmt.Errorf("validating creation: %v", err), ing) } errs := &multierror.Error{} @@ -115,6 +119,18 @@ func (s *Service) Reconcile(ctx context.Context, ing *networkingv1.Ingress, clas return s.handleResult(ing, cdnStatus, errs) } +func (s *Service) validateCreation(desiredDist Distribution, ing *networkingv1.Ingress) error { + if desiredDist.Exists() || desiredDist.IsEmpty() || ing.DeletionTimestamp != nil { + return nil + } + + if !s.Config.IsCreationAllowed(ing) { + return errors.New("creation of new CloudFront distributions is blocked") + } + + return nil +} + func (s *Service) validateIngress(ing *networkingv1.Ingress) error { if df := k8s.UsedDeprecatedFields(ing); len(df) > 0 { s.Recorder.Eventf( diff --git a/internal/cloudfront/service_test.go b/internal/cloudfront/service_test.go index b15dce4..cb6997c 100644 --- a/internal/cloudfront/service_test.go +++ b/internal/cloudfront/service_test.go @@ -23,6 +23,10 @@ import ( "testing" "github.com/stretchr/testify/suite" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/Gympass/cdn-origin-controller/internal/config" ) func TestRunCloudFrontServiceTestSuite(t *testing.T) { @@ -70,3 +74,43 @@ func (s *CloudFrontServiceTestSuite) Test_getDeletions() { s.Equal(tc.want, getDeletions(tc.desired, tc.current), "test: %s", tc.name) } } + +func (s *CloudFrontServiceTestSuite) Test_validateCreation_IngressesBeingDeletedReturnNoError() { + ing := &networkingv1.Ingress{} + ing.SetDeletionTimestamp(&metav1.Time{}) + + svc := Service{ + Config: config.Config{ + IsCreateBlocked: true, + }, + } + + s.NoError(svc.validateCreation(Distribution{}, ing)) +} + +func (s *CloudFrontServiceTestSuite) Test_validateCreation_DistributionsBeingDeletedReturnNoError() { + ing := &networkingv1.Ingress{} + + svc := Service{ + Config: config.Config{ + IsCreateBlocked: true, + }, + } + + s.NoError(svc.validateCreation(Distribution{}, ing)) +} + +func (s *CloudFrontServiceTestSuite) Test_validateCreation_ExistingDistributionsReturnNoError() { + ing := &networkingv1.Ingress{} + + svc := Service{ + Config: config.Config{ + IsCreateBlocked: true, + }, + } + dist := Distribution{ + ID: "some id", + } + + s.NoError(svc.validateCreation(dist, ing)) +} diff --git a/internal/config/config.go b/internal/config/config.go index aee725d..c224530 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,10 +20,13 @@ package config import ( + "fmt" "strings" awscloudfront "github.com/aws/aws-sdk-go/service/cloudfront" "github.com/spf13/viper" + networkingv1 "k8s.io/api/networking/v1" + "k8s.io/apimachinery/pkg/types" ) const ( @@ -43,9 +46,15 @@ const ( cfDefaultCacheRequestPolicyIDKey = "cf_default_cache_request_policy_id" cfDefaultPublicOriginAccessRequestPolicyIDKey = "cf_default_public_origin_access_request_policy_id" cfDefaultBucketOriginAccessRequestPolicyIDKey = "cf_default_bucket_origin_access_request_policy_id" + createBlockedKey = "block_creation" + createBlockedAllowListKey = "block_creation_allow_list" ) func init() { + initDefaults() +} + +func initDefaults() { viper.SetDefault(logLevelKey, "info") viper.SetDefault(devModeKey, "false") viper.SetDefault(enableDeletionKey, "false") @@ -70,6 +79,8 @@ func init() { // https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-origin-request-policies.html#managed-origin-request-policy-cors-s3 // Default is CORS S3 viper.SetDefault(cfDefaultBucketOriginAccessRequestPolicyIDKey, "88a5eaf4-2fd4-4709-b370-b4c650ea3fcf") + viper.SetDefault(createBlockedKey, false) + viper.AutomaticEnv() } @@ -109,6 +120,10 @@ type Config struct { CloudFrontDefaultPublicOriginAccessRequestPolicyID string // CloudFrontDefaultBucketOriginAccessRequestPolicyID is the default request policy for bucket origin access. CloudFrontDefaultBucketOriginAccessRequestPolicyID string + // IsCreateBlocked configure whether to block creation of new CloudFront distributions. Useful when phasing out clusters or accounts, for example + IsCreateBlocked bool + // CreateAllowList holds a list of Ingresses namespaced names for which we should allow creation, even if IsCreateBlocked is true + CreateAllowList []types.NamespacedName } // TLSIsEnabled returns whether TLS is enabled @@ -116,14 +131,39 @@ func (c Config) TLSIsEnabled() bool { return len(c.CloudFrontSecurityPolicy) > 0 } +// IsCreationAllowed returns whether the creation of a new CloudFront distribution for the given Ingress should be allowed +func (c Config) IsCreationAllowed(ing *networkingv1.Ingress) bool { + if !c.IsCreateBlocked { + return true + } + + ingName := types.NamespacedName{ + Namespace: ing.Namespace, + Name: ing.Name, + } + + for _, candidate := range c.CreateAllowList { + if candidate == ingName { + return true + } + } + + return false +} + // Parse environment variables into a config struct -func Parse() Config { +func Parse() (Config, error) { devMode := viper.GetBool(devModeKey) logLvl := viper.GetString(logLevelKey) if devMode { logLvl = "debug" } + createAllowList, err := parseNamespacedNames(parseList(viper.GetString(createBlockedAllowListKey))) + if err != nil { + return Config{}, fmt.Errorf("invalid %q: %v", createBlockedAllowListKey, err) + } + return Config{ LogLevel: logLvl, DevMode: devMode, @@ -139,9 +179,41 @@ func Parse() Config { CloudFrontCustomTags: extractTags(viper.GetString(cfCustomTagsKey)), CloudFrontDefaultCachingPolicyID: viper.GetString(cfDefaultCachingPolicyIDKey), CloudFrontDefaultCacheRequestPolicyID: viper.GetString(cfDefaultCacheRequestPolicyIDKey), + IsCreateBlocked: viper.GetBool(createBlockedKey), + CreateAllowList: createAllowList, CloudFrontDefaultPublicOriginAccessRequestPolicyID: viper.GetString(cfDefaultPublicOriginAccessRequestPolicyIDKey), CloudFrontDefaultBucketOriginAccessRequestPolicyID: viper.GetString(cfDefaultBucketOriginAccessRequestPolicyIDKey), + }, nil +} + +func parseNamespacedNames(names []string) ([]types.NamespacedName, error) { + var result []types.NamespacedName + for _, n := range names { + name, err := nsName(n) + if err != nil { + return nil, fmt.Errorf("parsing namespaced name: %v", err) + } + result = append(result, name) + } + return result, nil +} + +func nsName(name string) (types.NamespacedName, error) { + parts := strings.Split(name, "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return types.NamespacedName{}, fmt.Errorf(`namespaced name is not in "namespace/name" format: %q`, name) + } + return types.NamespacedName{ + Namespace: parts[0], + Name: parts[1], + }, nil +} + +func parseList(l string) []string { + if l == "" { + return nil } + return strings.Split(l, ",") } func extractTags(customTags string) map[string]string { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 694fce8..e640124 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -17,15 +17,15 @@ // IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN // CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -package config_test +package config import ( "testing" "github.com/spf13/viper" "github.com/stretchr/testify/suite" - - "github.com/Gympass/cdn-origin-controller/internal/config" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) func TestRunConfigTestSuite(t *testing.T) { @@ -37,6 +37,11 @@ type ConfigTestSuite struct { suite.Suite } +func (s *ConfigTestSuite) SetupTest() { + viper.Reset() + initDefaults() +} + func (s *ConfigTestSuite) TestConfigWithCustomTagsParsed() { expected := map[string]string{ "foo": "bar", @@ -45,9 +50,10 @@ func (s *ConfigTestSuite) TestConfigWithCustomTagsParsed() { viper.Set("cf_custom_tags", "foo=bar,area=platform") - cfg := config.Parse() + cfg, err := Parse() s.Equal(expected, cfg.CloudFrontCustomTags) + s.NoError(err) } func (s *ConfigTestSuite) TestConfigNoCustomTags() { @@ -55,7 +61,59 @@ func (s *ConfigTestSuite) TestConfigNoCustomTags() { viper.Set("cf_custom_tags", "") - cfg := config.Parse() + cfg, err := Parse() s.Equal(expected, cfg.CloudFrontCustomTags) + s.NoError(err) +} + +func (s *ConfigTestSuite) TestParse_DefaultToBlockCreationIsFalse() { + cfg, err := Parse() + + s.NoError(err) + s.False(cfg.IsCreateBlocked) +} + +func (s *ConfigTestSuite) TestIsCreationAllowed_UnblockedCreationReturnsTrue() { + viper.Set(createBlockedKey, "false") + + cfg, err := Parse() + s.NoError(err) + + s.True(cfg.IsCreationAllowed(&networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "ns", + Name: "name", + }, + })) +} + +func (s *ConfigTestSuite) TestIsCreationAllowed_AllowedIngressWithBlockedCreationReturnsTrue() { + viper.Set(createBlockedKey, "true") + viper.Set(createBlockedAllowListKey, "ns/allowed") + + cfg, err := Parse() + s.NoError(err) + + s.True(cfg.IsCreationAllowed(&networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "ns", + Name: "allowed", + }, + })) +} + +func (s *ConfigTestSuite) TestIsCreationAllowed_IngressNotOnAllowListWithBlockedCreationReturnsFalse() { + viper.Set(createBlockedKey, "true") + viper.Set(createBlockedAllowListKey, "ns/allowed") + + cfg, err := Parse() + s.NoError(err) + + s.False(cfg.IsCreationAllowed(&networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "ns", + Name: "forbidden", + }, + })) } diff --git a/main.go b/main.go index 32de9ce..863c26f 100644 --- a/main.go +++ b/main.go @@ -87,7 +87,11 @@ func main() { opts.BindFlags(flag.CommandLine) flag.Parse() - cfg := config.Parse() + cfg, err := config.Parse() + if err != nil { + setupLog.Error(err, "Failed to parse config") + os.Exit(1) + } ctrl.SetLogger(zap.New( zap.UseFlagOptions(&opts),