diff --git a/CHANGELOG.md b/CHANGELOG.md index 846095b5..22d4c5db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,22 @@ formae agent. ### Added +- `GCP::Logging::LogBucket` — where log entries are actually retained. A sink + routes entries into a bucket and a view is a window onto one, so this is what + decides how long logs live and where. + + Deleting a bucket does not remove it: it enters `DELETE_REQUESTED` and stays + for seven days so it can be undeleted, and a get answers 200 with that state + rather than 404. The plugin reports a bucket in that state as gone, so an + out-of-band delete leaves inventory and discovery does not offer buckets on + their way out. + + `locked` is modelled so an existing locked bucket reads correctly, but note a + locked bucket can never be deleted and locking cannot be undone. Every project + also has `_Default` and `_Required` buckets created by GCP, so discovery + reports two per project that nobody declared. + +### Fixed - `GCP::Storage::ManagedFolder` — an IAM boundary inside a bucket, letting a policy be attached to a prefix without granting it over the whole bucket. Requires uniform bucket-level access. diff --git a/pkg/resources/logging/api.go b/pkg/resources/logging/api.go index 5a7dd1c9..95d7ca4d 100644 --- a/pkg/resources/logging/api.go +++ b/pkg/resources/logging/api.go @@ -177,6 +177,27 @@ func parseLoggingViewNativeID(nativeID string) (base.PathContext, error) { }, nil } +// LoggingBucketNativeID - a log bucket's path carries its location: +// projects/{p}/locations/{loc}/buckets/{id}. The flat project-level form would +// not round-trip, and the view parser wants two more segments. +var LoggingBucketNativeID = base.NativeIDConfig{ + Format: base.FullPathFormat, + Parser: parseLoggingBucketNativeID, +} + +func parseLoggingBucketNativeID(nativeID string) (base.PathContext, error) { + parts := strings.Split(nativeID, "/") + if len(parts) != 6 || parts[0] != "projects" || parts[2] != "locations" || parts[4] != "buckets" { + return base.PathContext{}, fmt.Errorf("invalid Logging bucket native ID: %s", nativeID) + } + return base.PathContext{ + Project: parts[1], + Location: parts[3], + ResourceType: parts[4], + ResourceName: parts[5], + }, nil +} + // LoggingOperations - Cloud Logging log-metric operations are synchronous: // metrics.create/update return the LogMetric directly and metrics.delete // returns google.protobuf.Empty. No long-running Operation is involved. diff --git a/pkg/resources/logging/log_bucket.go b/pkg/resources/logging/log_bucket.go new file mode 100644 index 00000000..15386558 --- /dev/null +++ b/pkg/resources/logging/log_bucket.go @@ -0,0 +1,83 @@ +// © 2025 Platform Engineering Labs Inc. +// +// SPDX-License-Identifier: FSL-1.1-ALv2 + +package logging + +import ( + "context" + "encoding/json" + + "github.com/platform-engineering-labs/formae-plugin-gcp/pkg/config" + "github.com/platform-engineering-labs/formae-plugin-gcp/pkg/resources/base" + "github.com/platform-engineering-labs/formae-plugin-gcp/pkg/resources/prov" + "github.com/platform-engineering-labs/formae-plugin-gcp/pkg/resources/registry" + "github.com/platform-engineering-labs/formae-plugin-gcp/pkg/utils" + "github.com/platform-engineering-labs/formae/pkg/plugin/resource" +) + +// logBucketDeleteRequested is what Cloud Logging leaves behind instead of +// removing a bucket. A deleted bucket sits in this state for seven days so it +// can be undeleted, and a get answers 200 with it rather than 404. +const logBucketDeleteRequested = "DELETE_REQUESTED" + +// logBucketResponseTransformer puts back the location the API leaves in the +// path and shortens the name, so the read state matches what a forma declares. +func logBucketResponseTransformer( + props map[string]interface{}, _ base.TransformContext, +) map[string]interface{} { + out := make(map[string]interface{}, len(props)+1) + for k, v := range props { + out[k] = v + } + // projects/{p}/locations/{loc}/buckets/{id} + if ctx, err := parseLoggingBucketNativeID(utils.GetString(props, "name")); err == nil { + out["location"] = ctx.Location + out["name"] = ctx.ResourceName + } + return out +} + +// logBucketProvisioner reports a bucket awaiting deletion as gone. +// +// Cloud Logging does not remove a deleted bucket: it moves to DELETE_REQUESTED +// and stays for seven days so it can be undeleted. The generic Read would +// report it as present for a week, so an out-of-band delete would never leave +// inventory and discovery would keep offering buckets that are on their way +// out. +type logBucketProvisioner struct { + prov.Provisioner +} + +// registerLogBucketOverrides is called from the package init in resources.go so +// the generic registration is guaranteed to have landed first. +func registerLogBucketOverrides() { + registry.Register(LogBucketResourceType, + []resource.Operation{resource.OperationRead}, + func(cfg *config.Config) prov.Provisioner { + return &logBucketProvisioner{ + Provisioner: loggingRegistry.CreateProvisioner(cfg, LogBucketResourceType), + } + }) +} + +func (p *logBucketProvisioner) Read( + ctx context.Context, request *resource.ReadRequest, +) (*resource.ReadResult, error) { + result, err := p.Provisioner.Read(ctx, request) + if err != nil || result == nil || result.ErrorCode != "" || result.Properties == "" { + return result, err + } + + var props map[string]interface{} + if unmarshalErr := json.Unmarshal([]byte(result.Properties), &props); unmarshalErr != nil { + return result, nil + } + if utils.GetString(props, "lifecycleState") == logBucketDeleteRequested { + return &resource.ReadResult{ + ResourceType: request.ResourceType, + ErrorCode: resource.OperationErrorCodeNotFound, + }, nil + } + return result, nil +} diff --git a/pkg/resources/logging/resources.go b/pkg/resources/logging/resources.go index c9a56345..55a8f76d 100644 --- a/pkg/resources/logging/resources.go +++ b/pkg/resources/logging/resources.go @@ -16,6 +16,7 @@ const ( ProjectSinkResourceType = "GCP::Logging::ProjectSink" ProjectExclusionResourceType = "GCP::Logging::ProjectExclusion" LogViewResourceType = "GCP::Logging::LogView" + LogBucketResourceType = "GCP::Logging::LogBucket" SavedQueryResourceType = "GCP::Logging::SavedQuery" LogScopeResourceType = "GCP::Logging::LogScope" ) @@ -98,6 +99,32 @@ func init() { }, // LogView - a filtered window onto a log bucket, used to grant access to // a subset of a bucket's entries. + { + // A log bucket is where log entries are actually retained; a view is + // a window onto one, and a sink routes entries into one. The + // project's _Default and _Required buckets are created by GCP, so + // discovery reports two per project it did not create. + ResourceType: LogBucketResourceType, + APIConfig: LoggingViewAPI, + OperationConfig: LoggingViewOperations, + NativeIDConfig: LoggingBucketNativeID, + ResourceConfig: base.ResourceConfig{ + ResourceType: "buckets", + CreateIDParam: "bucketId", + SupportsUpdate: true, + UpdateMethod: base.UpdateMethodPatch, + UpdateMaskFromBody: true, + }, + // "location" is a path component, not a body field, and would + // otherwise land in the updateMask. + RequestTransformer: &base.CompositeRequestTransformer{ + Transformers: []base.RequestTransformer{ + base.DropFields("location"), + base.DropFieldsOnUpdate("name"), + }, + }, + ResponseTransformer: base.ResponseTransformerFunc(logBucketResponseTransformer), + }, { ResourceType: LogViewResourceType, APIConfig: LoggingViewAPI, @@ -198,6 +225,8 @@ func init() { panic(err) } + registerLogBucketOverrides() + // Log views need a List that walks the buckets; see log_view_list.go. registerLogViewListOverride() } diff --git a/schema/pkl/logging/logBucket.pkl b/schema/pkl/logging/logBucket.pkl new file mode 100644 index 00000000..4cd22fdb --- /dev/null +++ b/schema/pkl/logging/logBucket.pkl @@ -0,0 +1,79 @@ +/* + * © 2025 Platform Engineering Labs Inc. + * + * SPDX-License-Identifier: FSL-1.1-ALv2 + */ + +/// GCP Cloud Logging Log Bucket +/// +/// Where log entries are actually retained. A sink routes entries into a +/// bucket and a view is a window onto one, so this is the thing that decides +/// how long logs live and where. +/// +/// Every project has `_Default` and `_Required` buckets created by GCP, so +/// discovery reports two per project that nobody declared. +/// +/// Deleting a bucket does not remove it: it enters `DELETE_REQUESTED` and stays +/// for seven days so it can be undeleted. The plugin reports a bucket in that +/// state as gone. +module gcp.logging.logBucket + +import "../gcp.pkl" +import "@formae/formae.pkl" + +const type = "GCP::Logging::LogBucket" + +open class LogBucketResolvable extends formae.Resolvable { + hidden type = module.type + + hidden name: LogBucketResolvable = (this) { + property = "name" + } +} + +@gcp.ResourceHint { + type = module.type + identifier = "name" +} +open class LogBucket extends formae.Resource { + hidden parent = this + + /// Short bucket id (e.g. "my-logs"). Immutable. + @gcp.FieldHint { createOnly = true } + name: String + + /// Location the bucket lives in — "global", or a region. Immutable: a + /// bucket cannot be moved. + @gcp.FieldHint { createOnly = true } + location: String + + @gcp.FieldHint + description: String? + + /// How long entries are kept, in days. GCP defaults to 30. + @gcp.FieldHint { hasProviderDefault = true } + retentionDays: Int? + + /// Turns on Log Analytics, which makes the bucket queryable through + /// BigQuery. Cannot be turned off once enabled. + @gcp.FieldHint { hasProviderDefault = true } + analyticsEnabled: Boolean? + + /// Whether the bucket's retention is locked. + /// + /// **A locked bucket can never be deleted, and locking cannot be undone.** + /// It is modelled so an existing locked bucket reads correctly, not as + /// something to set casually. + @gcp.FieldHint { hasProviderDefault = true } + locked: Boolean? + + /// Lifecycle as GCP reports it: "ACTIVE", or "DELETE_REQUESTED" for a + /// bucket inside its seven-day undelete window. + @gcp.FieldHint { hasProviderDefault = true } + lifecycleState: String? + + hidden res: LogBucketResolvable = new { + label = parent.label + stack = parent.stack?.label + } +} diff --git a/testdata/logging-log-bucket-update.pkl b/testdata/logging-log-bucket-update.pkl new file mode 100644 index 00000000..e6ff3eef --- /dev/null +++ b/testdata/logging-log-bucket-update.pkl @@ -0,0 +1,26 @@ +/* + * © 2025 Platform Engineering Labs Inc. + * + * SPDX-License-Identifier: FSL-1.1-ALv2 + */ + +amends "@formae/forma.pkl" + +import "@gcp/logging/logBucket.pkl" as logBucket +import "./config/vars.pkl" as v + +// A bucket with nothing routed into it retains nothing and costs nothing. +// `locked` is deliberately left unset: locking a bucket cannot be undone and +// a locked bucket can never be deleted, which would strand the test resource. +forma { + v.stack + v.target + + new logBucket.LogBucket { + label = "plugin-sdk-test-logging-log-bucket" + name = "formae-test-logbucket-\(v.testRunID)" + location = v.gcpLocation + description = "Updated log bucket description" + retentionDays = 45 + } +} diff --git a/testdata/logging-log-bucket.pkl b/testdata/logging-log-bucket.pkl new file mode 100644 index 00000000..80294021 --- /dev/null +++ b/testdata/logging-log-bucket.pkl @@ -0,0 +1,26 @@ +/* + * © 2025 Platform Engineering Labs Inc. + * + * SPDX-License-Identifier: FSL-1.1-ALv2 + */ + +amends "@formae/forma.pkl" + +import "@gcp/logging/logBucket.pkl" as logBucket +import "./config/vars.pkl" as v + +// A bucket with nothing routed into it retains nothing and costs nothing. +// `locked` is deliberately left unset: locking a bucket cannot be undone and +// a locked bucket can never be deleted, which would strand the test resource. +forma { + v.stack + v.target + + new logBucket.LogBucket { + label = "plugin-sdk-test-logging-log-bucket" + name = "formae-test-logbucket-\(v.testRunID)" + location = v.gcpLocation + description = "Test log bucket for plugin SDK conformance tests" + retentionDays = 30 + } +}