diff --git a/docs/coverage/README.md b/docs/coverage/README.md index 641aa90db..cfb06a9d7 100644 --- a/docs/coverage/README.md +++ b/docs/coverage/README.md @@ -60,7 +60,7 @@ code does not implement. Machine-readable: [`coverage.json`](./coverage.json). | `kusto` | — | [Kusto](./azure/kusto.md) | — | — | 14 | | `loadbalancer` | [ELB](./aws/elb.md) | [LB](./azure/lb.md) | [LB](./gcp/lb.md) | — | 19 | | `locks` | — | [Locks](./azure/locks.md) | — | — | 4 | -| `logging` | [CloudWatchLogs](./aws/cloudwatchlogs.md) | [LogAnalytics](./azure/loganalytics.md) | [CloudLogging](./gcp/cloudlogging.md) | — | 17 | +| `logging` | [CloudWatchLogs](./aws/cloudwatchlogs.md) | [LogAnalytics](./azure/loganalytics.md) | [CloudLogging](./gcp/cloudlogging.md) | [Logging](./oci/logging.md) | 17 | | `lro` | — | — | [LRO](./gcp/lro.md) | — | 1 | | `managedcassandra` | — | [ManagedCassandra](./azure/managedcassandra.md) | — | — | 15 | | `managedidentity` | — | [ManagedIdentity](./azure/managedidentity.md) | — | — | 8 | diff --git a/docs/coverage/coverage.json b/docs/coverage/coverage.json index f73f33559..7a0acf294 100644 --- a/docs/coverage/coverage.json +++ b/docs/coverage/coverage.json @@ -7128,7 +7128,8 @@ "providers": { "aws": "CloudWatchLogs", "azure": "LogAnalytics", - "gcp": "CloudLogging" + "gcp": "CloudLogging", + "oci": "Logging" } }, { diff --git a/docs/coverage/oci/README.md b/docs/coverage/oci/README.md index a04d3407d..adf9bdc6c 100644 --- a/docs/coverage/oci/README.md +++ b/docs/coverage/oci/README.md @@ -6,6 +6,7 @@ Services cloudemu emulates for OCI, by native name. Back to the [cross-provider | OCI service | Portable service | Operations | | --- | --- | --- | | [Identity](./identity.md) | `iam` | 40 | +| [Logging](./logging.md) | `logging` | 17 | | [Monitoring](./monitoring.md) | `monitoring` | 12 | | [VCN](./vcn.md) | `networking` | 57 | | [Workrequest](./workrequest.md) | — (provider-native) | 4 | diff --git a/docs/coverage/oci/logging.md b/docs/coverage/oci/logging.md new file mode 100644 index 000000000..96b27dc04 --- /dev/null +++ b/docs/coverage/oci/logging.md @@ -0,0 +1,30 @@ + +# Logging + +OCI's `logging` service · portable interface `driver.Logging` · [OCI index](./README.md) + +## Operations (17) + +| Operation | Description | +| --- | --- | +| `CreateLogGroup` | | +| `CreateLogStream` | | +| `DeleteLogGroup` | | +| `DeleteLogStream` | | +| `DeleteMetricFilter` | | +| `DeleteSubscriptionFilter` | | +| `DescribeMetricFilters` | | +| `DescribeSubscriptionFilters` | | +| `FilterLogEvents` | | +| `GetLogEvents` | | +| `GetLogGroup` | | +| `ListLogGroups` | | +| `ListLogStreams` | | +| `PutLogEvents` | | +| `PutMetricFilter` | | +| `PutSubscriptionFilter` | | +| `UpdateLogGroup` | UpdateLogGroup replaces the mutable fields (retention, tags) of an | + +## Not in scope + +_Not documented yet. See the [emulator boundary](../../../README.md) for cloudemu-wide non-goals._ diff --git a/docs/services.md b/docs/services.md index da13153e8..1b649f547 100644 --- a/docs/services.md +++ b/docs/services.md @@ -1422,7 +1422,7 @@ a source cluster and detach on promote; clone-on-read on every path. ## 13. Logging **Driver interface:** `services/logging/driver/driver.go` -**AWS:** CloudWatch Logs | **Azure:** Log Analytics | **GCP:** Cloud Logging +**AWS:** CloudWatch Logs | **Azure:** Log Analytics | **GCP:** Cloud Logging | **OCI:** Logging (a log group is the log group; a CUSTOM log is the log stream; an ingested log entry is the log event — metric and subscription filters have no OCI equivalent and report `Unimplemented`) ### Log Group Operations @@ -1448,7 +1448,7 @@ a source cluster and detach on promote; clone-on-read on every path. | `PutLogEvents` | `(ctx, logGroup, streamName, events) error` | | `GetLogEvents` | `(ctx, input) ([]LogEvent, error)` | -### Filtering and Metric Filters +### Filtering, Metric Filters and Subscription Filters | Operation | Signature | |-----------|-----------| @@ -1456,8 +1456,79 @@ a source cluster and detach on promote; clone-on-read on every path. | `PutMetricFilter` | `(ctx, config) error` | | `DeleteMetricFilter` | `(ctx, logGroup, filterName) error` | | `DescribeMetricFilters` | `(ctx, logGroup) ([]MetricFilterInfo, error)` | +| `PutSubscriptionFilter` | `(ctx, config) error` | +| `DeleteSubscriptionFilter` | `(ctx, logGroup, filterName) error` | +| `DescribeSubscriptionFilters` | `(ctx, logGroup) ([]SubscriptionFilterInfo, error)` | -**Total: 13 operations** +**Total: 17 operations** + +### OCI Logging + +**Optional capability:** `server/oci/logging.Extras` — OCI addresses log groups +and logs by OCID inside a compartment, gives a log a type and a service source, +batches ingestion, and searches with its own query language, none of which the +portable model carries. Its value types live in `providers/oci/logging`. +**Provider:** `providers/oci/logging` | **Wire:** `server/oci/logging` + +OCI publishes the service on three API surfaces, each at its own version +prefix. They collapse onto one CloudEmu server, so `Matches` claims each +prefix's collections exactly. A top-level `/logs` collection belongs to the +ingestion plane alone; the control plane nests logs under their log group. + +| Operation | Route | +|-----------|-------| +| `CreateLogGroup` | `POST /20200531/logGroups` | +| `ListLogGroups` | `GET /20200531/logGroups` | +| `GetLogGroup` | `GET /20200531/logGroups/{logGroupId}` | +| `UpdateLogGroup` | `PUT /20200531/logGroups/{logGroupId}` | +| `DeleteLogGroup` | `DELETE /20200531/logGroups/{logGroupId}` | +| `ChangeLogGroupCompartment` | `POST /20200531/logGroups/{logGroupId}/actions/changeCompartment` | +| `CreateLog` | `POST /20200531/logGroups/{logGroupId}/logs` | +| `ListLogs` | `GET /20200531/logGroups/{logGroupId}/logs` | +| `GetLog` | `GET /20200531/logGroups/{logGroupId}/logs/{logId}` | +| `UpdateLog` | `PUT /20200531/logGroups/{logGroupId}/logs/{logId}` | +| `DeleteLog` | `DELETE /20200531/logGroups/{logGroupId}/logs/{logId}` | +| `PutLogs` | `POST /20200601/logs/{logId}/actions/push` | +| `SearchLogs` | `POST /20190909/search` | + +`ListLogGroups` requires `compartmentId` and paginates with `limit` / `page`, +returning the cursor as `opc-next-page`. `ListLogs` takes no `compartmentId` — +the log group in the path fixes the compartment, as it does in real OCI — and +narrows on `displayName`, `logType`, `sourceService`, `sourceResource` and +`lifecycleState`. Every log group and log mutation is asynchronous in real OCI, +so each answers `202` with an `opc-work-request-id`; the created resource's +OCID comes back on the work request. Ingestion and search are synchronous. + +A CUSTOM log takes entries from `PutLogs`; a SERVICE log is fed by the service +its `configuration.source` names, so ingesting into one is refused rather than +accepted and dropped, as is ingesting into a disabled log. + +Search queries are read in the form +`search "compartmentId[/logGroupId[/logId]]" | where = '' [and …] +| sort by datetime [asc|desc]`, with `*` as the wildcard and comma-separated +search targets. Everything else is rejected naming what it tripped on rather +than answered with an empty result set: the `summarize`, `stats`, `topN` and +`extract` operators; `or`, `not` and parenthesised where clauses; the `>`, `<`, +`>=`, `<=`, `=~` and `!~` operators; a field the record shape has no place for, +including a nested payload path; a sort on anything but datetime; and a search +target segment written as a name where OCI takes an OCID. + +Not emulated: `/20200531/unifiedAgentConfigurations` and +`/20200531/logSavedSearches`, which the logging driver has no shape for. Both +are claimed so a caller gets a `501` naming the gap rather than a bare `404`. +A log group's retention is CloudEmu's: real OCI carries retention on the log, +and the group holds the default its logs inherit so the portable +`RetentionDays` has somewhere to live. Log group display names are unique per +compartment, as in real OCI, so the same name may be used in two compartments. +The portable driver has only a name to address a group by, so a name held in +more than one compartment is rejected as ambiguous rather than resolved +arbitrarily; such a group is reachable through the OCI API by OCID. + +A read limit is bounded before it sizes an allocation: `GetLogEvents`, +`FilterLogEvents` and `SearchLogs` reject a negative limit and one above +10000 with `InvalidArgument`. Subscription filters, like metric filters, are +not an OCI Logging operation and report `Unimplemented` — OCI delivers log +entries to another service through a Service Connector. --- diff --git a/providers/oci/logging/group.go b/providers/oci/logging/group.go new file mode 100644 index 000000000..d6b07b40e --- /dev/null +++ b/providers/oci/logging/group.go @@ -0,0 +1,182 @@ +package logging + +import ( + "context" + + cerrors "github.com/stackshy/cloudemu/v2/errors" +) + +// CreateGroup creates a log group. OCI-only: the portable driver has no +// compartment or description. +func (m *Mock) CreateGroup(_ context.Context, spec LogGroupSpec) (*LogGroup, error) { + m.mu.Lock() + defer m.mu.Unlock() + + return m.createGroup(spec) +} + +// createGroup is CreateGroup with mu already held. +func (m *Mock) createGroup(spec LogGroupSpec) (*LogGroup, error) { + if err := requireName(spec.DisplayName, "log group displayName"); err != nil { + return nil, err + } + + compartment := m.compartmentOr(spec.CompartmentID) + if _, ok := m.groupByName(compartment, spec.DisplayName); ok { + return nil, cerrors.Newf(cerrors.AlreadyExists, + "log group %q already exists in compartment %s", spec.DisplayName, compartment) + } + + retention := spec.RetentionDays + if retention == 0 { + retention = defaultRetentionDays + } + + now := m.now() + g := &LogGroup{ + ID: m.newOCID(typeLogGroup), + CompartmentID: compartment, + DisplayName: spec.DisplayName, + Description: spec.Description, + LifecycleState: StateActive, + TimeCreated: now, + TimeLastModified: now, + FreeformTags: copyTags(spec.FreeformTags), + RetentionDays: retention, + } + + m.groups.Set(g.ID, g) + + out := *g + + return &out, nil +} + +// GetGroup returns a log group by OCID. +func (m *Mock) GetGroup(_ context.Context, id string) (*LogGroup, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + g, ok := m.groups.Get(id) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "log group %q not found", id) + } + + out := *g + + return &out, nil +} + +// ListGroups returns the log groups in a compartment, optionally narrowed to +// one display name. Matching is exact: real OCI descends the compartment tree +// only when the caller asks it to. +func (m *Mock) ListGroups(_ context.Context, compartmentID, displayName string) ([]LogGroup, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if err := requireName(compartmentID, "compartmentId"); err != nil { + return nil, err + } + + out := make([]LogGroup, 0, m.groups.Len()) + + for _, g := range m.groups.SortedValues() { + if g.CompartmentID != compartmentID { + continue + } + + if displayName != "" && g.DisplayName != displayName { + continue + } + + out = append(out, *g) + } + + return out, nil +} + +// UpdateGroup replaces the mutable fields of a log group. +func (m *Mock) UpdateGroup(_ context.Context, id string, u LogGroupUpdate) (*LogGroup, error) { + m.mu.Lock() + defer m.mu.Unlock() + + g, ok := m.groups.Get(id) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "log group %q not found", id) + } + + if u.DisplayName != nil && *u.DisplayName != g.DisplayName { + if _, taken := m.groupByName(g.CompartmentID, *u.DisplayName); taken { + return nil, cerrors.Newf(cerrors.AlreadyExists, + "log group %q already exists in compartment %s", *u.DisplayName, g.CompartmentID) + } + + g.DisplayName = *u.DisplayName + } + + if u.Description != nil { + g.Description = *u.Description + } + + if u.FreeformTags != nil { + g.FreeformTags = copyTags(u.FreeformTags) + } + + g.TimeLastModified = m.now() + + out := *g + + return &out, nil +} + +// DeleteGroup deletes a log group and the logs inside it. Deleting the group +// discards their entries with them. +func (m *Mock) DeleteGroup(_ context.Context, id string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if !m.groups.Has(id) { + return cerrors.Newf(cerrors.NotFound, "log group %q not found", id) + } + + for _, rec := range m.logsIn(id) { + m.logs.Delete(rec.Log.ID) + } + + m.groups.Delete(id) + + return nil +} + +// MoveGroup moves a log group and its logs to another compartment. +func (m *Mock) MoveGroup(_ context.Context, id, compartmentID string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if err := requireName(compartmentID, "compartmentId"); err != nil { + return err + } + + g, ok := m.groups.Get(id) + if !ok { + return cerrors.Newf(cerrors.NotFound, "log group %q not found", id) + } + + if other, taken := m.groupByName(compartmentID, g.DisplayName); taken && other.ID != id { + return cerrors.Newf(cerrors.AlreadyExists, + "log group %q already exists in compartment %s", g.DisplayName, compartmentID) + } + + g.CompartmentID = compartmentID + g.TimeLastModified = m.now() + + for _, rec := range m.logsIn(id) { + rec.Log.CompartmentID = compartmentID + + if rec.Log.Configuration != nil { + rec.Log.Configuration.CompartmentID = compartmentID + } + } + + return nil +} diff --git a/providers/oci/logging/ingestion.go b/providers/oci/logging/ingestion.go new file mode 100644 index 000000000..756dfe804 --- /dev/null +++ b/providers/oci/logging/ingestion.go @@ -0,0 +1,109 @@ +package logging + +import ( + "context" + "time" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" +) + +// PutLogs ingests batches of entries into a custom log — the loggingingestion +// data plane. A SERVICE log is fed by the service that owns it, so ingesting +// into one is refused rather than silently accepted. +func (m *Mock) PutLogs(ctx context.Context, logID string, batches []LogEntryBatch) error { + m.mu.Lock() + + rec, ok := m.logs.Get(logID) + if !ok { + m.mu.Unlock() + return cerrors.Newf(cerrors.NotFound, "log %q not found", logID) + } + + if rec.Log.LogType != LogTypeCustom { + m.mu.Unlock() + return cerrors.Newf(cerrors.InvalidArgument, + "log %q is a %s log; only a %s log accepts PutLogs", logID, rec.Log.LogType, LogTypeCustom) + } + + if !rec.Log.IsEnabled { + m.mu.Unlock() + return cerrors.Newf(cerrors.FailedPrecondition, "log %q is disabled and accepts no entries", logID) + } + + count, bytes := m.ingest(rec, batches) + + compartmentID, groupID := rec.Log.CompartmentID, rec.Log.LogGroupID + mon := m.monitoring + m.mu.Unlock() + + dims := map[string]string{"logId": logID, "logGroupId": groupID, "compartmentId": compartmentID} + m.emitMetric(ctx, mon, "IngestedLogEntries", float64(count), dims) + m.emitMetric(ctx, mon, "IngestedLogBytes", float64(bytes), dims) + + return nil +} + +// ingest appends every batch's entries to a log, returning how many entries +// and payload bytes landed. The caller holds mu. +func (m *Mock) ingest(rec *logRecord, batches []LogEntryBatch) (count, bytes int) { + ingested := m.opts.Clock.Now().UTC() + + for i := range batches { + batch := &batches[i] + + for j := range batch.Entries { + entry := buildEntry(rec.Log.ID, batch, &batch.Entries[j], ingested) + rec.Entries = append(rec.Entries, entry) + count++ + bytes += len(entry.Data) + } + } + + return count, bytes +} + +// buildEntry stamps one ingested entry, filling in the batch defaults an +// entry leaves unset. +func buildEntry(logID string, batch *LogEntryBatch, item *LogEntryItem, ingested time.Time) LogEntry { + id := item.ID + if id == "" { + id = idgen.GenerateID("logentry") + } + + when := item.Time + if when.IsZero() { + when = batch.DefaultLogEntryTime + } + + if when.IsZero() { + when = ingested + } + + return LogEntry{ + ID: id, + LogID: logID, + Time: when.UTC(), + IngestedTime: ingested, + Data: item.Data, + Source: batch.Source, + Subject: batch.Subject, + Type: batch.Type, + } +} + +// Entries returns the entries ingested into a log, ordered as they arrived. +func (m *Mock) Entries(_ context.Context, logID string) ([]LogEntry, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + rec, ok := m.logs.Get(logID) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "log %q not found", logID) + } + + out := make([]LogEntry, len(rec.Entries)) + copy(out, rec.Entries) + + return out, nil +} diff --git a/providers/oci/logging/log.go b/providers/oci/logging/log.go new file mode 100644 index 000000000..85dcb19ac --- /dev/null +++ b/providers/oci/logging/log.go @@ -0,0 +1,255 @@ +package logging + +import ( + "context" + + cerrors "github.com/stackshy/cloudemu/v2/errors" +) + +// CreateLog creates a log inside a log group. +func (m *Mock) CreateLog(_ context.Context, groupID string, spec LogSpec) (*Log, error) { + m.mu.Lock() + defer m.mu.Unlock() + + return m.createLog(groupID, spec) +} + +// createLog is CreateLog with mu already held. +func (m *Mock) createLog(groupID string, spec LogSpec) (*Log, error) { + g, ok := m.groups.Get(groupID) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "log group %q not found", groupID) + } + + if err := requireName(spec.DisplayName, "log displayName"); err != nil { + return nil, err + } + + logType, err := normalizeLogType(spec.LogType) + if err != nil { + return nil, err + } + + if _, taken := m.logByName(groupID, spec.DisplayName); taken { + return nil, cerrors.Newf(cerrors.AlreadyExists, + "log %q already exists in log group %q", spec.DisplayName, groupID) + } + + cfg, err := normalizeConfiguration(logType, spec.Configuration, g.CompartmentID) + if err != nil { + return nil, err + } + + retention := spec.RetentionDuration + if retention == 0 { + retention = g.RetentionDays + } + + now := m.now() + l := Log{ + ID: m.newOCID(typeLog), + LogGroupID: groupID, + CompartmentID: g.CompartmentID, + DisplayName: spec.DisplayName, + LogType: logType, + IsEnabled: spec.IsEnabled, + RetentionDuration: retention, + Configuration: cfg, + LifecycleState: StateActive, + TimeCreated: now, + TimeLastModified: now, + FreeformTags: copyTags(spec.FreeformTags), + } + + m.logs.Set(l.ID, &logRecord{Log: l}) + + out := l + + return &out, nil +} + +// normalizeLogType defaults an unset log type to CUSTOM and rejects anything +// OCI does not define. +func normalizeLogType(logType string) (string, error) { + switch logType { + case "": + return LogTypeCustom, nil + case LogTypeCustom, LogTypeService: + return logType, nil + default: + return "", cerrors.Newf(cerrors.InvalidArgument, + "logType %q is not valid; OCI defines %s and %s", logType, LogTypeCustom, LogTypeService) + } +} + +// normalizeConfiguration validates a log's source against its type. A SERVICE +// log must name the service and resource feeding it; a CUSTOM log takes its +// entries from PutLogs and names no source. +func normalizeConfiguration(logType string, cfg *LogConfiguration, compartmentID string) (*LogConfiguration, error) { + if logType == LogTypeService { + if cfg == nil || cfg.Source.Service == "" || cfg.Source.Resource == "" { + return nil, cerrors.New(cerrors.InvalidArgument, + "a SERVICE log requires configuration.source with a service and a resource") + } + } + + if cfg == nil { + return &LogConfiguration{ + CompartmentID: compartmentID, + Source: LogSource{SourceType: sourceTypeOCIService}, + }, nil + } + + out := *cfg + out.Source.Parameters = copyTags(cfg.Source.Parameters) + + if out.CompartmentID == "" { + out.CompartmentID = compartmentID + } + + if out.Source.SourceType == "" { + out.Source.SourceType = sourceTypeOCIService + } + + return &out, nil +} + +// GetLog returns a log by OCID within its group. +func (m *Mock) GetLog(_ context.Context, groupID, logID string) (*Log, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + rec, err := m.findLog(groupID, logID) + if err != nil { + return nil, err + } + + out := rec.Log + + return &out, nil +} + +// ListLogs returns the logs in a group. OCI takes no compartmentId here — the +// group determines the compartment — so the group OCID is what is required. +// +//nolint:gocritic // hugeParam: LogFilter mirrors the query parameters and reads better by value. +func (m *Mock) ListLogs(_ context.Context, groupID string, f LogFilter) ([]Log, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if !m.groups.Has(groupID) { + return nil, cerrors.Newf(cerrors.NotFound, "log group %q not found", groupID) + } + + recs := m.logsIn(groupID) + out := make([]Log, 0, len(recs)) + + for _, rec := range recs { + if matchesLogFilter(&rec.Log, f) { + out = append(out, rec.Log) + } + } + + return out, nil +} + +// matchesLogFilter reports whether a log passes every named filter. +// +//nolint:gocritic // hugeParam: LogFilter reads better by value alongside ListLogs. +func matchesLogFilter(l *Log, f LogFilter) bool { + if !matchesAll(l.DisplayName, f.DisplayName, l.LogType, f.LogType, l.LifecycleState, f.LifecycleState) { + return false + } + + var service, resource string + if l.Configuration != nil { + service, resource = l.Configuration.Source.Service, l.Configuration.Source.Resource + } + + return matchesAll(service, f.SourceService, resource, f.SourceResource) +} + +// matchesAll reports whether each value matches its filter, an empty filter +// matching anything. Arguments are read in value, filter pairs. +func matchesAll(pairs ...string) bool { + for i := 0; i+1 < len(pairs); i += 2 { + if pairs[i+1] != "" && pairs[i] != pairs[i+1] { + return false + } + } + + return true +} + +// UpdateLog replaces the mutable fields of a log. +func (m *Mock) UpdateLog(_ context.Context, groupID, logID string, u LogUpdate) (*Log, error) { + m.mu.Lock() + defer m.mu.Unlock() + + rec, err := m.findLog(groupID, logID) + if err != nil { + return nil, err + } + + if u.DisplayName != nil && *u.DisplayName != rec.Log.DisplayName { + if _, taken := m.logByName(groupID, *u.DisplayName); taken { + return nil, cerrors.Newf(cerrors.AlreadyExists, + "log %q already exists in log group %q", *u.DisplayName, groupID) + } + + rec.Log.DisplayName = *u.DisplayName + } + + if u.IsEnabled != nil { + rec.Log.IsEnabled = *u.IsEnabled + } + + if u.RetentionDuration != nil { + rec.Log.RetentionDuration = *u.RetentionDuration + } + + if u.Configuration != nil { + cfg, cfgErr := normalizeConfiguration(rec.Log.LogType, u.Configuration, rec.Log.CompartmentID) + if cfgErr != nil { + return nil, cfgErr + } + + rec.Log.Configuration = cfg + } + + if u.FreeformTags != nil { + rec.Log.FreeformTags = copyTags(u.FreeformTags) + } + + rec.Log.TimeLastModified = m.now() + + out := rec.Log + + return &out, nil +} + +// DeleteLog deletes a log and the entries ingested into it. +func (m *Mock) DeleteLog(_ context.Context, groupID, logID string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if _, err := m.findLog(groupID, logID); err != nil { + return err + } + + m.logs.Delete(logID) + + return nil +} + +// findLog resolves a log by OCID and checks it belongs to the named group. +// A log addressed through the wrong group is a 404, as it is in real OCI. +// The caller holds mu. +func (m *Mock) findLog(groupID, logID string) (*logRecord, error) { + rec, ok := m.logs.Get(logID) + if !ok || rec.Log.LogGroupID != groupID { + return nil, cerrors.Newf(cerrors.NotFound, "log %q not found in log group %q", logID, groupID) + } + + return rec, nil +} diff --git a/providers/oci/logging/logging.go b/providers/oci/logging/logging.go new file mode 100644 index 000000000..8a9bdcd7b --- /dev/null +++ b/providers/oci/logging/logging.go @@ -0,0 +1,366 @@ +// Package logging provides an in-memory mock implementation of OCI Logging. +// It implements the portable logging driver: a log group is the log group, an +// OCI log is the log stream, and an ingested log entry is the log event. +// +// Real OCI splits the service across three API surfaces — the logging control +// plane for log groups and logs, loggingingestion for PutLogs and +// loggingsearch for SearchLogs. The mock holds all three behind one type; the +// wire handler keeps their paths apart. +package logging + +import ( + "context" + "maps" + "sync" + "time" + + "github.com/stackshy/cloudemu/v2/config" + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/internal/memstore" + "github.com/stackshy/cloudemu/v2/services/logging/driver" + mondriver "github.com/stackshy/cloudemu/v2/services/monitoring/driver" +) + +const timeFormat = time.RFC3339 + +// defaultRetentionDays is what a log group hands to logs created in it when +// the caller names no retention. Real OCI defaults a log to 30 days. +const defaultRetentionDays = 30 + +// defaultLogLimit caps a portable read that names no limit. +const defaultLogLimit = 100 + +// maxLogLimit is the largest limit a read may ask for. A caller-supplied limit +// sizes an allocation, so it is bounded rather than trusted. +const maxLogLimit = 10000 + +// OCI lifecycle states for log groups and logs. +const ( + StateCreating = "CREATING" + StateActive = "ACTIVE" +) + +// Log types. A CUSTOM log takes entries from PutLogs; a SERVICE log is fed by +// an OCI service named in its configuration. +const ( + LogTypeCustom = "CUSTOM" + LogTypeService = "SERVICE" +) + +// sourceTypeOCIService is the only source type OCI defines for a service log. +const sourceTypeOCIService = "OCISERVICE" + +// OCID resource type segments. +const ( + typeLogGroup = "loggroup" + typeLog = "log" +) + +// metricNamespace is the OCI Monitoring namespace Logging publishes under. +const metricNamespace = "oci_logging" + +// Compile-time check that Mock implements driver.Logging. +var _ driver.Logging = (*Mock)(nil) + +// LogGroup is an OCI log group. +type LogGroup struct { + ID string + CompartmentID string + DisplayName string + Description string + LifecycleState string + TimeCreated string + TimeLastModified string + FreeformTags map[string]string + // RetentionDays is the retention new logs in the group inherit. Real OCI + // carries retention on the log; the portable driver carries it on the + // group, so the group holds the default the two agree on. + RetentionDays int +} + +// LogSource names the OCI service and resource feeding a SERVICE log. +type LogSource struct { + SourceType string + Service string + Resource string + Category string + Parameters map[string]string +} + +// LogConfiguration is a log's source and archiving configuration. +type LogConfiguration struct { + CompartmentID string + Source LogSource + ArchivingEnabled bool +} + +// Log is an OCI log inside a log group. +type Log struct { + ID string + LogGroupID string + CompartmentID string + DisplayName string + LogType string + IsEnabled bool + RetentionDuration int + Configuration *LogConfiguration + LifecycleState string + TimeCreated string + TimeLastModified string + FreeformTags map[string]string +} + +// LogEntry is a single ingested log entry. +type LogEntry struct { + ID string + LogID string + Time time.Time + IngestedTime time.Time + Data string + Source string + Subject string + Type string +} + +// LogEntryItem is one entry of a PutLogs batch. +type LogEntryItem struct { + ID string + Data string + Time time.Time +} + +// LogEntryBatch is one batch of a PutLogs request. Entries with no time of +// their own take DefaultLogEntryTime. +type LogEntryBatch struct { + Entries []LogEntryItem + Source string + Type string + Subject string + DefaultLogEntryTime time.Time +} + +// LogGroupSpec describes a log group to create. +type LogGroupSpec struct { + CompartmentID string + DisplayName string + Description string + FreeformTags map[string]string + RetentionDays int +} + +// LogGroupUpdate carries the mutable fields of a log group. A nil pointer +// leaves the field untouched. +type LogGroupUpdate struct { + DisplayName *string + Description *string + FreeformTags map[string]string +} + +// LogSpec describes a log to create. +type LogSpec struct { + DisplayName string + LogType string + IsEnabled bool + RetentionDuration int + Configuration *LogConfiguration + FreeformTags map[string]string +} + +// LogUpdate carries the mutable fields of a log. A nil pointer leaves the +// field untouched. +type LogUpdate struct { + DisplayName *string + IsEnabled *bool + RetentionDuration *int + Configuration *LogConfiguration + FreeformTags map[string]string +} + +// LogFilter narrows a ListLogs call to what the query parameters name. +type LogFilter struct { + DisplayName string + LogType string + SourceService string + SourceResource string + LifecycleState string +} + +// logRecord is a log and the entries ingested into it. Its fields are exported +// so the record round-trips through the generic memstore snapshot helper, which +// serializes as JSON. +type logRecord struct { + Log Log + Entries []LogEntry +} + +// Mock is an in-memory mock implementation of the OCI Logging service. +type Mock struct { + // mu guards every store and the values they hold. Operations span more + // than one store — deleting a group walks the logs, search walks groups + // and logs together — so one lock covers them rather than each store's own. + mu sync.RWMutex + + groups *memstore.Store[*LogGroup] + logs *memstore.Store[*logRecord] + opts *config.Options + + monitoring mondriver.Monitoring +} + +// New creates a new OCI Logging mock. +func New(opts *config.Options) *Mock { + return &Mock{ + groups: memstore.New[*LogGroup](), + logs: memstore.New[*logRecord](), + opts: opts, + } +} + +// SetMonitoring points the mock at the monitoring service so ingestion +// publishes OCI Logging's metrics. +func (m *Mock) SetMonitoring(mon mondriver.Monitoring) { + m.mu.Lock() + defer m.mu.Unlock() + + m.monitoring = mon +} + +// newOCID mints an OCID for the given resource type in the configured realm +// and region. +func (m *Mock) newOCID(resourceType string) string { + return idgen.OCID(resourceType, m.opts.Realm, m.opts.OCIRegion()) +} + +// now returns the current time in OCI's timestamp format. +func (m *Mock) now() string { + return m.opts.Clock.Now().UTC().Format(timeFormat) +} + +// compartmentOr falls back to the configured default compartment, which is +// where a resource lands when the caller names none. +func (m *Mock) compartmentOr(id string) string { + if id != "" { + return id + } + + return m.opts.CompartmentID +} + +// groupByName resolves a log group by display name within one compartment. +// Display names are unique per compartment, as in real OCI. The caller holds mu. +func (m *Mock) groupByName(compartmentID, name string) (*LogGroup, bool) { + for _, g := range m.groups.SortedValues() { + if g.CompartmentID == compartmentID && g.DisplayName == name { + return g, true + } + } + + return nil, false +} + +// portableGroupByName resolves a log group by display name alone, the only +// handle the portable driver has. A name held in more than one compartment is +// ambiguous, so it is rejected rather than resolved arbitrarily. The caller +// holds mu. +func (m *Mock) portableGroupByName(name string) (*LogGroup, error) { + var found *LogGroup + + for _, g := range m.groups.SortedValues() { + if g.DisplayName != name { + continue + } + + if found != nil { + return nil, cerrors.Newf(cerrors.InvalidArgument, + "log group %q exists in more than one compartment (%s, %s); "+ + "address it through the OCI API by OCID", + name, found.CompartmentID, g.CompartmentID) + } + + found = g + } + + if found == nil { + return nil, cerrors.Newf(cerrors.NotFound, "log group %q not found", name) + } + + return found, nil +} + +// logByName resolves a log by display name within a group. The caller holds mu. +func (m *Mock) logByName(groupID, name string) (*logRecord, bool) { + for _, rec := range m.logs.SortedValues() { + if rec.Log.LogGroupID == groupID && rec.Log.DisplayName == name { + return rec, true + } + } + + return nil, false +} + +// logsIn returns every log belonging to a group, ordered by OCID. The caller +// holds mu. +func (m *Mock) logsIn(groupID string) []*logRecord { + var out []*logRecord + + for _, rec := range m.logs.SortedValues() { + if rec.Log.LogGroupID == groupID { + out = append(out, rec) + } + } + + return out +} + +// storedBytes sums the entry payloads held under a group. The caller holds mu. +func (m *Mock) storedBytes(groupID string) int64 { + var total int64 + + for _, rec := range m.logsIn(groupID) { + for i := range rec.Entries { + total += int64(len(rec.Entries[i].Data)) + } + } + + return total +} + +// emitMetric publishes one Logging metric. Called with mu released, so a +// monitoring driver reaching back into this mock cannot deadlock. +func (m *Mock) emitMetric( + ctx context.Context, mon mondriver.Monitoring, name string, value float64, dims map[string]string, +) { + if mon == nil { + return + } + + // Publication is best-effort: a metric the monitoring mock refuses must + // not fail the ingestion that produced it. + _ = mon.PutMetricData(ctx, []mondriver.MetricDatum{{ + Namespace: metricNamespace, + MetricName: name, + Value: value, + Unit: "Count", + Dimensions: dims, + Timestamp: m.opts.Clock.Now(), + }}) +} + +// copyTags returns a copy of a tag map, or nil for an empty one. +func copyTags(tags map[string]string) map[string]string { + if len(tags) == 0 { + return nil + } + + return maps.Clone(tags) +} + +// requireName rejects an empty resource name, which OCI does not accept. +func requireName(name, what string) error { + if name == "" { + return cerrors.Newf(cerrors.InvalidArgument, "%s is required", what) + } + + return nil +} diff --git a/providers/oci/logging/logging_test.go b/providers/oci/logging/logging_test.go new file mode 100644 index 000000000..8fa4da84c --- /dev/null +++ b/providers/oci/logging/logging_test.go @@ -0,0 +1,1414 @@ +package logging_test + +import ( + "context" + "errors" + "strconv" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stackshy/cloudemu/v2/config" + cerrors "github.com/stackshy/cloudemu/v2/errors" + ocilogging "github.com/stackshy/cloudemu/v2/providers/oci/logging" + "github.com/stackshy/cloudemu/v2/services/logging/driver" + mondriver "github.com/stackshy/cloudemu/v2/services/monitoring/driver" + "github.com/stackshy/cloudemu/v2/services/scope" +) + +const ( + compartmentA = "ocid1.compartment.oc1..aaaaaaaacompa" + compartmentB = "ocid1.compartment.oc1..aaaaaaaacompb" +) + +func newMock(t *testing.T) *ocilogging.Mock { + t.Helper() + + return ocilogging.New(config.NewOptions( + config.WithClock(config.NewFakeClock(time.Date(2026, 8, 8, 12, 0, 0, 0, time.UTC))), + config.WithRegion("us-ashburn-1"), + config.WithCompartmentID(compartmentA), + )) +} + +// newGroup creates a log group and returns it, failing the test on error. +func newGroup(t *testing.T, m *ocilogging.Mock, compartmentID, name string) *ocilogging.LogGroup { + t.Helper() + + g, err := m.CreateGroup(context.Background(), ocilogging.LogGroupSpec{ + CompartmentID: compartmentID, + DisplayName: name, + }) + require.NoError(t, err) + + return g +} + +// newCustomLog creates an enabled custom log in a group. +func newCustomLog(t *testing.T, m *ocilogging.Mock, groupID, name string) *ocilogging.Log { + t.Helper() + + l, err := m.CreateLog(context.Background(), groupID, ocilogging.LogSpec{ + DisplayName: name, + LogType: ocilogging.LogTypeCustom, + IsEnabled: true, + }) + require.NoError(t, err) + + return l +} + +func TestCreateGroup(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + spec ocilogging.LogGroupSpec + existing string + expectCode cerrors.Code + }{ + { + name: "success", + spec: ocilogging.LogGroupSpec{CompartmentID: compartmentA, DisplayName: "app-logs"}, + }, + { + name: "compartment defaults to the configured one", + spec: ocilogging.LogGroupSpec{DisplayName: "app-logs"}, + }, + { + name: "display name is required", + spec: ocilogging.LogGroupSpec{CompartmentID: compartmentA}, + expectCode: cerrors.InvalidArgument, + }, + { + name: "already exists", + spec: ocilogging.LogGroupSpec{CompartmentID: compartmentA, DisplayName: "dup"}, + existing: "dup", + expectCode: cerrors.AlreadyExists, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + m := newMock(t) + + if tc.existing != "" { + newGroup(t, m, compartmentA, tc.existing) + } + + g, err := m.CreateGroup(ctx, tc.spec) + + if tc.expectCode != cerrors.OK { + require.Error(t, err) + assert.Equal(t, tc.expectCode, cerrors.GetCode(err)) + + return + } + + require.NoError(t, err) + assert.Equal(t, tc.spec.DisplayName, g.DisplayName) + assert.Equal(t, compartmentA, g.CompartmentID) + assert.Equal(t, ocilogging.StateActive, g.LifecycleState) + assert.NotEmpty(t, g.TimeCreated) + }) + } +} + +func TestLogGroupOCIDShape(t *testing.T) { + m := newMock(t) + g := newGroup(t, m, compartmentA, "app-logs") + l := newCustomLog(t, m, g.ID, "stdout") + + assert.True(t, strings.HasPrefix(g.ID, "ocid1.loggroup.oc1.iad."), "got %q", g.ID) + assert.True(t, strings.HasPrefix(l.ID, "ocid1.log.oc1.iad."), "got %q", l.ID) +} + +func TestGetGroup(t *testing.T) { + ctx := context.Background() + m := newMock(t) + g := newGroup(t, m, compartmentA, "app-logs") + + t.Run("success", func(t *testing.T) { + got, err := m.GetGroup(ctx, g.ID) + require.NoError(t, err) + assert.Equal(t, g.ID, got.ID) + }) + + t.Run("not found", func(t *testing.T) { + _, err := m.GetGroup(ctx, "ocid1.loggroup.oc1.iad.missing") + require.Error(t, err) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) + }) +} + +func TestListGroupsFiltersByCompartment(t *testing.T) { + ctx := context.Background() + m := newMock(t) + newGroup(t, m, compartmentA, "in-a") + newGroup(t, m, compartmentB, "in-b") + + tests := []struct { + name string + compartment string + displayName string + expect []string + expectErr bool + }{ + {name: "compartment a", compartment: compartmentA, expect: []string{"in-a"}}, + {name: "compartment b", compartment: compartmentB, expect: []string{"in-b"}}, + {name: "unknown compartment lists nothing", compartment: "ocid1.compartment.oc1..zzz", expect: []string{}}, + {name: "narrowed by display name", compartment: compartmentA, displayName: "in-a", expect: []string{"in-a"}}, + {name: "display name that does not match", compartment: compartmentA, displayName: "nope", expect: []string{}}, + {name: "compartment is required", expectErr: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := m.ListGroups(ctx, tc.compartment, tc.displayName) + + if tc.expectErr { + require.Error(t, err) + assert.Equal(t, cerrors.InvalidArgument, cerrors.GetCode(err)) + + return + } + + require.NoError(t, err) + + names := make([]string, 0, len(got)) + for _, g := range got { + names = append(names, g.DisplayName) + } + + assert.Equal(t, tc.expect, names) + }) + } +} + +func TestUpdateGroup(t *testing.T) { + ctx := context.Background() + rename := "renamed" + + t.Run("success", func(t *testing.T) { + m := newMock(t) + g := newGroup(t, m, compartmentA, "app-logs") + + got, err := m.UpdateGroup(ctx, g.ID, ocilogging.LogGroupUpdate{ + DisplayName: &rename, + FreeformTags: map[string]string{"env": "prod"}, + }) + require.NoError(t, err) + assert.Equal(t, rename, got.DisplayName) + assert.Equal(t, "prod", got.FreeformTags["env"]) + }) + + t.Run("rename onto a taken name conflicts", func(t *testing.T) { + m := newMock(t) + g := newGroup(t, m, compartmentA, "app-logs") + newGroup(t, m, compartmentA, rename) + + _, err := m.UpdateGroup(ctx, g.ID, ocilogging.LogGroupUpdate{DisplayName: &rename}) + require.Error(t, err) + assert.Equal(t, cerrors.AlreadyExists, cerrors.GetCode(err)) + }) + + t.Run("not found", func(t *testing.T) { + m := newMock(t) + + _, err := m.UpdateGroup(ctx, "ocid1.loggroup.oc1.iad.missing", ocilogging.LogGroupUpdate{}) + require.Error(t, err) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) + }) +} + +func TestDeleteGroupRemovesItsLogs(t *testing.T) { + ctx := context.Background() + m := newMock(t) + g := newGroup(t, m, compartmentA, "app-logs") + l := newCustomLog(t, m, g.ID, "stdout") + + require.NoError(t, m.DeleteGroup(ctx, g.ID)) + + _, err := m.GetGroup(ctx, g.ID) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) + + _, err = m.GetLog(ctx, g.ID, l.ID) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) + + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(m.DeleteGroup(ctx, g.ID))) +} + +func TestMoveGroupCarriesItsLogs(t *testing.T) { + ctx := context.Background() + m := newMock(t) + g := newGroup(t, m, compartmentA, "app-logs") + l := newCustomLog(t, m, g.ID, "stdout") + + require.NoError(t, m.MoveGroup(ctx, g.ID, compartmentB)) + + moved, err := m.GetGroup(ctx, g.ID) + require.NoError(t, err) + assert.Equal(t, compartmentB, moved.CompartmentID) + + movedLog, err := m.GetLog(ctx, g.ID, l.ID) + require.NoError(t, err) + assert.Equal(t, compartmentB, movedLog.CompartmentID) + + inA, err := m.ListGroups(ctx, compartmentA, "") + require.NoError(t, err) + assert.Empty(t, inA) +} + +func TestCreateLog(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + spec ocilogging.LogSpec + existing string + group string + expectCode cerrors.Code + }{ + { + name: "custom log", + spec: ocilogging.LogSpec{DisplayName: "stdout", LogType: ocilogging.LogTypeCustom, IsEnabled: true}, + }, + { + name: "log type defaults to custom", + spec: ocilogging.LogSpec{DisplayName: "stdout", IsEnabled: true}, + }, + { + name: "service log with a source", + spec: ocilogging.LogSpec{ + DisplayName: "flowlogs", + LogType: ocilogging.LogTypeService, + Configuration: &ocilogging.LogConfiguration{ + Source: ocilogging.LogSource{Service: "flowlogs", Resource: "ocid1.subnet.oc1.iad.a"}, + }, + }, + }, + { + name: "service log without a source", + spec: ocilogging.LogSpec{DisplayName: "flowlogs", LogType: ocilogging.LogTypeService}, + expectCode: cerrors.InvalidArgument, + }, + { + name: "unknown log type", + spec: ocilogging.LogSpec{DisplayName: "stdout", LogType: "WEIRD"}, + expectCode: cerrors.InvalidArgument, + }, + { + name: "display name is required", + spec: ocilogging.LogSpec{}, + expectCode: cerrors.InvalidArgument, + }, + { + name: "already exists in the group", + spec: ocilogging.LogSpec{DisplayName: "stdout"}, + existing: "stdout", + expectCode: cerrors.AlreadyExists, + }, + { + name: "unknown log group", + spec: ocilogging.LogSpec{DisplayName: "stdout"}, + group: "ocid1.loggroup.oc1.iad.missing", + expectCode: cerrors.NotFound, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + m := newMock(t) + g := newGroup(t, m, compartmentA, "app-logs") + + if tc.existing != "" { + newCustomLog(t, m, g.ID, tc.existing) + } + + groupID := g.ID + if tc.group != "" { + groupID = tc.group + } + + l, err := m.CreateLog(ctx, groupID, tc.spec) + + if tc.expectCode != cerrors.OK { + require.Error(t, err) + assert.Equal(t, tc.expectCode, cerrors.GetCode(err)) + + return + } + + require.NoError(t, err) + assert.Equal(t, tc.spec.DisplayName, l.DisplayName) + assert.Equal(t, g.ID, l.LogGroupID) + assert.Equal(t, compartmentA, l.CompartmentID) + assert.Equal(t, 30, l.RetentionDuration) + }) + } +} + +func TestGetLogThroughTheWrongGroupIsNotFound(t *testing.T) { + ctx := context.Background() + m := newMock(t) + g := newGroup(t, m, compartmentA, "app-logs") + other := newGroup(t, m, compartmentA, "other-logs") + l := newCustomLog(t, m, g.ID, "stdout") + + _, err := m.GetLog(ctx, other.ID, l.ID) + require.Error(t, err) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) +} + +func TestListLogs(t *testing.T) { + ctx := context.Background() + m := newMock(t) + g := newGroup(t, m, compartmentA, "app-logs") + newCustomLog(t, m, g.ID, "stdout") + + _, err := m.CreateLog(ctx, g.ID, ocilogging.LogSpec{ + DisplayName: "flowlogs", + LogType: ocilogging.LogTypeService, + Configuration: &ocilogging.LogConfiguration{ + Source: ocilogging.LogSource{Service: "flowlogs", Resource: "ocid1.subnet.oc1.iad.a"}, + }, + }) + require.NoError(t, err) + + tests := []struct { + name string + filter ocilogging.LogFilter + expect int + }{ + {name: "unfiltered", expect: 2}, + {name: "by display name", filter: ocilogging.LogFilter{DisplayName: "stdout"}, expect: 1}, + {name: "by log type", filter: ocilogging.LogFilter{LogType: ocilogging.LogTypeService}, expect: 1}, + {name: "by source service", filter: ocilogging.LogFilter{SourceService: "flowlogs"}, expect: 1}, + {name: "by source resource", filter: ocilogging.LogFilter{SourceResource: "ocid1.subnet.oc1.iad.a"}, expect: 1}, + {name: "by lifecycle state", filter: ocilogging.LogFilter{LifecycleState: ocilogging.StateActive}, expect: 2}, + {name: "no match", filter: ocilogging.LogFilter{DisplayName: "nope"}, expect: 0}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := m.ListLogs(ctx, g.ID, tc.filter) + require.NoError(t, err) + assert.Len(t, got, tc.expect) + }) + } + + t.Run("unknown log group", func(t *testing.T) { + _, err := m.ListLogs(ctx, "ocid1.loggroup.oc1.iad.missing", ocilogging.LogFilter{}) + require.Error(t, err) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) + }) +} + +func TestUpdateAndDeleteLog(t *testing.T) { + ctx := context.Background() + m := newMock(t) + g := newGroup(t, m, compartmentA, "app-logs") + l := newCustomLog(t, m, g.ID, "stdout") + + disabled := false + retention := 90 + + updated, err := m.UpdateLog(ctx, g.ID, l.ID, ocilogging.LogUpdate{ + IsEnabled: &disabled, + RetentionDuration: &retention, + }) + require.NoError(t, err) + assert.False(t, updated.IsEnabled) + assert.Equal(t, 90, updated.RetentionDuration) + + require.NoError(t, m.DeleteLog(ctx, g.ID, l.ID)) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(m.DeleteLog(ctx, g.ID, l.ID))) +} + +func TestPutLogs(t *testing.T) { + ctx := context.Background() + when := time.Date(2026, 8, 8, 11, 0, 0, 0, time.UTC) + + t.Run("success", func(t *testing.T) { + m := newMock(t) + g := newGroup(t, m, compartmentA, "app-logs") + l := newCustomLog(t, m, g.ID, "stdout") + + require.NoError(t, m.PutLogs(ctx, l.ID, []ocilogging.LogEntryBatch{{ + Source: "host-a", + Type: "custom", + Entries: []ocilogging.LogEntryItem{ + {Data: "first", Time: when}, + {Data: "second"}, + }, + DefaultLogEntryTime: when.Add(time.Minute), + }})) + + entries, err := m.Entries(ctx, l.ID) + require.NoError(t, err) + require.Len(t, entries, 2) + assert.Equal(t, "first", entries[0].Data) + assert.Equal(t, when, entries[0].Time) + assert.Equal(t, when.Add(time.Minute), entries[1].Time, "an entry with no time takes the batch default") + assert.NotEmpty(t, entries[0].ID, "an entry with no id is given one") + assert.Equal(t, "host-a", entries[0].Source) + }) + + t.Run("unknown log", func(t *testing.T) { + m := newMock(t) + + err := m.PutLogs(ctx, "ocid1.log.oc1.iad.missing", nil) + require.Error(t, err) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) + }) + + t.Run("a service log is fed by its service, not by PutLogs", func(t *testing.T) { + m := newMock(t) + g := newGroup(t, m, compartmentA, "app-logs") + + l, err := m.CreateLog(ctx, g.ID, ocilogging.LogSpec{ + DisplayName: "flowlogs", + LogType: ocilogging.LogTypeService, + IsEnabled: true, + Configuration: &ocilogging.LogConfiguration{ + Source: ocilogging.LogSource{Service: "flowlogs", Resource: "ocid1.subnet.oc1.iad.a"}, + }, + }) + require.NoError(t, err) + + err = m.PutLogs(ctx, l.ID, nil) + require.Error(t, err) + assert.Equal(t, cerrors.InvalidArgument, cerrors.GetCode(err)) + }) + + t.Run("a disabled log is refused rather than silently dropping entries", func(t *testing.T) { + m := newMock(t) + g := newGroup(t, m, compartmentA, "app-logs") + + l, err := m.CreateLog(ctx, g.ID, ocilogging.LogSpec{DisplayName: "stdout"}) + require.NoError(t, err) + + err = m.PutLogs(ctx, l.ID, []ocilogging.LogEntryBatch{{Entries: []ocilogging.LogEntryItem{{Data: "x"}}}}) + require.Error(t, err) + assert.Equal(t, cerrors.FailedPrecondition, cerrors.GetCode(err)) + }) +} + +// Portable driver projection. + +func TestPortableLogGroupCRUD(t *testing.T) { + ctx := context.Background() + m := newMock(t) + + info, err := m.CreateLogGroup(ctx, driver.LogGroupConfig{ + Name: "portable", + RetentionDays: 14, + Tags: map[string]string{"env": "dev"}, + Scope: scope.Scope{Compartment: compartmentB}, + }) + require.NoError(t, err) + assert.Equal(t, 14, info.RetentionDays) + assert.Equal(t, compartmentB, info.Scope.Compartment) + assert.True(t, strings.HasPrefix(info.ResourceID, "ocid1.loggroup.")) + + _, err = m.CreateLogGroup(ctx, driver.LogGroupConfig{ + Name: "portable", + Scope: scope.Scope{Compartment: compartmentB}, + }) + assert.Equal(t, cerrors.AlreadyExists, cerrors.GetCode(err)) + + got, err := m.GetLogGroup(ctx, "portable") + require.NoError(t, err) + assert.Equal(t, info.ResourceID, got.ResourceID) + + _, err = m.GetLogGroup(ctx, "missing") + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) + + updated, err := m.UpdateLogGroup(ctx, driver.LogGroupConfig{Name: "portable", RetentionDays: 60}) + require.NoError(t, err) + assert.Equal(t, 60, updated.RetentionDays) + + _, err = m.UpdateLogGroup(ctx, driver.LogGroupConfig{Name: "missing"}) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) + + require.NoError(t, m.DeleteLogGroup(ctx, "portable")) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(m.DeleteLogGroup(ctx, "portable"))) +} + +func TestPortableListLogGroupsFiltersByCompartment(t *testing.T) { + ctx := context.Background() + m := newMock(t) + newGroup(t, m, compartmentA, "in-a") + newGroup(t, m, compartmentB, "in-b") + + got, err := m.ListLogGroups(ctx, scope.Scope{Compartment: compartmentB}) + require.NoError(t, err) + require.Len(t, got, 1) + assert.Equal(t, "in-b", got[0].Name) + + all, err := m.ListLogGroups(ctx, scope.Scope{}) + require.NoError(t, err) + assert.Len(t, all, 2) +} + +func TestPortableStreamsAndEvents(t *testing.T) { + ctx := context.Background() + m := newMock(t) + + _, err := m.CreateLogGroup(ctx, driver.LogGroupConfig{Name: "app-logs"}) + require.NoError(t, err) + + _, err = m.CreateLogStream(ctx, "app-logs", "stdout") + require.NoError(t, err) + + _, err = m.CreateLogStream(ctx, "app-logs", "stdout") + assert.Equal(t, cerrors.AlreadyExists, cerrors.GetCode(err)) + + _, err = m.CreateLogStream(ctx, "missing", "stdout") + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) + + base := time.Date(2026, 8, 8, 10, 0, 0, 0, time.UTC) + require.NoError(t, m.PutLogEvents(ctx, "app-logs", "stdout", []driver.LogEvent{ + {Timestamp: base, Message: "hello world"}, + {Timestamp: base.Add(time.Hour), Message: "goodbye"}, + })) + + streams, err := m.ListLogStreams(ctx, "app-logs") + require.NoError(t, err) + require.Len(t, streams, 1) + assert.NotEmpty(t, streams[0].LastEvent) + + group, err := m.GetLogGroup(ctx, "app-logs") + require.NoError(t, err) + assert.Equal(t, int64(len("hello world")+len("goodbye")), group.StoredBytes) + + events, err := m.GetLogEvents(ctx, &driver.LogQueryInput{LogGroup: "app-logs", Pattern: "hello"}) + require.NoError(t, err) + require.Len(t, events, 1) + assert.Equal(t, "hello world", events[0].Message) + + windowed, err := m.GetLogEvents(ctx, &driver.LogQueryInput{ + LogGroup: "app-logs", + LogStream: "stdout", + StartTime: base.Add(30 * time.Minute), + }) + require.NoError(t, err) + assert.Len(t, windowed, 1) + + filtered, err := m.FilterLogEvents(ctx, &driver.FilterLogEventsInput{ + LogGroup: "app-logs", + FilterPattern: "goodbye", + }) + require.NoError(t, err) + require.Len(t, filtered, 1) + assert.Equal(t, "stdout", filtered[0].LogStream) + + _, err = m.FilterLogEvents(ctx, &driver.FilterLogEventsInput{LogGroup: "missing"}) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) + + require.NoError(t, m.DeleteLogStream(ctx, "app-logs", "stdout")) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(m.DeleteLogStream(ctx, "app-logs", "stdout"))) +} + +func TestMetricFiltersAreNotAnOCIOperation(t *testing.T) { + ctx := context.Background() + m := newMock(t) + + err := m.PutMetricFilter(ctx, &driver.MetricFilterConfig{Name: "errors"}) + require.Error(t, err) + assert.Equal(t, cerrors.Unimplemented, cerrors.GetCode(err)) + assert.Contains(t, err.Error(), "Service Connector") + + assert.Equal(t, cerrors.Unimplemented, cerrors.GetCode(m.DeleteMetricFilter(ctx, "g", "errors"))) + + _, err = m.DescribeMetricFilters(ctx, "g") + assert.Equal(t, cerrors.Unimplemented, cerrors.GetCode(err)) +} + +func TestSubscriptionFiltersAreNotAnOCIOperation(t *testing.T) { + ctx := context.Background() + m := newMock(t) + + err := m.PutSubscriptionFilter(ctx, &driver.SubscriptionFilterConfig{Name: "stream"}) + require.Error(t, err) + assert.Equal(t, cerrors.Unimplemented, cerrors.GetCode(err)) + assert.Contains(t, err.Error(), "Service Connector", + "an operation OCI has no equivalent for names what OCI does instead") + + assert.Equal(t, cerrors.Unimplemented, cerrors.GetCode(m.DeleteSubscriptionFilter(ctx, "g", "stream"))) + + _, err = m.DescribeSubscriptionFilters(ctx, "g") + assert.Equal(t, cerrors.Unimplemented, cerrors.GetCode(err)) +} + +// Per-compartment display-name uniqueness. + +func TestGroupNamesAreUniquePerCompartment(t *testing.T) { + ctx := context.Background() + + t.Run("the same name in two compartments is allowed", func(t *testing.T) { + m := newMock(t) + a := newGroup(t, m, compartmentA, "shared") + b := newGroup(t, m, compartmentB, "shared") + + assert.NotEqual(t, a.ID, b.ID) + }) + + t.Run("rename onto a sibling in the same compartment is rejected", func(t *testing.T) { + m := newMock(t) + newGroup(t, m, compartmentA, "taken") + g := newGroup(t, m, compartmentA, "free") + + name := "taken" + _, err := m.UpdateGroup(ctx, g.ID, ocilogging.LogGroupUpdate{DisplayName: &name}) + require.Error(t, err) + assert.Equal(t, cerrors.AlreadyExists, cerrors.GetCode(err)) + }) + + t.Run("rename onto a name held in another compartment is allowed", func(t *testing.T) { + m := newMock(t) + newGroup(t, m, compartmentB, "taken") + g := newGroup(t, m, compartmentA, "free") + + name := "taken" + updated, err := m.UpdateGroup(ctx, g.ID, ocilogging.LogGroupUpdate{DisplayName: &name}) + require.NoError(t, err) + assert.Equal(t, "taken", updated.DisplayName) + }) + + t.Run("moving onto a name the destination already holds is rejected", func(t *testing.T) { + m := newMock(t) + newGroup(t, m, compartmentB, "shared") + g := newGroup(t, m, compartmentA, "shared") + + err := m.MoveGroup(ctx, g.ID, compartmentB) + require.Error(t, err) + assert.Equal(t, cerrors.AlreadyExists, cerrors.GetCode(err)) + }) + + t.Run("moving a group to its own compartment is a no-op", func(t *testing.T) { + m := newMock(t) + g := newGroup(t, m, compartmentA, "stays") + + require.NoError(t, m.MoveGroup(ctx, g.ID, compartmentA)) + }) +} + +func TestPortableRejectsAnAmbiguousGroupName(t *testing.T) { + ctx := context.Background() + m := newMock(t) + newGroup(t, m, compartmentA, "shared") + newGroup(t, m, compartmentB, "shared") + + _, err := m.GetLogGroup(ctx, "shared") + require.Error(t, err) + assert.Equal(t, cerrors.InvalidArgument, cerrors.GetCode(err)) + assert.Contains(t, err.Error(), "more than one compartment") +} + +func TestPortableUpdateLogGroupMoveCollides(t *testing.T) { + ctx := context.Background() + m := newMock(t) + newGroup(t, m, compartmentB, "shared") + newGroup(t, m, compartmentA, "moving") + + _, err := m.UpdateLogGroup(ctx, driver.LogGroupConfig{ + Name: "moving", + Scope: scope.Scope{Compartment: compartmentB}, + }) + require.NoError(t, err, "no collision on a free name") + + newGroup(t, m, compartmentA, "shared2") + newGroup(t, m, compartmentB, "shared2") + + _, err = m.UpdateLogGroup(ctx, driver.LogGroupConfig{Name: "shared2"}) + assert.Equal(t, cerrors.InvalidArgument, cerrors.GetCode(err), "an ambiguous name is rejected") +} + +// Read limits. + +func TestPortableReadLimitIsBounded(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + limit int + expectCode cerrors.Code + }{ + {name: "unset falls back to the default", limit: 0}, + {name: "in range", limit: 10}, + {name: "negative", limit: -1, expectCode: cerrors.InvalidArgument}, + {name: "above the maximum", limit: 10_001, expectCode: cerrors.InvalidArgument}, + {name: "absurd", limit: 1 << 40, expectCode: cerrors.InvalidArgument}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + m := newMock(t) + g := newGroup(t, m, compartmentA, "app-logs") + newCustomLog(t, m, g.ID, "stdout") + + _, getErr := m.GetLogEvents(ctx, &driver.LogQueryInput{LogGroup: "app-logs", Limit: tc.limit}) + _, filterErr := m.FilterLogEvents(ctx, &driver.FilterLogEventsInput{LogGroup: "app-logs", Limit: tc.limit}) + + searchErr := searchWithLimit(t, m, tc.limit) + + if tc.expectCode == cerrors.OK { + require.NoError(t, getErr) + require.NoError(t, filterErr) + require.NoError(t, searchErr) + + return + } + + assert.Equal(t, tc.expectCode, cerrors.GetCode(getErr)) + assert.Equal(t, tc.expectCode, cerrors.GetCode(filterErr)) + assert.Equal(t, tc.expectCode, cerrors.GetCode(searchErr)) + }) + } +} + +// searchWithLimit runs a search over the whole window with the given limit. +func searchWithLimit(t *testing.T, m *ocilogging.Mock, limit int) error { + t.Helper() + + _, err := m.SearchLogs(context.Background(), ocilogging.SearchRequest{ + Query: `search "` + compartmentA + `"`, + TimeStart: searchWindowStart, + TimeEnd: searchWindowEnd, + Limit: limit, + }) + + return err +} + +func TestPortableReadLimitTruncates(t *testing.T) { + ctx := context.Background() + m := newMock(t) + g := newGroup(t, m, compartmentA, "app-logs") + l := newCustomLog(t, m, g.ID, "stdout") + + entries := make([]ocilogging.LogEntryItem, 5) + for i := range entries { + entries[i] = ocilogging.LogEntryItem{Data: "line-" + strconv.Itoa(i), Time: searchWindowStart} + } + + require.NoError(t, m.PutLogs(ctx, l.ID, []ocilogging.LogEntryBatch{{Entries: entries}})) + + events, err := m.GetLogEvents(ctx, &driver.LogQueryInput{LogGroup: "app-logs", Limit: 2}) + require.NoError(t, err) + assert.Len(t, events, 2) + + filtered, err := m.FilterLogEvents(ctx, &driver.FilterLogEventsInput{LogGroup: "app-logs", Limit: 3}) + require.NoError(t, err) + assert.Len(t, filtered, 3) +} + +// loggingsearch. + +// The window every search test runs over. +// +//nolint:gochecknoglobals // fixed test window. +var ( + searchWindowStart = time.Date(2026, 8, 8, 10, 0, 0, 0, time.UTC) + searchWindowEnd = time.Date(2026, 8, 8, 13, 0, 0, 0, time.UTC) +) + +// searchFixture is a mock holding two groups in two compartments, each with one +// log, and entries whose times are deliberately out of insertion order. +type searchFixture struct { + m *ocilogging.Mock + groupA *ocilogging.LogGroup + groupB *ocilogging.LogGroup + stdout *ocilogging.Log + stderr *ocilogging.Log + otherIn *ocilogging.Log +} + +func newSearchFixture(t *testing.T) *searchFixture { + t.Helper() + + ctx := context.Background() + m := newMock(t) + + f := &searchFixture{m: m} + f.groupA = newGroup(t, m, compartmentA, "app-logs") + f.groupB = newGroup(t, m, compartmentB, "other-logs") + f.stdout = newCustomLog(t, m, f.groupA.ID, "stdout") + f.stderr = newCustomLog(t, m, f.groupA.ID, "stderr") + f.otherIn = newCustomLog(t, m, f.groupB.ID, "audit") + + at := func(min int) time.Time { return searchWindowStart.Add(time.Duration(min) * time.Minute) } + + require.NoError(t, m.PutLogs(ctx, f.stdout.ID, []ocilogging.LogEntryBatch{{ + Source: "host-a", + Type: "com.oraclecloud.custom", + Subject: "app", + Entries: []ocilogging.LogEntryItem{ + {ID: "e-30", Data: `{"level":"info","code":200}`, Time: at(30)}, + {ID: "e-10", Data: `{"level":"error","code":500}`, Time: at(10)}, + {ID: "e-20", Data: "plain text line", Time: at(20)}, + }, + }})) + + require.NoError(t, m.PutLogs(ctx, f.stderr.ID, []ocilogging.LogEntryBatch{{ + Source: "host-b", + Type: "com.oraclecloud.custom", + Subject: "sidecar", + Entries: []ocilogging.LogEntryItem{{ID: "e-40", Data: `{"level":"warn"}`, Time: at(40)}}, + }})) + + require.NoError(t, m.PutLogs(ctx, f.otherIn.ID, []ocilogging.LogEntryBatch{{ + Source: "host-c", + Entries: []ocilogging.LogEntryItem{{ID: "e-50", Data: "audit line", Time: at(50)}}, + }})) + + return f +} + +// search runs a query over the fixture window and returns the matched entry ids +// in result order. +func (f *searchFixture) search(t *testing.T, query string) []string { + t.Helper() + + res, err := f.m.SearchLogs(context.Background(), ocilogging.SearchRequest{ + Query: query, + TimeStart: searchWindowStart, + TimeEnd: searchWindowEnd, + }) + require.NoError(t, err) + + ids := make([]string, 0, len(res.Entries)) + for i := range res.Entries { + ids = append(ids, res.Entries[i].ID) + } + + return ids +} + +func TestSearchScopes(t *testing.T) { + f := newSearchFixture(t) + + tests := []struct { + name string + query string + expect []string + }{ + { + name: "whole compartment", + query: `search "` + compartmentA + `"`, + expect: []string{"e-10", "e-20", "e-30", "e-40"}, + }, + { + name: "narrowed to a log group", + query: `search "` + compartmentA + `/` + f.groupA.ID + `"`, + expect: []string{"e-10", "e-20", "e-30", "e-40"}, + }, + { + name: "narrowed to one log", + query: `search "` + compartmentA + `/` + f.groupA.ID + `/` + f.stderr.ID + `"`, + expect: []string{"e-40"}, + }, + { + name: "two targets", + query: `search "` + compartmentA + `/` + f.groupA.ID + `/` + f.stderr.ID + `", "` + compartmentB + `"`, + expect: []string{"e-40", "e-50"}, + }, + { + name: "a log group in the wrong compartment matches nothing", + query: `search "` + compartmentB + `/` + f.groupA.ID + `"`, + expect: []string{}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expect, f.search(t, tc.query)) + }) + } +} + +func TestSearchSortOrder(t *testing.T) { + f := newSearchFixture(t) + all := `search "` + compartmentA + `"` + + tests := []struct { + name string + query string + expect []string + }{ + { + name: "default is ascending by time", + query: all, + expect: []string{"e-10", "e-20", "e-30", "e-40"}, + }, + { + name: "sort by datetime asc", + query: all + ` | sort by datetime asc`, + expect: []string{"e-10", "e-20", "e-30", "e-40"}, + }, + { + name: "sort by datetime desc", + query: all + ` | sort by datetime desc`, + expect: []string{"e-40", "e-30", "e-20", "e-10"}, + }, + { + name: "sort by time desc", + query: all + ` | sort by time desc`, + expect: []string{"e-40", "e-30", "e-20", "e-10"}, + }, + { + name: "sort by logContent.datetime desc", + query: all + ` | sort by logContent.datetime desc`, + expect: []string{"e-40", "e-30", "e-20", "e-10"}, + }, + { + name: "sort with no direction is ascending", + query: all + ` | sort by datetime`, + expect: []string{"e-10", "e-20", "e-30", "e-40"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expect, f.search(t, tc.query)) + }) + } +} + +func TestSearchSortIsStableOnEqualTimes(t *testing.T) { + ctx := context.Background() + m := newMock(t) + g := newGroup(t, m, compartmentA, "app-logs") + l := newCustomLog(t, m, g.ID, "stdout") + + same := searchWindowStart.Add(time.Minute) + require.NoError(t, m.PutLogs(ctx, l.ID, []ocilogging.LogEntryBatch{{Entries: []ocilogging.LogEntryItem{ + {ID: "e-c", Data: "c", Time: same}, + {ID: "e-a", Data: "a", Time: same}, + {ID: "e-b", Data: "b", Time: same}, + }}})) + + res, err := m.SearchLogs(ctx, ocilogging.SearchRequest{ + Query: `search "` + compartmentA + `" | sort by datetime desc`, + TimeStart: searchWindowStart, + TimeEnd: searchWindowEnd, + }) + require.NoError(t, err) + + ids := make([]string, 0, len(res.Entries)) + for i := range res.Entries { + ids = append(ids, res.Entries[i].ID) + } + + assert.Equal(t, []string{"e-c", "e-b", "e-a"}, ids, "entries at the same time break the tie on id") +} + +func TestSearchWhereFields(t *testing.T) { + f := newSearchFixture(t) + all := `search "` + compartmentA + `"` + + tests := []struct { + name string + query string + expect []string + }{ + { + name: "oracle.logid", + query: all + ` | where oracle.logid = '` + f.stderr.ID + `'`, + expect: []string{"e-40"}, + }, + { + name: "oracle.loggroupid", + query: all + ` | where oracle.loggroupid = '` + f.groupA.ID + `'`, + expect: []string{"e-10", "e-20", "e-30", "e-40"}, + }, + { + name: "oracle.compartmentid", + query: all + ` | where oracle.compartmentid = '` + compartmentA + `'`, + expect: []string{"e-10", "e-20", "e-30", "e-40"}, + }, + { + name: "oracle.compartmentid mismatch", + query: all + ` | where oracle.compartmentid = '` + compartmentB + `'`, + expect: []string{}, + }, + { + name: "oracle.ingestedtime is stamped by the clock", + query: all + ` | where oracle.ingestedtime = '2026-08-08T12:00:00Z'`, + expect: []string{"e-10", "e-20", "e-30", "e-40"}, + }, + { + name: "logContent.oracle prefix is accepted", + query: all + ` | where logContent.oracle.logid = '` + f.stderr.ID + `'`, + expect: []string{"e-40"}, + }, + { + name: "negated oracle.logid", + query: all + ` | where oracle.logid != '` + f.stderr.ID + `'`, + expect: []string{"e-10", "e-20", "e-30"}, + }, + { + name: "id", + query: all + ` | where id = 'e-20'`, + expect: []string{"e-20"}, + }, + { + name: "source", + query: all + ` | where source = 'host-b'`, + expect: []string{"e-40"}, + }, + { + name: "subject", + query: all + ` | where subject = 'sidecar'`, + expect: []string{"e-40"}, + }, + { + name: "type wildcard", + query: all + ` | where type = 'com.oraclecloud.*'`, + expect: []string{"e-10", "e-20", "e-30", "e-40"}, + }, + { + name: "datetime", + query: all + ` | where datetime = '2026-08-08T10:20:00Z'`, + expect: []string{"e-20"}, + }, + { + name: "time", + query: all + ` | where time = '2026-08-08T10:40:00Z'`, + expect: []string{"e-40"}, + }, + { + name: "data wildcard", + query: all + ` | where data = '*plain*'`, + expect: []string{"e-20"}, + }, + { + name: "data. of a JSON payload", + query: all + ` | where data.level = 'error'`, + expect: []string{"e-10"}, + }, + { + name: "data. that is not a string", + query: all + ` | where data.code = '500'`, + expect: []string{"e-10"}, + }, + { + name: "data. missing from a payload matches nothing", + query: all + ` | where data.missing = 'x'`, + expect: []string{}, + }, + { + name: "two comparisons joined by and", + query: all + ` | where data.level = 'info' and source = 'host-a'`, + expect: []string{"e-30"}, + }, + { + name: "where and sort together", + query: all + ` | where type = 'com.oraclecloud.*' | sort by datetime desc`, + expect: []string{"e-40", "e-30", "e-20", "e-10"}, + }, + { + name: "an unquoted value is compared literally", + query: all + ` | where source = host-b`, + expect: []string{"e-40"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expect, f.search(t, tc.query)) + }) + } +} + +func TestSearchTimeWindowAndFieldInfo(t *testing.T) { + ctx := context.Background() + f := newSearchFixture(t) + + t.Run("the window is half-open", func(t *testing.T) { + res, err := f.m.SearchLogs(ctx, ocilogging.SearchRequest{ + Query: `search "` + compartmentA + `"`, + TimeStart: searchWindowStart.Add(20 * time.Minute), + TimeEnd: searchWindowStart.Add(40 * time.Minute), + }) + require.NoError(t, err) + require.Len(t, res.Entries, 2) + assert.Equal(t, "e-20", res.Entries[0].ID, "start is inclusive") + assert.Equal(t, "e-30", res.Entries[1].ID, "end is exclusive") + }) + + t.Run("field info is returned on request", func(t *testing.T) { + res, err := f.m.SearchLogs(ctx, ocilogging.SearchRequest{ + Query: `search "` + compartmentA + `"`, + TimeStart: searchWindowStart, + TimeEnd: searchWindowEnd, + ReturnFieldInfo: true, + }) + require.NoError(t, err) + assert.NotEmpty(t, res.Fields) + assert.Equal(t, "datetime", res.Fields[0].Name) + }) + + t.Run("field info is withheld otherwise", func(t *testing.T) { + res, err := f.m.SearchLogs(ctx, ocilogging.SearchRequest{ + Query: `search "` + compartmentA + `"`, + TimeStart: searchWindowStart, + TimeEnd: searchWindowEnd, + }) + require.NoError(t, err) + assert.Empty(t, res.Fields) + }) + + t.Run("the limit truncates after sorting", func(t *testing.T) { + res, err := f.m.SearchLogs(ctx, ocilogging.SearchRequest{ + Query: `search "` + compartmentA + `" | sort by datetime desc`, + TimeStart: searchWindowStart, + TimeEnd: searchWindowEnd, + Limit: 2, + }) + require.NoError(t, err) + require.Len(t, res.Entries, 2) + assert.Equal(t, "e-40", res.Entries[0].ID) + assert.Equal(t, "e-30", res.Entries[1].ID) + }) + + t.Run("an entry carries the log it came from", func(t *testing.T) { + res, err := f.m.SearchLogs(ctx, ocilogging.SearchRequest{ + Query: `search "` + compartmentA + `/` + f.groupA.ID + `/` + f.stderr.ID + `"`, + TimeStart: searchWindowStart, + TimeEnd: searchWindowEnd, + }) + require.NoError(t, err) + require.Len(t, res.Entries, 1) + assert.Equal(t, compartmentA, res.Entries[0].CompartmentID) + assert.Equal(t, f.groupA.ID, res.Entries[0].LogGroupID) + assert.Equal(t, "stderr", res.Entries[0].LogName) + }) +} + +func TestSearchRejectsWhatItDoesNotModel(t *testing.T) { + f := newSearchFixture(t) + all := `search "` + compartmentA + `"` + + tests := []struct { + name string + query string + contains string + }{ + {name: "empty query", query: "", contains: "searchQuery is required"}, + {name: "blank first stage", query: " ", contains: "searchQuery is required"}, + {name: "does not begin with search", query: `where id = 'x'`, contains: "must begin with the search clause"}, + {name: "no target", query: `search`, contains: "names no target"}, + {name: "unquoted target", query: `search ` + compartmentA, contains: "must be quoted"}, + { + name: "too many segments", + query: `search "` + compartmentA + `/a/b/c"`, + contains: "expected compartmentId", + }, + { + name: "a name in place of a compartment OCID", + query: `search "my-compartment"`, + contains: "is not a compartment OCID", + }, + { + name: "a name in place of a log group OCID", + query: `search "` + compartmentA + `/app-logs"`, + contains: "is not a log group OCID", + }, + { + name: "a name in place of a log OCID", + query: `search "` + compartmentA + `/` + f.groupA.ID + `/stdout"`, + contains: "is not a log OCID", + }, + {name: "empty stage", query: all + ` | `, contains: "empty stage"}, + {name: "unsupported stage", query: all + ` | stats count()`, contains: "unsupported search operator"}, + {name: "sort without by", query: all + ` | sort datetime`, contains: "sort must be written as"}, + {name: "sort by an unmodelled field", query: all + ` | sort by source`, contains: "sorts by datetime only"}, + {name: "bad sort direction", query: all + ` | sort by datetime sideways`, contains: "is not asc or desc"}, + { + name: "sort with trailing tokens", + query: all + ` | sort by datetime desc then more`, + contains: "single field and an optional direction", + }, + {name: "parenthesized where", query: all + ` | where (id = 'a')`, contains: "parenthesized"}, + {name: "or in a where", query: all + ` | where id = 'a' or id = 'b'`, contains: `"or" operator is not modeled`}, + {name: "not in a where", query: all + ` | where not id = 'a'`, contains: `"not" operator is not modeled`}, + {name: "where with no comparison", query: all + ` | where `, contains: "empty comparison"}, + {name: "comparison with no operator", query: all + ` | where id`, contains: "has no operator"}, + {name: "unmodelled operator", query: all + ` | where id ~ 'a'`, contains: "is not modeled"}, + {name: "comparison naming no field", query: all + ` | where = 'a'`, contains: "names no field"}, + {name: "comparison with no value", query: all + ` | where id =`, contains: "has no value"}, + {name: "unmodelled field", query: all + ` | where nope = 'a'`, contains: "unsupported search field"}, + {name: "nested data path", query: all + ` | where data.a.b = 'x'`, contains: "not a nested path"}, + {name: "bare data prefix", query: all + ` | where data. = 'x'`, contains: "not a nested path"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := f.m.SearchLogs(context.Background(), ocilogging.SearchRequest{ + Query: tc.query, + TimeStart: searchWindowStart, + TimeEnd: searchWindowEnd, + }) + require.Error(t, err) + assert.Equal(t, cerrors.InvalidArgument, cerrors.GetCode(err)) + assert.Contains(t, err.Error(), tc.contains) + }) + } +} + +func TestSearchRequiresATimeRange(t *testing.T) { + ctx := context.Background() + f := newSearchFixture(t) + query := `search "` + compartmentA + `"` + + tests := []struct { + name string + start, end time.Time + contains string + }{ + {name: "no start", end: searchWindowEnd, contains: "are required"}, + {name: "no end", start: searchWindowStart, contains: "are required"}, + { + name: "end before start", + start: searchWindowEnd, + end: searchWindowStart, + contains: "must be after", + }, + { + name: "end equal to start", + start: searchWindowStart, + end: searchWindowStart, + contains: "must be after", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := f.m.SearchLogs(ctx, ocilogging.SearchRequest{ + Query: query, TimeStart: tc.start, TimeEnd: tc.end, + }) + require.Error(t, err) + assert.Equal(t, cerrors.InvalidArgument, cerrors.GetCode(err)) + assert.Contains(t, err.Error(), tc.contains) + }) + } +} + +func TestUpdateLogFields(t *testing.T) { + ctx := context.Background() + m := newMock(t) + g := newGroup(t, m, compartmentA, "app-logs") + l := newCustomLog(t, m, g.ID, "stdout") + newCustomLog(t, m, g.ID, "stderr") + + name := "renamed" + tags := map[string]string{"env": "dev"} + + updated, err := m.UpdateLog(ctx, g.ID, l.ID, ocilogging.LogUpdate{ + DisplayName: &name, + FreeformTags: tags, + }) + require.NoError(t, err) + assert.Equal(t, "renamed", updated.DisplayName) + assert.Equal(t, tags, updated.FreeformTags) + + taken := "stderr" + _, err = m.UpdateLog(ctx, g.ID, l.ID, ocilogging.LogUpdate{DisplayName: &taken}) + require.Error(t, err) + assert.Equal(t, cerrors.AlreadyExists, cerrors.GetCode(err)) + + same := "renamed" + _, err = m.UpdateLog(ctx, g.ID, l.ID, ocilogging.LogUpdate{DisplayName: &same}) + require.NoError(t, err, "renaming a log to the name it already has is a no-op") + + withCfg, err := m.UpdateLog(ctx, g.ID, l.ID, ocilogging.LogUpdate{ + Configuration: &ocilogging.LogConfiguration{ + Source: ocilogging.LogSource{Parameters: map[string]string{"k": "v"}}, + }, + }) + require.NoError(t, err) + assert.Equal(t, compartmentA, withCfg.Configuration.CompartmentID, "the log's compartment is filled in") + assert.Equal(t, "OCISERVICE", withCfg.Configuration.Source.SourceType, "the only source type OCI defines") + + sl, err := m.CreateLog(ctx, g.ID, ocilogging.LogSpec{ + DisplayName: "flowlogs", + LogType: ocilogging.LogTypeService, + Configuration: &ocilogging.LogConfiguration{ + Source: ocilogging.LogSource{Service: "flowlogs", Resource: "ocid1.subnet.oc1.iad.a"}, + }, + }) + require.NoError(t, err) + + _, err = m.UpdateLog(ctx, g.ID, sl.ID, ocilogging.LogUpdate{ + Configuration: &ocilogging.LogConfiguration{Source: ocilogging.LogSource{Service: "flowlogs"}}, + }) + require.Error(t, err, "a SERVICE log's configuration must still name a resource") + assert.Equal(t, cerrors.InvalidArgument, cerrors.GetCode(err)) +} + +func TestIngestionPublishesMetrics(t *testing.T) { + ctx := context.Background() + m := newMock(t) + g := newGroup(t, m, compartmentA, "app-logs") + l := newCustomLog(t, m, g.ID, "stdout") + + mon := &recordingMonitoring{} + m.SetMonitoring(mon) + + require.NoError(t, m.PutLogs(ctx, l.ID, []ocilogging.LogEntryBatch{{ + Entries: []ocilogging.LogEntryItem{{Data: "hello", Time: searchWindowStart}}, + }})) + + names := make([]string, 0, len(mon.data)) + for _, d := range mon.data { + names = append(names, d.MetricName) + assert.Equal(t, "oci_logging", d.Namespace) + assert.Equal(t, l.ID, d.Dimensions["logId"]) + } + + assert.Equal(t, []string{"IngestedLogEntries", "IngestedLogBytes"}, names) +} + +func TestIngestionSurvivesAMonitoringFailure(t *testing.T) { + ctx := context.Background() + m := newMock(t) + g := newGroup(t, m, compartmentA, "app-logs") + l := newCustomLog(t, m, g.ID, "stdout") + + m.SetMonitoring(&recordingMonitoring{err: errFailedPublish}) + + require.NoError(t, m.PutLogs(ctx, l.ID, []ocilogging.LogEntryBatch{{ + Entries: []ocilogging.LogEntryItem{{Data: "hello", Time: searchWindowStart}}, + }}), "metric publication is best-effort") + + entries, err := m.Entries(ctx, l.ID) + require.NoError(t, err) + assert.Len(t, entries, 1) +} + +// errFailedPublish is what the stub monitoring driver refuses with. +var errFailedPublish = errors.New("monitoring is down") + +// recordingMonitoring is a monitoring driver that records what Logging +// publishes. Every other operation is unused by this package. +type recordingMonitoring struct { + mondriver.Monitoring + + data []mondriver.MetricDatum + err error +} + +func (r *recordingMonitoring) PutMetricData(_ context.Context, data []mondriver.MetricDatum) error { + if r.err != nil { + return r.err + } + + r.data = append(r.data, data...) + + return nil +} diff --git a/providers/oci/logging/portable.go b/providers/oci/logging/portable.go new file mode 100644 index 000000000..3f29c4f55 --- /dev/null +++ b/providers/oci/logging/portable.go @@ -0,0 +1,426 @@ +package logging + +import ( + "context" + "strings" + "time" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/services/logging/driver" + "github.com/stackshy/cloudemu/v2/services/scope" +) + +// The portable driver's projection onto OCI Logging: a log group is the log +// group, a log stream is a CUSTOM log inside it, and a log event is an +// ingested log entry. + +// What OCI does instead of a metric filter and instead of a subscription filter. +const ( + viaServiceConnector = "a Service Connector routes matching log entries into Monitoring" + viaConnectorTarget = "a Service Connector delivers matching log entries to a target service" +) + +// resolveLimit turns a caller-supplied read limit into one safe to size an +// allocation with. Zero or unset means the default; anything negative or above +// maxLogLimit is rejected rather than silently clamped. +func resolveLimit(limit int) (int, error) { + switch { + case limit == 0: + return defaultLogLimit, nil + case limit < 0: + return 0, cerrors.Newf(cerrors.InvalidArgument, "limit %d must not be negative", limit) + case limit > maxLogLimit: + return 0, cerrors.Newf(cerrors.InvalidArgument, "limit %d exceeds the maximum of %d", limit, maxLogLimit) + default: + return limit, nil + } +} + +// CreateLogGroup creates a log group in the compartment the config's scope +// names, or the configured default compartment. +// +//nolint:gocritic // hugeParam: interface method signature cannot be changed. +func (m *Mock) CreateLogGroup(_ context.Context, cfg driver.LogGroupConfig) (*driver.LogGroupInfo, error) { + m.mu.Lock() + defer m.mu.Unlock() + + g, err := m.createGroup(LogGroupSpec{ + CompartmentID: cfg.Scope.Compartment, + DisplayName: cfg.Name, + FreeformTags: cfg.Tags, + RetentionDays: cfg.RetentionDays, + }) + if err != nil { + return nil, err + } + + info := m.toLogGroupInfo(g) + + return &info, nil +} + +// UpdateLogGroup replaces the mutable fields of an existing log group. +// +//nolint:gocritic // hugeParam: interface method signature cannot be changed. +func (m *Mock) UpdateLogGroup(_ context.Context, cfg driver.LogGroupConfig) (*driver.LogGroupInfo, error) { + m.mu.Lock() + defer m.mu.Unlock() + + g, err := m.portableGroupByName(cfg.Name) + if err != nil { + return nil, err + } + + if cfg.RetentionDays != 0 { + g.RetentionDays = cfg.RetentionDays + } + + if cfg.Tags != nil { + g.FreeformTags = copyTags(cfg.Tags) + } + + if cfg.Scope.Compartment != "" && cfg.Scope.Compartment != g.CompartmentID { + if other, taken := m.groupByName(cfg.Scope.Compartment, g.DisplayName); taken && other.ID != g.ID { + return nil, cerrors.Newf(cerrors.AlreadyExists, + "log group %q already exists in compartment %s", g.DisplayName, cfg.Scope.Compartment) + } + + g.CompartmentID = cfg.Scope.Compartment + } + + g.TimeLastModified = m.now() + + info := m.toLogGroupInfo(g) + + return &info, nil +} + +// DeleteLogGroup deletes a log group by display name. +func (m *Mock) DeleteLogGroup(_ context.Context, name string) error { + m.mu.Lock() + defer m.mu.Unlock() + + g, err := m.portableGroupByName(name) + if err != nil { + return err + } + + for _, rec := range m.logsIn(g.ID) { + m.logs.Delete(rec.Log.ID) + } + + m.groups.Delete(g.ID) + + return nil +} + +// GetLogGroup returns a log group by display name. +func (m *Mock) GetLogGroup(_ context.Context, name string) (*driver.LogGroupInfo, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + g, err := m.portableGroupByName(name) + if err != nil { + return nil, err + } + + info := m.toLogGroupInfo(g) + + return &info, nil +} + +// ListLogGroups lists the log groups visible under a compartment filter. +func (m *Mock) ListLogGroups(_ context.Context, filter scope.Scope) ([]driver.LogGroupInfo, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + out := make([]driver.LogGroupInfo, 0, m.groups.Len()) + + for _, g := range m.groups.SortedValues() { + if !(scope.Scope{Compartment: g.CompartmentID}).Matches(filter) { + continue + } + + out = append(out, m.toLogGroupInfo(g)) + } + + return out, nil +} + +// CreateLogStream creates an enabled CUSTOM log inside a log group. +func (m *Mock) CreateLogStream(_ context.Context, logGroup, streamName string) (*driver.LogStreamInfo, error) { + m.mu.Lock() + defer m.mu.Unlock() + + g, err := m.portableGroupByName(logGroup) + if err != nil { + return nil, err + } + + l, err := m.createLog(g.ID, LogSpec{ + DisplayName: streamName, + LogType: LogTypeCustom, + IsEnabled: true, + }) + if err != nil { + return nil, err + } + + return &driver.LogStreamInfo{Name: l.DisplayName, CreatedAt: l.TimeCreated}, nil +} + +// DeleteLogStream deletes a log from a log group. +func (m *Mock) DeleteLogStream(_ context.Context, logGroup, streamName string) error { + m.mu.Lock() + defer m.mu.Unlock() + + rec, err := m.portableLog(logGroup, streamName) + if err != nil { + return err + } + + m.logs.Delete(rec.Log.ID) + + return nil +} + +// ListLogStreams lists the logs in a log group. +func (m *Mock) ListLogStreams(_ context.Context, logGroup string) ([]driver.LogStreamInfo, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + g, err := m.portableGroupByName(logGroup) + if err != nil { + return nil, err + } + + recs := m.logsIn(g.ID) + out := make([]driver.LogStreamInfo, 0, len(recs)) + + for _, rec := range recs { + out = append(out, toStreamInfo(rec)) + } + + return out, nil +} + +// PutLogEvents ingests log events into a log, the portable spelling of PutLogs. +func (m *Mock) PutLogEvents(ctx context.Context, logGroup, streamName string, events []driver.LogEvent) error { + m.mu.Lock() + + rec, err := m.portableLog(logGroup, streamName) + if err != nil { + m.mu.Unlock() + return err + } + + batch := LogEntryBatch{Entries: make([]LogEntryItem, 0, len(events)), Type: "com.oraclecloud.logging.custom"} + for _, e := range events { + batch.Entries = append(batch.Entries, LogEntryItem{Data: e.Message, Time: e.Timestamp}) + } + + count, bytes := m.ingest(rec, []LogEntryBatch{batch}) + compartmentID, groupID, logID := rec.Log.CompartmentID, rec.Log.LogGroupID, rec.Log.ID + mon := m.monitoring + m.mu.Unlock() + + dims := map[string]string{"logId": logID, "logGroupId": groupID, "compartmentId": compartmentID} + m.emitMetric(ctx, mon, "IngestedLogEntries", float64(count), dims) + m.emitMetric(ctx, mon, "IngestedLogBytes", float64(bytes), dims) + + return nil +} + +// GetLogEvents reads log events out of a group, optionally from one log. +func (m *Mock) GetLogEvents(_ context.Context, input *driver.LogQueryInput) ([]driver.LogEvent, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + limit, err := resolveLimit(input.Limit) + if err != nil { + return nil, err + } + + recs, err := m.portableSelection(input.LogGroup, input.LogStream) + if err != nil { + return nil, err + } + + out := make([]driver.LogEvent, 0, limit) + + for _, rec := range recs { + for i := range rec.Entries { + e := &rec.Entries[i] + if !inWindow(e, input.StartTime, input.EndTime) || !containsPattern(e.Data, input.Pattern) { + continue + } + + out = append(out, driver.LogEvent{Timestamp: e.Time, Message: e.Data}) + } + } + + if len(out) > limit { + out = out[:limit] + } + + return out, nil +} + +// FilterLogEvents reads log events across a group's logs, reporting which log +// each came from. +func (m *Mock) FilterLogEvents( + _ context.Context, input *driver.FilterLogEventsInput, +) ([]driver.FilteredLogEvent, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + limit, err := resolveLimit(input.Limit) + if err != nil { + return nil, err + } + + recs, err := m.portableSelection(input.LogGroup, input.LogStream) + if err != nil { + return nil, err + } + + out := make([]driver.FilteredLogEvent, 0, limit) + + for _, rec := range recs { + for i := range rec.Entries { + e := &rec.Entries[i] + if !inWindow(e, input.StartTime, input.EndTime) || !containsPattern(e.Data, input.FilterPattern) { + continue + } + + out = append(out, driver.FilteredLogEvent{ + LogStream: rec.Log.DisplayName, + Timestamp: e.Time, + Message: e.Data, + }) + } + } + + if len(out) > limit { + out = out[:limit] + } + + return out, nil +} + +// PutMetricFilter is not an OCI Logging operation. +func (*Mock) PutMetricFilter(_ context.Context, _ *driver.MetricFilterConfig) error { + return unsupported("PutMetricFilter", viaServiceConnector) +} + +// DeleteMetricFilter is not an OCI Logging operation. +func (*Mock) DeleteMetricFilter(_ context.Context, _, _ string) error { + return unsupported("DeleteMetricFilter", viaServiceConnector) +} + +// DescribeMetricFilters is not an OCI Logging operation. +func (*Mock) DescribeMetricFilters(_ context.Context, _ string) ([]driver.MetricFilterInfo, error) { + return nil, unsupported("DescribeMetricFilters", viaServiceConnector) +} + +// PutSubscriptionFilter is not an OCI Logging operation. +func (*Mock) PutSubscriptionFilter(_ context.Context, _ *driver.SubscriptionFilterConfig) error { + return unsupported("PutSubscriptionFilter", viaConnectorTarget) +} + +// DeleteSubscriptionFilter is not an OCI Logging operation. +func (*Mock) DeleteSubscriptionFilter(_ context.Context, _, _ string) error { + return unsupported("DeleteSubscriptionFilter", viaConnectorTarget) +} + +// DescribeSubscriptionFilters is not an OCI Logging operation. +func (*Mock) DescribeSubscriptionFilters( + _ context.Context, _ string, +) ([]driver.SubscriptionFilterInfo, error) { + return nil, unsupported("DescribeSubscriptionFilters", viaConnectorTarget) +} + +// unsupported reports an operation OCI Logging has no equivalent for, naming +// what OCI does instead. +func unsupported(operation, instead string) error { + return cerrors.Newf(cerrors.Unimplemented, "%s is not an OCI Logging operation: %s", + operation, instead) +} + +// portableLog resolves a log by group and log display name. The caller holds mu. +func (m *Mock) portableLog(logGroup, streamName string) (*logRecord, error) { + g, err := m.portableGroupByName(logGroup) + if err != nil { + return nil, err + } + + rec, ok := m.logByName(g.ID, streamName) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "log %q not found in log group %q", streamName, logGroup) + } + + return rec, nil +} + +// portableSelection resolves the logs a read covers: one named log, or every +// log in the group. The caller holds mu. +func (m *Mock) portableSelection(logGroup, streamName string) ([]*logRecord, error) { + if streamName != "" { + rec, err := m.portableLog(logGroup, streamName) + if err != nil { + return nil, err + } + + return []*logRecord{rec}, nil + } + + g, err := m.portableGroupByName(logGroup) + if err != nil { + return nil, err + } + + return m.logsIn(g.ID), nil +} + +// toLogGroupInfo projects a log group onto the portable shape. The caller +// holds mu. +func (m *Mock) toLogGroupInfo(g *LogGroup) driver.LogGroupInfo { + return driver.LogGroupInfo{ + Name: g.DisplayName, + ResourceID: g.ID, + RetentionDays: g.RetentionDays, + CreatedAt: g.TimeCreated, + StoredBytes: m.storedBytes(g.ID), + Tags: copyTags(g.FreeformTags), + Scope: scope.Scope{Compartment: g.CompartmentID}, + } +} + +// toStreamInfo projects a log onto the portable stream shape. The caller holds mu. +func toStreamInfo(rec *logRecord) driver.LogStreamInfo { + info := driver.LogStreamInfo{Name: rec.Log.DisplayName, CreatedAt: rec.Log.TimeCreated} + if n := len(rec.Entries); n > 0 { + info.LastEvent = rec.Entries[n-1].Time.UTC().Format(timeFormat) + } + + return info +} + +// inWindow reports whether an entry falls in the caller's time range. A zero +// bound is open. +func inWindow(e *LogEntry, start, end time.Time) bool { + if !start.IsZero() && e.Time.Before(start) { + return false + } + + if !end.IsZero() && e.Time.After(end) { + return false + } + + return true +} + +// containsPattern reports whether a payload carries the caller's substring. +func containsPattern(data, pattern string) bool { + return pattern == "" || strings.Contains(data, pattern) +} diff --git a/providers/oci/logging/query.go b/providers/oci/logging/query.go new file mode 100644 index 000000000..8c01bb103 --- /dev/null +++ b/providers/oci/logging/query.go @@ -0,0 +1,466 @@ +package logging + +import ( + "strings" + + cerrors "github.com/stackshy/cloudemu/v2/errors" +) + +// OCID prefixes a search scope's segments must carry. +const ( + prefixCompartment = "ocid1.compartment." + prefixLogGroup = "ocid1.loggroup." + prefixLog = "ocid1.log." +) + +// maxScopeSegments is compartment/logGroup/log. +const maxScopeSegments = 3 + +// operatorChars are the characters a comparison operator is built from. +const operatorChars = "=!<>~" + +// The comparison operators a where clause may use. +const ( + opEqual = "=" + opNotEqual = "!=" +) + +// parseSearchQuery parses the subset of OCI's search query language CloudEmu +// models: a search clause, an optional where clause of = and != comparisons +// joined by and, and an optional sort by datetime. Everything else is rejected +// by name. +func parseSearchQuery(query string) (*searchQuery, error) { + stages := splitOutsideQuotes(query, '|') + if len(stages) == 0 || strings.TrimSpace(stages[0]) == "" { + return nil, cerrors.New(cerrors.InvalidArgument, "searchQuery is required") + } + + keyword, rest := splitKeyword(stages[0]) + if keyword != stageSearch { + return nil, cerrors.Newf(cerrors.InvalidArgument, + "a search query must begin with the search clause, got %q", keyword) + } + + scopes, err := parseScopes(rest) + if err != nil { + return nil, err + } + + q := &searchQuery{scopes: scopes} + + for _, stage := range stages[1:] { + if err := q.applyStage(stage); err != nil { + return nil, err + } + } + + return q, nil +} + +// applyStage folds one pipeline stage into the query. +func (q *searchQuery) applyStage(stage string) error { + keyword, rest := splitKeyword(stage) + + switch keyword { + case stageWhere: + conds, err := parseConditions(rest) + if err != nil { + return err + } + + q.conditions = append(q.conditions, conds...) + + return nil + case stageSort: + return q.applySort(rest) + case "": + return cerrors.New(cerrors.InvalidArgument, "empty stage in search query") + default: + return cerrors.Newf(cerrors.InvalidArgument, + "unsupported search operator %q; CloudEmu's OCI Logging search models %s", keyword, supportedOperators) + } +} + +// applySort parses `sort by datetime [asc|desc]`. Entries are ordered by time, +// so a sort on any other field is refused rather than ignored. +func (q *searchQuery) applySort(rest string) error { + fields := strings.Fields(rest) + if len(fields) < 2 || !strings.EqualFold(fields[0], "by") { + return cerrors.New(cerrors.InvalidArgument, "sort must be written as 'sort by [asc|desc]'") + } + + field := strings.ToLower(strings.TrimSuffix(fields[1], ",")) + field = strings.TrimPrefix(field, "logcontent.") + + if field != fieldDatetime && field != fieldTime { + return cerrors.Newf(cerrors.InvalidArgument, + "sort by %q is not modeled; CloudEmu's OCI Logging search sorts by datetime only", fields[1]) + } + + if len(fields) > 2 { //nolint:mnd // the optional direction + switch strings.ToLower(fields[2]) { + case "asc": + q.sortDesc = false + case "desc": + q.sortDesc = true + default: + return cerrors.Newf(cerrors.InvalidArgument, "sort direction %q is not asc or desc", fields[2]) + } + } + + if len(fields) > 3 { //nolint:mnd // nothing follows the direction + return cerrors.New(cerrors.InvalidArgument, "sort takes a single field and an optional direction") + } + + return nil +} + +// parseScopes parses the comma-separated, quoted targets of a search clause. +func parseScopes(rest string) ([]searchScope, error) { + parts := splitOutsideQuotes(rest, ',') + + scopes := make([]searchScope, 0, len(parts)) + + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + + literal, ok := unquote(part) + if !ok { + return nil, cerrors.Newf(cerrors.InvalidArgument, + "search target %s must be quoted, as \"compartmentId[/logGroupId[/logId]]\"", part) + } + + s, err := parseScope(literal) + if err != nil { + return nil, err + } + + scopes = append(scopes, s) + } + + if len(scopes) == 0 { + return nil, cerrors.New(cerrors.InvalidArgument, + "the search clause names no target; expected \"compartmentId[/logGroupId[/logId]]\"") + } + + return scopes, nil +} + +// parseScope parses one compartment/logGroup/log target. Real OCI addresses +// each segment by OCID, and a name in their place is refused rather than +// quietly matching nothing. +func parseScope(literal string) (searchScope, error) { + segments := strings.Split(literal, "/") + if len(segments) > maxScopeSegments { + return searchScope{}, cerrors.Newf(cerrors.InvalidArgument, + "search target %q has %d segments; expected compartmentId[/logGroupId[/logId]]", + literal, len(segments)) + } + + prefixes := []string{prefixCompartment, prefixLogGroup, prefixLog} + names := []string{"compartment", "log group", "log"} + + var s searchScope + + for i, segment := range segments { + if !strings.HasPrefix(segment, prefixes[i]) { + return searchScope{}, cerrors.Newf(cerrors.InvalidArgument, + "search target segment %q is not a %s OCID; CloudEmu's OCI Logging search addresses each "+ + "segment by OCID", segment, names[i]) + } + } + + s.compartmentID = segments[0] + + if len(segments) > 1 { + s.logGroupID = segments[1] + } + + if len(segments) > 2 { //nolint:mnd // the log is the third segment + s.logID = segments[2] + } + + return s, nil +} + +// parseConditions parses a where clause: comparisons joined by and. +func parseConditions(rest string) ([]condition, error) { + if strings.ContainsAny(stripQuoted(rest), "()") { + return nil, cerrors.New(cerrors.InvalidArgument, + "parenthesized where clauses are not modeled; CloudEmu joins comparisons with and") + } + + for _, word := range wordsOutsideQuotes(rest) { + switch strings.ToLower(word) { + case "or", "not": + return nil, cerrors.Newf(cerrors.InvalidArgument, + "the %q operator is not modeled in a where clause; CloudEmu joins comparisons with and", + strings.ToLower(word)) + } + } + + parts := splitOnWord(rest, "and") + + conds := make([]condition, 0, len(parts)) + + for _, part := range parts { + c, err := parseCondition(part) + if err != nil { + return nil, err + } + + conds = append(conds, c) + } + + if len(conds) == 0 { + return nil, cerrors.New(cerrors.InvalidArgument, "where takes at least one comparison") + } + + return conds, nil +} + +// parseCondition parses one `field = 'value'` or `field != 'value'`. +func parseCondition(part string) (condition, error) { + part = strings.TrimSpace(part) + if part == "" { + return condition{}, cerrors.New(cerrors.InvalidArgument, "empty comparison in where clause") + } + + idx, op := findOperator(part) + if idx < 0 { + return condition{}, cerrors.Newf(cerrors.InvalidArgument, + "where comparison %q has no operator; CloudEmu models = and !=", part) + } + + if op != opEqual && op != opNotEqual { + return condition{}, cerrors.Newf(cerrors.InvalidArgument, + "the %q operator is not modeled; CloudEmu's OCI Logging search models = and !=, "+ + "with * as the wildcard", op) + } + + field := strings.TrimSpace(part[:idx]) + if field == "" { + return condition{}, cerrors.Newf(cerrors.InvalidArgument, "where comparison %q names no field", part) + } + + literal := strings.TrimSpace(part[idx+len(op):]) + if literal == "" { + return condition{}, cerrors.Newf(cerrors.InvalidArgument, "where comparison %q has no value", part) + } + + ref, err := resolveField(field) + if err != nil { + return condition{}, err + } + + pattern, ok := unquote(literal) + if !ok { + pattern = literal + } + + return condition{field: ref, negated: op == opNotEqual, pattern: pattern}, nil +} + +// findOperator returns the offset and text of the first comparison operator +// outside a quoted literal. +func findOperator(s string) (offset int, operator string) { + var quote rune + + for i, r := range s { + switch { + case quote != 0: + if r == quote { + quote = 0 + } + case r == '\'' || r == '"': + quote = r + case strings.ContainsRune(operatorChars, r): + end := i + for end < len(s) && strings.ContainsRune(operatorChars, rune(s[end])) { + end++ + } + + return i, s[i:end] + } + } + + return -1, "" +} + +// splitOutsideQuotes splits on sep, ignoring separators inside a quoted +// literal. +func splitOutsideQuotes(s string, sep rune) []string { + var ( + out []string + buf strings.Builder + quote rune + ) + + for _, r := range s { + switch { + case quote != 0: + if r == quote { + quote = 0 + } + case r == '\'' || r == '"': + quote = r + case r == sep: + out = append(out, buf.String()) + buf.Reset() + + continue + } + + buf.WriteRune(r) + } + + return append(out, buf.String()) +} + +// splitOnWord splits on a bare keyword outside quoted literals. +func splitOnWord(s, word string) []string { + var ( + out []string + cur []string + seen = strings.EqualFold + ) + + for _, token := range tokenize(s) { + if !token.quoted && seen(token.text, word) { + out = append(out, strings.Join(cur, " ")) + cur = nil + + continue + } + + cur = append(cur, token.raw) + } + + return append(out, strings.Join(cur, " ")) +} + +// wordsOutsideQuotes returns the bare tokens of s, skipping quoted literals. +func wordsOutsideQuotes(s string) []string { + var out []string + + for _, token := range tokenize(s) { + if !token.quoted { + out = append(out, token.text) + } + } + + return out +} + +// stripQuoted returns s with every quoted literal removed, so a structural +// check does not trip over punctuation inside a value. +func stripQuoted(s string) string { + var ( + buf strings.Builder + quote rune + ) + + for _, r := range s { + switch { + case quote != 0: + if r == quote { + quote = 0 + } + case r == '\'' || r == '"': + quote = r + default: + buf.WriteRune(r) + } + } + + return buf.String() +} + +// token is one whitespace-delimited piece of a clause. raw keeps the quotes a +// literal was written with; text drops them. +type token struct { + raw string + text string + quoted bool +} + +// tokenize splits a clause on whitespace, keeping a quoted literal whole. +func tokenize(s string) []token { + var ( + out []token + buf strings.Builder + quote rune + saw bool + ) + + flush := func() { + if buf.Len() == 0 { + return + } + + raw := buf.String() + text, _ := unquote(raw) + + out = append(out, token{raw: raw, text: text, quoted: saw}) + + buf.Reset() + + saw = false + } + + for _, r := range s { + switch { + case quote != 0: + buf.WriteRune(r) + + if r == quote { + quote = 0 + } + case r == '\'' || r == '"': + quote = r + saw = true + + buf.WriteRune(r) + case r == ' ' || r == '\t' || r == '\n': + flush() + default: + buf.WriteRune(r) + } + } + + flush() + + return out +} + +// unquote strips a matching pair of surrounding quotes, reporting whether the +// text was quoted. +func unquote(s string) (string, bool) { + s = strings.TrimSpace(s) + if len(s) < 2 { //nolint:mnd // a quoted literal is at least a pair of quotes + return s, false + } + + first, last := s[0], s[len(s)-1] + if (first == '\'' || first == '"') && first == last { + return s[1 : len(s)-1], true + } + + return s, false +} + +// splitKeyword splits a stage into its leading keyword, lowercased, and the +// rest of the clause. +func splitKeyword(stage string) (keyword, rest string) { + stage = strings.TrimSpace(stage) + + idx := strings.IndexFunc(stage, func(r rune) bool { return r == ' ' || r == '\t' || r == '\n' }) + if idx < 0 { + return strings.ToLower(stage), "" + } + + return strings.ToLower(stage[:idx]), strings.TrimSpace(stage[idx+1:]) +} diff --git a/providers/oci/logging/race_test.go b/providers/oci/logging/race_test.go new file mode 100644 index 000000000..206f583e5 --- /dev/null +++ b/providers/oci/logging/race_test.go @@ -0,0 +1,110 @@ +package logging_test + +import ( + "context" + "strconv" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + ocilogging "github.com/stackshy/cloudemu/v2/providers/oci/logging" + "github.com/stackshy/cloudemu/v2/services/logging/driver" + "github.com/stackshy/cloudemu/v2/services/scope" +) + +// concurrency is how many goroutines each phase runs. +const concurrency = 16 + +// TestConcurrentOperations exercises every store the mock holds from many +// goroutines at once, so -race catches a lock a method forgot to take. +func TestConcurrentOperations(t *testing.T) { + ctx := context.Background() + m := newMock(t) + g := newGroup(t, m, compartmentA, "app-logs") + + logs := make([]string, concurrency) + for i := range logs { + logs[i] = newCustomLog(t, m, g.ID, "log-"+strconv.Itoa(i)).ID + } + + base := time.Date(2026, 8, 8, 10, 0, 0, 0, time.UTC) + + var wg sync.WaitGroup + + for i := range concurrency { + wg.Add(1) + + go func(i int) { + defer wg.Done() + + _ = m.PutLogs(ctx, logs[i], []ocilogging.LogEntryBatch{{ + Entries: []ocilogging.LogEntryItem{{Data: "entry-" + strconv.Itoa(i), Time: base}}, + }}) + + _, _ = m.GetLog(ctx, g.ID, logs[i]) + _, _ = m.ListLogs(ctx, g.ID, ocilogging.LogFilter{}) + _, _ = m.ListGroups(ctx, compartmentA, "") + _, _ = m.GetGroup(ctx, g.ID) + _, _ = m.Entries(ctx, logs[i]) + + _, _ = m.SearchLogs(ctx, ocilogging.SearchRequest{ + Query: `search "` + compartmentA + `"`, + TimeStart: base.Add(-time.Hour), + TimeEnd: base.Add(time.Hour), + }) + + _, _ = m.ListLogGroups(ctx, scope.Scope{Compartment: compartmentA}) + _, _ = m.ListLogStreams(ctx, "app-logs") + _, _ = m.GetLogEvents(ctx, &driver.LogQueryInput{LogGroup: "app-logs"}) + _, _ = m.FilterLogEvents(ctx, &driver.FilterLogEventsInput{LogGroup: "app-logs"}) + _ = m.PutLogEvents(ctx, "app-logs", "log-"+strconv.Itoa(i), []driver.LogEvent{ + {Timestamp: base, Message: "portable"}, + }) + }(i) + } + + wg.Wait() + + entries, err := m.Entries(ctx, logs[0]) + require.NoError(t, err) + require.Len(t, entries, 2) +} + +// TestConcurrentCreateAndDelete races creates against deletes across both +// stores, where a group delete walks the logs. +func TestConcurrentCreateAndDelete(t *testing.T) { + ctx := context.Background() + m := newMock(t) + + var wg sync.WaitGroup + + for i := range concurrency { + wg.Add(1) + + go func(i int) { + defer wg.Done() + + name := "group-" + strconv.Itoa(i) + + created, err := m.CreateGroup(ctx, ocilogging.LogGroupSpec{ + CompartmentID: compartmentA, + DisplayName: name, + }) + if err != nil { + return + } + + _, _ = m.CreateLog(ctx, created.ID, ocilogging.LogSpec{DisplayName: "stdout", IsEnabled: true}) + _ = m.MoveGroup(ctx, created.ID, compartmentB) + _ = m.DeleteGroup(ctx, created.ID) + }(i) + } + + wg.Wait() + + groups, err := m.ListGroups(ctx, compartmentA, "") + require.NoError(t, err) + require.Empty(t, groups) +} diff --git a/providers/oci/logging/search.go b/providers/oci/logging/search.go new file mode 100644 index 000000000..40cb94ac8 --- /dev/null +++ b/providers/oci/logging/search.go @@ -0,0 +1,373 @@ +package logging + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + cerrors "github.com/stackshy/cloudemu/v2/errors" +) + +// SearchRequest is a loggingsearch query over a time range. +type SearchRequest struct { + Query string + TimeStart time.Time + TimeEnd time.Time + Limit int + ReturnFieldInfo bool +} + +// SearchEntry is one entry a search matched, with the log it came from. +type SearchEntry struct { + LogEntry + + CompartmentID string + LogGroupID string + LogName string +} + +// SearchField describes a field of the returned records. +type SearchField struct { + Name string + Type string +} + +// SearchResult is what SearchLogs returns. +type SearchResult struct { + Entries []SearchEntry + Fields []SearchField +} + +// searchScope is one target of a search query's search clause: +// compartment[/logGroup[/log]]. +type searchScope struct { + compartmentID string + logGroupID string + logID string +} + +// fieldRef is a where clause's field, resolved when the query is parsed so an +// unmodelled field is rejected before any entry is walked rather than quietly +// matching nothing. +type fieldRef struct { + // name is the canonical field, or "data" when jsonKey is set. + name string + // jsonKey is the top-level key of a JSON payload, for data.. + jsonKey string +} + +// condition is one comparison of a where clause. Pattern may carry * wildcards. +type condition struct { + field fieldRef + negated bool + pattern string +} + +// searchQuery is a parsed OCI Logging search query. +type searchQuery struct { + scopes []searchScope + conditions []condition + sortDesc bool +} + +// Search stage keywords CloudEmu parses. +const ( + stageSearch = "search" + stageWhere = "where" + stageSort = "sort" +) + +// supportedOperators names what a rejection message points the caller at. +const supportedOperators = "'search', 'where' and 'sort by'" + +// Fields naming an entry's time, which is also the only field a sort may name. +const ( + fieldTime = "time" + fieldDatetime = "datetime" +) + +// canonicalFields is the record shape a search returns, reported when the +// caller asks for field info. +// +//nolint:gochecknoglobals // immutable record-shape table. +var canonicalFields = []SearchField{ + {Name: "datetime", Type: "STRING"}, + {Name: "logContent.data", Type: "STRING"}, + {Name: "logContent.id", Type: "STRING"}, + {Name: "logContent.source", Type: "STRING"}, + {Name: "logContent.subject", Type: "STRING"}, + {Name: "logContent.time", Type: "STRING"}, + {Name: "logContent.type", Type: "STRING"}, + {Name: "logContent.oracle.compartmentid", Type: "STRING"}, + {Name: "logContent.oracle.loggroupid", Type: "STRING"}, + {Name: "logContent.oracle.logid", Type: "STRING"}, +} + +// SearchLogs runs a search query over a time range — the loggingsearch data +// plane. A query CloudEmu does not model is rejected by name rather than +// answered with an empty result set. +// +//nolint:gocritic // hugeParam: SearchRequest mirrors the wire request and reads better by value. +func (m *Mock) SearchLogs(_ context.Context, req SearchRequest) (*SearchResult, error) { + if req.TimeStart.IsZero() || req.TimeEnd.IsZero() { + return nil, cerrors.New(cerrors.InvalidArgument, "timeStart and timeEnd are required") + } + + if !req.TimeEnd.After(req.TimeStart) { + return nil, cerrors.New(cerrors.InvalidArgument, "timeEnd must be after timeStart") + } + + q, err := parseSearchQuery(req.Query) + if err != nil { + return nil, err + } + + limit, err := resolveLimit(req.Limit) + if err != nil { + return nil, err + } + + m.mu.RLock() + matched := m.collect(q, req.TimeStart, req.TimeEnd) + m.mu.RUnlock() + + sortEntries(matched, q) + + if len(matched) > limit { + matched = matched[:limit] + } + + out := &SearchResult{Entries: matched} + if req.ReturnFieldInfo { + out.Fields = canonicalFields + } + + return out, nil +} + +// collect walks the logs a query selects and keeps the entries in range that +// satisfy every condition. The caller holds mu. +func (m *Mock) collect(q *searchQuery, start, end time.Time) []SearchEntry { + out := make([]SearchEntry, 0) + + for _, rec := range m.logs.SortedValues() { + g, ok := m.groups.Get(rec.Log.LogGroupID) + if !ok || !q.selects(g, &rec.Log) { + continue + } + + for i := range rec.Entries { + e := &rec.Entries[i] + if e.Time.Before(start) || !e.Time.Before(end) { + continue + } + + if q.matches(e, g, &rec.Log) { + out = append(out, SearchEntry{ + LogEntry: *e, + CompartmentID: g.CompartmentID, + LogGroupID: rec.Log.LogGroupID, + LogName: rec.Log.DisplayName, + }) + } + } + } + + return out +} + +// selects reports whether a log falls under any of the query's search scopes. +func (q *searchQuery) selects(g *LogGroup, l *Log) bool { + for _, s := range q.scopes { + if s.compartmentID != g.CompartmentID { + continue + } + + if s.logGroupID != "" && s.logGroupID != l.LogGroupID { + continue + } + + if s.logID != "" && s.logID != l.ID { + continue + } + + return true + } + + return false +} + +// matches reports whether an entry satisfies every where condition. +func (q *searchQuery) matches(e *LogEntry, g *LogGroup, l *Log) bool { + for _, c := range q.conditions { + if globMatch(c.pattern, fieldValue(e, g, l, c.field)) == c.negated { + return false + } + } + + return true +} + +// sortEntries orders the results. Without a sort clause the order is by entry +// time and then id, so a search is reproducible. +func sortEntries(entries []SearchEntry, q *searchQuery) { + before := func(a, b *SearchEntry) bool { + if a.Time.Equal(b.Time) { + return a.ID < b.ID + } + + return a.Time.Before(b.Time) + } + + sort.SliceStable(entries, func(i, j int) bool { + if q.sortDesc { + return before(&entries[j], &entries[i]) + } + + return before(&entries[i], &entries[j]) + }) +} + +// entryFields are the fields of a returned record a where clause may name, +// with the logContent. prefix OCI writes them under already stripped. +// +//nolint:gochecknoglobals // immutable field lookup table. +var entryFields = map[string]struct{}{ + "data": {}, "id": {}, "type": {}, "subject": {}, "source": {}, + fieldTime: {}, fieldDatetime: {}, + "oracle.compartmentid": {}, "oracle.loggroupid": {}, + "oracle.logid": {}, "oracle.ingestedtime": {}, +} + +// resolveField canonicalises a field named in a where clause, rejecting one +// CloudEmu cannot resolve. The logContent. prefix is optional, matching how +// OCI writes the field in a search clause. +func resolveField(field string) (fieldRef, error) { + name := strings.ToLower(strings.TrimSpace(field)) + name = strings.TrimPrefix(name, "logcontent.") + + if key, ok := strings.CutPrefix(name, "data."); ok { + if key == "" || strings.Contains(key, ".") { + return fieldRef{}, cerrors.Newf(cerrors.InvalidArgument, + "unsupported search field %q; CloudEmu resolves a single top-level key of a JSON payload, "+ + "not a nested path", field) + } + + return fieldRef{name: "data", jsonKey: key}, nil + } + + if _, ok := entryFields[name]; !ok { + return fieldRef{}, cerrors.Newf(cerrors.InvalidArgument, + "unsupported search field %q; CloudEmu resolves %s and data. of a JSON payload", + field, strings.Join(sortedFieldNames(), ", ")) + } + + return fieldRef{name: name}, nil +} + +// sortedFieldNames lists the resolvable fields, for a rejection message. +func sortedFieldNames() []string { + out := make([]string, 0, len(entryFields)) + for name := range entryFields { + out = append(out, name) + } + + sort.Strings(out) + + return out +} + +// fieldValue reads a resolved field off an entry. +func fieldValue(e *LogEntry, g *LogGroup, l *Log, ref fieldRef) string { + if ref.jsonKey != "" { + return jsonField(e.Data, ref.jsonKey) + } + + switch ref.name { + case "data": + return e.Data + case "id": + return e.ID + case "type": + return e.Type + case "subject": + return e.Subject + case "source": + return e.Source + case fieldTime, fieldDatetime: + return e.Time.UTC().Format(timeFormat) + default: + return provenanceValue(e, g, l, ref.name) + } +} + +// provenanceValue reads one of the oracle.* fields OCI stamps onto a record. +func provenanceValue(e *LogEntry, g *LogGroup, l *Log, name string) string { + switch name { + case "oracle.compartmentid": + return g.CompartmentID + case "oracle.loggroupid": + return l.LogGroupID + case "oracle.logid": + return l.ID + case "oracle.ingestedtime": + return e.IngestedTime.UTC().Format(timeFormat) + default: + return "" + } +} + +// jsonField reads a top-level key out of a JSON entry payload. A payload that +// is not a JSON object, or that lacks the key, resolves to the empty string — +// the field is absent from that record rather than unmodelled. +func jsonField(data, key string) string { + var payload map[string]any + + if err := json.Unmarshal([]byte(data), &payload); err != nil { + return "" + } + + v, ok := payload[key] + if !ok { + return "" + } + + if s, isString := v.(string); isString { + return s + } + + return fmt.Sprint(v) +} + +// globMatch reports whether value matches a pattern whose * stands for any run +// of characters. OCI's search wildcard is *, and a pattern without one is an +// exact comparison. +func globMatch(pattern, value string) bool { + parts := strings.Split(pattern, "*") + if len(parts) == 1 { + return pattern == value + } + + rest := value + if !strings.HasPrefix(rest, parts[0]) { + return false + } + + rest = rest[len(parts[0]):] + + for _, part := range parts[1 : len(parts)-1] { + idx := strings.Index(rest, part) + if idx < 0 { + return false + } + + rest = rest[idx+len(part):] + } + + last := parts[len(parts)-1] + + return strings.HasSuffix(rest, last) && len(rest) >= len(last) +} diff --git a/providers/oci/logging/snapshot.go b/providers/oci/logging/snapshot.go new file mode 100644 index 000000000..d9553e8c6 --- /dev/null +++ b/providers/oci/logging/snapshot.go @@ -0,0 +1,81 @@ +package logging + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/stackshy/cloudemu/v2/internal/snapshot" +) + +var _ snapshot.Snapshottable = (*Mock)(nil) + +// loggingSnapshot is the full serialized state of the OCI Logging mock. Groups +// are keyed by log group OCID and log records by log OCID, so a log's +// LogGroupID still resolves to its group after a restore. Every value type is +// exported, so both stores round-trip through the generic memstore helper; the +// mutex, *config.Options and the monitoring driver are not serialized. +type loggingSnapshot struct { + Groups json.RawMessage `json:"groups,omitempty"` + Logs json.RawMessage `json:"logs,omitempty"` +} + +// Snapshot captures the mock's entire state as JSON. includeAssets is unused — +// an ingested log entry is part of the state, not a bulk object body. +func (m *Mock) Snapshot(_ context.Context, _ bool) (json.RawMessage, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + var snap loggingSnapshot + + for _, d := range m.snapshotDumps(&snap) { + b, err := d.fn() + if err != nil { + return nil, fmt.Errorf("logging: snapshot store: %w", err) + } + + *d.dst = b + } + + return json.Marshal(snap) +} + +// Restore rebuilds the mock's state under the original identities: every OCID, +// each log's group cross-reference and every ingested entry are preserved. +func (m *Mock) Restore(_ context.Context, data json.RawMessage) error { + var snap loggingSnapshot + if err := json.Unmarshal(data, &snap); err != nil { + return fmt.Errorf("logging: parse snapshot: %w", err) + } + + m.mu.Lock() + defer m.mu.Unlock() + + for _, d := range m.snapshotDumps(&snap) { + if len(*d.dst) == 0 { + continue + } + + if err := d.load(*d.dst); err != nil { + return fmt.Errorf("logging: restore store: %w", err) + } + } + + return nil +} + +// storeDump pairs a snapshot field with its store's dump and load functions, so +// Snapshot and Restore share one table and cannot drift apart. +type storeDump struct { + dst *json.RawMessage + fn func() ([]byte, error) + load func([]byte) error +} + +// snapshotDumps lists every store alongside the snapshot field it maps to. +func (m *Mock) snapshotDumps(snap *loggingSnapshot) []storeDump { + return []storeDump{ + {&snap.Groups, m.groups.Snapshot, m.groups.LoadSnapshot}, + {&snap.Logs, m.logs.Snapshot, m.logs.LoadSnapshot}, + } +} diff --git a/providers/oci/logging/snapshot_test.go b/providers/oci/logging/snapshot_test.go new file mode 100644 index 000000000..2d7dfc903 --- /dev/null +++ b/providers/oci/logging/snapshot_test.go @@ -0,0 +1,120 @@ +package logging_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + ocilogging "github.com/stackshy/cloudemu/v2/providers/oci/logging" +) + +// TestSnapshotRestoreRoundTrip seeds groups in two compartments, logs inside +// them and ingested entries, snapshots, restores into a fresh mock and asserts +// each resource comes back under its original OCID with its cross-references +// and its entries intact — a log still points at its group, and the entries +// ingested into it survive. +func TestSnapshotRestoreRoundTrip(t *testing.T) { + ctx := t.Context() + src := newMock(t) + + groupA := newGroup(t, src, compartmentA, "app-logs") + groupB := newGroup(t, src, compartmentB, "other-logs") + stdout := newCustomLog(t, src, groupA.ID, "stdout") + audit := newCustomLog(t, src, groupB.ID, "audit") + + when := time.Date(2026, 8, 8, 10, 0, 0, 0, time.UTC) + require.NoError(t, src.PutLogs(ctx, stdout.ID, []ocilogging.LogEntryBatch{{ + Source: "host-a", + Type: "com.oraclecloud.custom", + Subject: "app", + Entries: []ocilogging.LogEntryItem{ + {ID: "e-1", Data: `{"level":"error"}`, Time: when}, + {ID: "e-2", Data: "plain line", Time: when.Add(time.Minute)}, + }, + }})) + + data, err := src.Snapshot(ctx, false) + require.NoError(t, err) + + dst := newMock(t) + require.NoError(t, dst.Restore(ctx, data)) + + t.Run("groups come back under their OCIDs and compartments", func(t *testing.T) { + got, groupErr := dst.GetGroup(ctx, groupA.ID) + require.NoError(t, groupErr) + assert.Equal(t, "app-logs", got.DisplayName) + assert.Equal(t, compartmentA, got.CompartmentID) + assert.Equal(t, groupA.TimeCreated, got.TimeCreated) + + other, otherErr := dst.GetGroup(ctx, groupB.ID) + require.NoError(t, otherErr) + assert.Equal(t, compartmentB, other.CompartmentID) + }) + + t.Run("a log still resolves through its group", func(t *testing.T) { + got, logErr := dst.GetLog(ctx, groupA.ID, stdout.ID) + require.NoError(t, logErr) + assert.Equal(t, "stdout", got.DisplayName) + assert.Equal(t, groupA.ID, got.LogGroupID) + + auditLog, auditErr := dst.GetLog(ctx, groupB.ID, audit.ID) + require.NoError(t, auditErr) + assert.Equal(t, groupB.ID, auditLog.LogGroupID) + }) + + t.Run("ingested entries survive with their fields", func(t *testing.T) { + entries, entryErr := dst.Entries(ctx, stdout.ID) + require.NoError(t, entryErr) + require.Len(t, entries, 2) + + assert.Equal(t, "e-1", entries[0].ID) + assert.Equal(t, `{"level":"error"}`, entries[0].Data) + assert.Equal(t, when, entries[0].Time.UTC()) + assert.Equal(t, "host-a", entries[0].Source) + assert.Equal(t, "app", entries[0].Subject) + assert.Equal(t, "e-2", entries[1].ID) + }) + + t.Run("a restored group is searchable", func(t *testing.T) { + res, searchErr := dst.SearchLogs(ctx, ocilogging.SearchRequest{ + Query: `search "` + compartmentA + `" | where oracle.logid = '` + stdout.ID + `'`, + TimeStart: when.Add(-time.Hour), + TimeEnd: when.Add(time.Hour), + }) + require.NoError(t, searchErr) + require.Len(t, res.Entries, 2) + assert.Equal(t, groupA.ID, res.Entries[0].LogGroupID) + }) + + t.Run("restoring keeps the mock usable", func(t *testing.T) { + l := newCustomLog(t, dst, groupA.ID, "stderr") + assert.NotEqual(t, stdout.ID, l.ID, "a restored mock still mints fresh OCIDs") + }) +} + +func TestRestoreRejectsMalformedSnapshot(t *testing.T) { + m := newMock(t) + + err := m.Restore(t.Context(), []byte("{not json")) + require.Error(t, err) + assert.Contains(t, err.Error(), "parse snapshot") +} + +// TestSnapshotOfAnEmptyMockRestores guards the omitempty fields: an empty +// snapshot must restore cleanly rather than fail on an absent store. +func TestSnapshotOfAnEmptyMockRestores(t *testing.T) { + ctx := t.Context() + src := newMock(t) + + data, err := src.Snapshot(ctx, false) + require.NoError(t, err) + + dst := newMock(t) + require.NoError(t, dst.Restore(ctx, data)) + + groups, err := dst.ListGroups(ctx, compartmentA, "") + require.NoError(t, err) + assert.Empty(t, groups) +} diff --git a/providers/oci/oci.go b/providers/oci/oci.go index 0036a9a58..b4ea30bbf 100644 --- a/providers/oci/oci.go +++ b/providers/oci/oci.go @@ -5,6 +5,7 @@ import ( "github.com/stackshy/cloudemu/v2/config" "github.com/stackshy/cloudemu/v2/internal/snapshot" "github.com/stackshy/cloudemu/v2/providers/oci/identity" + logprovider "github.com/stackshy/cloudemu/v2/providers/oci/logging" "github.com/stackshy/cloudemu/v2/providers/oci/monitoring" vcnprovider "github.com/stackshy/cloudemu/v2/providers/oci/vcn" cachedriver "github.com/stackshy/cloudemu/v2/services/cache/driver" @@ -78,6 +79,7 @@ func New(opts ...config.Option) *Provider { p.VCN = vcnprovider.New(o) p.Monitoring = monitoring.New(o) + p.Logging = logprovider.New(o) p.wireMonitoring() p.wireDiscovery() diff --git a/server/oci/logging/group.go b/server/oci/logging/group.go new file mode 100644 index 000000000..3ddd429eb --- /dev/null +++ b/server/oci/logging/group.go @@ -0,0 +1,206 @@ +package logging + +import ( + "net/http" + + logprovider "github.com/stackshy/cloudemu/v2/providers/oci/logging" + "github.com/stackshy/cloudemu/v2/server/oci/workrequest" + "github.com/stackshy/cloudemu/v2/server/wire/ocirest" +) + +// serveGroupCRUD maps method and path shape onto the log group operations. +func (h *Handler) serveGroupCRUD(w http.ResponseWriter, r *http.Request, rt *route) { + if rt.ID == "" { + switch r.Method { + case http.MethodPost: + h.createGroup(w, r) + case http.MethodGet: + h.listGroups(w, r) + default: + methodNotAllowed(w, r) + } + + return + } + + switch r.Method { + case http.MethodGet: + h.getGroup(w, r, rt.ID) + case http.MethodPut: + h.updateGroup(w, r, rt.ID) + case http.MethodDelete: + h.deleteGroup(w, r, rt.ID) + default: + methodNotAllowed(w, r) + } +} + +// serveGroupAction serves the one action OCI defines on a log group. +func (h *Handler) serveGroupAction(w http.ResponseWriter, r *http.Request, rt *route) { + if rt.SubID != actionChangeComp { + ocirest.WriteError(w, r, http.StatusNotFound, codeNotFound, "unknown action "+rt.SubID) + return + } + + if r.Method != http.MethodPost { + methodNotAllowed(w, r) + return + } + + h.moveGroup(w, r, rt.ID) +} + +// createGroup creates a log group. Real OCI runs the mutation asynchronously, +// so it answers 202 with the work request a waiter polls. +func (h *Handler) createGroup(w http.ResponseWriter, r *http.Request) { + if !h.requireWork(w, r) { + return + } + + var req createLogGroupRequest + + if !ocirest.DecodeJSON(w, r, &req) { + return + } + + if req.CompartmentID == "" { + ocirest.WriteError(w, r, http.StatusBadRequest, codeInvalidParameter, "compartmentId is required") + return + } + + g, err := h.extras.CreateGroup(r.Context(), logprovider.LogGroupSpec{ + CompartmentID: req.CompartmentID, + DisplayName: req.DisplayName, + Description: req.Description, + FreeformTags: req.FreeformTags, + }) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + h.accept(w, r, operationCreateGroup, g.CompartmentID, entityTypeGroup, workrequest.ActionCreated, g.ID) +} + +// listGroups lists the log groups in a compartment. +func (h *Handler) listGroups(w http.ResponseWriter, r *http.Request) { + compartmentID, ok := ocirest.RequireCompartmentID(w, r) + if !ok { + return + } + + groups, err := h.extras.ListGroups(r.Context(), compartmentID, r.URL.Query().Get("displayName")) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + out := make([]logGroupResponse, 0, len(groups)) + for i := range groups { + out = append(out, toLogGroupResponse(&groups[i])) + } + + ocirest.WriteJSON(w, r, http.StatusOK, paginate(w, r, out)) +} + +func (h *Handler) getGroup(w http.ResponseWriter, r *http.Request, id string) { + g, err := h.extras.GetGroup(r.Context(), id) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + ocirest.WriteJSON(w, r, http.StatusOK, toLogGroupResponse(g)) +} + +func (h *Handler) updateGroup(w http.ResponseWriter, r *http.Request, id string) { + if !h.requireWork(w, r) { + return + } + + var req updateLogGroupRequest + + if !ocirest.DecodeJSON(w, r, &req) { + return + } + + g, err := h.extras.UpdateGroup(r.Context(), id, logprovider.LogGroupUpdate{ + DisplayName: req.DisplayName, + Description: req.Description, + FreeformTags: req.FreeformTags, + }) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + h.accept(w, r, operationUpdateGroup, g.CompartmentID, entityTypeGroup, workrequest.ActionUpdated, g.ID) +} + +func (h *Handler) deleteGroup(w http.ResponseWriter, r *http.Request, id string) { + if !h.requireWork(w, r) { + return + } + + g, err := h.extras.GetGroup(r.Context(), id) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + if err := h.extras.DeleteGroup(r.Context(), id); err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + h.accept(w, r, operationDeleteGroup, g.CompartmentID, entityTypeGroup, workrequest.ActionDeleted, id) +} + +// moveGroup moves a log group between compartments. +func (h *Handler) moveGroup(w http.ResponseWriter, r *http.Request, id string) { + if !h.requireWork(w, r) { + return + } + + var req changeCompartmentRequest + + if !ocirest.DecodeJSON(w, r, &req) { + return + } + + if req.TargetCompartmentID == "" { + ocirest.WriteError(w, r, http.StatusBadRequest, codeInvalidParameter, "targetCompartmentId is required") + return + } + + if err := h.extras.MoveGroup(r.Context(), id, req.TargetCompartmentID); err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + h.accept(w, r, operationMoveGroup, req.TargetCompartmentID, entityTypeGroup, workrequest.ActionUpdated, id) +} + +func toLogGroupResponse(g *logprovider.LogGroup) logGroupResponse { + return logGroupResponse{ + ID: g.ID, + CompartmentID: g.CompartmentID, + DisplayName: g.DisplayName, + Description: g.Description, + LifecycleState: g.LifecycleState, + TimeCreated: g.TimeCreated, + TimeLastModified: g.TimeLastModified, + FreeformTags: orEmptyTags(g.FreeformTags), + DefinedTags: definedTags{}, + } +} + +// orEmptyTags keeps a tag map from serializing as null, which no OCI response +// does. +func orEmptyTags(tags map[string]string) map[string]string { + if tags == nil { + return map[string]string{} + } + + return tags +} diff --git a/server/oci/logging/handler.go b/server/oci/logging/handler.go new file mode 100644 index 000000000..c98fa4f9d --- /dev/null +++ b/server/oci/logging/handler.go @@ -0,0 +1,308 @@ +// Package logging implements OCI's Logging REST API against a CloudEmu +// logging driver. Real github.com/oracle/oci-go-sdk logging, loggingingestion +// and loggingsearch clients hit this handler the same way they hit +// logging..oci.oraclecloud.com and its ingestion and search siblings. +// +// OCI splits the service across three API surfaces, each with its own version +// prefix. CloudEmu collapses them onto one server, so Matches claims each +// prefix's collections exactly and nothing else: +// +// logging — the control plane, /20200531 +// POST/GET /20200531/logGroups — create, list +// GET/PUT/DELETE /20200531/logGroups/{logGroupId} — get, update, delete +// POST /20200531/logGroups/{logGroupId}/actions/changeCompartment +// POST/GET /20200531/logGroups/{logGroupId}/logs — create, list +// GET/PUT/DELETE /20200531/logGroups/{logGroupId}/logs/{logId} +// +// loggingingestion — the data plane, /20200601 +// POST /20200601/logs/{logId}/actions/push — PutLogs +// +// loggingsearch — the query plane, /20190909 +// POST /20190909/search — SearchLogs +// +// A log lives at a top-level /logs collection only under the ingestion prefix; +// the control plane nests it under its log group. That is what keeps the two +// apart, and Matches claims /logs for /20200601 alone. +// +// Not emulated: /20200531/unifiedAgentConfigurations and +// /20200531/logSavedSearches, which the logging driver has no shape for — the +// handler claims them so a caller gets a 501 naming the gap rather than a bare +// 404. Log groups and logs report ACTIVE from the moment they are created: +// every CloudEmu mutation is synchronous, so the CREATING state an SDK waiter +// may poll for is never observable. +package logging + +import ( + "context" + "net/http" + "strconv" + "strings" + + logprovider "github.com/stackshy/cloudemu/v2/providers/oci/logging" + "github.com/stackshy/cloudemu/v2/server/oci/workrequest" + "github.com/stackshy/cloudemu/v2/server/wire/ocirest" + logdriver "github.com/stackshy/cloudemu/v2/services/logging/driver" +) + +// The three API version prefixes OCI Logging is published under. +const ( + versionControl = "20200531" + versionIngestion = "20200601" + versionSearch = "20190909" +) + +// Collections this handler claims. +const ( + segLogGroups = "logGroups" + segLogs = "logs" + segSearch = "search" + segUnifiedAgent = "unifiedAgentConfigurations" + segSavedSearches = "logSavedSearches" + subActions = "actions" + actionChangeComp = "changeCompartment" + actionPush = "push" + entityTypeGroup = "loggroup" + entityTypeLog = "log" + specVersionOCI = "1.0" + fieldTypeString = "STRING" + sourceTypeService = "OCISERVICE" +) + +// Work request operations the asynchronous mutations record. +const ( + operationCreateGroup = "CREATE_LOG_GROUP" + operationUpdateGroup = "UPDATE_LOG_GROUP" + operationDeleteGroup = "DELETE_LOG_GROUP" + operationMoveGroup = "CHANGE_LOG_GROUP_COMPARTMENT" + operationCreateLog = "CREATE_LOG" + operationUpdateLog = "UPDATE_LOG" + operationDeleteLog = "DELETE_LOG" +) + +// OCI error codes the handler raises itself. +const ( + codeInvalidParameter = "InvalidParameter" + codeMethodNotAllowed = "MethodNotAllowed" + codeNotImplemented = "NotImplemented" + codeNotFound = "NotAuthorizedOrNotFound" +) + +// maxPathSegments is /{version}/{collection}/{id}/{sub}/{subId}. +const maxPathSegments = 5 + +// Extras is the OCI-only surface the portable logging driver cannot express: +// log groups and logs are addressed by OCID inside a compartment, a log +// carries a type and a service source, ingestion batches entries, and search +// speaks OCI's own query language. +// *providers/oci/logging.Mock satisfies it; any driver that does not is served +// 501 for every path this handler claims. +type Extras interface { + CreateGroup(ctx context.Context, spec logprovider.LogGroupSpec) (*logprovider.LogGroup, error) + GetGroup(ctx context.Context, id string) (*logprovider.LogGroup, error) + ListGroups(ctx context.Context, compartmentID, displayName string) ([]logprovider.LogGroup, error) + UpdateGroup(ctx context.Context, id string, u logprovider.LogGroupUpdate) (*logprovider.LogGroup, error) + DeleteGroup(ctx context.Context, id string) error + MoveGroup(ctx context.Context, id, compartmentID string) error + + CreateLog(ctx context.Context, groupID string, spec logprovider.LogSpec) (*logprovider.Log, error) + GetLog(ctx context.Context, groupID, logID string) (*logprovider.Log, error) + ListLogs(ctx context.Context, groupID string, f logprovider.LogFilter) ([]logprovider.Log, error) + UpdateLog(ctx context.Context, groupID, logID string, u logprovider.LogUpdate) (*logprovider.Log, error) + DeleteLog(ctx context.Context, groupID, logID string) error + + PutLogs(ctx context.Context, logID string, batches []logprovider.LogEntryBatch) error + SearchLogs(ctx context.Context, req logprovider.SearchRequest) (*logprovider.SearchResult, error) +} + +// Handler serves OCI Logging against a logging driver. +type Handler struct { + extras Extras + work *workrequest.Store +} + +// New returns a Logging handler. work records the asynchronous log group and +// log mutations; a nil store leaves those paths unserved. +func New(l logdriver.Logging, work *workrequest.Store) *Handler { + extras, _ := l.(Extras) + + return &Handler{extras: extras, work: work} +} + +// route is a parsed Logging path. +type route struct { + Version string + Collection string + ID string + Sub string + SubID string +} + +// Matches claims each of the three Logging API surfaces at its own version +// prefix, and nothing else. A top-level /logs collection belongs to the +// ingestion plane alone — the control plane nests logs under their group. +func (*Handler) Matches(r *http.Request) bool { + rt, ok := parsePath(r.URL.Path) + if !ok { + return false + } + + switch rt.Version { + case versionControl: + return rt.Collection == segLogGroups || + rt.Collection == segUnifiedAgent || + rt.Collection == segSavedSearches + case versionIngestion: + return rt.Collection == segLogs + case versionSearch: + return rt.Collection == segSearch && rt.ID == "" + default: + return false + } +} + +// ServeHTTP routes on the API surface the path names, then on path shape and +// method. +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + rt, ok := parsePath(r.URL.Path) + if !ok { + ocirest.WriteError(w, r, http.StatusBadRequest, codeInvalidParameter, "malformed logging path") + return + } + + if h.extras == nil { + ocirest.WriteError(w, r, http.StatusNotImplemented, codeNotImplemented, + "the wired logging driver does not implement OCI log groups, ingestion and search") + + return + } + + switch rt.Version { + case versionControl: + h.serveControlPlane(w, r, &rt) + case versionIngestion: + h.servePush(w, r, &rt) + case versionSearch: + h.serveSearch(w, r) + default: + ocirest.WriteError(w, r, http.StatusNotFound, codeNotFound, "unknown logging API version "+rt.Version) + } +} + +// serveControlPlane dispatches the log group and log collections. +func (h *Handler) serveControlPlane(w http.ResponseWriter, r *http.Request, rt *route) { + switch rt.Collection { + case segLogGroups: + h.serveLogGroups(w, r, rt) + case segUnifiedAgent, segSavedSearches: + unemulated(w, r, rt.Collection) + default: + ocirest.WriteError(w, r, http.StatusNotFound, codeNotFound, "unknown collection "+rt.Collection) + } +} + +// serveLogGroups routes the log group collection, the actions on one group, +// and the logs nested under it. +func (h *Handler) serveLogGroups(w http.ResponseWriter, r *http.Request, rt *route) { + switch { + case rt.ID != "" && rt.Sub == segLogs: + h.serveLogs(w, r, rt) + case rt.ID != "" && rt.Sub == subActions: + h.serveGroupAction(w, r, rt) + case rt.Sub != "": + ocirest.WriteError(w, r, http.StatusNotFound, codeNotFound, "unknown sub-collection "+rt.Sub) + default: + h.serveGroupCRUD(w, r, rt) + } +} + +// unemulated reports a collection the handler claims but cannot serve. The +// logging driver models no unified monitoring agent and no saved search, so +// both would be shapes with nothing behind them. +func unemulated(w http.ResponseWriter, r *http.Request, collection string) { + ocirest.WriteError(w, r, http.StatusNotImplemented, codeNotImplemented, + collection+" is not emulated; CloudEmu models log groups, logs, ingestion and search") +} + +// accept records a work request for an asynchronous mutation and answers 202, +// which is what real OCI returns from every log group and log mutation. +func (h *Handler) accept( + w http.ResponseWriter, r *http.Request, operation, compartmentID, entityType, actionType, id string, +) { + wrID := h.work.Accept(operation, compartmentID, workrequest.Resource{ + EntityType: entityType, + ActionType: actionType, + Identifier: id, + }) + + ocirest.SetWorkRequestID(w, wrID) + ocirest.WriteJSON(w, r, http.StatusAccepted, nil) +} + +// requireWork reports whether asynchronous mutations can be served at all. +func (h *Handler) requireWork(w http.ResponseWriter, r *http.Request) bool { + if h.work == nil { + ocirest.WriteError(w, r, http.StatusNotImplemented, codeNotImplemented, "work requests are not configured") + return false + } + + return true +} + +// parsePath splits /{version}/{collection}[/{id}[/{sub}[/{subId}]]]. +func parsePath(urlPath string) (route, bool) { + parts := strings.Split(strings.Trim(urlPath, "/"), "/") + if len(parts) < 2 || len(parts) > maxPathSegments { + return route{}, false + } + + for _, part := range parts { + if part == "" { + return route{}, false + } + } + + rt := route{Version: parts[0], Collection: parts[1]} + + if len(parts) > 2 { //nolint:mnd // the id follows the collection + rt.ID = parts[2] + } + + if len(parts) > 3 { //nolint:mnd // then the sub-collection + rt.Sub = parts[3] + } + + if len(parts) > 4 { //nolint:mnd // then the resource or action on it + rt.SubID = parts[4] + } + + return rt, true +} + +// paginate applies OCI's limit and opaque page cursor, stamping the cursor for +// the next page. The cursor is the offset the next page starts at. +func paginate[T any](w http.ResponseWriter, r *http.Request, items []T) []T { + start := 0 + + if token := ocirest.Page(r); token != "" { + if n, err := strconv.Atoi(token); err == nil && n > 0 { + start = n + } + } + + // items[:0] rather than nil: an empty page is [] on the wire, not null. + if start >= len(items) { + return items[:0] + } + + end := min(start+ocirest.Limit(r), len(items)) + if end < len(items) { + ocirest.SetNextPage(w, strconv.Itoa(end)) + } + + return items[start:end] +} + +// methodNotAllowed is the response for a verb a collection does not serve. +func methodNotAllowed(w http.ResponseWriter, r *http.Request) { + ocirest.WriteError(w, r, http.StatusMethodNotAllowed, codeMethodNotAllowed, "method not allowed") +} diff --git a/server/oci/logging/handler_test.go b/server/oci/logging/handler_test.go new file mode 100644 index 000000000..e523caa83 --- /dev/null +++ b/server/oci/logging/handler_test.go @@ -0,0 +1,1183 @@ +package logging_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stackshy/cloudemu/v2/config" + logprovider "github.com/stackshy/cloudemu/v2/providers/oci/logging" + ocilogging "github.com/stackshy/cloudemu/v2/server/oci/logging" + "github.com/stackshy/cloudemu/v2/server/oci/workrequest" + "github.com/stackshy/cloudemu/v2/server/wire/ocirest" + logdriver "github.com/stackshy/cloudemu/v2/services/logging/driver" +) + +// The mock must satisfy the handler's OCI-only capability interface. +var _ ocilogging.Extras = (*logprovider.Mock)(nil) + +const ( + compartmentA = "ocid1.compartment.oc1..aaaaaaaacompa" + compartmentB = "ocid1.compartment.oc1..aaaaaaaacompb" +) + +func newOptions() *config.Options { + return config.NewOptions( + config.WithClock(config.NewFakeClock(time.Date(2026, 8, 8, 12, 0, 0, 0, time.UTC))), + config.WithRegion("us-ashburn-1"), + config.WithCompartmentID(compartmentA), + ) +} + +func newHandler(t *testing.T) (*ocilogging.Handler, *workrequest.Store) { + t.Helper() + + opts := newOptions() + work := workrequest.New(opts) + + return ocilogging.New(logprovider.New(opts), work), work +} + +// do runs one request through the handler. +func do(t *testing.T, h *ocilogging.Handler, method, path string, body any) *httptest.ResponseRecorder { + t.Helper() + + var reader *bytes.Reader + + if body != nil { + raw, err := json.Marshal(body) + require.NoError(t, err) + reader = bytes.NewReader(raw) + } else { + reader = bytes.NewReader(nil) + } + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(method, path, reader)) + + return rec +} + +// createGroup creates a log group over the wire and returns its OCID. +func createGroup(t *testing.T, h *ocilogging.Handler, work *workrequest.Store, name string) string { + t.Helper() + + rec := do(t, h, http.MethodPost, "/20200531/logGroups", map[string]any{ + "compartmentId": compartmentA, + "displayName": name, + }) + require.Equal(t, http.StatusAccepted, rec.Code, rec.Body.String()) + + return resourceOf(t, work, rec, "loggroup") +} + +// createLog creates a custom log over the wire and returns its OCID. +func createLog(t *testing.T, h *ocilogging.Handler, work *workrequest.Store, groupID, name string) string { + t.Helper() + + rec := do(t, h, http.MethodPost, "/20200531/logGroups/"+groupID+"/logs", map[string]any{ + "displayName": name, + "logType": "CUSTOM", + }) + require.Equal(t, http.StatusAccepted, rec.Code, rec.Body.String()) + + return resourceOf(t, work, rec, "log") +} + +// resourceOf reads the created resource's OCID out of the work request a 202 +// stamps, which is the only place an asynchronous create reports it. +func resourceOf( + t *testing.T, work *workrequest.Store, rec *httptest.ResponseRecorder, entityType string, +) string { + t.Helper() + + id := rec.Header().Get(ocirest.HeaderWorkRequestID) + require.NotEmpty(t, id, "an asynchronous mutation must stamp opc-work-request-id") + + wr, ok := work.Get(id) + require.True(t, ok, "the stamped work request must be pollable") + require.Len(t, wr.Resources, 1) + assert.Equal(t, entityType, wr.Resources[0].EntityType) + assert.Equal(t, workrequest.StatusSucceeded, wr.Status) + + return wr.Resources[0].Identifier +} + +// TestMatches is the core of this handler: three OCI API surfaces collapse +// onto one server, so each prefix must be claimed exactly and nothing else. +func TestMatches(t *testing.T) { + h, _ := newHandler(t) + + tests := []struct { + name string + method string + path string + expect bool + }{ + // Control plane, /20200531. + {name: "log group collection", method: http.MethodPost, path: "/20200531/logGroups", expect: true}, + {name: "log group list", method: http.MethodGet, path: "/20200531/logGroups?compartmentId=c", expect: true}, + {name: "single log group", method: http.MethodGet, path: "/20200531/logGroups/ocid1.loggroup.oc1.iad.a", expect: true}, + {name: "nested log collection", method: http.MethodGet, path: "/20200531/logGroups/g/logs", expect: true}, + {name: "single nested log", method: http.MethodGet, path: "/20200531/logGroups/g/logs/l", expect: true}, + {name: "change compartment action", method: http.MethodPost, path: "/20200531/logGroups/g/actions/changeCompartment", expect: true}, + {name: "unified agent configurations are claimed to be reported unemulated", method: http.MethodGet, path: "/20200531/unifiedAgentConfigurations", expect: true}, + {name: "saved searches are claimed to be reported unemulated", method: http.MethodGet, path: "/20200531/logSavedSearches", expect: true}, + + // Ingestion plane, /20200601. + {name: "ingestion push", method: http.MethodPost, path: "/20200601/logs/ocid1.log.oc1.iad.a/actions/push", expect: true}, + + // Search plane, /20190909. + {name: "search", method: http.MethodPost, path: "/20190909/search", expect: true}, + + // A collection claimed under one prefix must not be claimed under another. + {name: "top-level logs is the ingestion plane's, not the control plane's", method: http.MethodGet, path: "/20200531/logs", expect: false}, + {name: "log groups are not on the ingestion prefix", method: http.MethodPost, path: "/20200601/logGroups", expect: false}, + {name: "search is not on the ingestion prefix", method: http.MethodPost, path: "/20200601/search", expect: false}, + {name: "log groups are not on the search prefix", method: http.MethodGet, path: "/20190909/logGroups", expect: false}, + {name: "logs are not on the search prefix", method: http.MethodPost, path: "/20190909/logs/a/actions/push", expect: false}, + {name: "search is not on the control prefix", method: http.MethodPost, path: "/20200531/search", expect: false}, + {name: "search takes no id", method: http.MethodPost, path: "/20190909/search/abc", expect: false}, + + // Other services keep their traffic. + {name: "core networking", method: http.MethodGet, path: "/20160918/vcns", expect: false}, + {name: "monitoring", method: http.MethodPost, path: "/20180401/metrics", expect: false}, + {name: "object storage", method: http.MethodGet, path: "/n/tenancy/b/bucket/o/key", expect: false}, + {name: "work requests belong to the shared poller", method: http.MethodGet, path: "/20200531/workRequests/abc", expect: false}, + + // Malformed shapes. + {name: "version alone", method: http.MethodGet, path: "/20200531", expect: false}, + {name: "root", method: http.MethodGet, path: "/", expect: false}, + {name: "unknown version", method: http.MethodGet, path: "/19990101/logGroups", expect: false}, + {name: "too many segments", method: http.MethodGet, path: "/20200531/logGroups/g/logs/l/extra", expect: false}, + {name: "empty segment", method: http.MethodGet, path: "/20200531//logGroups", expect: false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(tc.method, tc.path, nil) + assert.Equal(t, tc.expect, h.Matches(req)) + }) + } +} + +func TestLogGroupLifecycle(t *testing.T) { + h, work := newHandler(t) + + groupID := createGroup(t, h, work, "app-logs") + + t.Run("get", func(t *testing.T) { + rec := do(t, h, http.MethodGet, "/20200531/logGroups/"+groupID, nil) + require.Equal(t, http.StatusOK, rec.Code) + + var body map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + assert.Equal(t, groupID, body["id"]) + assert.Equal(t, "app-logs", body["displayName"]) + assert.Equal(t, compartmentA, body["compartmentId"]) + assert.Equal(t, "ACTIVE", body["lifecycleState"]) + }) + + t.Run("list", func(t *testing.T) { + rec := do(t, h, http.MethodGet, "/20200531/logGroups?compartmentId="+compartmentA, nil) + require.Equal(t, http.StatusOK, rec.Code) + + var body []map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + require.Len(t, body, 1) + assert.Equal(t, groupID, body[0]["id"]) + }) + + t.Run("list requires compartmentId", func(t *testing.T) { + rec := do(t, h, http.MethodGet, "/20200531/logGroups", nil) + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Equal(t, "InvalidParameter", codeOf(t, rec)) + }) + + t.Run("list in another compartment is empty", func(t *testing.T) { + rec := do(t, h, http.MethodGet, "/20200531/logGroups?compartmentId=ocid1.compartment.oc1..other", nil) + require.Equal(t, http.StatusOK, rec.Code) + assert.JSONEq(t, `[]`, rec.Body.String()) + }) + + t.Run("update is asynchronous", func(t *testing.T) { + rec := do(t, h, http.MethodPut, "/20200531/logGroups/"+groupID, map[string]any{"description": "the app"}) + require.Equal(t, http.StatusAccepted, rec.Code) + assert.NotEmpty(t, rec.Header().Get(ocirest.HeaderWorkRequestID)) + }) + + t.Run("change compartment", func(t *testing.T) { + rec := do(t, h, http.MethodPost, "/20200531/logGroups/"+groupID+"/actions/changeCompartment", + map[string]any{"targetCompartmentId": "ocid1.compartment.oc1..moved"}) + require.Equal(t, http.StatusAccepted, rec.Code) + assert.NotEmpty(t, rec.Header().Get(ocirest.HeaderWorkRequestID)) + }) + + t.Run("change compartment needs a target", func(t *testing.T) { + rec := do(t, h, http.MethodPost, "/20200531/logGroups/"+groupID+"/actions/changeCompartment", + map[string]any{}) + assert.Equal(t, http.StatusBadRequest, rec.Code) + }) + + t.Run("delete is asynchronous", func(t *testing.T) { + rec := do(t, h, http.MethodDelete, "/20200531/logGroups/"+groupID, nil) + require.Equal(t, http.StatusAccepted, rec.Code) + assert.NotEmpty(t, rec.Header().Get(ocirest.HeaderWorkRequestID)) + }) + + t.Run("get after delete is 404", func(t *testing.T) { + rec := do(t, h, http.MethodGet, "/20200531/logGroups/"+groupID, nil) + assert.Equal(t, http.StatusNotFound, rec.Code) + assert.Equal(t, "NotAuthorizedOrNotFound", codeOf(t, rec)) + }) +} + +func TestLogGroupErrors(t *testing.T) { + h, work := newHandler(t) + createGroup(t, h, work, "taken") + + tests := []struct { + name string + method string + path string + body any + expectCode int + }{ + { + name: "create without a compartment", method: http.MethodPost, path: "/20200531/logGroups", + body: map[string]any{"displayName": "x"}, expectCode: http.StatusBadRequest, + }, + { + name: "create a duplicate", method: http.MethodPost, path: "/20200531/logGroups", + body: map[string]any{"compartmentId": compartmentA, "displayName": "taken"}, + expectCode: http.StatusConflict, + }, + { + name: "create without a display name", method: http.MethodPost, path: "/20200531/logGroups", + body: map[string]any{"compartmentId": compartmentA}, expectCode: http.StatusBadRequest, + }, + { + name: "get an unknown group", method: http.MethodGet, + path: "/20200531/logGroups/ocid1.loggroup.oc1.iad.missing", expectCode: http.StatusNotFound, + }, + { + name: "unsupported method on the collection", method: http.MethodDelete, + path: "/20200531/logGroups", expectCode: http.StatusMethodNotAllowed, + }, + { + name: "unknown action", method: http.MethodPost, + path: "/20200531/logGroups/g/actions/teleport", expectCode: http.StatusNotFound, + }, + { + name: "unknown sub-collection", method: http.MethodGet, + path: "/20200531/logGroups/g/entries", expectCode: http.StatusNotFound, + }, + { + name: "unified agent configurations are reported unemulated", method: http.MethodGet, + path: "/20200531/unifiedAgentConfigurations", expectCode: http.StatusNotImplemented, + }, + { + name: "saved searches are reported unemulated", method: http.MethodGet, + path: "/20200531/logSavedSearches", expectCode: http.StatusNotImplemented, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rec := do(t, h, tc.method, tc.path, tc.body) + assert.Equal(t, tc.expectCode, rec.Code, rec.Body.String()) + assert.NotEmpty(t, codeOf(t, rec)) + }) + } +} + +func TestLogLifecycle(t *testing.T) { + h, work := newHandler(t) + groupID := createGroup(t, h, work, "app-logs") + logID := createLog(t, h, work, groupID, "stdout") + + t.Run("get", func(t *testing.T) { + rec := do(t, h, http.MethodGet, "/20200531/logGroups/"+groupID+"/logs/"+logID, nil) + require.Equal(t, http.StatusOK, rec.Code) + + var body map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + assert.Equal(t, logID, body["id"]) + assert.Equal(t, groupID, body["logGroupId"]) + assert.Equal(t, "CUSTOM", body["logType"]) + assert.Equal(t, true, body["isEnabled"], "an absent isEnabled must default to true") + }) + + t.Run("list", func(t *testing.T) { + rec := do(t, h, http.MethodGet, "/20200531/logGroups/"+groupID+"/logs", nil) + require.Equal(t, http.StatusOK, rec.Code) + + var body []map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + assert.Len(t, body, 1) + }) + + t.Run("list narrows by log type", func(t *testing.T) { + rec := do(t, h, http.MethodGet, "/20200531/logGroups/"+groupID+"/logs?logType=SERVICE", nil) + require.Equal(t, http.StatusOK, rec.Code) + assert.JSONEq(t, `[]`, rec.Body.String()) + }) + + t.Run("update is asynchronous", func(t *testing.T) { + rec := do(t, h, http.MethodPut, "/20200531/logGroups/"+groupID+"/logs/"+logID, + map[string]any{"retentionDuration": 90}) + require.Equal(t, http.StatusAccepted, rec.Code) + assert.NotEmpty(t, rec.Header().Get(ocirest.HeaderWorkRequestID)) + }) + + t.Run("delete is asynchronous", func(t *testing.T) { + rec := do(t, h, http.MethodDelete, "/20200531/logGroups/"+groupID+"/logs/"+logID, nil) + require.Equal(t, http.StatusAccepted, rec.Code) + assert.NotEmpty(t, rec.Header().Get(ocirest.HeaderWorkRequestID)) + + rec = do(t, h, http.MethodGet, "/20200531/logGroups/"+groupID+"/logs/"+logID, nil) + assert.Equal(t, http.StatusNotFound, rec.Code) + }) +} + +func TestLogErrors(t *testing.T) { + h, work := newHandler(t) + groupID := createGroup(t, h, work, "app-logs") + + tests := []struct { + name string + method string + path string + body any + expectCode int + }{ + { + name: "create in an unknown group", method: http.MethodPost, + path: "/20200531/logGroups/ocid1.loggroup.oc1.iad.missing/logs", + body: map[string]any{"displayName": "x"}, expectCode: http.StatusNotFound, + }, + { + name: "create a service log without a source", method: http.MethodPost, + path: "/20200531/logGroups/" + groupID + "/logs", + body: map[string]any{"displayName": "flow", "logType": "SERVICE"}, expectCode: http.StatusBadRequest, + }, + { + name: "create with an unknown log type", method: http.MethodPost, + path: "/20200531/logGroups/" + groupID + "/logs", + body: map[string]any{"displayName": "x", "logType": "WEIRD"}, expectCode: http.StatusBadRequest, + }, + { + name: "list in an unknown group", method: http.MethodGet, + path: "/20200531/logGroups/ocid1.loggroup.oc1.iad.missing/logs", expectCode: http.StatusNotFound, + }, + { + name: "unsupported method on a log", method: http.MethodPatch, + path: "/20200531/logGroups/" + groupID + "/logs/x", expectCode: http.StatusMethodNotAllowed, + }, + { + name: "malformed body", method: http.MethodPost, + path: "/20200531/logGroups/" + groupID + "/logs", body: nil, expectCode: http.StatusBadRequest, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rec := do(t, h, tc.method, tc.path, tc.body) + assert.Equal(t, tc.expectCode, rec.Code, rec.Body.String()) + }) + } +} + +func TestPutLogs(t *testing.T) { + h, work := newHandler(t) + groupID := createGroup(t, h, work, "app-logs") + logID := createLog(t, h, work, groupID, "stdout") + + push := func(body any) *httptest.ResponseRecorder { + return do(t, h, http.MethodPost, "/20200601/logs/"+logID+"/actions/push", body) + } + + t.Run("success", func(t *testing.T) { + rec := push(map[string]any{ + "specversion": "1.0", + "logEntryBatches": []any{map[string]any{ + "source": "host-a", + "type": "custom", + "entries": []any{ + map[string]any{"data": "hello", "id": "e1", "time": "2026-08-08T10:00:00Z"}, + }, + }}, + }) + assert.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + }) + + tests := []struct { + name string + path string + method string + body any + expectCode int + }{ + { + name: "specversion is required", method: http.MethodPost, + path: "/20200601/logs/" + logID + "/actions/push", + body: map[string]any{"logEntryBatches": []any{}}, expectCode: http.StatusBadRequest, + }, + { + name: "unreadable timestamp", method: http.MethodPost, + path: "/20200601/logs/" + logID + "/actions/push", + body: map[string]any{ + "specversion": "1.0", + "logEntryBatches": []any{map[string]any{"entries": []any{map[string]any{"data": "x", "time": "yesterday"}}}}, + }, + expectCode: http.StatusBadRequest, + }, + { + name: "unknown log", method: http.MethodPost, + path: "/20200601/logs/ocid1.log.oc1.iad.missing/actions/push", + body: map[string]any{"specversion": "1.0"}, expectCode: http.StatusNotFound, + }, + { + name: "the ingestion plane publishes only push", method: http.MethodPost, + path: "/20200601/logs/" + logID + "/actions/pull", + body: map[string]any{"specversion": "1.0"}, expectCode: http.StatusNotFound, + }, + { + name: "the ingestion plane has no collection", method: http.MethodGet, + path: "/20200601/logs", expectCode: http.StatusNotFound, + }, + { + name: "push is POST only", method: http.MethodGet, + path: "/20200601/logs/" + logID + "/actions/push", expectCode: http.StatusMethodNotAllowed, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rec := do(t, h, tc.method, tc.path, tc.body) + assert.Equal(t, tc.expectCode, rec.Code, rec.Body.String()) + }) + } +} + +func TestSearchLogs(t *testing.T) { + h, work := newHandler(t) + groupID := createGroup(t, h, work, "app-logs") + logID := createLog(t, h, work, groupID, "stdout") + + rec := do(t, h, http.MethodPost, "/20200601/logs/"+logID+"/actions/push", map[string]any{ + "specversion": "1.0", + "logEntryBatches": []any{map[string]any{ + "source": "host-a", + "type": "custom", + "entries": []any{ + map[string]any{"data": `{"level":"ERROR","msg":"boom"}`, "id": "e1", "time": "2026-08-08T10:00:00Z"}, + map[string]any{"data": `{"level":"INFO","msg":"fine"}`, "id": "e2", "time": "2026-08-08T10:05:00Z"}, + }, + }}, + }) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + + search := func(query string) *httptest.ResponseRecorder { + return do(t, h, http.MethodPost, "/20190909/search", map[string]any{ + "searchQuery": query, + "timeStart": "2026-08-08T09:00:00Z", + "timeEnd": "2026-08-08T11:00:00Z", + }) + } + + t.Run("whole compartment", func(t *testing.T) { + res := search(`search "` + compartmentA + `"`) + require.Equal(t, http.StatusOK, res.Code, res.Body.String()) + + var body map[string]any + require.NoError(t, json.Unmarshal(res.Body.Bytes(), &body)) + assert.Len(t, body["results"], 2) + }) + + t.Run("narrowed to one log", func(t *testing.T) { + res := search(`search "` + compartmentA + "/" + groupID + "/" + logID + `"`) + require.Equal(t, http.StatusOK, res.Code, res.Body.String()) + + var body map[string]any + require.NoError(t, json.Unmarshal(res.Body.Bytes(), &body)) + assert.Len(t, body["results"], 2) + }) + + t.Run("where on a JSON payload field", func(t *testing.T) { + res := search(`search "` + compartmentA + `" | where data.level = 'ERROR'`) + require.Equal(t, http.StatusOK, res.Code, res.Body.String()) + + var body map[string]any + require.NoError(t, json.Unmarshal(res.Body.Bytes(), &body)) + require.Len(t, body["results"], 1) + + content := body["results"].([]any)[0].(map[string]any)["data"].(map[string]any)["logContent"].(map[string]any) + assert.Equal(t, "e1", content["id"]) + assert.Equal(t, "ERROR", content["data"].(map[string]any)["level"]) + assert.Equal(t, logID, content["oracle"].(map[string]any)["logid"]) + }) + + t.Run("wildcards", func(t *testing.T) { + res := search(`search "` + compartmentA + `" | where data = '*boom*'`) + require.Equal(t, http.StatusOK, res.Code, res.Body.String()) + + var body map[string]any + require.NoError(t, json.Unmarshal(res.Body.Bytes(), &body)) + assert.Len(t, body["results"], 1) + }) + + t.Run("a search outside the time range returns nothing", func(t *testing.T) { + res := do(t, h, http.MethodPost, "/20190909/search", map[string]any{ + "searchQuery": `search "` + compartmentA + `"`, + "timeStart": "2026-08-07T00:00:00Z", + "timeEnd": "2026-08-07T23:59:59Z", + }) + require.Equal(t, http.StatusOK, res.Code) + + var body map[string]any + require.NoError(t, json.Unmarshal(res.Body.Bytes(), &body)) + assert.Empty(t, body["results"]) + }) + + t.Run("field info on request", func(t *testing.T) { + res := do(t, h, http.MethodPost, "/20190909/search", map[string]any{ + "searchQuery": `search "` + compartmentA + `"`, + "timeStart": "2026-08-08T09:00:00Z", + "timeEnd": "2026-08-08T11:00:00Z", + "isReturnFieldInfo": true, + }) + require.Equal(t, http.StatusOK, res.Code) + + var body map[string]any + require.NoError(t, json.Unmarshal(res.Body.Bytes(), &body)) + assert.NotEmpty(t, body["fields"]) + }) +} + +// TestSearchRejectsWhatItDoesNotModel is the guard against the accept-and- +// return-nothing failure mode: every unmodelled query names what it tripped on. +func TestSearchRejectsWhatItDoesNotModel(t *testing.T) { + h, work := newHandler(t) + groupID := createGroup(t, h, work, "app-logs") + createLog(t, h, work, groupID, "stdout") + + tests := []struct { + name string + query string + expectNamed string + }{ + {name: "summarize", query: `search "` + compartmentA + `" | summarize count() by data.level`, expectNamed: "summarize"}, + {name: "stats", query: `search "` + compartmentA + `" | stats count()`, expectNamed: "stats"}, + {name: "topN", query: `search "` + compartmentA + `" | topN 5 by data.level`, expectNamed: "topn"}, + {name: "extract", query: `search "` + compartmentA + `" | extract '(\d+)'`, expectNamed: "extract"}, + {name: "or in a where clause", query: `search "` + compartmentA + `" | where data = 'a' or data = 'b'`, expectNamed: `the \"or\" operator`}, + {name: "not in a where clause", query: `search "` + compartmentA + `" | where not data = 'a'`, expectNamed: `the \"not\" operator`}, + {name: "parenthesized where", query: `search "` + compartmentA + `" | where (data = 'a')`, expectNamed: "parenthesized"}, + {name: "greater than", query: `search "` + compartmentA + `" | where data.count > 3`, expectNamed: "operator is not modeled"}, + {name: "regex match operator", query: `search "` + compartmentA + `" | where data =~ 'a.*'`, expectNamed: "=~"}, + {name: "unknown field", query: `search "` + compartmentA + `" | where data.level.nested.deep = 'a'`, expectNamed: "unsupported search field"}, + {name: "sort by another field", query: `search "` + compartmentA + `" | sort by data.level desc`, expectNamed: "sorts by datetime only"}, + {name: "sort with a bad direction", query: `search "` + compartmentA + `" | sort by datetime sideways`, expectNamed: "asc or desc"}, + {name: "no search clause", query: `where data = 'a'`, expectNamed: "must begin with the search clause"}, + {name: "an unquoted target", query: `search ` + compartmentA, expectNamed: "must be quoted"}, + {name: "a name where an OCID belongs", query: `search "app-logs"`, expectNamed: "compartment OCID"}, + {name: "a log group name in the second segment", query: `search "` + compartmentA + `/app-logs"`, expectNamed: "log group OCID"}, + {name: "too many scope segments", query: `search "` + compartmentA + `/g/l/x"`, expectNamed: "segments"}, + {name: "a comparison with no operator", query: `search "` + compartmentA + `" | where data`, expectNamed: "no operator"}, + {name: "an empty query", query: ``, expectNamed: "required"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rec := do(t, h, http.MethodPost, "/20190909/search", map[string]any{ + "searchQuery": tc.query, + "timeStart": "2026-08-08T09:00:00Z", + "timeEnd": "2026-08-08T11:00:00Z", + }) + + require.Equal(t, http.StatusBadRequest, rec.Code, "an unmodelled query must be rejected, not answered empty: %s", rec.Body.String()) + assert.Contains(t, strings.ToLower(rec.Body.String()), strings.ToLower(tc.expectNamed)) + }) + } +} + +func TestSearchRequestErrors(t *testing.T) { + h, _ := newHandler(t) + + tests := []struct { + name string + method string + body any + expectCode int + }{ + { + name: "missing time range", method: http.MethodPost, + body: map[string]any{"searchQuery": `search "` + compartmentA + `"`}, + expectCode: http.StatusBadRequest, + }, + { + name: "end before start", method: http.MethodPost, + body: map[string]any{ + "searchQuery": `search "` + compartmentA + `"`, + "timeStart": "2026-08-08T11:00:00Z", + "timeEnd": "2026-08-08T09:00:00Z", + }, + expectCode: http.StatusBadRequest, + }, + { + name: "unreadable timestamp", method: http.MethodPost, + body: map[string]any{ + "searchQuery": `search "` + compartmentA + `"`, + "timeStart": "yesterday", + "timeEnd": "2026-08-08T09:00:00Z", + }, + expectCode: http.StatusBadRequest, + }, + {name: "search is POST only", method: http.MethodGet, expectCode: http.StatusMethodNotAllowed}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rec := do(t, h, tc.method, "/20190909/search", tc.body) + assert.Equal(t, tc.expectCode, rec.Code, rec.Body.String()) + }) + } +} + +// portableOnly implements the portable driver without the OCI capability, so +// the handler has nothing to serve OCI's operations with. +type portableOnly struct { + logdriver.Logging +} + +func TestDriverWithoutOCICapabilityIs501(t *testing.T) { + h := ocilogging.New(portableOnly{}, workrequest.New(newOptions())) + + for _, path := range []string{ + "/20200531/logGroups", + "/20200601/logs/l/actions/push", + "/20190909/search", + } { + t.Run(path, func(t *testing.T) { + rec := do(t, h, http.MethodPost, path, map[string]any{}) + assert.Equal(t, http.StatusNotImplemented, rec.Code) + assert.Equal(t, "NotImplemented", codeOf(t, rec)) + }) + } +} + +func TestWorkRequestsUnconfigured(t *testing.T) { + h := ocilogging.New(logprovider.New(newOptions()), nil) + + rec := do(t, h, http.MethodPost, "/20200531/logGroups", + map[string]any{"compartmentId": compartmentA, "displayName": "x"}) + assert.Equal(t, http.StatusNotImplemented, rec.Code) +} + +// codeOf reads the OCI error code out of a response body. +func codeOf(t *testing.T, rec *httptest.ResponseRecorder) string { + t.Helper() + + var body ocirest.ErrorBody + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + return "" + } + + return body.Code +} + +// resultIDs reads the entry ids out of a search response, in result order. +func resultIDs(t *testing.T, rec *httptest.ResponseRecorder) []string { + t.Helper() + + var body struct { + Results []struct { + Data struct { + LogContent struct { + ID string `json:"id"` + Oracle struct { + CompartmentID string `json:"compartmentid"` + LogGroupID string `json:"loggroupid"` + LogID string `json:"logid"` + IngestedTime string `json:"ingestedtime"` + } `json:"oracle"` + } `json:"logContent"` + } `json:"data"` + } `json:"results"` + } + + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + + ids := make([]string, 0, len(body.Results)) + for i := range body.Results { + ids = append(ids, body.Results[i].Data.LogContent.ID) + } + + return ids +} + +// TestSearchSortAndProvenance is the positive counterpart to the rejection +// table: a successful sort must come back in the asked-for order, and an +// oracle.* comparison must resolve against the log an entry came from. +func TestSearchSortAndProvenance(t *testing.T) { + h, work := newHandler(t) + groupID := createGroup(t, h, work, "app-logs") + stdout := createLog(t, h, work, groupID, "stdout") + stderr := createLog(t, h, work, groupID, "stderr") + + push := func(logID string, entries ...any) { + t.Helper() + + rec := do(t, h, http.MethodPost, "/20200601/logs/"+logID+"/actions/push", map[string]any{ + "specversion": "1.0", + "logEntryBatches": []any{map[string]any{"source": "host-a", "type": "custom", "entries": entries}}, + }) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + } + + // Pushed out of time order, so a sorted result cannot pass by accident. + push(stdout, + map[string]any{"data": "third", "id": "e3", "time": "2026-08-08T10:30:00Z"}, + map[string]any{"data": "first", "id": "e1", "time": "2026-08-08T10:10:00Z"}, + map[string]any{"data": "second", "id": "e2", "time": "2026-08-08T10:20:00Z"}, + ) + push(stderr, map[string]any{"data": "fourth", "id": "e4", "time": "2026-08-08T10:40:00Z"}) + + search := func(query string) *httptest.ResponseRecorder { + rec := do(t, h, http.MethodPost, "/20190909/search", map[string]any{ + "searchQuery": query, + "timeStart": "2026-08-08T09:00:00Z", + "timeEnd": "2026-08-08T11:00:00Z", + }) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + + return rec + } + + all := `search "` + compartmentA + `"` + + tests := []struct { + name string + query string + expect []string + }{ + {name: "no sort clause is ascending", query: all, expect: []string{"e1", "e2", "e3", "e4"}}, + {name: "sort by datetime asc", query: all + ` | sort by datetime asc`, expect: []string{"e1", "e2", "e3", "e4"}}, + {name: "sort by datetime desc", query: all + ` | sort by datetime desc`, expect: []string{"e4", "e3", "e2", "e1"}}, + {name: "sort by time desc", query: all + ` | sort by time desc`, expect: []string{"e4", "e3", "e2", "e1"}}, + { + name: "where oracle.logid", + query: all + ` | where oracle.logid = '` + stderr + `'`, + expect: []string{"e4"}, + }, + { + name: "where oracle.logid negated", + query: all + ` | where oracle.logid != '` + stderr + `'`, + expect: []string{"e1", "e2", "e3"}, + }, + { + name: "where oracle.loggroupid", + query: all + ` | where oracle.loggroupid = '` + groupID + `'`, + expect: []string{"e1", "e2", "e3", "e4"}, + }, + { + name: "where oracle.compartmentid", + query: all + ` | where oracle.compartmentid = '` + compartmentA + `'`, + expect: []string{"e1", "e2", "e3", "e4"}, + }, + { + name: "where oracle.ingestedtime", + query: all + ` | where oracle.ingestedtime = '2026-08-08T12:00:00Z'`, + expect: []string{"e1", "e2", "e3", "e4"}, + }, + { + name: "where and sort together", + query: all + ` | where oracle.loggroupid = '` + groupID + `' | sort by datetime desc`, + expect: []string{"e4", "e3", "e2", "e1"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expect, resultIDs(t, search(tc.query))) + }) + } + + t.Run("the oracle block carries the log an entry came from", func(t *testing.T) { + rec := search(all + ` | where oracle.logid = '` + stderr + `'`) + + var body map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + require.Len(t, body["results"], 1) + + content := body["results"].([]any)[0].(map[string]any)["data"].(map[string]any)["logContent"].(map[string]any) + oracle := content["oracle"].(map[string]any) + assert.Equal(t, compartmentA, oracle["compartmentid"]) + assert.Equal(t, groupID, oracle["loggroupid"]) + assert.Equal(t, stderr, oracle["logid"]) + assert.Equal(t, "2026-08-08T12:00:00Z", oracle["ingestedtime"]) + }) + + t.Run("a non-JSON payload comes back as the raw string", func(t *testing.T) { + rec := search(all + ` | where id = 'e1'`) + + var body map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + require.Len(t, body["results"], 1) + + content := body["results"].([]any)[0].(map[string]any)["data"].(map[string]any)["logContent"].(map[string]any) + assert.Equal(t, "first", content["data"]) + }) + + t.Run("the limit truncates after sorting", func(t *testing.T) { + rec := do(t, h, http.MethodPost, "/20190909/search?limit=2", map[string]any{ + "searchQuery": all + ` | sort by datetime desc`, + "timeStart": "2026-08-08T09:00:00Z", + "timeEnd": "2026-08-08T11:00:00Z", + }) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Equal(t, []string{"e4", "e3"}, resultIDs(t, rec)) + }) +} + +func TestLogGroupMutations(t *testing.T) { + h, work := newHandler(t) + groupID := createGroup(t, h, work, "app-logs") + + t.Run("update", func(t *testing.T) { + rec := do(t, h, http.MethodPut, "/20200531/logGroups/"+groupID, map[string]any{ + "displayName": "renamed", + "description": "the app's logs", + "freeformTags": map[string]string{"env": "dev"}, + }) + require.Equal(t, http.StatusAccepted, rec.Code, rec.Body.String()) + + got := do(t, h, http.MethodGet, "/20200531/logGroups/"+groupID, nil) + require.Equal(t, http.StatusOK, got.Code) + + var body map[string]any + require.NoError(t, json.Unmarshal(got.Body.Bytes(), &body)) + assert.Equal(t, "renamed", body["displayName"]) + assert.Equal(t, "the app's logs", body["description"]) + assert.Equal(t, map[string]any{"env": "dev"}, body["freeformTags"]) + }) + + t.Run("move between compartments", func(t *testing.T) { + rec := do(t, h, http.MethodPost, + "/20200531/logGroups/"+groupID+"/actions/changeCompartment", + map[string]any{"targetCompartmentId": compartmentB}) + require.Equal(t, http.StatusAccepted, rec.Code, rec.Body.String()) + + got := do(t, h, http.MethodGet, "/20200531/logGroups/"+groupID, nil) + + var body map[string]any + require.NoError(t, json.Unmarshal(got.Body.Bytes(), &body)) + assert.Equal(t, compartmentB, body["compartmentId"]) + }) + + t.Run("delete", func(t *testing.T) { + rec := do(t, h, http.MethodDelete, "/20200531/logGroups/"+groupID, nil) + require.Equal(t, http.StatusAccepted, rec.Code, rec.Body.String()) + + got := do(t, h, http.MethodGet, "/20200531/logGroups/"+groupID, nil) + assert.Equal(t, http.StatusNotFound, got.Code) + }) +} + +func TestLogGroupMutationErrors(t *testing.T) { + h, work := newHandler(t) + groupID := createGroup(t, h, work, "app-logs") + missing := "ocid1.loggroup.oc1.iad.missing" + + tests := []struct { + name string + method string + path string + body any + expectCode int + }{ + { + name: "update an unknown group", method: http.MethodPut, + path: "/20200531/logGroups/" + missing, body: map[string]any{"description": "x"}, + expectCode: http.StatusNotFound, + }, + { + name: "delete an unknown group", method: http.MethodDelete, + path: "/20200531/logGroups/" + missing, expectCode: http.StatusNotFound, + }, + { + name: "move an unknown group", method: http.MethodPost, + path: "/20200531/logGroups/" + missing + "/actions/changeCompartment", + body: map[string]any{"targetCompartmentId": compartmentB}, expectCode: http.StatusNotFound, + }, + { + name: "move needs a target compartment", method: http.MethodPost, + path: "/20200531/logGroups/" + groupID + "/actions/changeCompartment", + body: map[string]any{}, expectCode: http.StatusBadRequest, + }, + { + name: "an unknown group action", method: http.MethodPost, + path: "/20200531/logGroups/" + groupID + "/actions/archive", + body: map[string]any{}, expectCode: http.StatusNotFound, + }, + { + name: "a group action is POST only", method: http.MethodGet, + path: "/20200531/logGroups/" + groupID + "/actions/changeCompartment", + expectCode: http.StatusMethodNotAllowed, + }, + { + name: "the collection takes no PUT", method: http.MethodPut, + path: "/20200531/logGroups", body: map[string]any{}, expectCode: http.StatusMethodNotAllowed, + }, + { + name: "a group takes no PATCH", method: http.MethodPatch, + path: "/20200531/logGroups/" + groupID, body: map[string]any{}, + expectCode: http.StatusMethodNotAllowed, + }, + { + name: "listing needs a compartment", method: http.MethodGet, + path: "/20200531/logGroups", expectCode: http.StatusBadRequest, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rec := do(t, h, tc.method, tc.path, tc.body) + assert.Equal(t, tc.expectCode, rec.Code, rec.Body.String()) + }) + } +} + +func TestListLogGroupsPaginates(t *testing.T) { + h, work := newHandler(t) + + for _, name := range []string{"a", "b", "c"} { + createGroup(t, h, work, name) + } + + first := do(t, h, http.MethodGet, "/20200531/logGroups?compartmentId="+compartmentA+"&limit=2", nil) + require.Equal(t, http.StatusOK, first.Code, first.Body.String()) + + var page []map[string]any + + require.NoError(t, json.Unmarshal(first.Body.Bytes(), &page)) + assert.Len(t, page, 2) + + next := first.Header().Get(ocirest.HeaderNextPage) + require.NotEmpty(t, next, "a truncated page must stamp opc-next-page") + + second := do(t, h, http.MethodGet, + "/20200531/logGroups?compartmentId="+compartmentA+"&limit=2&page="+next, nil) + require.Equal(t, http.StatusOK, second.Code) + + require.NoError(t, json.Unmarshal(second.Body.Bytes(), &page)) + assert.Len(t, page, 1) + assert.Empty(t, second.Header().Get(ocirest.HeaderNextPage), "the last page stamps no cursor") + + byName := do(t, h, http.MethodGet, + "/20200531/logGroups?compartmentId="+compartmentA+"&displayName=b", nil) + require.Equal(t, http.StatusOK, byName.Code) + + require.NoError(t, json.Unmarshal(byName.Body.Bytes(), &page)) + require.Len(t, page, 1) + assert.Equal(t, "b", page[0]["displayName"]) +} + +func TestLogMutations(t *testing.T) { + h, work := newHandler(t) + groupID := createGroup(t, h, work, "app-logs") + logID := createLog(t, h, work, groupID, "stdout") + + t.Run("update", func(t *testing.T) { + rec := do(t, h, http.MethodPut, "/20200531/logGroups/"+groupID+"/logs/"+logID, map[string]any{ + "displayName": "renamed", + "isEnabled": false, + "retentionDuration": 90, + "freeformTags": map[string]string{"env": "dev"}, + "configuration": map[string]any{ + "source": map[string]any{"parameters": map[string]string{"k": "v"}}, + "archiving": map[string]any{"isEnabled": true}, + }, + }) + require.Equal(t, http.StatusAccepted, rec.Code, rec.Body.String()) + + got := do(t, h, http.MethodGet, "/20200531/logGroups/"+groupID+"/logs/"+logID, nil) + require.Equal(t, http.StatusOK, got.Code) + + var body map[string]any + require.NoError(t, json.Unmarshal(got.Body.Bytes(), &body)) + assert.Equal(t, "renamed", body["displayName"]) + assert.Equal(t, false, body["isEnabled"]) + assert.InDelta(t, 90, body["retentionDuration"], 0) + + cfg := body["configuration"].(map[string]any) + assert.Equal(t, "OCISERVICE", cfg["source"].(map[string]any)["sourceType"]) + assert.Equal(t, true, cfg["archiving"].(map[string]any)["isEnabled"]) + }) + + t.Run("delete", func(t *testing.T) { + rec := do(t, h, http.MethodDelete, "/20200531/logGroups/"+groupID+"/logs/"+logID, nil) + require.Equal(t, http.StatusAccepted, rec.Code, rec.Body.String()) + + got := do(t, h, http.MethodGet, "/20200531/logGroups/"+groupID+"/logs/"+logID, nil) + assert.Equal(t, http.StatusNotFound, got.Code) + }) +} + +func TestLogMutationErrors(t *testing.T) { + h, work := newHandler(t) + groupID := createGroup(t, h, work, "app-logs") + logID := createLog(t, h, work, groupID, "stdout") + missing := "ocid1.log.oc1.iad.missing" + + tests := []struct { + name string + method string + path string + body any + expectCode int + }{ + { + name: "update an unknown log", method: http.MethodPut, + path: "/20200531/logGroups/" + groupID + "/logs/" + missing, + body: map[string]any{"isEnabled": true}, expectCode: http.StatusNotFound, + }, + { + name: "delete an unknown log", method: http.MethodDelete, + path: "/20200531/logGroups/" + groupID + "/logs/" + missing, expectCode: http.StatusNotFound, + }, + { + name: "a log takes no PATCH", method: http.MethodPatch, + path: "/20200531/logGroups/" + groupID + "/logs/" + logID, body: map[string]any{}, + expectCode: http.StatusMethodNotAllowed, + }, + { + name: "the log collection takes no PUT", method: http.MethodPut, + path: "/20200531/logGroups/" + groupID + "/logs", body: map[string]any{}, + expectCode: http.StatusMethodNotAllowed, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rec := do(t, h, tc.method, tc.path, tc.body) + assert.Equal(t, tc.expectCode, rec.Code, rec.Body.String()) + }) + } +} + +func TestRoutingEdges(t *testing.T) { + h, work := newHandler(t) + groupID := createGroup(t, h, work, "app-logs") + + tests := []struct { + name string + method string + path string + expectCode int + }{ + {name: "malformed path", method: http.MethodGet, path: "/20200531", expectCode: http.StatusBadRequest}, + { + name: "too many segments", method: http.MethodGet, + path: "/20200531/logGroups/" + groupID + "/logs/l/extra/more", expectCode: http.StatusBadRequest, + }, + { + name: "unknown API version", method: http.MethodGet, + path: "/19990101/logGroups", expectCode: http.StatusNotFound, + }, + { + name: "unknown control-plane collection", method: http.MethodGet, + path: "/20200531/somethingElse", expectCode: http.StatusNotFound, + }, + { + name: "unemulated unified agent", method: http.MethodGet, + path: "/20200531/unifiedAgentConfigurations", expectCode: http.StatusNotImplemented, + }, + { + name: "unemulated saved searches", method: http.MethodGet, + path: "/20200531/logSavedSearches", expectCode: http.StatusNotImplemented, + }, + { + name: "unknown sub-collection", method: http.MethodGet, + path: "/20200531/logGroups/" + groupID + "/exports", expectCode: http.StatusNotFound, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rec := do(t, h, tc.method, tc.path, nil) + assert.Equal(t, tc.expectCode, rec.Code, rec.Body.String()) + }) + } +} + +// TestMalformedBodies covers the decode failure of every operation that reads +// one, since a bad body must be a 400 rather than a panic. +func TestMalformedBodies(t *testing.T) { + h, work := newHandler(t) + groupID := createGroup(t, h, work, "app-logs") + logID := createLog(t, h, work, groupID, "stdout") + + paths := map[string]struct { + method string + path string + }{ + "create group": {http.MethodPost, "/20200531/logGroups"}, + "update group": {http.MethodPut, "/20200531/logGroups/" + groupID}, + "move group": { + http.MethodPost, "/20200531/logGroups/" + groupID + "/actions/changeCompartment", + }, + "create log": {http.MethodPost, "/20200531/logGroups/" + groupID + "/logs"}, + "update log": {http.MethodPut, "/20200531/logGroups/" + groupID + "/logs/" + logID}, + "push": {http.MethodPost, "/20200601/logs/" + logID + "/actions/push"}, + "search": {http.MethodPost, "/20190909/search"}, + } + + for name, tc := range paths { + t.Run(name, func(t *testing.T) { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(tc.method, tc.path, strings.NewReader("{not json"))) + assert.Equal(t, http.StatusBadRequest, rec.Code, rec.Body.String()) + }) + } +} + +func TestCreateLogErrors(t *testing.T) { + h, work := newHandler(t) + groupID := createGroup(t, h, work, "app-logs") + + tests := []struct { + name string + path string + body any + expectCode int + }{ + { + name: "an unknown group", path: "/20200531/logGroups/ocid1.loggroup.oc1.iad.missing/logs", + body: map[string]any{"displayName": "stdout", "logType": "CUSTOM"}, expectCode: http.StatusNotFound, + }, + { + name: "no display name", path: "/20200531/logGroups/" + groupID + "/logs", + body: map[string]any{"logType": "CUSTOM"}, expectCode: http.StatusBadRequest, + }, + { + name: "a SERVICE log with no source", path: "/20200531/logGroups/" + groupID + "/logs", + body: map[string]any{"displayName": "flow", "logType": "SERVICE"}, expectCode: http.StatusBadRequest, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rec := do(t, h, http.MethodPost, tc.path, tc.body) + assert.Equal(t, tc.expectCode, rec.Code, rec.Body.String()) + }) + } +} diff --git a/server/oci/logging/ingestion.go b/server/oci/logging/ingestion.go new file mode 100644 index 000000000..e15b5b563 --- /dev/null +++ b/server/oci/logging/ingestion.go @@ -0,0 +1,87 @@ +package logging + +import ( + "net/http" + + logprovider "github.com/stackshy/cloudemu/v2/providers/oci/logging" + "github.com/stackshy/cloudemu/v2/server/wire/ocirest" +) + +// servePush serves the loggingingestion plane: PutLogs, the only operation it +// publishes. Ingestion is synchronous in real OCI, so it records no work +// request. +func (h *Handler) servePush(w http.ResponseWriter, r *http.Request, rt *route) { + if rt.ID == "" || rt.Sub != subActions || rt.SubID != actionPush { + ocirest.WriteError(w, r, http.StatusNotFound, codeNotFound, + "the ingestion API publishes only POST /"+versionIngestion+"/logs/{logId}/actions/push") + + return + } + + if r.Method != http.MethodPost { + methodNotAllowed(w, r) + return + } + + var req putLogsRequest + + if !ocirest.DecodeJSON(w, r, &req) { + return + } + + if req.SpecVersion == "" { + ocirest.WriteError(w, r, http.StatusBadRequest, codeInvalidParameter, "specversion is required") + return + } + + batches := make([]logprovider.LogEntryBatch, 0, len(req.LogEntryBatches)) + + for i := range req.LogEntryBatches { + batch, err := toProviderBatch(&req.LogEntryBatches[i]) + if err != nil { + ocirest.WriteError(w, r, http.StatusBadRequest, codeInvalidParameter, err.Error()) + return + } + + batches = append(batches, batch) + } + + if err := h.extras.PutLogs(r.Context(), rt.ID, batches); err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + ocirest.WriteJSON(w, r, http.StatusOK, nil) +} + +// toProviderBatch converts one wire batch, rejecting a timestamp it cannot read. +func toProviderBatch(b *putLogsBatch) (logprovider.LogEntryBatch, error) { + out := logprovider.LogEntryBatch{ + Entries: make([]logprovider.LogEntryItem, 0, len(b.Entries)), + Source: b.Source, + Type: b.Type, + Subject: b.Subject, + } + + defaultTime, err := parseTime(b.DefaultLogEntryTime, "defaultlogentrytime") + if err != nil { + return logprovider.LogEntryBatch{}, err + } + + out.DefaultLogEntryTime = defaultTime + + for i := range b.Entries { + when, entryErr := parseTime(b.Entries[i].Time, "entry time") + if entryErr != nil { + return logprovider.LogEntryBatch{}, entryErr + } + + out.Entries = append(out.Entries, logprovider.LogEntryItem{ + ID: b.Entries[i].ID, + Data: b.Entries[i].Data, + Time: when, + }) + } + + return out, nil +} diff --git a/server/oci/logging/log.go b/server/oci/logging/log.go new file mode 100644 index 000000000..2333c9c71 --- /dev/null +++ b/server/oci/logging/log.go @@ -0,0 +1,213 @@ +package logging + +import ( + "net/http" + + logprovider "github.com/stackshy/cloudemu/v2/providers/oci/logging" + "github.com/stackshy/cloudemu/v2/server/oci/workrequest" + "github.com/stackshy/cloudemu/v2/server/wire/ocirest" +) + +// serveLogs maps method and path shape onto the log operations nested under a +// log group. +func (h *Handler) serveLogs(w http.ResponseWriter, r *http.Request, rt *route) { + if rt.SubID == "" { + switch r.Method { + case http.MethodPost: + h.createLog(w, r, rt.ID) + case http.MethodGet: + h.listLogs(w, r, rt.ID) + default: + methodNotAllowed(w, r) + } + + return + } + + switch r.Method { + case http.MethodGet: + h.getLog(w, r, rt.ID, rt.SubID) + case http.MethodPut: + h.updateLog(w, r, rt.ID, rt.SubID) + case http.MethodDelete: + h.deleteLog(w, r, rt.ID, rt.SubID) + default: + methodNotAllowed(w, r) + } +} + +func (h *Handler) createLog(w http.ResponseWriter, r *http.Request, groupID string) { + if !h.requireWork(w, r) { + return + } + + var req createLogRequest + + if !ocirest.DecodeJSON(w, r, &req) { + return + } + + // Real OCI defaults isEnabled to true; an absent field must not silently + // create a log that drops everything ingested into it. + enabled := true + if req.IsEnabled != nil { + enabled = *req.IsEnabled + } + + spec := logprovider.LogSpec{ + DisplayName: req.DisplayName, + LogType: req.LogType, + IsEnabled: enabled, + Configuration: toProviderConfiguration(req.Configuration), + FreeformTags: req.FreeformTags, + } + + if req.RetentionDuration != nil { + spec.RetentionDuration = *req.RetentionDuration + } + + l, err := h.extras.CreateLog(r.Context(), groupID, spec) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + h.accept(w, r, operationCreateLog, l.CompartmentID, entityTypeLog, workrequest.ActionCreated, l.ID) +} + +// listLogs lists the logs in a group. OCI takes no compartmentId here — the +// group in the path fixes the compartment — so the log group OCID is the +// required parameter and the query narrows by log attributes only. +func (h *Handler) listLogs(w http.ResponseWriter, r *http.Request, groupID string) { + q := r.URL.Query() + + logs, err := h.extras.ListLogs(r.Context(), groupID, logprovider.LogFilter{ + DisplayName: q.Get("displayName"), + LogType: q.Get("logType"), + SourceService: q.Get("sourceService"), + SourceResource: q.Get("sourceResource"), + LifecycleState: q.Get("lifecycleState"), + }) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + out := make([]logResponse, 0, len(logs)) + for i := range logs { + out = append(out, toLogResponse(&logs[i])) + } + + ocirest.WriteJSON(w, r, http.StatusOK, paginate(w, r, out)) +} + +func (h *Handler) getLog(w http.ResponseWriter, r *http.Request, groupID, logID string) { + l, err := h.extras.GetLog(r.Context(), groupID, logID) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + ocirest.WriteJSON(w, r, http.StatusOK, toLogResponse(l)) +} + +func (h *Handler) updateLog(w http.ResponseWriter, r *http.Request, groupID, logID string) { + if !h.requireWork(w, r) { + return + } + + var req updateLogRequest + + if !ocirest.DecodeJSON(w, r, &req) { + return + } + + l, err := h.extras.UpdateLog(r.Context(), groupID, logID, logprovider.LogUpdate{ + DisplayName: req.DisplayName, + IsEnabled: req.IsEnabled, + RetentionDuration: req.RetentionDuration, + Configuration: toProviderConfiguration(req.Configuration), + FreeformTags: req.FreeformTags, + }) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + h.accept(w, r, operationUpdateLog, l.CompartmentID, entityTypeLog, workrequest.ActionUpdated, l.ID) +} + +func (h *Handler) deleteLog(w http.ResponseWriter, r *http.Request, groupID, logID string) { + if !h.requireWork(w, r) { + return + } + + l, err := h.extras.GetLog(r.Context(), groupID, logID) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + if err := h.extras.DeleteLog(r.Context(), groupID, logID); err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + h.accept(w, r, operationDeleteLog, l.CompartmentID, entityTypeLog, workrequest.ActionDeleted, logID) +} + +func toProviderConfiguration(cfg *logConfigurationBody) *logprovider.LogConfiguration { + if cfg == nil { + return nil + } + + out := &logprovider.LogConfiguration{ + CompartmentID: cfg.CompartmentID, + Source: logprovider.LogSource{ + SourceType: cfg.Source.SourceType, + Service: cfg.Source.Service, + Resource: cfg.Source.Resource, + Category: cfg.Source.Category, + Parameters: cfg.Source.Parameters, + }, + } + + if cfg.Archiving != nil { + out.ArchivingEnabled = cfg.Archiving.IsEnabled + } + + return out +} + +func toLogResponse(l *logprovider.Log) logResponse { + out := logResponse{ + ID: l.ID, + LogGroupID: l.LogGroupID, + CompartmentID: l.CompartmentID, + DisplayName: l.DisplayName, + LogType: l.LogType, + IsEnabled: l.IsEnabled, + LifecycleState: l.LifecycleState, + RetentionDuration: l.RetentionDuration, + TimeCreated: l.TimeCreated, + TimeLastModified: l.TimeLastModified, + FreeformTags: orEmptyTags(l.FreeformTags), + DefinedTags: definedTags{}, + } + + if l.Configuration != nil { + out.Configuration = &logConfigurationBody{ + CompartmentID: l.Configuration.CompartmentID, + Source: logSourceBody{ + SourceType: l.Configuration.Source.SourceType, + Service: l.Configuration.Source.Service, + Resource: l.Configuration.Source.Resource, + Category: l.Configuration.Source.Category, + Parameters: l.Configuration.Source.Parameters, + }, + Archiving: &archivingBody{IsEnabled: l.Configuration.ArchivingEnabled}, + } + } + + return out +} diff --git a/server/oci/logging/search.go b/server/oci/logging/search.go new file mode 100644 index 000000000..af51f40dd --- /dev/null +++ b/server/oci/logging/search.go @@ -0,0 +1,106 @@ +package logging + +import ( + "encoding/json" + "net/http" + "time" + + logprovider "github.com/stackshy/cloudemu/v2/providers/oci/logging" + "github.com/stackshy/cloudemu/v2/server/wire/ocirest" +) + +// serveSearch serves the loggingsearch plane: SearchLogs. +func (h *Handler) serveSearch(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + methodNotAllowed(w, r) + return + } + + var req searchLogsRequest + + if !ocirest.DecodeJSON(w, r, &req) { + return + } + + start, err := parseTime(req.TimeStart, "timeStart") + if err != nil { + ocirest.WriteError(w, r, http.StatusBadRequest, codeInvalidParameter, err.Error()) + return + } + + end, err := parseTime(req.TimeEnd, "timeEnd") + if err != nil { + ocirest.WriteError(w, r, http.StatusBadRequest, codeInvalidParameter, err.Error()) + return + } + + result, err := h.extras.SearchLogs(r.Context(), logprovider.SearchRequest{ + Query: req.SearchQuery, + TimeStart: start, + TimeEnd: end, + Limit: ocirest.Limit(r), + ReturnFieldInfo: req.IsReturnFieldInfo, + }) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + ocirest.WriteJSON(w, r, http.StatusOK, toSearchResponse(result)) +} + +func toSearchResponse(result *logprovider.SearchResult) searchLogsResponse { + out := searchLogsResponse{ + Results: make([]searchResult, 0, len(result.Entries)), + Fields: make([]fieldInfo, 0, len(result.Fields)), + } + + for i := range result.Entries { + out.Results = append(out.Results, toSearchResult(&result.Entries[i])) + } + + for _, f := range result.Fields { + out.Fields = append(out.Fields, fieldInfo{FieldName: f.Name, FieldType: f.Type}) + } + + out.Summary = searchSummary{ResultCount: len(out.Results), FieldCount: len(out.Fields)} + + if len(out.Fields) == 0 { + out.Fields = nil + } + + return out +} + +func toSearchResult(e *logprovider.SearchEntry) searchResult { + return searchResult{Data: searchResultData{ + Datetime: e.Time.UnixMilli(), + LogContent: logContent{ + Data: decodePayload(e.Data), + ID: e.ID, + Oracle: oracleFields{ + CompartmentID: e.CompartmentID, + IngestedTime: e.IngestedTime.UTC().Format(time.RFC3339), + LogGroupID: e.LogGroupID, + LogID: e.LogID, + }, + Source: e.Source, + SpecVersion: specVersionOCI, + Subject: e.Subject, + Time: e.Time.UTC().Format(time.RFC3339), + Type: e.Type, + }, + }} +} + +// decodePayload returns a JSON entry payload as an object, matching what real +// OCI does, and anything else as the raw string. +func decodePayload(data string) any { + var obj map[string]any + + if err := json.Unmarshal([]byte(data), &obj); err == nil { + return obj + } + + return data +} diff --git a/server/oci/logging/types.go b/server/oci/logging/types.go new file mode 100644 index 000000000..78666d21d --- /dev/null +++ b/server/oci/logging/types.go @@ -0,0 +1,199 @@ +package logging + +import "time" + +// OCI Logging REST shapes, across all three API surfaces. + +// definedTags is OCI's namespaced tag map. CloudEmu does not model tag +// namespaces, so it is echoed back empty. +type definedTags map[string]map[string]any + +// Control plane — /20200531. + +type createLogGroupRequest struct { + CompartmentID string `json:"compartmentId"` + DisplayName string `json:"displayName"` + Description string `json:"description,omitempty"` + FreeformTags map[string]string `json:"freeformTags,omitempty"` + DefinedTags definedTags `json:"definedTags,omitempty"` +} + +type updateLogGroupRequest struct { + DisplayName *string `json:"displayName,omitempty"` + Description *string `json:"description,omitempty"` + FreeformTags map[string]string `json:"freeformTags,omitempty"` + DefinedTags definedTags `json:"definedTags,omitempty"` +} + +type changeCompartmentRequest struct { + TargetCompartmentID string `json:"targetCompartmentId"` +} + +type logGroupResponse struct { + ID string `json:"id"` + CompartmentID string `json:"compartmentId"` + DisplayName string `json:"displayName"` + Description string `json:"description,omitempty"` + LifecycleState string `json:"lifecycleState"` + TimeCreated string `json:"timeCreated"` + TimeLastModified string `json:"timeLastModified"` + FreeformTags map[string]string `json:"freeformTags"` + DefinedTags definedTags `json:"definedTags"` +} + +// logSourceBody is the source clause of a log's configuration. +type logSourceBody struct { + SourceType string `json:"sourceType,omitempty"` + Service string `json:"service,omitempty"` + Resource string `json:"resource,omitempty"` + Category string `json:"category,omitempty"` + Parameters map[string]string `json:"parameters,omitempty"` +} + +type archivingBody struct { + IsEnabled bool `json:"isEnabled"` +} + +type logConfigurationBody struct { + CompartmentID string `json:"compartmentId,omitempty"` + Source logSourceBody `json:"source"` + Archiving *archivingBody `json:"archiving,omitempty"` +} + +type createLogRequest struct { + DisplayName string `json:"displayName"` + LogType string `json:"logType"` + IsEnabled *bool `json:"isEnabled,omitempty"` + RetentionDuration *int `json:"retentionDuration,omitempty"` + Configuration *logConfigurationBody `json:"configuration,omitempty"` + FreeformTags map[string]string `json:"freeformTags,omitempty"` + DefinedTags definedTags `json:"definedTags,omitempty"` +} + +type updateLogRequest struct { + DisplayName *string `json:"displayName,omitempty"` + IsEnabled *bool `json:"isEnabled,omitempty"` + RetentionDuration *int `json:"retentionDuration,omitempty"` + Configuration *logConfigurationBody `json:"configuration,omitempty"` + FreeformTags map[string]string `json:"freeformTags,omitempty"` + DefinedTags definedTags `json:"definedTags,omitempty"` +} + +type logResponse struct { + ID string `json:"id"` + LogGroupID string `json:"logGroupId"` + CompartmentID string `json:"compartmentId"` + DisplayName string `json:"displayName"` + LogType string `json:"logType"` + IsEnabled bool `json:"isEnabled"` + LifecycleState string `json:"lifecycleState"` + RetentionDuration int `json:"retentionDuration"` + Configuration *logConfigurationBody `json:"configuration,omitempty"` + TimeCreated string `json:"timeCreated"` + TimeLastModified string `json:"timeLastModified"` + FreeformTags map[string]string `json:"freeformTags"` + DefinedTags definedTags `json:"definedTags"` +} + +// Ingestion plane — /20200601. + +type putLogsEntry struct { + Data string `json:"data"` + ID string `json:"id,omitempty"` + Time string `json:"time,omitempty"` +} + +type putLogsBatch struct { + Entries []putLogsEntry `json:"entries"` + Source string `json:"source,omitempty"` + Type string `json:"type,omitempty"` + Subject string `json:"subject,omitempty"` + // DefaultLogEntryTime is spelled all-lowercase by the ingestion API. + DefaultLogEntryTime string `json:"defaultlogentrytime,omitempty"` +} + +type putLogsRequest struct { + SpecVersion string `json:"specversion"` + LogEntryBatches []putLogsBatch `json:"logEntryBatches"` +} + +// Search plane — /20190909. + +type searchLogsRequest struct { + SearchQuery string `json:"searchQuery"` + TimeStart string `json:"timeStart"` + TimeEnd string `json:"timeEnd"` + IsReturnFieldInfo bool `json:"isReturnFieldInfo,omitempty"` +} + +// oracleFields are the log-provenance fields OCI stamps onto every record. +type oracleFields struct { + CompartmentID string `json:"compartmentid"` + IngestedTime string `json:"ingestedtime"` + LogGroupID string `json:"loggroupid"` + LogID string `json:"logid"` +} + +// logContent is the CloudEvents-shaped record a search returns. Data is the +// decoded payload when it is a JSON object, and the raw string otherwise. +type logContent struct { + Data any `json:"data"` + ID string `json:"id"` + Oracle oracleFields `json:"oracle"` + Source string `json:"source"` + SpecVersion string `json:"specversion"` + Subject string `json:"subject,omitempty"` + Time string `json:"time"` + Type string `json:"type,omitempty"` +} + +type searchResultData struct { + // Datetime is milliseconds since the epoch, which is how the search API + // reports a record's time alongside the RFC 3339 one in logContent. + Datetime int64 `json:"datetime"` + LogContent logContent `json:"logContent"` +} + +type searchResult struct { + Data searchResultData `json:"data"` +} + +type fieldInfo struct { + FieldName string `json:"fieldName"` + FieldType string `json:"fieldType"` +} + +type searchSummary struct { + ResultCount int `json:"resultCount"` + FieldCount int `json:"fieldCount"` +} + +type searchLogsResponse struct { + Results []searchResult `json:"results"` + Fields []fieldInfo `json:"fields,omitempty"` + Summary searchSummary `json:"summary"` +} + +// parseTime reads an OCI timestamp, treating an empty one as unset. +func parseTime(value, field string) (time.Time, error) { + if value == "" { + return time.Time{}, nil + } + + t, err := time.Parse(time.RFC3339, value) + if err != nil { + return time.Time{}, &timeError{field: field, value: value} + } + + return t, nil +} + +// timeError reports a timestamp the handler could not read. +type timeError struct { + field string + value string +} + +func (e *timeError) Error() string { + return e.field + " " + e.value + " is not an RFC 3339 timestamp" +} diff --git a/server/oci/oci.go b/server/oci/oci.go index a367d4810..e2436a357 100644 --- a/server/oci/oci.go +++ b/server/oci/oci.go @@ -10,6 +10,7 @@ import ( "github.com/stackshy/cloudemu/v2/config" "github.com/stackshy/cloudemu/v2/server" "github.com/stackshy/cloudemu/v2/server/oci/identity" + ocilogging "github.com/stackshy/cloudemu/v2/server/oci/logging" "github.com/stackshy/cloudemu/v2/server/oci/monitoring" "github.com/stackshy/cloudemu/v2/server/oci/vcn" "github.com/stackshy/cloudemu/v2/server/oci/workrequest" @@ -92,6 +93,10 @@ func New(d Drivers) *server.Server { srv.Register(monitoring.New(d.Monitoring)) } + if d.Logging != nil { + srv.Register(ocilogging.New(d.Logging, d.WorkRequests)) + } + if d.VCN != nil { srv.Register(vcn.New(d.VCN, d.WorkRequests)) }