Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 21 additions & 0 deletions pkg/resources/logging/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
83 changes: 83 additions & 0 deletions pkg/resources/logging/log_bucket.go
Original file line number Diff line number Diff line change
@@ -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
}
29 changes: 29 additions & 0 deletions pkg/resources/logging/resources.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -198,6 +225,8 @@ func init() {
panic(err)
}

registerLogBucketOverrides()

// Log views need a List that walks the buckets; see log_view_list.go.
registerLogViewListOverride()
}
79 changes: 79 additions & 0 deletions schema/pkl/logging/logBucket.pkl
Original file line number Diff line number Diff line change
@@ -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
}
}
26 changes: 26 additions & 0 deletions testdata/logging-log-bucket-update.pkl
Original file line number Diff line number Diff line change
@@ -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
}
}
26 changes: 26 additions & 0 deletions testdata/logging-log-bucket.pkl
Original file line number Diff line number Diff line change
@@ -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
}
}