diff --git a/.changelog/27610.txt b/.changelog/27610.txt new file mode 100644 index 00000000000..456792a87ba --- /dev/null +++ b/.changelog/27610.txt @@ -0,0 +1,3 @@ +```release-note:new-resource +aws_ssm_default_patch_baseline +``` diff --git a/.ci/.semgrep-configs.yml b/.ci/.semgrep-configs.yml index c98fce788eb..6aa39013090 100644 --- a/.ci/.semgrep-configs.yml +++ b/.ci/.semgrep-configs.yml @@ -19,7 +19,9 @@ rules: patterns: - pattern-not-regex: "testAcc[a-zA-Z0-9]+Config(_[a-zA-Z0-9_]+_|_)[a-z0-9].*" - pattern-not: acctest.ConfigCompose(...) + - pattern-not: "..." severity: WARNING + - id: test-configcompose-funcs-correct-form languages: - go @@ -40,6 +42,7 @@ rules: - pattern-not-regex: "testAcc[a-zA-Z0-9]+Config(_[a-zA-Z0-9_]+_|_)[a-z0-9].*" - pattern-not-regex: "acctest\\..*" severity: WARNING + - id: test-config-funcs-check languages: - go @@ -59,6 +62,7 @@ rules: patterns: - pattern-regex: "testAccCheck.*" severity: WARNING + - id: test-configcompose-funcs-check languages: - go diff --git a/.ci/.semgrep-service-name3.yml b/.ci/.semgrep-service-name3.yml index 51428e82ca8..0306295019e 100644 --- a/.ci/.semgrep-service-name3.yml +++ b/.ci/.semgrep-service-name3.yml @@ -2069,6 +2069,8 @@ rules: metavariable: $NAME patterns: - pattern-regex: "(?i)SSM" + - pattern-not-regex: ^testAccSSMDefaultPatchBaseline_.+ + - pattern-not-regex: ^testAccSSMPatchBaseline_.+ - pattern-not-regex: ^TestAcc.* severity: WARNING - id: ssm-in-test-name diff --git a/go.mod b/go.mod index 23ba4979c6b..b0049fbf989 100644 --- a/go.mod +++ b/go.mod @@ -64,6 +64,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/iam v1.18.4 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.4 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.19 // indirect + github.com/aws/aws-sdk-go-v2/service/ssm v1.31.3 github.com/aws/aws-sdk-go-v2/service/sso v1.11.4 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.16.4 // indirect github.com/aws/smithy-go v1.13.4 // indirect diff --git a/go.sum b/go.sum index c36be743780..b671a081914 100644 --- a/go.sum +++ b/go.sum @@ -76,6 +76,8 @@ github.com/aws/aws-sdk-go-v2/service/s3control v1.25.0 h1:f71DbTsBMbfqSr6XnFz64c github.com/aws/aws-sdk-go-v2/service/s3control v1.25.0/go.mod h1:F2RWJqngKxHGxZUYA4jt/veKlbyXEpgSMZ67VyVpSEg= github.com/aws/aws-sdk-go-v2/service/sesv2 v1.15.0 h1:I/EKJDC0fQTlUE656GEBSsrvwYSDd22EKxuXk46kFIU= github.com/aws/aws-sdk-go-v2/service/sesv2 v1.15.0/go.mod h1:IeH7fIK+ReovHp+9rw9n5xxsxg5dDMLPF0DnL0AjPZc= +github.com/aws/aws-sdk-go-v2/service/ssm v1.31.3 h1:U+Zum+CFTxGydzOjfkQiQ3UOdsvMzf+D72/m9W0CvA8= +github.com/aws/aws-sdk-go-v2/service/ssm v1.31.3/go.mod h1:rEsqsZrOp9YvSGPOrcL3pR9+i/QJaWRkAYbuxMa7yCU= github.com/aws/aws-sdk-go-v2/service/sso v1.11.4 h1:Uw5wBybFQ1UeA9ts0Y07gbv0ncZnIAyw858tDW0NP2o= github.com/aws/aws-sdk-go-v2/service/sso v1.11.4/go.mod h1:cPDwJwsP4Kff9mldCXAmddjJL6JGQqtA3Mzer2zyr88= github.com/aws/aws-sdk-go-v2/service/sts v1.16.4 h1:+xtV90n3abQmgzk1pS++FdxZTrPEDgQng6e4/56WR2A= diff --git a/internal/conns/awsclient.go b/internal/conns/awsclient.go index 9e14e2d8386..8e84c8d25de 100644 --- a/internal/conns/awsclient.go +++ b/internal/conns/awsclient.go @@ -2,6 +2,8 @@ package conns import ( "fmt" + + "github.com/aws/aws-sdk-go-v2/service/ssm" ) // PartitionHostname returns a hostname with the provider domain suffix for the partition @@ -17,3 +19,7 @@ func (client *AWSClient) PartitionHostname(prefix string) string { func (client *AWSClient) RegionalHostname(prefix string) string { return fmt.Sprintf("%s.%s.%s", prefix, client.Region, client.DNSSuffix) } + +func (client *AWSClient) SSMClient() *ssm.Client { + return client.ssmClient.Client() +} diff --git a/internal/conns/awsclient_gen.go b/internal/conns/awsclient_gen.go index c3af861898f..add40d1bda8 100644 --- a/internal/conns/awsclient_gen.go +++ b/internal/conns/awsclient_gen.go @@ -13,6 +13,7 @@ import ( "github.com/aws/aws-sdk-go-v2/service/route53domains" s3control_sdkv2 "github.com/aws/aws-sdk-go-v2/service/s3control" "github.com/aws/aws-sdk-go-v2/service/sesv2" + ssm_sdkv2 "github.com/aws/aws-sdk-go-v2/service/ssm" "github.com/aws/aws-sdk-go-v2/service/transcribe" "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/accessanalyzer" @@ -326,6 +327,8 @@ type AWSClient struct { SupportedPlatforms []string TerraformVersion string + ssmClient lazyClient[*ssm_sdkv2.Client] + ACMConn *acm.ACM ACMPCAConn *acmpca.ACMPCA AMPConn *prometheusservice.PrometheusService diff --git a/internal/conns/config.go b/internal/conns/config.go index a7ead347df4..2aceb177b7b 100644 --- a/internal/conns/config.go +++ b/internal/conns/config.go @@ -17,6 +17,7 @@ import ( "github.com/aws/aws-sdk-go-v2/service/route53domains" "github.com/aws/aws-sdk-go-v2/service/s3control" "github.com/aws/aws-sdk-go-v2/service/sesv2" + "github.com/aws/aws-sdk-go-v2/service/ssm" "github.com/aws/aws-sdk-go-v2/service/transcribe" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/endpoints" @@ -276,6 +277,14 @@ func (c *Config) ConfigureProvider(ctx context.Context, client *AWSClient) (*AWS } }) + client.ssmClient.init(&cfg, func() *ssm.Client { + return ssm.NewFromConfig(cfg, func(o *ssm.Options) { + if endpoint := c.Endpoints[names.SSM]; endpoint != "" { + o.EndpointResolver = ssm.EndpointResolverFromURL(endpoint) + } + }) + }) + // sts stsConfig := &aws.Config{ Endpoint: aws.String(c.Endpoints[names.STS]), diff --git a/internal/conns/lazy.go b/internal/conns/lazy.go new file mode 100644 index 00000000000..c0ef723554d --- /dev/null +++ b/internal/conns/lazy.go @@ -0,0 +1,27 @@ +package conns + +import ( + "sync" + + "github.com/aws/aws-sdk-go-v2/aws" +) + +type clientInitFunc[T any] func() T + +type lazyClient[T any] struct { + initf clientInitFunc[T] + + once sync.Once + client T +} + +func (l *lazyClient[T]) init(config *aws.Config, f clientInitFunc[T]) { + l.initf = f +} + +func (l *lazyClient[T]) Client() T { + l.once.Do(func() { + l.client = l.initf() + }) + return l.client +} diff --git a/internal/create/errors.go b/internal/create/errors.go index a10e13d0fb8..c66cc917432 100644 --- a/internal/create/errors.go +++ b/internal/create/errors.go @@ -57,6 +57,15 @@ func DiagError(service, action, resource, id string, gotError error) diag.Diagno } } +func DiagErrorMessage(service, action, resource, id, message string) diag.Diagnostics { + return diag.Diagnostics{ + diag.Diagnostic{ + Severity: diag.Error, + Summary: ProblemStandardMessage(service, action, resource, id, fmt.Errorf(message)), + }, + } +} + // ErrorSetting returns an errors.Error with a standardized error message when setting // arguments and attributes values. func SettingError(service, resource, id, argument string, gotError error) error { diff --git a/internal/generate/awsclient/file.tmpl b/internal/generate/awsclient/file.tmpl index e03807b1079..8762c1f8069 100644 --- a/internal/generate/awsclient/file.tmpl +++ b/internal/generate/awsclient/file.tmpl @@ -5,6 +5,7 @@ import ( {{ range .Services }} {{ if ne .GoPackageOverride "" }}{{ .GoPackageOverride }}{{ end -}} "github.com/aws/aws-sdk-go{{ if eq .SDKVersion "2" }}-v2{{ end }}/service/{{ .GoPackage }}" {{- end }} + ssm_sdkv2 "github.com/aws/aws-sdk-go-v2/service/ssm" "github.com/aws/aws-sdk-go/aws/session" "github.com/hashicorp/terraform-provider-aws/internal/experimental/intf" tftags "github.com/hashicorp/terraform-provider-aws/internal/tags" @@ -25,6 +26,8 @@ type AWSClient struct { SupportedPlatforms []string TerraformVersion string + ssmClient lazyClient[*ssm_sdkv2.Client] + {{ range .Services }} {{ .ProviderNameUpper }}{{ if eq .SDKVersion "1" }}Conn{{ else }}Client{{end}} *{{ if ne .GoPackageOverride "" }}{{ .GoPackageOverride }}{{ else }}{{ .GoPackage }}{{ end }}.{{ .ClientTypeName }} {{- end }} diff --git a/internal/generate/clientconfig/main.go b/internal/generate/clientconfig/main.go index 92f3f725157..d899f451534 100644 --- a/internal/generate/clientconfig/main.go +++ b/internal/generate/clientconfig/main.go @@ -93,8 +93,7 @@ func main() { } func writeTemplate(body string, templateName string, td TemplateData) { - // If the file doesn't exist, create it, or append to the file - f, err := os.OpenFile(filename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + f, err := os.OpenFile(filename, os.O_TRUNC|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { log.Fatalf("error opening file (%s): %s", filename, err) } diff --git a/internal/generate/servicesemgrep/configs.tmpl b/internal/generate/servicesemgrep/configs.tmpl index c98fce788eb..6aa39013090 100644 --- a/internal/generate/servicesemgrep/configs.tmpl +++ b/internal/generate/servicesemgrep/configs.tmpl @@ -19,7 +19,9 @@ rules: patterns: - pattern-not-regex: "testAcc[a-zA-Z0-9]+Config(_[a-zA-Z0-9_]+_|_)[a-z0-9].*" - pattern-not: acctest.ConfigCompose(...) + - pattern-not: "..." severity: WARNING + - id: test-configcompose-funcs-correct-form languages: - go @@ -40,6 +42,7 @@ rules: - pattern-not-regex: "testAcc[a-zA-Z0-9]+Config(_[a-zA-Z0-9_]+_|_)[a-z0-9].*" - pattern-not-regex: "acctest\\..*" severity: WARNING + - id: test-config-funcs-check languages: - go @@ -59,6 +62,7 @@ rules: patterns: - pattern-regex: "testAccCheck.*" severity: WARNING + - id: test-configcompose-funcs-check languages: - go diff --git a/internal/generate/servicesemgrep/service.tmpl b/internal/generate/servicesemgrep/service.tmpl index df0fa036f62..0ce3eb9b40e 100644 --- a/internal/generate/servicesemgrep/service.tmpl +++ b/internal/generate/servicesemgrep/service.tmpl @@ -25,6 +25,10 @@ {{- if eq .ServiceAlias "CloudTrail" }} - pattern-not-regex: ^testAccCloudTrailConfig_.* {{- end }} + {{- if eq .ServiceAlias "SSM" }} + - pattern-not-regex: ^testAccSSMDefaultPatchBaseline_.+ + - pattern-not-regex: ^testAccSSMPatchBaseline_.+ + {{- end }} - pattern-not-regex: ^TestAcc.* severity: WARNING {{- end }} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 0f291089218..22b2674e274 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -2099,6 +2099,7 @@ func New(_ context.Context) (*schema.Provider, error) { "aws_ssm_activation": ssm.ResourceActivation(), "aws_ssm_association": ssm.ResourceAssociation(), + "aws_ssm_default_patch_baseline": ssm.ResourceDefaultPatchBaseline(), "aws_ssm_document": ssm.ResourceDocument(), "aws_ssm_maintenance_window": ssm.ResourceMaintenanceWindow(), "aws_ssm_maintenance_window_target": ssm.ResourceMaintenanceWindowTarget(), diff --git a/internal/service/apigatewayv2/api_mapping_test.go b/internal/service/apigatewayv2/api_mapping_test.go index f6890bb686a..fc5566f03ac 100644 --- a/internal/service/apigatewayv2/api_mapping_test.go +++ b/internal/service/apigatewayv2/api_mapping_test.go @@ -51,8 +51,8 @@ func testAccAPIMapping_createCertificate(t *testing.T, rName string, certificate ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, CheckDestroy: nil, Steps: []resource.TestStep{ - { // nosemgrep:ci.test-config-funcs-correct-form - Config: "# Dummy config.", + { + Config: "# Empty config", Check: resource.ComposeTestCheckFunc( testAccCheckAPIMappingCreateCertificate(rName, certificateArn), ), diff --git a/internal/service/ssm/default_patch_baseline.go b/internal/service/ssm/default_patch_baseline.go new file mode 100644 index 00000000000..8f79ba0a244 --- /dev/null +++ b/internal/service/ssm/default_patch_baseline.go @@ -0,0 +1,340 @@ +package ssm + +import ( + "context" + "errors" + "fmt" + "log" + "regexp" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/aws/arn" + "github.com/aws/aws-sdk-go-v2/service/ssm" + "github.com/aws/aws-sdk-go-v2/service/ssm/types" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" + "github.com/hashicorp/terraform-provider-aws/internal/conns" + "github.com/hashicorp/terraform-provider-aws/internal/create" + "github.com/hashicorp/terraform-provider-aws/internal/enum" + "github.com/hashicorp/terraform-provider-aws/internal/errs" + "github.com/hashicorp/terraform-provider-aws/internal/tfresource" + "github.com/hashicorp/terraform-provider-aws/names" + "golang.org/x/exp/slices" +) + +const ( + patchBaselineIDRegexPattern = `pb-[0-9a-f]{17}` +) + +func ResourceDefaultPatchBaseline() *schema.Resource { + return &schema.Resource{ + CreateWithoutTimeout: resourceDefaultPatchBaselineCreate, + ReadWithoutTimeout: resourceDefaultPatchBaselineRead, + DeleteWithoutTimeout: resourceDefaultPatchBaselineDelete, + + Importer: &schema.ResourceImporter{ + StateContext: func(ctx context.Context, d *schema.ResourceData, meta any) ([]*schema.ResourceData, error) { + id := d.Id() + + if isPatchBaselineID(id) || isPatchBaselineARN(id) { + conn := meta.(*conns.AWSClient).SSMClient() + + patchbaseline, err := findPatchBaselineByID(ctx, conn, id) + if err != nil { + return nil, fmt.Errorf("reading SSM Patch Baseline (%s): %w", id, err) + } + + d.SetId(string(patchbaseline.OperatingSystem)) + } else if vals := enum.Values[types.OperatingSystem](); !slices.Contains(vals, id) { + return nil, fmt.Errorf("ID (%s) must be either a Patch Baseline ID, Patch Baseline ARN, or one of %v", id, vals) + } + + return []*schema.ResourceData{d}, nil + }, + }, + + Schema: map[string]*schema.Schema{ + "baseline_id": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + DiffSuppressFunc: diffSuppressPatchBaselineID, + ValidateFunc: validation.Any( + validatePatchBaselineID, + validatePatchBaselineARN, + ), + }, + + "operating_system": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateDiagFunc: enum.Validate[types.OperatingSystem](), + }, + }, + } +} + +func diffSuppressPatchBaselineID(_, oldValue, newValue string, _ *schema.ResourceData) bool { + if oldValue == newValue { + return true + } + + oldId := oldValue + if arn.IsARN(oldValue) { + oldId = patchBaselineIDFromARN(oldValue) + } + + newId := newValue + if arn.IsARN(newValue) { + newId = patchBaselineIDFromARN(newValue) + } + + if oldId == newId { + return true + } + + return false +} + +var validatePatchBaselineID = validation.StringMatch(regexp.MustCompile(`^`+patchBaselineIDRegexPattern+`$`), `must match "pb-" followed by 17 hexadecimal characters`) + +func validatePatchBaselineARN(v any, k string) (ws []string, errors []error) { + value, ok := v.(string) + if !ok { + errors = append(errors, fmt.Errorf("expected type of %s to be string", k)) + return + } + + if value == "" { + return + } + + if _, err := arn.Parse(value); err != nil { + errors = append(errors, fmt.Errorf("%q (%s) is not a valid ARN: %s", k, value, err)) + return + } + + if !isPatchBaselineARN(value) { + errors = append(errors, fmt.Errorf("%q (%s) is not a valid SSM Patch Baseline ARN", k, value)) + return + } + + return +} + +func isPatchBaselineID(s string) bool { + re := regexp.MustCompile(`^` + patchBaselineIDRegexPattern + `$`) + + return re.MatchString(s) +} + +func isPatchBaselineARN(s string) bool { + parsedARN, err := arn.Parse(s) + if err != nil { + return false + } + + return patchBaselineIDFromARNResource(parsedARN.Resource) != "" +} + +func patchBaselineIDFromARN(s string) string { + arn, err := arn.Parse(s) + if err != nil { + return "" + } + + return patchBaselineIDFromARNResource(arn.Resource) +} + +func patchBaselineIDFromARNResource(s string) string { + re := regexp.MustCompile(`^patchbaseline/(` + patchBaselineIDRegexPattern + ")$") + matches := re.FindStringSubmatch(s) + if matches == nil || len(matches) != 2 { + return "" + } + + return matches[1] +} + +const ( + ResNameDefaultPatchBaseline = "Default Patch Baseline" +) + +func resourceDefaultPatchBaselineCreate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + conn := meta.(*conns.AWSClient).SSMClient() + + baselineID := d.Get("baseline_id").(string) + + patchBaseline, err := findPatchBaselineByID(ctx, conn, baselineID) + if err != nil { + return create.DiagErrorMessage(names.SSM, "registering", ResNameDefaultPatchBaseline, baselineID, + create.ProblemStandardMessage(names.SSM, create.ErrActionReading, resNamePatchBaseline, baselineID, err), + ) + } + if pbOS, cOS := string(patchBaseline.OperatingSystem), d.Get("operating_system"); pbOS != cOS { + return create.DiagErrorMessage(names.SSM, "registering", ResNameDefaultPatchBaseline, baselineID, + fmt.Sprintf("Patch Baseline Operating System (%s) does not match %s", pbOS, cOS), + ) + } + + in := &ssm.RegisterDefaultPatchBaselineInput{ + BaselineId: aws.String(baselineID), + } + _, err = conn.RegisterDefaultPatchBaseline(ctx, in) + if err != nil { + return create.DiagError(names.SSM, "registering", ResNameDefaultPatchBaseline, baselineID, err) + } + + // We need to retrieve the Operating System from the Patch Baseline to store for the ID + + d.SetId(string(patchBaseline.OperatingSystem)) + + return resourceDefaultPatchBaselineRead(ctx, d, meta) +} + +func resourceDefaultPatchBaselineRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + conn := meta.(*conns.AWSClient).SSMClient() + + out, err := FindDefaultPatchBaseline(ctx, conn, types.OperatingSystem(d.Id())) + if !d.IsNewResource() && tfresource.NotFound(err) { + log.Printf("[WARN] SSM Default Patch Baseline (%s) not found, removing from state", d.Id()) + d.SetId("") + return nil + } + if err != nil { + return create.DiagError(names.SSM, create.ErrActionReading, ResNameDefaultPatchBaseline, d.Id(), err) + } + + d.Set("baseline_id", out.BaselineId) + d.Set("operating_system", out.OperatingSystem) + + return nil +} + +func operatingSystemFilter(os ...string) types.PatchOrchestratorFilter { + return types.PatchOrchestratorFilter{ + Key: aws.String("OPERATING_SYSTEM"), + Values: os, + } +} + +func ownerIsAWSFilter() types.PatchOrchestratorFilter { // nosemgrep:ci.aws-in-func-name + return types.PatchOrchestratorFilter{ + Key: aws.String("OWNER"), + Values: []string{"AWS"}, + } +} + +func resourceDefaultPatchBaselineDelete(ctx context.Context, d *schema.ResourceData, meta any) (diags diag.Diagnostics) { + return defaultPatchBaselineRestoreOSDefault(ctx, meta.(*conns.AWSClient), d.Id()) +} + +func defaultPatchBaselineRestoreOSDefault(ctx context.Context, meta *conns.AWSClient, os string) (diags diag.Diagnostics) { + conn := meta.SSMClient() + + baselineID, err := FindDefaultDefaultPatchBaselineIDForOS(ctx, conn, types.OperatingSystem(os)) + if errors.Is(err, tfresource.ErrEmptyResult) { + diags = errs.AppendWarningf(diags, "no AWS-owned default Patch Baseline found for operating system %q", os) + return + } + var tmr *tfresource.TooManyResultsError + if errors.As(err, &tmr) { + diags = errs.AppendWarningf(diags, "found %d AWS-owned default Patch Baselines found for operating system %q", tmr.Count, os) + } + + in := &ssm.RegisterDefaultPatchBaselineInput{ + BaselineId: aws.String(baselineID), + } + _, err = conn.RegisterDefaultPatchBaseline(ctx, in) + if err != nil { + diags = errs.AppendErrorf(diags, "restoring SSM Default Patch Baseline for operating system %q to %q: %s", os, baselineID, err) + } + + return +} + +func FindDefaultPatchBaseline(ctx context.Context, conn *ssm.Client, os types.OperatingSystem) (*ssm.GetDefaultPatchBaselineOutput, error) { + in := &ssm.GetDefaultPatchBaselineInput{ + OperatingSystem: os, + } + out, err := conn.GetDefaultPatchBaseline(ctx, in) + if err != nil { + var nfe *types.DoesNotExistException + if errors.As(err, &nfe) { + return nil, &resource.NotFoundError{ + LastError: err, + LastRequest: in, + } + } + + return nil, err + } + + if out == nil { + return nil, tfresource.NewEmptyResultError(in) + } + + return out, nil +} + +func findPatchBaselineByID(ctx context.Context, conn *ssm.Client, id string) (*ssm.GetPatchBaselineOutput, error) { + in := &ssm.GetPatchBaselineInput{ + BaselineId: aws.String(id), + } + out, err := conn.GetPatchBaseline(ctx, in) + if err != nil { + var nfe *types.DoesNotExistException + if errors.As(err, &nfe) { + return nil, &resource.NotFoundError{ + LastError: err, + LastRequest: in, + } + } + + return nil, err + } + + if out == nil { + return nil, tfresource.NewEmptyResultError(in) + } + + return out, nil +} + +func patchBaselinesPaginator(conn *ssm.Client, filters ...types.PatchOrchestratorFilter) *ssm.DescribePatchBaselinesPaginator { + return ssm.NewDescribePatchBaselinesPaginator(conn, &ssm.DescribePatchBaselinesInput{ + Filters: filters, + }) +} + +func FindDefaultDefaultPatchBaselineIDForOS(ctx context.Context, conn *ssm.Client, os types.OperatingSystem) (string, error) { + paginator := patchBaselinesPaginator(conn, + operatingSystemFilter(string(os)), + ownerIsAWSFilter(), + ) + re := regexp.MustCompile(`^AWS-[A-Za-z0-9]+PatchBaseline$`) + var baselineIdentityIDs []string + for paginator.HasMorePages() { + out, err := paginator.NextPage(ctx) + if err != nil { + return "", fmt.Errorf("listing Patch Baselines for operating system %q: %s", os, err) + } + + for _, identity := range out.BaselineIdentities { + if id := aws.ToString(identity.BaselineName); re.MatchString(id) { + baselineIdentityIDs = append(baselineIdentityIDs, aws.ToString(identity.BaselineId)) + } + } + } + + if l := len(baselineIdentityIDs); l == 0 { + return "", tfresource.NewEmptyResultError(nil) + } else if l > 1 { + return "", tfresource.NewTooManyResultsError(l, nil) + } + + return baselineIdentityIDs[0], nil +} diff --git a/internal/service/ssm/default_patch_baseline_test.go b/internal/service/ssm/default_patch_baseline_test.go new file mode 100644 index 00000000000..880b13626cb --- /dev/null +++ b/internal/service/ssm/default_patch_baseline_test.go @@ -0,0 +1,533 @@ +package ssm_test + +import ( + "context" + "errors" + "fmt" + "regexp" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/ssm" + "github.com/aws/aws-sdk-go-v2/service/ssm/types" + sdkacctest "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + "github.com/hashicorp/terraform-provider-aws/internal/acctest" + "github.com/hashicorp/terraform-provider-aws/internal/conns" + "github.com/hashicorp/terraform-provider-aws/internal/create" + tfssm "github.com/hashicorp/terraform-provider-aws/internal/service/ssm" + "github.com/hashicorp/terraform-provider-aws/internal/tfresource" + "github.com/hashicorp/terraform-provider-aws/names" +) + +func testAccSSMDefaultPatchBaseline_basic(t *testing.T) { + var defaultpatchbaseline ssm.GetDefaultPatchBaselineOutput + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_ssm_default_patch_baseline.test" + baselineResourceName := "aws_ssm_patch_baseline.test" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(t) + acctest.PreCheckPartitionHasService(names.SSMEndpointID, t) + }, + ErrorCheck: acctest.ErrorCheck(t, names.SSMEndpointID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckDefaultPatchBaselineDestroy, + Steps: []resource.TestStep{ + { + Config: testAccDefaultPatchBaselineConfig_basic(rName), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckDefaultPatchBaselineExists(resourceName, &defaultpatchbaseline), + resource.TestCheckResourceAttrPair(resourceName, "baseline_id", baselineResourceName, "id"), + resource.TestCheckResourceAttrPair(resourceName, "id", baselineResourceName, "operating_system"), + ), + }, + // Import by OS + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + // Import by Baseline ID + { + ResourceName: resourceName, + ImportStateIdFunc: testAccDefaultPatchBaselineImportStateIdFunc(resourceName), + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func testAccSSMDefaultPatchBaseline_disappears(t *testing.T) { + var defaultpatchbaseline ssm.GetDefaultPatchBaselineOutput + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_ssm_default_patch_baseline.test" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(t) + acctest.PreCheckPartitionHasService(names.SSMEndpointID, t) + }, + ErrorCheck: acctest.ErrorCheck(t, names.SSMEndpointID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckDefaultPatchBaselineDestroy, + Steps: []resource.TestStep{ + { + Config: testAccDefaultPatchBaselineConfig_basic(rName), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckDefaultPatchBaselineExists(resourceName, &defaultpatchbaseline), + acctest.CheckResourceDisappears(acctest.Provider, tfssm.ResourceDefaultPatchBaseline(), resourceName), + ), + ExpectNonEmptyPlan: true, + }, + }, + }) +} + +func testAccSSMDefaultPatchBaseline_patchBaselineARN(t *testing.T) { + var defaultpatchbaseline ssm.GetDefaultPatchBaselineOutput + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_ssm_default_patch_baseline.test" + baselineResourceName := "aws_ssm_patch_baseline.test" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(t) + acctest.PreCheckPartitionHasService(names.SSMEndpointID, t) + }, + ErrorCheck: acctest.ErrorCheck(t, names.SSMEndpointID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckDefaultPatchBaselineDestroy, + Steps: []resource.TestStep{ + { + Config: testAccDefaultPatchBaselineConfig_patchBaselineARN(rName), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckDefaultPatchBaselineExists(resourceName, &defaultpatchbaseline), + resource.TestCheckResourceAttrPair(resourceName, "baseline_id", baselineResourceName, "id"), + resource.TestCheckResourceAttrPair(resourceName, "id", baselineResourceName, "operating_system"), + ), + }, + // Import by OS + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + // Import by Baseline ID + { + ResourceName: resourceName, + ImportStateIdFunc: testAccDefaultPatchBaselineImportStateIdFunc(resourceName), + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func testAccSSMDefaultPatchBaseline_otherOperatingSystem(t *testing.T) { + var defaultpatchbaseline ssm.GetDefaultPatchBaselineOutput + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_ssm_default_patch_baseline.test" + baselineResourceName := "aws_ssm_patch_baseline.test" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(t) + acctest.PreCheckPartitionHasService(names.SSMEndpointID, t) + }, + ErrorCheck: acctest.ErrorCheck(t, names.SSMEndpointID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckDefaultPatchBaselineDestroy, + Steps: []resource.TestStep{ + { + Config: testAccDefaultPatchBaselineConfig_operatingSystem(rName, types.OperatingSystemAmazonLinux2022), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckDefaultPatchBaselineExists(resourceName, &defaultpatchbaseline), + resource.TestCheckResourceAttrPair(resourceName, "baseline_id", baselineResourceName, "id"), + resource.TestCheckResourceAttrPair(resourceName, "id", baselineResourceName, "operating_system"), + ), + }, + // Import by OS + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + // Import by Baseline ID + { + ResourceName: resourceName, + ImportStateIdFunc: testAccDefaultPatchBaselineImportStateIdFunc(resourceName), + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func testAccSSMDefaultPatchBaseline_wrongOperatingSystem(t *testing.T) { + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(t) + acctest.PreCheckPartitionHasService(names.SSMEndpointID, t) + }, + ErrorCheck: acctest.ErrorCheck(t, names.SSMEndpointID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckDefaultPatchBaselineDestroy, + Steps: []resource.TestStep{ + { + Config: testAccDefaultPatchBaselineConfig_wrongOperatingSystem(rName, types.OperatingSystemAmazonLinux2022, types.OperatingSystemUbuntu), + ExpectError: regexp.MustCompile(regexp.QuoteMeta(fmt.Sprintf("Patch Baseline Operating System (%s) does not match %s", types.OperatingSystemAmazonLinux2022, types.OperatingSystemUbuntu))), + }, + }, + }) +} + +func testAccSSMDefaultPatchBaseline_systemDefault(t *testing.T) { + var defaultpatchbaseline ssm.GetDefaultPatchBaselineOutput + resourceName := "aws_ssm_default_patch_baseline.test" + baselineDataSourceName := "data.aws_ssm_patch_baseline.test" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(t) + acctest.PreCheckPartitionHasService(names.SSMEndpointID, t) + }, + ErrorCheck: acctest.ErrorCheck(t, names.SSMEndpointID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckDefaultPatchBaselineDestroy, + Steps: []resource.TestStep{ + { + Config: testAccDefaultPatchBaselineConfig_systemDefault(), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckDefaultPatchBaselineExists(resourceName, &defaultpatchbaseline), + resource.TestCheckResourceAttrPair(resourceName, "baseline_id", baselineDataSourceName, "id"), + resource.TestCheckResourceAttrPair(resourceName, "id", baselineDataSourceName, "operating_system"), + ), + }, + // Import by OS + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + // Import by Baseline ID + { + ResourceName: resourceName, + ImportStateIdFunc: testAccDefaultPatchBaselineImportStateIdFunc(resourceName), + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func testAccSSMDefaultPatchBaseline_update(t *testing.T) { + var v1, v2 ssm.GetDefaultPatchBaselineOutput + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_ssm_default_patch_baseline.test" + baselineResourceName := "aws_ssm_patch_baseline.test" + baselineUpdatedResourceName := "aws_ssm_patch_baseline.updated" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(t) + acctest.PreCheckPartitionHasService(names.SSMEndpointID, t) + }, + ErrorCheck: acctest.ErrorCheck(t, names.SSMEndpointID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckDefaultPatchBaselineDestroy, + Steps: []resource.TestStep{ + { + Config: testAccDefaultPatchBaselineConfig_operatingSystem(rName, types.OperatingSystemWindows), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckDefaultPatchBaselineExists(resourceName, &v1), + resource.TestCheckResourceAttrPair(resourceName, "baseline_id", baselineResourceName, "id"), + resource.TestCheckResourceAttrPair(resourceName, "id", baselineResourceName, "operating_system"), + ), + }, + { + Config: testAccDefaultPatchBaselineConfig_updated(rName, types.OperatingSystemWindows), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckDefaultPatchBaselineExists(resourceName, &v2), + resource.TestCheckResourceAttrPair(resourceName, "baseline_id", baselineUpdatedResourceName, "id"), + resource.TestCheckResourceAttrPair(resourceName, "id", baselineUpdatedResourceName, "operating_system"), + ), + }, + // Import by OS + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + // Import by Baseline ID + { + ResourceName: resourceName, + ImportStateIdFunc: testAccDefaultPatchBaselineImportStateIdFunc(resourceName), + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func testAccSSMDefaultPatchBaseline_multiRegion(t *testing.T) { + var main, alternate ssm.GetDefaultPatchBaselineOutput + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_ssm_default_patch_baseline.test" + resourceAlternateName := "aws_ssm_default_patch_baseline.alternate" + baselineResourceName := "aws_ssm_patch_baseline.test" + baselineAlternateResourceName := "aws_ssm_patch_baseline.alternate" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(t) + acctest.PreCheckPartitionHasService(names.SSMEndpointID, t) + acctest.PreCheckMultipleRegion(t, 2) + }, + ErrorCheck: acctest.ErrorCheck(t, names.SSMEndpointID), + ProtoV5ProviderFactories: acctest.ProtoV5FactoriesMultipleRegions(t, 2), + CheckDestroy: testAccCheckDefaultPatchBaselineDestroy, + Steps: []resource.TestStep{ + { + Config: testAccDefaultPatchBaselineConfig_multiRegion(rName), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckDefaultPatchBaselineExists(resourceName, &main), + resource.TestCheckResourceAttrPair(resourceName, "baseline_id", baselineResourceName, "id"), + resource.TestCheckResourceAttrPair(resourceName, "id", baselineResourceName, "operating_system"), + + testAccCheckDefaultPatchBaselineExists(resourceName, &alternate), + resource.TestCheckResourceAttrPair(resourceAlternateName, "baseline_id", baselineAlternateResourceName, "id"), + resource.TestCheckResourceAttrPair(resourceAlternateName, "id", baselineAlternateResourceName, "operating_system"), + ), + }, + // Import by OS + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + // Import by Baseline ID + { + ResourceName: resourceName, + ImportStateIdFunc: testAccDefaultPatchBaselineImportStateIdFunc(resourceName), + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func testAccCheckDefaultPatchBaselineDestroy(s *terraform.State) error { + conn := acctest.Provider.Meta().(*conns.AWSClient).SSMClient() + ctx := context.Background() + + for _, rs := range s.RootModule().Resources { + if rs.Type != "aws_ssm_default_patch_baseline" { + continue + } + + defaultOSPatchBaseline, err := tfssm.FindDefaultDefaultPatchBaselineIDForOS(ctx, conn, types.OperatingSystem(rs.Primary.ID)) + if err != nil { + return err + } + + // If the resource has been deleted, the default patch baseline will be the AWS-provided patch baseline for the OS + out, err := tfssm.FindDefaultPatchBaseline(ctx, conn, types.OperatingSystem(rs.Primary.ID)) + if tfresource.NotFound(err) { + return nil + } + if err != nil { + return err + } + + if aws.ToString(out.BaselineId) == defaultOSPatchBaseline { + return nil + } + + return create.Error(names.SSM, create.ErrActionCheckingDestroyed, tfssm.ResNameDefaultPatchBaseline, rs.Primary.ID, errors.New("not destroyed")) + } + + return nil +} + +func testAccCheckDefaultPatchBaselineExists(name string, defaultpatchbaseline *ssm.GetDefaultPatchBaselineOutput) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[name] + if !ok { + return create.Error(names.SSM, create.ErrActionCheckingExistence, tfssm.ResNameDefaultPatchBaseline, name, errors.New("not found")) + } + + if rs.Primary.ID == "" { + return create.Error(names.SSM, create.ErrActionCheckingExistence, tfssm.ResNameDefaultPatchBaseline, name, errors.New("not set")) + } + + conn := acctest.Provider.Meta().(*conns.AWSClient).SSMClient() + ctx := context.Background() + + resp, err := tfssm.FindDefaultPatchBaseline(ctx, conn, types.OperatingSystem(rs.Primary.ID)) + if err != nil { + return create.Error(names.SSM, create.ErrActionCheckingExistence, tfssm.ResNameDefaultPatchBaseline, rs.Primary.ID, err) + } + + *defaultpatchbaseline = *resp + + return nil + } +} + +func testAccDefaultPatchBaselineImportStateIdFunc(resourceName string) resource.ImportStateIdFunc { + return func(s *terraform.State) (string, error) { + rs, ok := s.RootModule().Resources[resourceName] + if !ok { + return "", fmt.Errorf("Not found: %s", resourceName) + } + + return rs.Primary.Attributes["baseline_id"], nil + } +} + +func testAccDefaultPatchBaselineConfig_basic(rName string) string { + return fmt.Sprintf(` +resource "aws_ssm_default_patch_baseline" "test" { + baseline_id = aws_ssm_patch_baseline.test.id + operating_system = aws_ssm_patch_baseline.test.operating_system +} + +resource "aws_ssm_patch_baseline" "test" { + name = %[1]q + + approved_patches = ["KB123456"] + approved_patches_compliance_level = "CRITICAL" +} +`, rName) +} + +func testAccDefaultPatchBaselineConfig_operatingSystem(rName string, os types.OperatingSystem) string { + return fmt.Sprintf(` +resource "aws_ssm_default_patch_baseline" "test" { + baseline_id = aws_ssm_patch_baseline.test.id + operating_system = aws_ssm_patch_baseline.test.operating_system +} + +resource "aws_ssm_patch_baseline" "test" { + name = %[1]q + operating_system = %[2]q + + approved_patches = ["KB123456"] + approved_patches_compliance_level = "CRITICAL" +} +`, rName, os) +} + +func testAccDefaultPatchBaselineConfig_wrongOperatingSystem(rName string, baselineOS, defaultOS types.OperatingSystem) string { + return fmt.Sprintf(` +resource "aws_ssm_default_patch_baseline" "test" { + baseline_id = aws_ssm_patch_baseline.test.id + operating_system = %[3]q +} + +resource "aws_ssm_patch_baseline" "test" { + name = %[1]q + operating_system = %[2]q + + approved_patches = ["KB123456"] + approved_patches_compliance_level = "CRITICAL" +} +`, rName, baselineOS, defaultOS) +} + +func testAccDefaultPatchBaselineConfig_patchBaselineARN(rName string) string { + return fmt.Sprintf(` +resource "aws_ssm_default_patch_baseline" "test" { + baseline_id = aws_ssm_patch_baseline.test.arn + operating_system = aws_ssm_patch_baseline.test.operating_system +} + +resource "aws_ssm_patch_baseline" "test" { + name = %[1]q + + approved_patches = ["KB123456"] + approved_patches_compliance_level = "CRITICAL" +} +`, rName) +} + +func testAccDefaultPatchBaselineConfig_systemDefault() string { + return ` +resource "aws_ssm_default_patch_baseline" "test" { + baseline_id = data.aws_ssm_patch_baseline.test.id + operating_system = data.aws_ssm_patch_baseline.test.operating_system +} + +data "aws_ssm_patch_baseline" "test" { + owner = "AWS" + name_prefix = "AWS-" + operating_system = "CENTOS" +} +` +} + +func testAccDefaultPatchBaselineConfig_updated(rName string, os types.OperatingSystem) string { + return fmt.Sprintf(` +resource "aws_ssm_default_patch_baseline" "test" { + baseline_id = aws_ssm_patch_baseline.updated.id + operating_system = aws_ssm_patch_baseline.updated.operating_system +} + +resource "aws_ssm_patch_baseline" "test" { + name = %[1]q + operating_system = %[2]q + + approved_patches = ["KB123456"] + approved_patches_compliance_level = "CRITICAL" +} + +resource "aws_ssm_patch_baseline" "updated" { + name = "%[1]s-updated" + operating_system = %[2]q + + approved_patches = ["KB123456"] + approved_patches_compliance_level = "CRITICAL" +} +`, rName, os) +} + +func testAccDefaultPatchBaselineConfig_multiRegion(rName string) string { + return acctest.ConfigCompose( + acctest.ConfigMultipleRegionProvider(2), + fmt.Sprintf(` +resource "aws_ssm_default_patch_baseline" "test" { + baseline_id = aws_ssm_patch_baseline.test.id + operating_system = aws_ssm_patch_baseline.test.operating_system +} + +resource "aws_ssm_patch_baseline" "test" { + name = %[1]q + + approved_patches = ["KB123456"] + approved_patches_compliance_level = "CRITICAL" +} + +resource "aws_ssm_default_patch_baseline" "alternate" { + provider = awsalternate + + baseline_id = aws_ssm_patch_baseline.alternate.id + operating_system = aws_ssm_patch_baseline.alternate.operating_system +} + +resource "aws_ssm_patch_baseline" "alternate" { + provider = awsalternate + + name = "%[1]s-alternate" + + approved_patches = ["KB123456"] + approved_patches_compliance_level = "CRITICAL" +} +`, rName), + ) +} diff --git a/internal/service/ssm/patch_baseline.go b/internal/service/ssm/patch_baseline.go index a471b204e5d..ba19efa5815 100644 --- a/internal/service/ssm/patch_baseline.go +++ b/internal/service/ssm/patch_baseline.go @@ -1,6 +1,7 @@ package ssm import ( + "context" "fmt" "log" "regexp" @@ -10,9 +11,11 @@ import ( "github.com/aws/aws-sdk-go/aws/arn" "github.com/aws/aws-sdk-go/service/ssm" "github.com/hashicorp/aws-sdk-go-base/v2/awsv1shim/v2/tfawserr" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" "github.com/hashicorp/terraform-provider-aws/internal/conns" + "github.com/hashicorp/terraform-provider-aws/internal/errs" "github.com/hashicorp/terraform-provider-aws/internal/flex" tftags "github.com/hashicorp/terraform-provider-aws/internal/tags" "github.com/hashicorp/terraform-provider-aws/internal/verify" @@ -20,10 +23,10 @@ import ( func ResourcePatchBaseline() *schema.Resource { return &schema.Resource{ - Create: resourcePatchBaselineCreate, - Read: resourcePatchBaselineRead, - Update: resourcePatchBaselineUpdate, - Delete: resourcePatchBaselineDelete, + Create: resourcePatchBaselineCreate, + Read: resourcePatchBaselineRead, + Update: resourcePatchBaselineUpdate, + DeleteWithoutTimeout: resourcePatchBaselineDelete, Importer: &schema.ResourceImporter{ State: schema.ImportStatePassthrough, }, @@ -218,6 +221,10 @@ func ResourcePatchBaseline() *schema.Resource { } } +const ( + resNamePatchBaseline = "Patch Baseline" +) + func resourcePatchBaselineCreate(d *schema.ResourceData, meta interface{}) error { conn := meta.(*conns.AWSClient).SSMConn defaultTagsConfig := meta.(*conns.AWSClient).DefaultTagsConfig @@ -325,7 +332,7 @@ func resourcePatchBaselineUpdate(d *schema.ResourceData, meta interface{}) error if d.HasChangesExcept("tags", "tags_all") { _, err := conn.UpdatePatchBaseline(params) if err != nil { - return fmt.Errorf("error updating SSM Patch Baseline (%s): %w", d.Id(), err) + return fmt.Errorf("updating SSM Patch Baseline (%s): %w", d.Id(), err) } } @@ -333,12 +340,13 @@ func resourcePatchBaselineUpdate(d *schema.ResourceData, meta interface{}) error o, n := d.GetChange("tags_all") if err := UpdateTags(conn, d.Id(), ssm.ResourceTypeForTaggingPatchBaseline, o, n); err != nil { - return fmt.Errorf("error updating SSM Patch Baseline (%s) tags: %s", d.Id(), err) + return fmt.Errorf("updating SSM Patch Baseline (%s) tags: %w", d.Id(), err) } } return resourcePatchBaselineRead(d, meta) } + func resourcePatchBaselineRead(d *schema.ResourceData, meta interface{}) error { conn := meta.(*conns.AWSClient).SSMConn defaultTagsConfig := meta.(*conns.AWSClient).DefaultTagsConfig @@ -368,15 +376,15 @@ func resourcePatchBaselineRead(d *schema.ResourceData, meta interface{}) error { d.Set("approved_patches_enable_non_security", resp.ApprovedPatchesEnableNonSecurity) if err := d.Set("global_filter", flattenPatchFilterGroup(resp.GlobalFilters)); err != nil { - return fmt.Errorf("Error setting global filters error: %#v", err) + return fmt.Errorf("setting global filters: %w", err) } if err := d.Set("approval_rule", flattenPatchRuleGroup(resp.ApprovalRules)); err != nil { - return fmt.Errorf("Error setting approval rules error: %#v", err) + return fmt.Errorf("setting approval rules: %w", err) } if err := d.Set("source", flattenPatchSource(resp.Sources)); err != nil { - return fmt.Errorf("Error setting patch sources error: %#v", err) + return fmt.Errorf("setting patch sources: %w", err) } arn := arn.ARN{ @@ -391,24 +399,24 @@ func resourcePatchBaselineRead(d *schema.ResourceData, meta interface{}) error { tags, err := ListTags(conn, d.Id(), ssm.ResourceTypeForTaggingPatchBaseline) if err != nil { - return fmt.Errorf("error listing tags for SSM Patch Baseline (%s): %s", d.Id(), err) + return fmt.Errorf("listing tags for SSM Patch Baseline (%s): %w", d.Id(), err) } tags = tags.IgnoreAWS().IgnoreConfig(ignoreTagsConfig) //lintignore:AWSR002 if err := d.Set("tags", tags.RemoveDefaultConfig(defaultTagsConfig).Map()); err != nil { - return fmt.Errorf("error setting tags: %w", err) + return fmt.Errorf("setting tags: %w", err) } if err := d.Set("tags_all", tags.Map()); err != nil { - return fmt.Errorf("error setting tags_all: %w", err) + return fmt.Errorf("setting tags_all: %w", err) } return nil } -func resourcePatchBaselineDelete(d *schema.ResourceData, meta interface{}) error { +func resourcePatchBaselineDelete(ctx context.Context, d *schema.ResourceData, meta any) (diags diag.Diagnostics) { conn := meta.(*conns.AWSClient).SSMConn log.Printf("[INFO] Deleting SSM Patch Baseline: %s", d.Id()) @@ -418,11 +426,19 @@ func resourcePatchBaselineDelete(d *schema.ResourceData, meta interface{}) error } _, err := conn.DeletePatchBaseline(params) + if tfawserr.ErrCodeEquals(err, ssm.ErrCodeResourceInUseException) { + // Reset the default patch baseline before retrying + diags = append(diags, defaultPatchBaselineRestoreOSDefault(ctx, meta.(*conns.AWSClient), d.Get("operating_system").(string))...) + if diags.HasError() { + return + } + _, err = conn.DeletePatchBaseline(params) + } if err != nil { - return fmt.Errorf("error deleting SSM Patch Baseline (%s): %s", d.Id(), err) + diags = errs.AppendErrorf(diags, "deleting SSM Patch Baseline (%s): %s", d.Id(), err) } - return nil + return } func expandPatchFilterGroup(d *schema.ResourceData) *ssm.PatchFilterGroup { diff --git a/internal/service/ssm/patch_baseline_test.go b/internal/service/ssm/patch_baseline_test.go index b5967606650..5d5d63b70cc 100644 --- a/internal/service/ssm/patch_baseline_test.go +++ b/internal/service/ssm/patch_baseline_test.go @@ -333,6 +333,42 @@ func TestAccSSMPatchBaseline_rejectPatchesAction(t *testing.T) { }) } +// testAccSSMPatchBaseline_deleteDefault needs to be serialized with the other +// Default Patch Baseline acceptance tests because it sets the default patch baseline +func testAccSSMPatchBaseline_deleteDefault(t *testing.T) { + var ssmPatch ssm.PatchBaselineIdentity + name := sdkacctest.RandString(10) + resourceName := "aws_ssm_patch_baseline.test" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ErrorCheck: acctest.ErrorCheck(t, ssm.EndpointsID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckPatchBaselineDestroy, + Steps: []resource.TestStep{ + { + Config: testAccPatchBaselineConfig_basic(name), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckPatchBaselineExists(resourceName, &ssmPatch), + ), + }, + { + PreConfig: func() { + conn := acctest.Provider.Meta().(*conns.AWSClient).SSMConn + + input := &ssm.RegisterDefaultPatchBaselineInput{ + BaselineId: ssmPatch.BaselineId, + } + if _, err := conn.RegisterDefaultPatchBaseline(input); err != nil { + t.Fatalf("registering Default Patch Baseline (%s): %s", aws.StringValue(ssmPatch.BaselineId), err) + } + }, + Config: "# Empty config", // Deletes the patch baseline + }, + }, + }) +} + func testAccCheckPatchBaselineRecreated(t *testing.T, before, after *ssm.PatchBaselineIdentity) resource.TestCheckFunc { return func(s *terraform.State) error { diff --git a/internal/service/ssm/ssm_test.go b/internal/service/ssm/ssm_test.go new file mode 100644 index 00000000000..60ea110aab6 --- /dev/null +++ b/internal/service/ssm/ssm_test.go @@ -0,0 +1,35 @@ +package ssm_test + +import "testing" + +// These tests affect regional defaults, so they needs to be serialized +func TestAccSSM_serial(t *testing.T) { + testCases := map[string]map[string]func(t *testing.T){ + "DefaultPatchBaseline": { + "basic": testAccSSMDefaultPatchBaseline_basic, + "disappears": testAccSSMDefaultPatchBaseline_disappears, + "otherOperatingSystem": testAccSSMDefaultPatchBaseline_otherOperatingSystem, + "patchBaselineARN": testAccSSMDefaultPatchBaseline_patchBaselineARN, + "systemDefault": testAccSSMDefaultPatchBaseline_systemDefault, + "update": testAccSSMDefaultPatchBaseline_update, + "deleteDefault": testAccSSMPatchBaseline_deleteDefault, + "multiRegion": testAccSSMDefaultPatchBaseline_multiRegion, + "wrongOperatingSystem": testAccSSMDefaultPatchBaseline_wrongOperatingSystem, + }, + "PatchBaseline": { + "deleteDefault": testAccSSMPatchBaseline_deleteDefault, + }, + } + + for group, m := range testCases { + m := m + t.Run(group, func(t *testing.T) { + for name, tc := range m { + tc := tc + t.Run(name, func(t *testing.T) { + tc(t) + }) + } + }) + } +} diff --git a/names/names.go b/names/names.go index 70e37fb95c0..700f22b906c 100644 --- a/names/names.go +++ b/names/names.go @@ -32,6 +32,7 @@ const ( RolesAnywhereEndpointID = "rolesanywhere" Route53DomainsEndpointID = "route53domains" SESV2EndpointID = "sesv2" + SSMEndpointID = "ssm" TranscribeEndpointID = "transcribe" ) diff --git a/website/docs/r/ssm_default_patch_baseline.html.markdown b/website/docs/r/ssm_default_patch_baseline.html.markdown new file mode 100644 index 00000000000..8406d6f1607 --- /dev/null +++ b/website/docs/r/ssm_default_patch_baseline.html.markdown @@ -0,0 +1,70 @@ +--- +subcategory: "SSM (Systems Manager)" +layout: "aws" +page_title: "AWS: aws_ssm_default_patch_baseline" +description: |- + Terraform resource for managing an AWS Systems Manager Default Patch Baseline. +--- + +# Resource: aws_ssm_default_patch_baseline + +Terraform resource for registering an AWS Systems Manager Default Patch Baseline. + +## Example Usage + +### Basic Usage + +```terraform +resource "aws_ssm_default_patch_baseline" "example" { + baseline_id = aws_ssm_patch_baseline.example.id + operating_system = aws_ssm_patch_baseline.example.operating_system +} + +resource "aws_ssm_patch_baseline" "example" { + name = "example" + approved_patches = ["KB123456"] +} +``` + +## Argument Reference + +The following arguments are required: + +* `baseline_id` - (Required) ID of the patch baseline. + Can be an ID or an ARN. + When specifying an AWS-provided patch baseline, must be the ARN. +* `operating_system` - (Required) The operating system the patch baseline applies to. + Valid values are + `AMAZON_LINUX`, + `AMAZON_LINUX_2`, + `AMAZON_LINUX_2022`, + `CENTOS`, + `DEBIAN`, + `MACOS`, + `ORACLE_LINUX`, + `RASPBIAN`, + `REDHAT_ENTERPRISE_LINUX`, + `ROCKY_LINUX`, + `SUSE`, + `UBUNTU`, and + `WINDOWS`. + +## Attributes Reference + +No additional attributes are exported. + +## Import + +The Systems Manager Default Patch Baseline can be imported using the patch baseline ID, patch baseline ARN, or the operating system value, e.g., + +``` +$ terraform import aws_ssm_default_patch_baseline.example pb-1234567890abcdef1 +``` + +``` +$ terraform import aws_ssm_default_patch_baseline.example arn:aws:ssm:us-west-2:123456789012:patchbaseline/pb-1234567890abcdef1 +``` + +``` +$ terraform import aws_ssm_default_patch_baseline.example CENTOS +``` diff --git a/website/docs/r/ssm_patch_baseline.html.markdown b/website/docs/r/ssm_patch_baseline.html.markdown index 55027f83e72..180ac63c853 100644 --- a/website/docs/r/ssm_patch_baseline.html.markdown +++ b/website/docs/r/ssm_patch_baseline.html.markdown @@ -8,7 +8,7 @@ description: |- # Resource: aws_ssm_patch_baseline -Provides an SSM Patch Baseline resource +Provides an SSM Patch Baseline resource. ~> **NOTE on Patch Baselines:** The `approved_patches` and `approval_rule` are both marked as optional fields, but the Patch Baseline requires that at least one @@ -16,7 +16,9 @@ of them is specified. ## Example Usage -Basic usage using `approved_patches` only +### Basic Usage + +Using `approved_patches` only. ```terraform resource "aws_ssm_patch_baseline" "production" { @@ -25,7 +27,7 @@ resource "aws_ssm_patch_baseline" "production" { } ``` -Advanced usage, specifying patch filters +### Advanced Usage, specifying patch filters ```terraform resource "aws_ssm_patch_baseline" "production" { @@ -80,7 +82,7 @@ resource "aws_ssm_patch_baseline" "production" { } ``` -Advanced usage, specifying Microsoft application and Windows patch rules +### Advanced usage, specifying Microsoft application and Windows patch rules ```terraform resource "aws_ssm_patch_baseline" "windows_os_apps" { @@ -119,7 +121,7 @@ resource "aws_ssm_patch_baseline" "windows_os_apps" { } ``` -Advanced usage, specifying alternate patch source repository +### Advanced usage, specifying alternate patch source repository ```terraform resource "aws_ssm_patch_baseline" "al_2017_09" { @@ -160,31 +162,76 @@ The following arguments are supported: * `name` - (Required) The name of the patch baseline. * `description` - (Optional) The description of the patch baseline. -* `operating_system` - (Optional) Defines the operating system the patch baseline applies to. Supported operating systems are `AMAZON_LINUX`, `AMAZON_LINUX_2`, `UBUNTU`, `REDHAT_ENTERPRISE_LINUX`, `SUSE`, `CENTOS`, `ORACLE_LINUX`, `DEBIAN`, `MACOS`, `RASPBIAN` and `ROCKY_LINUX`. The Default value is `WINDOWS`. -* `approved_patches_compliance_level` - (Optional) Defines the compliance level for approved patches. This means that if an approved patch is reported as missing, this is the severity of the compliance violation. Valid compliance levels include the following: `CRITICAL`, `HIGH`, `MEDIUM`, `LOW`, `INFORMATIONAL`, `UNSPECIFIED`. The default value is `UNSPECIFIED`. +* `operating_system` - (Optional) The operating system the patch baseline applies to. + Valid values are + `AMAZON_LINUX`, + `AMAZON_LINUX_2`, + `AMAZON_LINUX_2022`, + `CENTOS`, + `DEBIAN`, + `MACOS`, + `ORACLE_LINUX`, + `RASPBIAN`, + `REDHAT_ENTERPRISE_LINUX`, + `ROCKY_LINUX`, + `SUSE`, + `UBUNTU`, and + `WINDOWS`. + The default value is `WINDOWS`. +* `approved_patches_compliance_level` - (Optional) The compliance level for approved patches. + This means that if an approved patch is reported as missing, this is the severity of the compliance violation. + Valid values are `CRITICAL`, `HIGH`, `MEDIUM`, `LOW`, `INFORMATIONAL`, `UNSPECIFIED`. + The default value is `UNSPECIFIED`. * `approved_patches` - (Optional) A list of explicitly approved patches for the baseline. + Cannot be specified with `approval_rule`. * `rejected_patches` - (Optional) A list of rejected patches. -* `global_filter` - (Optional) A set of global filters used to exclude patches from the baseline. Up to 4 global filters can be specified using Key/Value pairs. Valid Keys are `PRODUCT | CLASSIFICATION | MSRC_SEVERITY | PATCH_ID`. -* `approval_rule` - (Optional) A set of rules used to include patches in the baseline. up to 10 approval rules can be specified. Each approval_rule block requires the fields documented below. -* `source` - (Optional) Configuration block(s) with alternate sources for patches. Applies to Linux instances only. Documented below. -* `rejected_patches_action` - (Optional) The action for Patch Manager to take on patches included in the `rejected_patches` list. Allow values are `ALLOW_AS_DEPENDENCY` and `BLOCK`. -* `approved_patches_enable_non_security` - (Optional) Indicates whether the list of approved patches includes non-security updates that should be applied to the instances. Applies to Linux instances only. +* `global_filter` - (Optional) A set of global filters used to exclude patches from the baseline. + Up to 4 global filters can be specified using Key/Value pairs. + Valid Keys are `PRODUCT`, `CLASSIFICATION`, `MSRC_SEVERITY`, and `PATCH_ID`. +* `approval_rule` - (Optional) A set of rules used to include patches in the baseline. + Up to 10 approval rules can be specified. + See [`approval_rule`](#approval_rule-block) below. +* `source` - (Optional) Configuration block with alternate sources for patches. + Applies to Linux instances only. + See [`source`](#source-block) below. +* `rejected_patches_action` - (Optional) The action for Patch Manager to take on patches included in the `rejected_patches` list. + Valid values are `ALLOW_AS_DEPENDENCY` and `BLOCK`. +* `approved_patches_enable_non_security` - (Optional) Indicates whether the list of approved patches includes non-security updates that should be applied to the instances. + Applies to Linux instances only. * `tags` - (Optional) A map of tags to assign to the resource. If configured with a provider [`default_tags` configuration block](https://registry.terraform.io/providers/hashicorp/aws/latest/docs#default_tags-configuration-block) present, tags with matching keys will overwrite those defined at the provider-level. +### `approval_rule` Block + The `approval_rule` block supports: -* `approve_after_days` - (Optional) The number of days after the release date of each patch matched by the rule the patch is marked as approved in the patch baseline. Valid Range: 0 to 100. Conflicts with `approve_until_date` -* `approve_until_date` - (Optional) The cutoff date for auto approval of released patches. Any patches released on or before this date are installed automatically. Date is formatted as `YYYY-MM-DD`. Conflicts with `approve_after_days` -* `patch_filter` - (Required) The patch filter group that defines the criteria for the rule. Up to 5 patch filters can be specified per approval rule using Key/Value pairs. Valid combinations of these Keys and the `operating_system` value can be found in the [SSM DescribePatchProperties API Reference](https://docs.aws.amazon.com/systems-manager/latest/APIReference/API_DescribePatchProperties.html). Valid Values are exact values for the patch property given as the key, or a wildcard `*`, which matches all values. +* `approve_after_days` - (Optional) The number of days after the release date of each patch matched by the rule the patch is marked as approved in the patch baseline. + Valid Range: 0 to 100. + Conflicts with `approve_until_date`. +* `approve_until_date` - (Optional) The cutoff date for auto approval of released patches. + Any patches released on or before this date are installed automatically. + Date is formatted as `YYYY-MM-DD`. + Conflicts with `approve_after_days` +* `patch_filter` - (Required) The patch filter group that defines the criteria for the rule. + Up to 5 patch filters can be specified per approval rule using Key/Value pairs. + Valid combinations of these Keys and the `operating_system` value can be found in the [SSM DescribePatchProperties API Reference](https://docs.aws.amazon.com/systems-manager/latest/APIReference/API_DescribePatchProperties.html). + Valid Values are exact values for the patch property given as the key, or a wildcard `*`, which matches all values. * `PATCH_SET` defaults to `OS` if unspecified -* `compliance_level` - (Optional) Defines the compliance level for patches approved by this rule. Valid compliance levels include the following: `CRITICAL`, `HIGH`, `MEDIUM`, `LOW`, `INFORMATIONAL`, `UNSPECIFIED`. The default value is `UNSPECIFIED`. -* `enable_non_security` - (Optional) Boolean enabling the application of non-security updates. The default value is 'false'. Valid for Linux instances only. +* `compliance_level` - (Optional) The compliance level for patches approved by this rule. + Valid values are `CRITICAL`, `HIGH`, `MEDIUM`, `LOW`, `INFORMATIONAL`, and `UNSPECIFIED`. + The default value is `UNSPECIFIED`. +* `enable_non_security` - (Optional) Boolean enabling the application of non-security updates. + The default value is `false`. + Valid for Linux instances only. + +### `source` Block The `source` block supports: * `name` - (Required) The name specified to identify the patch source. -* `configuration` - (Required) The value of the yum repo configuration. For information about other options available for your yum repository configuration, see the [`dnf.conf` documentation](https://man7.org/linux/man-pages/man5/dnf.conf.5.html) -* `products` - (Required) The specific operating system versions a patch repository applies to, such as `"Ubuntu16.04"`, `"AmazonLinux2016.09"`, `"RedhatEnterpriseLinux7.2"` or `"Suse12.7"`. For lists of supported product values, see [PatchFilter](https://docs.aws.amazon.com/systems-manager/latest/APIReference/API_PatchFilter.html). +* `configuration` - (Required) The value of the yum repo configuration. + For information about other options available for your yum repository configuration, see the [`dnf.conf` documentation](https://man7.org/linux/man-pages/man5/dnf.conf.5.html) +* `products` - (Required) The specific operating system versions a patch repository applies to, such as `"Ubuntu16.04"`, `"AmazonLinux2016.09"`, `"RedhatEnterpriseLinux7.2"` or `"Suse12.7"`. + For lists of supported product values, see [PatchFilter](https://docs.aws.amazon.com/systems-manager/latest/APIReference/API_PatchFilter.html). ## Attributes Reference