From ec5476d803fa0e935e773f8aef3b2f34828009a8 Mon Sep 17 00:00:00 2001 From: arunesh-j Date: Sat, 22 Aug 2026 00:07:04 +0530 Subject: [PATCH 1/5] =?UTF-8?q?feat(oci):=20add=20Logging=20=E2=80=94=20lo?= =?UTF-8?q?g=20groups,=20logs,=20ingestion=20and=20search?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the portable logging driver against OCI Logging, with the OCI-only surface behind a consumer-side Extras interface. OCI publishes the service on three API surfaces, which collapse onto one CloudEmu server, so Matches claims each prefix's collections exactly: /20200531 for the log group and log control plane, /20200601 for the loggingingestion push, and /20190909 for loggingsearch. A top-level /logs collection belongs to the ingestion plane alone — the control plane nests logs under their log group — which is what keeps the two apart. A log group is the portable log group, a CUSTOM log is the log stream and an ingested entry is the log event. Every log group and log mutation is asynchronous in real OCI, so each answers 202 with an opc-work-request-id carrying the created resource's OCID. Ingesting into a SERVICE log or a disabled one is refused rather than accepted and dropped. Metric filters have no OCI equivalent and report Unimplemented. Search reads the straightforward query form — a search clause over compartment[/logGroup[/log]], an optional where clause of = and != comparisons joined by and, and an optional sort by datetime — and rejects everything else naming what it tripped on rather than returning an empty result set: the summarize, stats, topN and extract operators, or/not/parenthesized where clauses, the ordering and pattern operators, an unresolvable field, and a search target written as a name where OCI takes an OCID. --- docs/coverage/coverage.json | 3 +- docs/coverage/oci/README.md | 1 + docs/coverage/oci/logging.md | 27 + docs/services.md | 62 ++- providers/oci/logging/group.go | 174 +++++++ providers/oci/logging/ingestion.go | 109 ++++ providers/oci/logging/log.go | 255 ++++++++++ providers/oci/logging/logging.go | 332 ++++++++++++ providers/oci/logging/logging_test.go | 623 +++++++++++++++++++++++ providers/oci/logging/portable.go | 384 ++++++++++++++ providers/oci/logging/query.go | 466 +++++++++++++++++ providers/oci/logging/race_test.go | 110 ++++ providers/oci/logging/search.go | 373 ++++++++++++++ providers/oci/oci.go | 2 + server/oci/logging/dataplane.go | 209 ++++++++ server/oci/logging/groups.go | 206 ++++++++ server/oci/logging/handler.go | 308 ++++++++++++ server/oci/logging/handler_test.go | 692 ++++++++++++++++++++++++++ server/oci/logging/logs.go | 213 ++++++++ server/oci/logging/types.go | 173 +++++++ server/oci/oci.go | 5 + 21 files changed, 4725 insertions(+), 2 deletions(-) create mode 100644 docs/coverage/oci/logging.md create mode 100644 providers/oci/logging/group.go create mode 100644 providers/oci/logging/ingestion.go create mode 100644 providers/oci/logging/log.go create mode 100644 providers/oci/logging/logging.go create mode 100644 providers/oci/logging/logging_test.go create mode 100644 providers/oci/logging/portable.go create mode 100644 providers/oci/logging/query.go create mode 100644 providers/oci/logging/race_test.go create mode 100644 providers/oci/logging/search.go create mode 100644 server/oci/logging/dataplane.go create mode 100644 server/oci/logging/groups.go create mode 100644 server/oci/logging/handler.go create mode 100644 server/oci/logging/handler_test.go create mode 100644 server/oci/logging/logs.go create mode 100644 server/oci/logging/types.go diff --git a/docs/coverage/coverage.json b/docs/coverage/coverage.json index 9ff0dda5d..693b247e7 100644 --- a/docs/coverage/coverage.json +++ b/docs/coverage/coverage.json @@ -10680,7 +10680,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 8a022e2ab..3b00fbc44 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` | 14 | | [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..f3dc53298 --- /dev/null +++ b/docs/coverage/oci/logging.md @@ -0,0 +1,27 @@ + +# Logging + +OCI's `logging` service · portable interface `driver.Logging` · [OCI index](./README.md) + +## Operations (14) + +| Operation | Description | +| --- | --- | +| `CreateLogGroup` | | +| `CreateLogStream` | | +| `DeleteLogGroup` | | +| `DeleteLogStream` | | +| `DeleteMetricFilter` | | +| `DescribeMetricFilters` | | +| `FilterLogEvents` | | +| `GetLogEvents` | | +| `GetLogGroup` | | +| `ListLogGroups` | | +| `ListLogStreams` | | +| `PutLogEvents` | | +| `PutMetricFilter` | | +| `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 de9a8f545..6d0386fd0 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 filters have no OCI equivalent and report `Unimplemented`) ### Log Group Operations @@ -1459,6 +1459,66 @@ a source cluster and detach on promote; clone-on-read on every path. **Total: 13 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 +across the emulator, not per compartment, which is what lets the portable +driver address a group by name. + --- ## 14. Notification diff --git a/providers/oci/logging/group.go b/providers/oci/logging/group.go new file mode 100644 index 000000000..9a5af8c20 --- /dev/null +++ b/providers/oci/logging/group.go @@ -0,0 +1,174 @@ +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 + } + + if _, ok := m.groupByName(spec.DisplayName); ok { + return nil, cerrors.Newf(cerrors.AlreadyExists, "log group %q already exists", spec.DisplayName) + } + + retention := spec.RetentionDays + if retention == 0 { + retention = defaultRetentionDays + } + + now := m.now() + g := &LogGroup{ + ID: m.newOCID(typeLogGroup), + CompartmentID: m.compartmentOr(spec.CompartmentID), + 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(*u.DisplayName); taken { + return nil, cerrors.Newf(cerrors.AlreadyExists, "log group %q already exists", *u.DisplayName) + } + + 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) + } + + 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..141f269d5 --- /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..eb2d3fb92 --- /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..0be26d9e6 --- /dev/null +++ b/providers/oci/logging/logging.go @@ -0,0 +1,332 @@ +// 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 + +// 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. +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. Display names are unique +// across the mock, which is what lets the portable driver key groups by name. +// The caller holds mu. +func (m *Mock) groupByName(name string) (*LogGroup, bool) { + for _, g := range m.groups.SortedValues() { + if g.DisplayName == name { + return g, true + } + } + + return nil, false +} + +// 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..86cb52add --- /dev/null +++ b/providers/oci/logging/logging_test.go @@ -0,0 +1,623 @@ +package logging_test + +import ( + "context" + "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" + "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"}) + 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)) +} diff --git a/providers/oci/logging/portable.go b/providers/oci/logging/portable.go new file mode 100644 index 000000000..772851e4d --- /dev/null +++ b/providers/oci/logging/portable.go @@ -0,0 +1,384 @@ +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. + +// viaServiceConnector is what OCI does instead of a metric filter. +const viaServiceConnector = "a Service Connector routes matching log entries into Monitoring" + +// 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, ok := m.groupByName(cfg.Name) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "log group %q not found", cfg.Name) + } + + if cfg.RetentionDays != 0 { + g.RetentionDays = cfg.RetentionDays + } + + if cfg.Tags != nil { + g.FreeformTags = copyTags(cfg.Tags) + } + + if 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, ok := m.groupByName(name) + if !ok { + return cerrors.Newf(cerrors.NotFound, "log group %q not found", name) + } + + 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, ok := m.groupByName(name) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "log group %q not found", name) + } + + 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, ok := m.groupByName(logGroup) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "log group %q not found", logGroup) + } + + 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, ok := m.groupByName(logGroup) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "log group %q not found", logGroup) + } + + 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() + + recs, err := m.portableSelection(input.LogGroup, input.LogStream) + if err != nil { + return nil, err + } + + limit := input.Limit + if limit <= 0 { + limit = defaultLogLimit + } + + 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() + + recs, err := m.portableSelection(input.LogGroup, input.LogStream) + if err != nil { + return nil, err + } + + limit := input.Limit + if limit <= 0 { + limit = defaultLogLimit + } + + 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") +} + +// DeleteMetricFilter is not an OCI Logging operation. +func (*Mock) DeleteMetricFilter(_ context.Context, _, _ string) error { + return unsupported("DeleteMetricFilter") +} + +// DescribeMetricFilters is not an OCI Logging operation. +func (*Mock) DescribeMetricFilters(_ context.Context, _ string) ([]driver.MetricFilterInfo, error) { + return nil, unsupported("DescribeMetricFilters") +} + +// unsupported reports an operation OCI Logging has no equivalent for. +func unsupported(operation string) error { + return cerrors.Newf(cerrors.Unimplemented, "%s is not an OCI Logging operation: %s", + operation, viaServiceConnector) +} + +// portableLog resolves a log by group and log display name. The caller holds mu. +func (m *Mock) portableLog(logGroup, streamName string) (*logRecord, error) { + g, ok := m.groupByName(logGroup) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "log group %q not found", logGroup) + } + + 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, ok := m.groupByName(logGroup) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "log group %q not found", logGroup) + } + + 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..5df5c15a7 --- /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 := req.Limit + if limit <= 0 { + limit = defaultLogLimit + } + + 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/oci.go b/providers/oci/oci.go index 76e721a43..21f4988f8 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/dataplane.go b/server/oci/logging/dataplane.go new file mode 100644 index 000000000..fd2edb76c --- /dev/null +++ b/server/oci/logging/dataplane.go @@ -0,0 +1,209 @@ +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" +) + +// 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 +} + +// 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 +} + +// 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/logging/groups.go b/server/oci/logging/groups.go new file mode 100644 index 000000000..3ddd429eb --- /dev/null +++ b/server/oci/logging/groups.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..4b57fe22f --- /dev/null +++ b/server/oci/logging/handler_test.go @@ -0,0 +1,692 @@ +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" + +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 +} diff --git a/server/oci/logging/logs.go b/server/oci/logging/logs.go new file mode 100644 index 000000000..2333c9c71 --- /dev/null +++ b/server/oci/logging/logs.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/types.go b/server/oci/logging/types.go new file mode 100644 index 000000000..2b6cc150b --- /dev/null +++ b/server/oci/logging/types.go @@ -0,0 +1,173 @@ +package logging + +// 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"` +} diff --git a/server/oci/oci.go b/server/oci/oci.go index 20b2d8f31..b9613b524 100644 --- a/server/oci/oci.go +++ b/server/oci/oci.go @@ -12,6 +12,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" @@ -94,6 +95,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 { vcnHandler := vcn.New(d.VCN, d.WorkRequests) From fbf343df5f29a298a3231ad639e71be3456f995a Mon Sep 17 00:00:00 2001 From: arunesh-j Date: Sun, 6 Sep 2026 00:12:18 +0530 Subject: [PATCH 2/5] fix(oci): bound logging read limits, scope log-group names per compartment Guard the caller-supplied read limit before it sizes an allocation: GetLogEvents, FilterLogEvents and SearchLogs now reject a negative limit and one above maxLogLimit with InvalidArgument. Resolves the CodeQL uncontrolled-allocation-size findings in portable.go. Scope log-group displayName uniqueness per compartment, as real OCI does. 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. Rename the wire files to match the provider's, per STRUCTURE.md section 3: groups.go -> group.go, logs.go -> log.go, and dataplane.go split into ingestion.go and search.go. --- providers/oci/logging/group.go | 18 +- providers/oci/logging/logging.go | 42 +- providers/oci/logging/logging_test.go | 777 +++++++++++++++++- providers/oci/logging/portable.go | 81 +- providers/oci/logging/search.go | 6 +- server/oci/logging/{groups.go => group.go} | 0 server/oci/logging/handler_test.go | 493 ++++++++++- server/oci/logging/ingestion.go | 87 ++ server/oci/logging/{logs.go => log.go} | 0 .../oci/logging/{dataplane.go => search.go} | 103 --- server/oci/logging/types.go | 26 + 11 files changed, 1485 insertions(+), 148 deletions(-) rename server/oci/logging/{groups.go => group.go} (100%) create mode 100644 server/oci/logging/ingestion.go rename server/oci/logging/{logs.go => log.go} (100%) rename server/oci/logging/{dataplane.go => search.go} (50%) diff --git a/providers/oci/logging/group.go b/providers/oci/logging/group.go index 9a5af8c20..6febca10a 100644 --- a/providers/oci/logging/group.go +++ b/providers/oci/logging/group.go @@ -21,8 +21,10 @@ func (m *Mock) createGroup(spec LogGroupSpec) (*LogGroup, error) { return nil, err } - if _, ok := m.groupByName(spec.DisplayName); ok { - return nil, cerrors.Newf(cerrors.AlreadyExists, "log group %q already exists", spec.DisplayName) + 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 @@ -33,7 +35,7 @@ func (m *Mock) createGroup(spec LogGroupSpec) (*LogGroup, error) { now := m.now() g := &LogGroup{ ID: m.newOCID(typeLogGroup), - CompartmentID: m.compartmentOr(spec.CompartmentID), + CompartmentID: compartment, DisplayName: spec.DisplayName, Description: spec.Description, LifecycleState: StateActive, @@ -104,8 +106,9 @@ func (m *Mock) UpdateGroup(_ context.Context, id string, u LogGroupUpdate) (*Log } if u.DisplayName != nil && *u.DisplayName != g.DisplayName { - if _, taken := m.groupByName(*u.DisplayName); taken { - return nil, cerrors.Newf(cerrors.AlreadyExists, "log group %q already exists", *u.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 @@ -159,6 +162,11 @@ func (m *Mock) MoveGroup(_ context.Context, id, compartmentID string) error { 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() diff --git a/providers/oci/logging/logging.go b/providers/oci/logging/logging.go index 0be26d9e6..bfee6a418 100644 --- a/providers/oci/logging/logging.go +++ b/providers/oci/logging/logging.go @@ -31,6 +31,10 @@ 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" @@ -241,12 +245,11 @@ func (m *Mock) compartmentOr(id string) string { return m.opts.CompartmentID } -// groupByName resolves a log group by display name. Display names are unique -// across the mock, which is what lets the portable driver key groups by name. -// The caller holds mu. -func (m *Mock) groupByName(name string) (*LogGroup, bool) { +// 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.DisplayName == name { + if g.CompartmentID == compartmentID && g.DisplayName == name { return g, true } } @@ -254,6 +257,35 @@ func (m *Mock) groupByName(name string) (*LogGroup, bool) { 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() { diff --git a/providers/oci/logging/logging_test.go b/providers/oci/logging/logging_test.go index 86cb52add..a8e3f2175 100644 --- a/providers/oci/logging/logging_test.go +++ b/providers/oci/logging/logging_test.go @@ -2,6 +2,8 @@ package logging_test import ( "context" + "errors" + "strconv" "strings" "testing" "time" @@ -13,6 +15,7 @@ import ( 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" ) @@ -511,7 +514,10 @@ func TestPortableLogGroupCRUD(t *testing.T) { assert.Equal(t, compartmentB, info.Scope.Compartment) assert.True(t, strings.HasPrefix(info.ResourceID, "ocid1.loggroup.")) - _, err = m.CreateLogGroup(ctx, driver.LogGroupConfig{Name: "portable"}) + _, 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") @@ -621,3 +627,772 @@ func TestMetricFiltersAreNotAnOCIOperation(t *testing.T) { _, err = m.DescribeMetricFilters(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 index 772851e4d..0fb6b2582 100644 --- a/providers/oci/logging/portable.go +++ b/providers/oci/logging/portable.go @@ -17,6 +17,22 @@ import ( // viaServiceConnector is what OCI does instead of a metric filter. const viaServiceConnector = "a Service Connector routes matching log entries into Monitoring" +// 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. // @@ -47,9 +63,9 @@ func (m *Mock) UpdateLogGroup(_ context.Context, cfg driver.LogGroupConfig) (*dr m.mu.Lock() defer m.mu.Unlock() - g, ok := m.groupByName(cfg.Name) - if !ok { - return nil, cerrors.Newf(cerrors.NotFound, "log group %q not found", cfg.Name) + g, err := m.portableGroupByName(cfg.Name) + if err != nil { + return nil, err } if cfg.RetentionDays != 0 { @@ -60,7 +76,12 @@ func (m *Mock) UpdateLogGroup(_ context.Context, cfg driver.LogGroupConfig) (*dr g.FreeformTags = copyTags(cfg.Tags) } - if cfg.Scope.Compartment != "" { + 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 } @@ -76,9 +97,9 @@ func (m *Mock) DeleteLogGroup(_ context.Context, name string) error { m.mu.Lock() defer m.mu.Unlock() - g, ok := m.groupByName(name) - if !ok { - return cerrors.Newf(cerrors.NotFound, "log group %q not found", name) + g, err := m.portableGroupByName(name) + if err != nil { + return err } for _, rec := range m.logsIn(g.ID) { @@ -95,9 +116,9 @@ func (m *Mock) GetLogGroup(_ context.Context, name string) (*driver.LogGroupInfo m.mu.RLock() defer m.mu.RUnlock() - g, ok := m.groupByName(name) - if !ok { - return nil, cerrors.Newf(cerrors.NotFound, "log group %q not found", name) + g, err := m.portableGroupByName(name) + if err != nil { + return nil, err } info := m.toLogGroupInfo(g) @@ -128,9 +149,9 @@ func (m *Mock) CreateLogStream(_ context.Context, logGroup, streamName string) ( m.mu.Lock() defer m.mu.Unlock() - g, ok := m.groupByName(logGroup) - if !ok { - return nil, cerrors.Newf(cerrors.NotFound, "log group %q not found", logGroup) + g, err := m.portableGroupByName(logGroup) + if err != nil { + return nil, err } l, err := m.createLog(g.ID, LogSpec{ @@ -165,9 +186,9 @@ func (m *Mock) ListLogStreams(_ context.Context, logGroup string) ([]driver.LogS m.mu.RLock() defer m.mu.RUnlock() - g, ok := m.groupByName(logGroup) - if !ok { - return nil, cerrors.Newf(cerrors.NotFound, "log group %q not found", logGroup) + g, err := m.portableGroupByName(logGroup) + if err != nil { + return nil, err } recs := m.logsIn(g.ID) @@ -212,14 +233,14 @@ func (m *Mock) GetLogEvents(_ context.Context, input *driver.LogQueryInput) ([]d m.mu.RLock() defer m.mu.RUnlock() - recs, err := m.portableSelection(input.LogGroup, input.LogStream) + limit, err := resolveLimit(input.Limit) if err != nil { return nil, err } - limit := input.Limit - if limit <= 0 { - limit = defaultLogLimit + recs, err := m.portableSelection(input.LogGroup, input.LogStream) + if err != nil { + return nil, err } out := make([]driver.LogEvent, 0, limit) @@ -250,14 +271,14 @@ func (m *Mock) FilterLogEvents( m.mu.RLock() defer m.mu.RUnlock() - recs, err := m.portableSelection(input.LogGroup, input.LogStream) + limit, err := resolveLimit(input.Limit) if err != nil { return nil, err } - limit := input.Limit - if limit <= 0 { - limit = defaultLogLimit + recs, err := m.portableSelection(input.LogGroup, input.LogStream) + if err != nil { + return nil, err } out := make([]driver.FilteredLogEvent, 0, limit) @@ -307,9 +328,9 @@ func unsupported(operation string) error { // portableLog resolves a log by group and log display name. The caller holds mu. func (m *Mock) portableLog(logGroup, streamName string) (*logRecord, error) { - g, ok := m.groupByName(logGroup) - if !ok { - return nil, cerrors.Newf(cerrors.NotFound, "log group %q not found", logGroup) + g, err := m.portableGroupByName(logGroup) + if err != nil { + return nil, err } rec, ok := m.logByName(g.ID, streamName) @@ -332,9 +353,9 @@ func (m *Mock) portableSelection(logGroup, streamName string) ([]*logRecord, err return []*logRecord{rec}, nil } - g, ok := m.groupByName(logGroup) - if !ok { - return nil, cerrors.Newf(cerrors.NotFound, "log group %q not found", logGroup) + g, err := m.portableGroupByName(logGroup) + if err != nil { + return nil, err } return m.logsIn(g.ID), nil diff --git a/providers/oci/logging/search.go b/providers/oci/logging/search.go index 5df5c15a7..752e190c8 100644 --- a/providers/oci/logging/search.go +++ b/providers/oci/logging/search.go @@ -125,9 +125,9 @@ func (m *Mock) SearchLogs(_ context.Context, req SearchRequest) (*SearchResult, return nil, err } - limit := req.Limit - if limit <= 0 { - limit = defaultLogLimit + limit, err := resolveLimit(req.Limit) + if err != nil { + return nil, err } m.mu.RLock() diff --git a/server/oci/logging/groups.go b/server/oci/logging/group.go similarity index 100% rename from server/oci/logging/groups.go rename to server/oci/logging/group.go diff --git a/server/oci/logging/handler_test.go b/server/oci/logging/handler_test.go index 4b57fe22f..e523caa83 100644 --- a/server/oci/logging/handler_test.go +++ b/server/oci/logging/handler_test.go @@ -23,7 +23,10 @@ import ( // The mock must satisfy the handler's OCI-only capability interface. var _ ocilogging.Extras = (*logprovider.Mock)(nil) -const compartmentA = "ocid1.compartment.oc1..aaaaaaaacompa" +const ( + compartmentA = "ocid1.compartment.oc1..aaaaaaaacompa" + compartmentB = "ocid1.compartment.oc1..aaaaaaaacompb" +) func newOptions() *config.Options { return config.NewOptions( @@ -690,3 +693,491 @@ func codeOf(t *testing.T, rec *httptest.ResponseRecorder) string { 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/logs.go b/server/oci/logging/log.go similarity index 100% rename from server/oci/logging/logs.go rename to server/oci/logging/log.go diff --git a/server/oci/logging/dataplane.go b/server/oci/logging/search.go similarity index 50% rename from server/oci/logging/dataplane.go rename to server/oci/logging/search.go index fd2edb76c..af51f40dd 100644 --- a/server/oci/logging/dataplane.go +++ b/server/oci/logging/search.go @@ -9,85 +9,6 @@ import ( "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 -} - // serveSearch serves the loggingsearch plane: SearchLogs. func (h *Handler) serveSearch(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { @@ -183,27 +104,3 @@ func decodePayload(data string) any { return data } - -// 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/logging/types.go b/server/oci/logging/types.go index 2b6cc150b..78666d21d 100644 --- a/server/oci/logging/types.go +++ b/server/oci/logging/types.go @@ -1,5 +1,7 @@ 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 @@ -171,3 +173,27 @@ type searchLogsResponse struct { 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" +} From 601fbbcb9de7eb12815ace03c901e6c330352b0e Mon Sep 17 00:00:00 2001 From: arunesh-j Date: Sun, 6 Sep 2026 01:41:19 +0530 Subject: [PATCH 3/5] feat(oci): persist Logging state; stub subscription filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement snapshot.Snapshottable for the Logging mock, which the #582 completeness guard requires of any provider field holding a memstore — without it a stop/start silently dropped every log group, log and ingested entry. logRecord's fields are exported so the record round-trips through the generic memstore helper, which serializes as JSON. Subscription filters, added to the shared driver upstream, are not an OCI Logging operation: OCI delivers log entries to another service through a Service Connector, so all three report Unimplemented naming that rather than accepting a filter nothing would honour. --- docs/coverage/oci/README.md | 2 +- docs/coverage/oci/logging.md | 5 +- docs/services.md | 23 +++-- providers/oci/logging/group.go | 8 +- providers/oci/logging/ingestion.go | 16 ++-- providers/oci/logging/log.go | 28 +++--- providers/oci/logging/logging.go | 16 ++-- providers/oci/logging/logging_test.go | 16 ++++ providers/oci/logging/portable.go | 59 ++++++++---- providers/oci/logging/search.go | 14 +-- providers/oci/logging/snapshot.go | 81 +++++++++++++++++ providers/oci/logging/snapshot_test.go | 120 +++++++++++++++++++++++++ 12 files changed, 321 insertions(+), 67 deletions(-) create mode 100644 providers/oci/logging/snapshot.go create mode 100644 providers/oci/logging/snapshot_test.go diff --git a/docs/coverage/oci/README.md b/docs/coverage/oci/README.md index 3b00fbc44..9ea4574b1 100644 --- a/docs/coverage/oci/README.md +++ b/docs/coverage/oci/README.md @@ -6,7 +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` | 14 | +| [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 index f3dc53298..96b27dc04 100644 --- a/docs/coverage/oci/logging.md +++ b/docs/coverage/oci/logging.md @@ -3,7 +3,7 @@ OCI's `logging` service · portable interface `driver.Logging` · [OCI index](./README.md) -## Operations (14) +## Operations (17) | Operation | Description | | --- | --- | @@ -12,7 +12,9 @@ OCI's `logging` service · portable interface `driver.Logging` · [OCI index](./ | `DeleteLogGroup` | | | `DeleteLogStream` | | | `DeleteMetricFilter` | | +| `DeleteSubscriptionFilter` | | | `DescribeMetricFilters` | | +| `DescribeSubscriptionFilters` | | | `FilterLogEvents` | | | `GetLogEvents` | | | `GetLogGroup` | | @@ -20,6 +22,7 @@ OCI's `logging` service · portable interface `driver.Logging` · [OCI index](./ | `ListLogStreams` | | | `PutLogEvents` | | | `PutMetricFilter` | | +| `PutSubscriptionFilter` | | | `UpdateLogGroup` | UpdateLogGroup replaces the mutable fields (retention, tags) of an | ## Not in scope diff --git a/docs/services.md b/docs/services.md index 6d0386fd0..ca966d69f 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 | **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 filters have no OCI equivalent and report `Unimplemented`) +**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,11 @@ 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 @@ -1515,9 +1518,17 @@ Not emulated: `/20200531/unifiedAgentConfigurations` and 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 -across the emulator, not per compartment, which is what lets the portable -driver address a group by name. +`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 index 6febca10a..d6b07b40e 100644 --- a/providers/oci/logging/group.go +++ b/providers/oci/logging/group.go @@ -140,7 +140,7 @@ func (m *Mock) DeleteGroup(_ context.Context, id string) error { } for _, rec := range m.logsIn(id) { - m.logs.Delete(rec.log.ID) + m.logs.Delete(rec.Log.ID) } m.groups.Delete(id) @@ -171,10 +171,10 @@ func (m *Mock) MoveGroup(_ context.Context, id, compartmentID string) error { g.TimeLastModified = m.now() for _, rec := range m.logsIn(id) { - rec.log.CompartmentID = compartmentID + rec.Log.CompartmentID = compartmentID - if rec.log.Configuration != nil { - rec.log.Configuration.CompartmentID = compartmentID + if rec.Log.Configuration != nil { + rec.Log.Configuration.CompartmentID = compartmentID } } diff --git a/providers/oci/logging/ingestion.go b/providers/oci/logging/ingestion.go index 141f269d5..756dfe804 100644 --- a/providers/oci/logging/ingestion.go +++ b/providers/oci/logging/ingestion.go @@ -20,20 +20,20 @@ func (m *Mock) PutLogs(ctx context.Context, logID string, batches []LogEntryBatc return cerrors.Newf(cerrors.NotFound, "log %q not found", logID) } - if rec.log.LogType != LogTypeCustom { + 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) + "log %q is a %s log; only a %s log accepts PutLogs", logID, rec.Log.LogType, LogTypeCustom) } - if !rec.log.IsEnabled { + 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 + compartmentID, groupID := rec.Log.CompartmentID, rec.Log.LogGroupID mon := m.monitoring m.mu.Unlock() @@ -53,8 +53,8 @@ func (m *Mock) ingest(rec *logRecord, batches []LogEntryBatch) (count, bytes int batch := &batches[i] for j := range batch.Entries { - entry := buildEntry(rec.log.ID, batch, &batch.Entries[j], ingested) - rec.entries = append(rec.entries, entry) + entry := buildEntry(rec.Log.ID, batch, &batch.Entries[j], ingested) + rec.Entries = append(rec.Entries, entry) count++ bytes += len(entry.Data) } @@ -102,8 +102,8 @@ func (m *Mock) Entries(_ context.Context, logID string) ([]LogEntry, error) { return nil, cerrors.Newf(cerrors.NotFound, "log %q not found", logID) } - out := make([]LogEntry, len(rec.entries)) - copy(out, rec.entries) + 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 index eb2d3fb92..85dcb19ac 100644 --- a/providers/oci/logging/log.go +++ b/providers/oci/logging/log.go @@ -61,7 +61,7 @@ func (m *Mock) createLog(groupID string, spec LogSpec) (*Log, error) { FreeformTags: copyTags(spec.FreeformTags), } - m.logs.Set(l.ID, &logRecord{log: l}) + m.logs.Set(l.ID, &logRecord{Log: l}) out := l @@ -124,7 +124,7 @@ func (m *Mock) GetLog(_ context.Context, groupID, logID string) (*Log, error) { return nil, err } - out := rec.log + out := rec.Log return &out, nil } @@ -145,8 +145,8 @@ func (m *Mock) ListLogs(_ context.Context, groupID string, f LogFilter) ([]Log, out := make([]Log, 0, len(recs)) for _, rec := range recs { - if matchesLogFilter(&rec.log, f) { - out = append(out, rec.log) + if matchesLogFilter(&rec.Log, f) { + out = append(out, rec.Log) } } @@ -191,39 +191,39 @@ func (m *Mock) UpdateLog(_ context.Context, groupID, logID string, u LogUpdate) return nil, err } - if u.DisplayName != nil && *u.DisplayName != rec.log.DisplayName { + 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 + rec.Log.DisplayName = *u.DisplayName } if u.IsEnabled != nil { - rec.log.IsEnabled = *u.IsEnabled + rec.Log.IsEnabled = *u.IsEnabled } if u.RetentionDuration != nil { - rec.log.RetentionDuration = *u.RetentionDuration + rec.Log.RetentionDuration = *u.RetentionDuration } if u.Configuration != nil { - cfg, cfgErr := normalizeConfiguration(rec.log.LogType, u.Configuration, rec.log.CompartmentID) + cfg, cfgErr := normalizeConfiguration(rec.Log.LogType, u.Configuration, rec.Log.CompartmentID) if cfgErr != nil { return nil, cfgErr } - rec.log.Configuration = cfg + rec.Log.Configuration = cfg } if u.FreeformTags != nil { - rec.log.FreeformTags = copyTags(u.FreeformTags) + rec.Log.FreeformTags = copyTags(u.FreeformTags) } - rec.log.TimeLastModified = m.now() + rec.Log.TimeLastModified = m.now() - out := rec.log + out := rec.Log return &out, nil } @@ -247,7 +247,7 @@ func (m *Mock) DeleteLog(_ context.Context, groupID, logID string) error { // 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 { + if !ok || rec.Log.LogGroupID != groupID { return nil, cerrors.Newf(cerrors.NotFound, "log %q not found in log group %q", logID, groupID) } diff --git a/providers/oci/logging/logging.go b/providers/oci/logging/logging.go index bfee6a418..8a9bdcd7b 100644 --- a/providers/oci/logging/logging.go +++ b/providers/oci/logging/logging.go @@ -186,10 +186,12 @@ type LogFilter struct { LifecycleState string } -// logRecord is a log and the entries ingested into it. +// 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 + Log Log + Entries []LogEntry } // Mock is an in-memory mock implementation of the OCI Logging service. @@ -289,7 +291,7 @@ func (m *Mock) portableGroupByName(name string) (*LogGroup, error) { // 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 { + if rec.Log.LogGroupID == groupID && rec.Log.DisplayName == name { return rec, true } } @@ -303,7 +305,7 @@ func (m *Mock) logsIn(groupID string) []*logRecord { var out []*logRecord for _, rec := range m.logs.SortedValues() { - if rec.log.LogGroupID == groupID { + if rec.Log.LogGroupID == groupID { out = append(out, rec) } } @@ -316,8 +318,8 @@ 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)) + for i := range rec.Entries { + total += int64(len(rec.Entries[i].Data)) } } diff --git a/providers/oci/logging/logging_test.go b/providers/oci/logging/logging_test.go index a8e3f2175..8fa4da84c 100644 --- a/providers/oci/logging/logging_test.go +++ b/providers/oci/logging/logging_test.go @@ -628,6 +628,22 @@ func TestMetricFiltersAreNotAnOCIOperation(t *testing.T) { 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) { diff --git a/providers/oci/logging/portable.go b/providers/oci/logging/portable.go index 0fb6b2582..3f29c4f55 100644 --- a/providers/oci/logging/portable.go +++ b/providers/oci/logging/portable.go @@ -14,8 +14,11 @@ import ( // group, a log stream is a CUSTOM log inside it, and a log event is an // ingested log entry. -// viaServiceConnector is what OCI does instead of a metric filter. -const viaServiceConnector = "a Service Connector routes matching log entries into Monitoring" +// 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 @@ -103,7 +106,7 @@ func (m *Mock) DeleteLogGroup(_ context.Context, name string) error { } for _, rec := range m.logsIn(g.ID) { - m.logs.Delete(rec.log.ID) + m.logs.Delete(rec.Log.ID) } m.groups.Delete(g.ID) @@ -176,7 +179,7 @@ func (m *Mock) DeleteLogStream(_ context.Context, logGroup, streamName string) e return err } - m.logs.Delete(rec.log.ID) + m.logs.Delete(rec.Log.ID) return nil } @@ -217,7 +220,7 @@ func (m *Mock) PutLogEvents(ctx context.Context, logGroup, streamName string, ev } count, bytes := m.ingest(rec, []LogEntryBatch{batch}) - compartmentID, groupID, logID := rec.log.CompartmentID, rec.log.LogGroupID, rec.log.ID + compartmentID, groupID, logID := rec.Log.CompartmentID, rec.Log.LogGroupID, rec.Log.ID mon := m.monitoring m.mu.Unlock() @@ -246,8 +249,8 @@ func (m *Mock) GetLogEvents(_ context.Context, input *driver.LogQueryInput) ([]d out := make([]driver.LogEvent, 0, limit) for _, rec := range recs { - for i := range rec.entries { - e := &rec.entries[i] + for i := range rec.Entries { + e := &rec.Entries[i] if !inWindow(e, input.StartTime, input.EndTime) || !containsPattern(e.Data, input.Pattern) { continue } @@ -284,14 +287,14 @@ func (m *Mock) FilterLogEvents( out := make([]driver.FilteredLogEvent, 0, limit) for _, rec := range recs { - for i := range rec.entries { - e := &rec.entries[i] + 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, + LogStream: rec.Log.DisplayName, Timestamp: e.Time, Message: e.Data, }) @@ -307,23 +310,41 @@ func (m *Mock) FilterLogEvents( // PutMetricFilter is not an OCI Logging operation. func (*Mock) PutMetricFilter(_ context.Context, _ *driver.MetricFilterConfig) error { - return unsupported("PutMetricFilter") + return unsupported("PutMetricFilter", viaServiceConnector) } // DeleteMetricFilter is not an OCI Logging operation. func (*Mock) DeleteMetricFilter(_ context.Context, _, _ string) error { - return unsupported("DeleteMetricFilter") + return unsupported("DeleteMetricFilter", viaServiceConnector) } // DescribeMetricFilters is not an OCI Logging operation. func (*Mock) DescribeMetricFilters(_ context.Context, _ string) ([]driver.MetricFilterInfo, error) { - return nil, unsupported("DescribeMetricFilters") + 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. -func unsupported(operation string) error { +// 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, viaServiceConnector) + operation, instead) } // portableLog resolves a log by group and log display name. The caller holds mu. @@ -377,9 +398,9 @@ func (m *Mock) toLogGroupInfo(g *LogGroup) driver.LogGroupInfo { // 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) + 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 diff --git a/providers/oci/logging/search.go b/providers/oci/logging/search.go index 752e190c8..40cb94ac8 100644 --- a/providers/oci/logging/search.go +++ b/providers/oci/logging/search.go @@ -154,23 +154,23 @@ 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) { + 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] + 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) { + if q.matches(e, g, &rec.Log) { out = append(out, SearchEntry{ LogEntry: *e, CompartmentID: g.CompartmentID, - LogGroupID: rec.log.LogGroupID, - LogName: rec.log.DisplayName, + LogGroupID: rec.Log.LogGroupID, + LogName: rec.Log.DisplayName, }) } } 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) +} From 0ff5c4ca8fdec018334fabfa980ceb4024f87067 Mon Sep 17 00:00:00 2001 From: arunesh-j Date: Tue, 6 Oct 2026 00:35:34 +0530 Subject: [PATCH 4/5] docs(coverage): regenerate after rebase onto development --- docs/coverage/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/coverage/README.md b/docs/coverage/README.md index 3a049805b..8d8ec1ff1 100644 --- a/docs/coverage/README.md +++ b/docs/coverage/README.md @@ -115,7 +115,7 @@ code does not implement. Machine-readable: [`coverage.json`](./coverage.json). | `loadtesting` | - | [LoadTesting](./azure/loadtesting.md) | - | - | 8 | | `location` | [Location](./aws/location.md) | - | - | - | 28 | | `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 | | `logic` | - | [Logic](./azure/logic.md) | - | - | 14 | | `lro` | - | - | [LRO](./gcp/lro.md) | - | 1 | | `managedcassandra` | - | [ManagedCassandra](./azure/managedcassandra.md) | - | - | 15 | From e3e7530176296f7639817eb3145d9d7c38df8830 Mon Sep 17 00:00:00 2001 From: arunesh-j Date: Tue, 6 Oct 2026 01:10:32 +0530 Subject: [PATCH 5/5] fix(oci): match OCI Logging's wire contract to the real oci-go-sdk Serve PutLogs at /20200831, the loggingingestion client's BasePath; the handler claimed /20200601, so a real client's PutLogs got 501. Decode ChangeLogGroupCompartment's target from compartmentId, the ChangeLogGroupCompartmentDetails field, and record the move as MOVE_LOG_GROUP, a value OperationTypesEnum defines. A new SDK-contract test pins these as literals copied from the SDK, not the handler's constants. Serve ChangeLogLogGroup (POST .../logs/{logId}/actions/changeLogGroup, recorded as MOVE_LOG), which fell outside the parsed path depth and got 501. Fix a data race: a read returned a Log sharing its *LogConfiguration with the store, which MoveGroup then rewrote in place. Every read now returns a deep copy, and moves replace the configuration rather than mutate it. Reject a log group created in, or moved into, a compartment that does not exist with 404 NotAuthorizedOrNotFound, wired from Identity as VCN is. Match OCI where it differs: deleting a group that still holds logs is 409 IncorrectState (the portable DeleteLogGroup still cascades); retentionDuration must be 30-180 days in 30-day steps; a CUSTOM log carries no synthesized OCISERVICE source; a log reports its tenancyId; a batch missing source, type or defaultlogentrytime is 400 and ingests nothing; UpdateLog validates every field before applying any. SearchLogs now pages with limit/page and returns opc-next-page, and a search scoped to the tenancy OCID finds the root compartment's logs. A page cursor this API never minted is 400 rather than a silent restart at zero. --- docs/services.md | 41 +- providers/oci/logging/group.go | 26 +- providers/oci/logging/ingestion.go | 25 +- providers/oci/logging/log.go | 130 +++++- providers/oci/logging/logging.go | 33 ++ providers/oci/logging/logging_test.go | 429 +++++++++++++++++++- providers/oci/logging/portable.go | 2 +- providers/oci/logging/query.go | 5 +- providers/oci/logging/race_test.go | 75 +++- providers/oci/logging/search.go | 89 ++-- providers/oci/logging/snapshot_test.go | 4 +- server/oci/logging/group.go | 23 +- server/oci/logging/handler.go | 97 ++++- server/oci/logging/handler_test.go | 344 ++++++++++++++-- server/oci/logging/log.go | 61 ++- server/oci/logging/search.go | 11 + server/oci/logging/types.go | 11 +- server/oci/logging_compartment_gate_test.go | 95 +++++ server/oci/oci.go | 12 +- 19 files changed, 1353 insertions(+), 160 deletions(-) create mode 100644 server/oci/logging_compartment_gate_test.go diff --git a/docs/services.md b/docs/services.md index ca966d69f..8b2bd3de0 100644 --- a/docs/services.md +++ b/docs/services.md @@ -1488,25 +1488,48 @@ ingestion plane alone; the control plane nests logs under their log group. | `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` | +| `ChangeLogLogGroup` | `POST /20200531/logGroups/{logGroupId}/logs/{logId}/actions/changeLogGroup` | +| `PutLogs` | `POST /20200831/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 version prefixes are the oci-go-sdk clients' `BasePath`s — `20200531` for +`logging`, `20200831` for `loggingingestion`, `20190909` for `loggingsearch` — +and the handler tests pin them as SDK literals rather than handler constants. + +`ListLogGroups` requires `compartmentId`; it, `ListLogs` and `SearchLogs` +paginate with `limit` / `page`, returning the cursor as `opc-next-page`. A +`page` this API never returned is rejected with `400` rather than silently +restarting at the first 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. +OCID comes back on the work request, whose `operationType` is one of the SDK's +`OperationTypesEnum` values (`CREATE_LOG_GROUP`, `MOVE_LOG_GROUP`, `MOVE_LOG`, +…). Ingestion and search are synchronous. + +When the server wires Identity, creating a log group in, or moving one into, a +compartment that does not exist is `404 NotAuthorizedOrNotFound`, as in VCN. +`ChangeLogGroupCompartment` reads the target from `compartmentId`, the +`ChangeLogGroupCompartmentDetails` field. Deleting a log group that still holds +logs is `409 IncorrectState`, as real OCI requires the group empty; the +portable `DeleteLogGroup` is the path that cascades. A log's +`retentionDuration` must be 30 to 180 days in 30-day steps; any other value is +`400`. A log response carries the configured `tenancyId`. + +A CUSTOM log takes entries from `PutLogs` and has no service source: it carries +a `configuration` only when the caller supplied one, never a synthesized +`sourceType`. 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. Every `LogEntryBatch` must carry `source`, `type` +and `defaultlogentrytime`, which the SDK marks mandatory; a call with a batch +missing one is `400` naming the batch and field, and ingests nothing. 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 +search targets. The tenancy OCID addresses the root compartment, where log +groups created without one land. 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, diff --git a/providers/oci/logging/group.go b/providers/oci/logging/group.go index d6b07b40e..197ca473c 100644 --- a/providers/oci/logging/group.go +++ b/providers/oci/logging/group.go @@ -47,7 +47,7 @@ func (m *Mock) createGroup(spec LogGroupSpec) (*LogGroup, error) { m.groups.Set(g.ID, g) - out := *g + out := g.clone() return &out, nil } @@ -62,7 +62,7 @@ func (m *Mock) GetGroup(_ context.Context, id string) (*LogGroup, error) { return nil, cerrors.Newf(cerrors.NotFound, "log group %q not found", id) } - out := *g + out := g.clone() return &out, nil } @@ -74,7 +74,7 @@ func (m *Mock) ListGroups(_ context.Context, compartmentID, displayName string) m.mu.RLock() defer m.mu.RUnlock() - if err := requireName(compartmentID, "compartmentId"); err != nil { + if err := requireName(compartmentID, compartmentIDName); err != nil { return nil, err } @@ -89,7 +89,7 @@ func (m *Mock) ListGroups(_ context.Context, compartmentID, displayName string) continue } - out = append(out, *g) + out = append(out, g.clone()) } return out, nil @@ -124,13 +124,14 @@ func (m *Mock) UpdateGroup(_ context.Context, id string, u LogGroupUpdate) (*Log g.TimeLastModified = m.now() - out := *g + out := g.clone() return &out, nil } -// DeleteGroup deletes a log group and the logs inside it. Deleting the group -// discards their entries with them. +// DeleteGroup deletes an empty log group. Real OCI refuses to delete a group +// that still holds logs, so this does too; the portable DeleteLogGroup is the +// path that cascades. func (m *Mock) DeleteGroup(_ context.Context, id string) error { m.mu.Lock() defer m.mu.Unlock() @@ -139,8 +140,9 @@ func (m *Mock) DeleteGroup(_ context.Context, id string) error { return cerrors.Newf(cerrors.NotFound, "log group %q not found", id) } - for _, rec := range m.logsIn(id) { - m.logs.Delete(rec.Log.ID) + if n := len(m.logsIn(id)); n > 0 { + return cerrors.Newf(cerrors.FailedPrecondition, + "log group %q still holds %d log(s); delete them before the group", id, n) } m.groups.Delete(id) @@ -153,7 +155,7 @@ func (m *Mock) MoveGroup(_ context.Context, id, compartmentID string) error { m.mu.Lock() defer m.mu.Unlock() - if err := requireName(compartmentID, "compartmentId"); err != nil { + if err := requireName(compartmentID, compartmentIDName); err != nil { return err } @@ -174,7 +176,9 @@ func (m *Mock) MoveGroup(_ context.Context, id, compartmentID string) error { rec.Log.CompartmentID = compartmentID if rec.Log.Configuration != nil { - rec.Log.Configuration.CompartmentID = compartmentID + cfg := *rec.Log.Configuration + cfg.CompartmentID = compartmentID + rec.Log.Configuration = &cfg } } diff --git a/providers/oci/logging/ingestion.go b/providers/oci/logging/ingestion.go index 756dfe804..6f19f533b 100644 --- a/providers/oci/logging/ingestion.go +++ b/providers/oci/logging/ingestion.go @@ -12,6 +12,10 @@ import ( // 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 { + if err := validateBatches(batches); err != nil { + return err + } + m.mu.Lock() rec, ok := m.logs.Get(logID) @@ -37,7 +41,7 @@ func (m *Mock) PutLogs(ctx context.Context, logID string, batches []LogEntryBatc mon := m.monitoring m.mu.Unlock() - dims := map[string]string{"logId": logID, "logGroupId": groupID, "compartmentId": compartmentID} + dims := ingestionDims(compartmentID, groupID, logID) m.emitMetric(ctx, mon, "IngestedLogEntries", float64(count), dims) m.emitMetric(ctx, mon, "IngestedLogBytes", float64(bytes), dims) @@ -107,3 +111,22 @@ func (m *Mock) Entries(_ context.Context, logID string) ([]LogEntry, error) { return out, nil } + +// validateBatches rejects a batch missing a field OCI's LogEntryBatch makes +// mandatory, before any batch is ingested, so a rejected call stores nothing. +func validateBatches(batches []LogEntryBatch) error { + for i := range batches { + b := &batches[i] + + switch { + case b.Source == "": + return cerrors.Newf(cerrors.InvalidArgument, "logEntryBatches[%d].source is required", i) + case b.Type == "": + return cerrors.Newf(cerrors.InvalidArgument, "logEntryBatches[%d].type is required", i) + case b.DefaultLogEntryTime.IsZero(): + return cerrors.Newf(cerrors.InvalidArgument, "logEntryBatches[%d].defaultlogentrytime is required", i) + } + } + + return nil +} diff --git a/providers/oci/logging/log.go b/providers/oci/logging/log.go index 85dcb19ac..e0e02df96 100644 --- a/providers/oci/logging/log.go +++ b/providers/oci/logging/log.go @@ -43,11 +43,14 @@ func (m *Mock) createLog(groupID string, spec LogSpec) (*Log, error) { retention := spec.RetentionDuration if retention == 0 { retention = g.RetentionDays + } else if err := validateRetention(retention); err != nil { + return nil, err } now := m.now() l := Log{ ID: m.newOCID(typeLog), + TenancyID: m.opts.TenancyOCID, LogGroupID: groupID, CompartmentID: g.CompartmentID, DisplayName: spec.DisplayName, @@ -63,7 +66,7 @@ func (m *Mock) createLog(groupID string, spec LogSpec) (*Log, error) { m.logs.Set(l.ID, &logRecord{Log: l}) - out := l + out := l.clone() return &out, nil } @@ -93,11 +96,10 @@ func normalizeConfiguration(logType string, cfg *LogConfiguration, compartmentID } } + // A CUSTOM log has no service source: it carries a configuration only + // when the caller supplied one, and never a synthesized source type. if cfg == nil { - return &LogConfiguration{ - CompartmentID: compartmentID, - Source: LogSource{SourceType: sourceTypeOCIService}, - }, nil + return nil, nil //nolint:nilnil // no configuration is a valid CUSTOM log } out := *cfg @@ -107,7 +109,7 @@ func normalizeConfiguration(logType string, cfg *LogConfiguration, compartmentID out.CompartmentID = compartmentID } - if out.Source.SourceType == "" { + if logType == LogTypeService && out.Source.SourceType == "" { out.Source.SourceType = sourceTypeOCIService } @@ -124,7 +126,7 @@ func (m *Mock) GetLog(_ context.Context, groupID, logID string) (*Log, error) { return nil, err } - out := rec.Log + out := rec.Log.clone() return &out, nil } @@ -146,7 +148,7 @@ func (m *Mock) ListLogs(_ context.Context, groupID string, f LogFilter) ([]Log, for _, rec := range recs { if matchesLogFilter(&rec.Log, f) { - out = append(out, rec.Log) + out = append(out, rec.Log.clone()) } } @@ -191,12 +193,14 @@ func (m *Mock) UpdateLog(_ context.Context, groupID, logID string, u LogUpdate) 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) - } + // Validate every field before applying any, so a rejected update leaves + // the log as it was. + rename, cfg, err := m.validateLogUpdate(rec, groupID, &u) + if err != nil { + return nil, err + } + if rename { rec.Log.DisplayName = *u.DisplayName } @@ -208,12 +212,7 @@ func (m *Mock) UpdateLog(_ context.Context, groupID, logID string, u LogUpdate) 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 - } - + if cfg != nil { rec.Log.Configuration = cfg } @@ -223,11 +222,83 @@ func (m *Mock) UpdateLog(_ context.Context, groupID, logID string, u LogUpdate) rec.Log.TimeLastModified = m.now() - out := rec.Log + out := rec.Log.clone() return &out, nil } +// validateLogUpdate checks an update against the log it applies to, reporting +// whether it renames the log and the normalized configuration it sets, if any. +// The caller holds mu. +func (m *Mock) validateLogUpdate( + rec *logRecord, groupID string, u *LogUpdate, +) (rename bool, cfg *LogConfiguration, err error) { + rename = u.DisplayName != nil && *u.DisplayName != rec.Log.DisplayName + if rename { + if _, taken := m.logByName(groupID, *u.DisplayName); taken { + return false, nil, cerrors.Newf(cerrors.AlreadyExists, + "log %q already exists in log group %q", *u.DisplayName, groupID) + } + } + + if u.RetentionDuration != nil { + if retErr := validateRetention(*u.RetentionDuration); retErr != nil { + return false, nil, retErr + } + } + + if u.Configuration != nil { + cfg, err = normalizeConfiguration(rec.Log.LogType, u.Configuration, rec.Log.CompartmentID) + if err != nil { + return false, nil, err + } + } + + return rename, cfg, nil +} + +// MoveLog moves a log, with its entries, into another log group. The log takes +// the target group's compartment. +func (m *Mock) MoveLog(_ context.Context, groupID, logID, targetGroupID string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if err := requireName(targetGroupID, "targetLogGroupId"); err != nil { + return err + } + + rec, err := m.findLog(groupID, logID) + if err != nil { + return err + } + + target, ok := m.groups.Get(targetGroupID) + if !ok { + return cerrors.Newf(cerrors.NotFound, "log group %q not found", targetGroupID) + } + + if targetGroupID == groupID { + return nil + } + + if _, taken := m.logByName(targetGroupID, rec.Log.DisplayName); taken { + return cerrors.Newf(cerrors.AlreadyExists, + "log %q already exists in log group %q", rec.Log.DisplayName, targetGroupID) + } + + rec.Log.LogGroupID = targetGroupID + rec.Log.CompartmentID = target.CompartmentID + rec.Log.TimeLastModified = m.now() + + if rec.Log.Configuration != nil { + cfg := *rec.Log.Configuration + cfg.CompartmentID = target.CompartmentID + rec.Log.Configuration = &cfg + } + + return nil +} + // DeleteLog deletes a log and the entries ingested into it. func (m *Mock) DeleteLog(_ context.Context, groupID, logID string) error { m.mu.Lock() @@ -253,3 +324,22 @@ func (m *Mock) findLog(groupID, logID string) (*logRecord, error) { return rec, nil } + +// Retention bounds OCI accepts for a log, in days. +const ( + minRetentionDays = 30 + maxRetentionDays = 180 + retentionStepDays = 30 +) + +// validateRetention rejects a retentionDuration OCI does not accept: 30 to 180 +// days, in 30-day steps. +func validateRetention(days int) error { + if days < minRetentionDays || days > maxRetentionDays || days%retentionStepDays != 0 { + return cerrors.Newf(cerrors.InvalidArgument, + "retentionDuration %d is not valid; OCI accepts %d to %d days in steps of %d", + days, minRetentionDays, maxRetentionDays, retentionStepDays) + } + + return nil +} diff --git a/providers/oci/logging/logging.go b/providers/oci/logging/logging.go index 8a9bdcd7b..250e6d5fb 100644 --- a/providers/oci/logging/logging.go +++ b/providers/oci/logging/logging.go @@ -60,6 +60,10 @@ const ( // metricNamespace is the OCI Monitoring namespace Logging publishes under. const metricNamespace = "oci_logging" +// compartmentIDName is OCI's name for a compartment, as a request parameter and +// as a metric dimension. +const compartmentIDName = "compartmentId" + // Compile-time check that Mock implements driver.Logging. var _ driver.Logging = (*Mock)(nil) @@ -100,6 +104,7 @@ type Log struct { ID string LogGroupID string CompartmentID string + TenancyID string DisplayName string LogType string IsEnabled bool @@ -111,6 +116,29 @@ type Log struct { FreeformTags map[string]string } +// clone returns a copy of the group that shares no map with it. +func (g *LogGroup) clone() LogGroup { + out := *g + out.FreeformTags = copyTags(g.FreeformTags) + + return out +} + +// clone returns a copy of the log that shares no pointer or map with it, so a +// value handed to a caller is not written by a later mutation of the store. +func (l *Log) clone() Log { + out := *l + out.FreeformTags = copyTags(l.FreeformTags) + + if l.Configuration != nil { + cfg := *l.Configuration + cfg.Source.Parameters = copyTags(l.Configuration.Source.Parameters) + out.Configuration = &cfg + } + + return out +} + // LogEntry is a single ingested log entry. type LogEntry struct { ID string @@ -326,6 +354,11 @@ func (m *Mock) storedBytes(groupID string) int64 { return total } +// ingestionDims are the dimensions OCI Logging's ingestion metrics carry. +func ingestionDims(compartmentID, groupID, logID string) map[string]string { + return map[string]string{"logId": logID, "logGroupId": groupID, compartmentIDName: compartmentID} +} + // emitMetric publishes one Logging metric. Called with mu released, so a // monitoring driver reaching back into this mock cannot deadlock. func (m *Mock) emitMetric( diff --git a/providers/oci/logging/logging_test.go b/providers/oci/logging/logging_test.go index 8fa4da84c..7129349c3 100644 --- a/providers/oci/logging/logging_test.go +++ b/providers/oci/logging/logging_test.go @@ -224,23 +224,40 @@ func TestUpdateGroup(t *testing.T) { }) } -func TestDeleteGroupRemovesItsLogs(t *testing.T) { +func TestDeleteGroupRequiresItEmpty(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.DeleteGroup(ctx, g.ID) + require.Error(t, err, "real OCI refuses to delete a group that still holds logs") + assert.Equal(t, cerrors.FailedPrecondition, cerrors.GetCode(err)) _, err = m.GetLog(ctx, g.ID, l.ID) + require.NoError(t, err, "a refused delete leaves the logs in place") + + require.NoError(t, m.DeleteLog(ctx, g.ID, l.ID)) + require.NoError(t, m.DeleteGroup(ctx, g.ID)) + + _, err = m.GetGroup(ctx, g.ID) assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) assert.Equal(t, cerrors.NotFound, cerrors.GetCode(m.DeleteGroup(ctx, g.ID))) } +func TestPortableDeleteLogGroupCascades(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.DeleteLogGroup(ctx, "app-logs")) + + _, err := m.GetLog(ctx, g.ID, l.ID) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err), "the portable delete takes the group's logs with it") +} + func TestMoveGroupCarriesItsLogs(t *testing.T) { ctx := context.Background() m := newMock(t) @@ -437,7 +454,7 @@ func TestPutLogs(t *testing.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{{ + require.NoError(t, m.PutLogs(ctx, l.ID, validBatches([]ocilogging.LogEntryBatch{{ Source: "host-a", Type: "custom", Entries: []ocilogging.LogEntryItem{ @@ -445,7 +462,7 @@ func TestPutLogs(t *testing.T) { {Data: "second"}, }, DefaultLogEntryTime: when.Add(time.Minute), - }})) + }}))) entries, err := m.Entries(ctx, l.ID) require.NoError(t, err) @@ -491,7 +508,7 @@ func TestPutLogs(t *testing.T) { 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"}}}}) + err = m.PutLogs(ctx, l.ID, validBatches([]ocilogging.LogEntryBatch{{Entries: []ocilogging.LogEntryItem{{Data: "x"}}}})) require.Error(t, err) assert.Equal(t, cerrors.FailedPrecondition, cerrors.GetCode(err)) }) @@ -796,7 +813,7 @@ func TestPortableReadLimitTruncates(t *testing.T) { entries[i] = ocilogging.LogEntryItem{Data: "line-" + strconv.Itoa(i), Time: searchWindowStart} } - require.NoError(t, m.PutLogs(ctx, l.ID, []ocilogging.LogEntryBatch{{Entries: entries}})) + require.NoError(t, m.PutLogs(ctx, l.ID, validBatches([]ocilogging.LogEntryBatch{{Entries: entries}}))) events, err := m.GetLogEvents(ctx, &driver.LogQueryInput{LogGroup: "app-logs", Limit: 2}) require.NoError(t, err) @@ -843,7 +860,7 @@ func newSearchFixture(t *testing.T) *searchFixture { 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{{ + require.NoError(t, m.PutLogs(ctx, f.stdout.ID, validBatches([]ocilogging.LogEntryBatch{{ Source: "host-a", Type: "com.oraclecloud.custom", Subject: "app", @@ -852,19 +869,19 @@ func newSearchFixture(t *testing.T) *searchFixture { {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{{ + require.NoError(t, m.PutLogs(ctx, f.stderr.ID, validBatches([]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{{ + require.NoError(t, m.PutLogs(ctx, f.otherIn.ID, validBatches([]ocilogging.LogEntryBatch{{ Source: "host-c", Entries: []ocilogging.LogEntryItem{{ID: "e-50", Data: "audit line", Time: at(50)}}, - }})) + }}))) return f } @@ -986,11 +1003,11 @@ func TestSearchSortIsStableOnEqualTimes(t *testing.T) { 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{ + require.NoError(t, m.PutLogs(ctx, l.ID, validBatches([]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`, @@ -1333,7 +1350,7 @@ func TestUpdateLogFields(t *testing.T) { }) 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") + assert.Empty(t, withCfg.Configuration.Source.SourceType, "a CUSTOM log is given no service source type") sl, err := m.CreateLog(ctx, g.ID, ocilogging.LogSpec{ DisplayName: "flowlogs", @@ -1360,9 +1377,9 @@ func TestIngestionPublishesMetrics(t *testing.T) { mon := &recordingMonitoring{} m.SetMonitoring(mon) - require.NoError(t, m.PutLogs(ctx, l.ID, []ocilogging.LogEntryBatch{{ + require.NoError(t, m.PutLogs(ctx, l.ID, validBatches([]ocilogging.LogEntryBatch{{ Entries: []ocilogging.LogEntryItem{{Data: "hello", Time: searchWindowStart}}, - }})) + }}))) names := make([]string, 0, len(mon.data)) for _, d := range mon.data { @@ -1382,9 +1399,9 @@ func TestIngestionSurvivesAMonitoringFailure(t *testing.T) { m.SetMonitoring(&recordingMonitoring{err: errFailedPublish}) - require.NoError(t, m.PutLogs(ctx, l.ID, []ocilogging.LogEntryBatch{{ + require.NoError(t, m.PutLogs(ctx, l.ID, validBatches([]ocilogging.LogEntryBatch{{ Entries: []ocilogging.LogEntryItem{{Data: "hello", Time: searchWindowStart}}, - }}), "metric publication is best-effort") + }})), "metric publication is best-effort") entries, err := m.Entries(ctx, l.ID) require.NoError(t, err) @@ -1412,3 +1429,371 @@ func (r *recordingMonitoring) PutMetricData(_ context.Context, data []mondriver. return nil } + +// TestReadsDoNotAliasTheStore guards the copy every read makes: a caller +// mutating what it was handed must not change what the mock holds. +func TestReadsDoNotAliasTheStore(t *testing.T) { + ctx := context.Background() + m := newMock(t) + + g, err := m.CreateGroup(ctx, ocilogging.LogGroupSpec{ + CompartmentID: compartmentA, DisplayName: "app-logs", FreeformTags: map[string]string{"env": "dev"}, + }) + require.NoError(t, err) + + l, err := m.CreateLog(ctx, g.ID, ocilogging.LogSpec{ + DisplayName: "flowlogs", + LogType: ocilogging.LogTypeService, + FreeformTags: map[string]string{"env": "dev"}, + Configuration: &ocilogging.LogConfiguration{Source: ocilogging.LogSource{ + Service: "flowlogs", Resource: "ocid1.subnet.oc1.iad.a", Category: "all", + Parameters: map[string]string{"k": "v"}, + }}, + }) + require.NoError(t, err) + + gotGroup, err := m.GetGroup(ctx, g.ID) + require.NoError(t, err) + gotGroup.FreeformTags["env"] = "changed" + + gotLog, err := m.GetLog(ctx, g.ID, l.ID) + require.NoError(t, err) + gotLog.FreeformTags["env"] = "changed" + gotLog.Configuration.CompartmentID = "changed" + gotLog.Configuration.Source.Parameters["k"] = "changed" + + againGroup, err := m.GetGroup(ctx, g.ID) + require.NoError(t, err) + assert.Equal(t, "dev", againGroup.FreeformTags["env"]) + + againLog, err := m.GetLog(ctx, g.ID, l.ID) + require.NoError(t, err) + assert.Equal(t, "dev", againLog.FreeformTags["env"]) + assert.Equal(t, compartmentA, againLog.Configuration.CompartmentID) + assert.Equal(t, "v", againLog.Configuration.Source.Parameters["k"]) +} + +func TestCustomLogHasNoSynthesizedSource(t *testing.T) { + ctx := context.Background() + m := newMock(t) + g := newGroup(t, m, compartmentA, "app-logs") + + l := newCustomLog(t, m, g.ID, "stdout") + assert.Nil(t, l.Configuration, "a CUSTOM log created without a configuration carries none") + + got, err := m.GetLog(ctx, g.ID, l.ID) + require.NoError(t, err) + assert.Nil(t, got.Configuration) + + svc, 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", Category: "all", + }}, + }) + require.NoError(t, err) + assert.Equal(t, "OCISERVICE", svc.Configuration.Source.SourceType, "a SERVICE log defaults the only source type OCI defines") +} + +func TestRetentionDurationIsValidated(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + days int + expectCode cerrors.Code + }{ + {name: "30 is the minimum", days: 30}, + {name: "a 30-day step", days: 90}, + {name: "180 is the maximum", days: 180}, + {name: "below the minimum", days: 7, expectCode: cerrors.InvalidArgument}, + {name: "not a 30-day step", days: 45, expectCode: cerrors.InvalidArgument}, + {name: "above the maximum", days: 210, expectCode: cerrors.InvalidArgument}, + {name: "negative", days: -30, 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") + + _, createErr := m.CreateLog(ctx, g.ID, ocilogging.LogSpec{ + DisplayName: "created", RetentionDuration: tc.days, + }) + + l := newCustomLog(t, m, g.ID, "updated") + name := "renamed" + days := tc.days + _, updateErr := m.UpdateLog(ctx, g.ID, l.ID, ocilogging.LogUpdate{ + DisplayName: &name, RetentionDuration: &days, + }) + + if tc.expectCode == cerrors.OK { + require.NoError(t, createErr) + require.NoError(t, updateErr) + + return + } + + assert.Equal(t, tc.expectCode, cerrors.GetCode(createErr)) + assert.Equal(t, tc.expectCode, cerrors.GetCode(updateErr)) + + got, err := m.GetLog(ctx, g.ID, l.ID) + require.NoError(t, err) + assert.Equal(t, "updated", got.DisplayName, "a rejected update applies none of its fields") + }) + } +} + +// validBatches fills the fields OCI's LogEntryBatch makes mandatory where a +// test leaves them unset, so a test states only what it is about. The default +// entry time is the fake clock's now, which is when an untimed entry lands. +func validBatches(batches []ocilogging.LogEntryBatch) []ocilogging.LogEntryBatch { + for i := range batches { + if batches[i].Source == "" { + batches[i].Source = "test-host" + } + + if batches[i].Type == "" { + batches[i].Type = "com.oraclecloud.custom" + } + + if batches[i].DefaultLogEntryTime.IsZero() { + batches[i].DefaultLogEntryTime = time.Date(2026, 8, 8, 12, 0, 0, 0, time.UTC) + } + } + + return batches +} + +func TestPutLogsRequiresTheMandatoryBatchFields(t *testing.T) { + ctx := context.Background() + when := time.Date(2026, 8, 8, 11, 0, 0, 0, time.UTC) + full := ocilogging.LogEntryBatch{ + Source: "host-a", Type: "custom", DefaultLogEntryTime: when, + Entries: []ocilogging.LogEntryItem{{Data: "x"}}, + } + + tests := []struct { + name string + mutate func(b *ocilogging.LogEntryBatch) + contains string + }{ + {name: "source", mutate: func(b *ocilogging.LogEntryBatch) { b.Source = "" }, contains: "source is required"}, + {name: "type", mutate: func(b *ocilogging.LogEntryBatch) { b.Type = "" }, contains: "type is required"}, + { + name: "defaultlogentrytime", + mutate: func(b *ocilogging.LogEntryBatch) { b.DefaultLogEntryTime = time.Time{} }, + contains: "defaultlogentrytime is required", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + m := newMock(t) + g := newGroup(t, m, compartmentA, "app-logs") + l := newCustomLog(t, m, g.ID, "stdout") + + bad := full + tc.mutate(&bad) + + // The valid batch first: a rejected call must store none of it. + err := m.PutLogs(ctx, l.ID, []ocilogging.LogEntryBatch{full, bad}) + require.Error(t, err) + assert.Equal(t, cerrors.InvalidArgument, cerrors.GetCode(err)) + assert.Contains(t, err.Error(), tc.contains) + assert.Contains(t, err.Error(), "logEntryBatches[1]", "the error names the batch") + + entries, entryErr := m.Entries(ctx, l.ID) + require.NoError(t, entryErr) + assert.Empty(t, entries, "a rejected call ingests nothing") + }) + } +} + +func TestMoveLog(t *testing.T) { + ctx := context.Background() + + t.Run("the log and its entries follow it into the target group", func(t *testing.T) { + m := newMock(t) + src := newGroup(t, m, compartmentA, "src") + dst := newGroup(t, m, compartmentB, "dst") + l := newCustomLog(t, m, src.ID, "stdout") + + require.NoError(t, m.PutLogs(ctx, l.ID, validBatches([]ocilogging.LogEntryBatch{{ + Entries: []ocilogging.LogEntryItem{{Data: "kept"}}, + }}))) + + require.NoError(t, m.MoveLog(ctx, src.ID, l.ID, dst.ID)) + + got, err := m.GetLog(ctx, dst.ID, l.ID) + require.NoError(t, err) + assert.Equal(t, dst.ID, got.LogGroupID) + assert.Equal(t, compartmentB, got.CompartmentID, "a moved log takes its new group's compartment") + + _, err = m.GetLog(ctx, src.ID, l.ID) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) + + entries, err := m.Entries(ctx, l.ID) + require.NoError(t, err) + assert.Len(t, entries, 1) + }) + + t.Run("a service log's configuration follows the compartment", func(t *testing.T) { + m := newMock(t) + src := newGroup(t, m, compartmentA, "src") + dst := newGroup(t, m, compartmentB, "dst") + + l, err := m.CreateLog(ctx, src.ID, ocilogging.LogSpec{ + DisplayName: "flowlogs", LogType: ocilogging.LogTypeService, + Configuration: &ocilogging.LogConfiguration{Source: ocilogging.LogSource{ + Service: "flowlogs", Resource: "ocid1.subnet.oc1.iad.a", Category: "all", + }}, + }) + require.NoError(t, err) + + require.NoError(t, m.MoveLog(ctx, src.ID, l.ID, dst.ID)) + + got, err := m.GetLog(ctx, dst.ID, l.ID) + require.NoError(t, err) + assert.Equal(t, compartmentB, got.Configuration.CompartmentID) + assert.Equal(t, compartmentA, l.Configuration.CompartmentID, "the value handed out earlier is unchanged") + }) + + t.Run("moving into its own group is a no-op", func(t *testing.T) { + m := newMock(t) + g := newGroup(t, m, compartmentA, "src") + l := newCustomLog(t, m, g.ID, "stdout") + + require.NoError(t, m.MoveLog(ctx, g.ID, l.ID, g.ID)) + }) + + tests := []struct { + name string + setup func(m *ocilogging.Mock) (groupID, logID, target string) + expectCode cerrors.Code + }{ + { + name: "no target", + setup: func(m *ocilogging.Mock) (string, string, string) { + g := newGroup(t, m, compartmentA, "src") + return g.ID, newCustomLog(t, m, g.ID, "stdout").ID, "" + }, + expectCode: cerrors.InvalidArgument, + }, + { + name: "unknown log", + setup: func(m *ocilogging.Mock) (string, string, string) { + g := newGroup(t, m, compartmentA, "src") + return g.ID, "ocid1.log.oc1.iad.missing", g.ID + }, + expectCode: cerrors.NotFound, + }, + { + name: "unknown target group", + setup: func(m *ocilogging.Mock) (string, string, string) { + g := newGroup(t, m, compartmentA, "src") + return g.ID, newCustomLog(t, m, g.ID, "stdout").ID, "ocid1.loggroup.oc1.iad.missing" + }, + expectCode: cerrors.NotFound, + }, + { + name: "the target already holds a log of that name", + setup: func(m *ocilogging.Mock) (string, string, string) { + src := newGroup(t, m, compartmentA, "src") + dst := newGroup(t, m, compartmentA, "dst") + newCustomLog(t, m, dst.ID, "stdout") + + return src.ID, newCustomLog(t, m, src.ID, "stdout").ID, dst.ID + }, + expectCode: cerrors.AlreadyExists, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + m := newMock(t) + groupID, logID, target := tc.setup(m) + + err := m.MoveLog(ctx, groupID, logID, target) + require.Error(t, err) + assert.Equal(t, tc.expectCode, cerrors.GetCode(err)) + }) + } +} + +func TestSearchOffsets(t *testing.T) { + ctx := context.Background() + f := newSearchFixture(t) + + page := func(offset, limit int) *ocilogging.SearchResult { + t.Helper() + + res, err := f.m.SearchLogs(ctx, ocilogging.SearchRequest{ + Query: `search "` + compartmentA + `"`, + TimeStart: searchWindowStart, + TimeEnd: searchWindowEnd, + Limit: limit, + Offset: offset, + }) + require.NoError(t, err) + + return res + } + + first := page(0, 3) + require.Len(t, first.Entries, 3) + assert.Equal(t, 3, first.NextOffset, "a truncated page says where the next starts") + + last := page(first.NextOffset, 3) + require.Len(t, last.Entries, 1) + assert.Equal(t, "e-40", last.Entries[0].ID) + assert.Zero(t, last.NextOffset, "the last page has no next") + + assert.Empty(t, page(100, 3).Entries, "an offset past the end is an empty page") + + _, err := f.m.SearchLogs(ctx, ocilogging.SearchRequest{ + Query: `search "` + compartmentA + `"`, + TimeStart: searchWindowStart, + TimeEnd: searchWindowEnd, + Offset: -1, + }) + assert.Equal(t, cerrors.InvalidArgument, cerrors.GetCode(err)) +} + +// TestSearchTheRootCompartmentByTenancy covers the default compartment: the +// tenancy is the root compartment, so a search scoped to the tenancy OCID must +// find what was logged there rather than refuse the OCID. +func TestSearchTheRootCompartmentByTenancy(t *testing.T) { + ctx := context.Background() + m := newMock(t) + + const tenancy = "ocid1.tenancy.oc1..aaaaaaaacloudemulocaltenancy" + + g := newGroup(t, m, tenancy, "root-logs") + l := newCustomLog(t, m, g.ID, "stdout") + + require.NoError(t, m.PutLogs(ctx, l.ID, validBatches([]ocilogging.LogEntryBatch{{ + Entries: []ocilogging.LogEntryItem{{ID: "e-root", Data: "x", Time: searchWindowStart}}, + }}))) + + for _, query := range []string{ + `search "` + tenancy + `"`, + `search "` + tenancy + `/` + g.ID + `/` + l.ID + `"`, + } { + res, err := m.SearchLogs(ctx, ocilogging.SearchRequest{ + Query: query, TimeStart: searchWindowStart, TimeEnd: searchWindowEnd, + }) + require.NoError(t, err, query) + require.Len(t, res.Entries, 1, query) + assert.Equal(t, "e-root", res.Entries[0].ID) + } + + _, err := m.SearchLogs(ctx, ocilogging.SearchRequest{ + Query: `search "` + tenancy + `/` + tenancy + `"`, + TimeStart: searchWindowStart, TimeEnd: searchWindowEnd, + }) + require.Error(t, err, "a tenancy names a compartment, not a log group") + assert.Contains(t, err.Error(), "is not a log group OCID") +} diff --git a/providers/oci/logging/portable.go b/providers/oci/logging/portable.go index 3f29c4f55..c49fb2ce4 100644 --- a/providers/oci/logging/portable.go +++ b/providers/oci/logging/portable.go @@ -224,7 +224,7 @@ func (m *Mock) PutLogEvents(ctx context.Context, logGroup, streamName string, ev mon := m.monitoring m.mu.Unlock() - dims := map[string]string{"logId": logID, "logGroupId": groupID, "compartmentId": compartmentID} + dims := ingestionDims(compartmentID, groupID, logID) m.emitMetric(ctx, mon, "IngestedLogEntries", float64(count), dims) m.emitMetric(ctx, mon, "IngestedLogBytes", float64(bytes), dims) diff --git a/providers/oci/logging/query.go b/providers/oci/logging/query.go index 8c01bb103..b9164a42f 100644 --- a/providers/oci/logging/query.go +++ b/providers/oci/logging/query.go @@ -9,6 +9,7 @@ import ( // OCID prefixes a search scope's segments must carry. const ( prefixCompartment = "ocid1.compartment." + prefixTenancy = "ocid1.tenancy." prefixLogGroup = "ocid1.loggroup." prefixLog = "ocid1.log." ) @@ -166,7 +167,9 @@ func parseScope(literal string) (searchScope, error) { var s searchScope for i, segment := range segments { - if !strings.HasPrefix(segment, prefixes[i]) { + // The tenancy is the root compartment, so it addresses one too. + isRoot := i == 0 && strings.HasPrefix(segment, prefixTenancy) + if !isRoot && !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]) diff --git a/providers/oci/logging/race_test.go b/providers/oci/logging/race_test.go index 206f583e5..85e5ff247 100644 --- a/providers/oci/logging/race_test.go +++ b/providers/oci/logging/race_test.go @@ -39,9 +39,9 @@ func TestConcurrentOperations(t *testing.T) { go func(i int) { defer wg.Done() - _ = m.PutLogs(ctx, logs[i], []ocilogging.LogEntryBatch{{ + _ = m.PutLogs(ctx, logs[i], validBatches([]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{}) @@ -96,15 +96,80 @@ func TestConcurrentCreateAndDelete(t *testing.T) { return } - _, _ = m.CreateLog(ctx, created.ID, ocilogging.LogSpec{DisplayName: "stdout", IsEnabled: true}) + l, logErr := m.CreateLog(ctx, created.ID, ocilogging.LogSpec{DisplayName: "stdout", IsEnabled: true}) + if logErr != nil { + return + } + _ = m.MoveGroup(ctx, created.ID, compartmentB) + _ = m.DeleteLog(ctx, created.ID, l.ID) _ = m.DeleteGroup(ctx, created.ID) }(i) } wg.Wait() - groups, err := m.ListGroups(ctx, compartmentA, "") + for _, c := range []string{compartmentA, compartmentB} { + groups, err := m.ListGroups(ctx, c, "") + require.NoError(t, err) + require.Empty(t, groups, "every group was emptied and deleted") + } +} + +// TestConcurrentMoveAndRead races a compartment move, which rewrites each log's +// configuration, against reads that hand that configuration back. A read must +// not share the stored configuration with the caller. +func TestConcurrentMoveAndRead(t *testing.T) { + ctx := context.Background() + 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", Category: "all", + }}, + }) require.NoError(t, err) - require.Empty(t, groups) + + var wg sync.WaitGroup + + wg.Add(1) + + go func() { + defer wg.Done() + + for i := range concurrency * 4 { + target := compartmentA + if i%2 == 0 { + target = compartmentB + } + + _ = m.MoveGroup(ctx, g.ID, target) + } + }() + + for range concurrency { + wg.Add(1) + + go func() { + defer wg.Done() + + for range 4 { + if got, getErr := m.GetLog(ctx, g.ID, l.ID); getErr == nil { + _ = got.Configuration.CompartmentID + } + + if logs, listErr := m.ListLogs(ctx, g.ID, ocilogging.LogFilter{}); listErr == nil { + for i := range logs { + _ = logs[i].Configuration.CompartmentID + } + } + } + }() + } + + wg.Wait() } diff --git a/providers/oci/logging/search.go b/providers/oci/logging/search.go index 40cb94ac8..2ec1875dc 100644 --- a/providers/oci/logging/search.go +++ b/providers/oci/logging/search.go @@ -13,10 +13,12 @@ import ( // SearchRequest is a loggingsearch query over a time range. type SearchRequest struct { - Query string - TimeStart time.Time - TimeEnd time.Time - Limit int + Query string + TimeStart time.Time + TimeEnd time.Time + Limit int + // Offset is how many matching entries to skip, the page cursor's position. + Offset int ReturnFieldInfo bool } @@ -39,6 +41,8 @@ type SearchField struct { type SearchResult struct { Entries []SearchEntry Fields []SearchField + // NextOffset is where the next page starts, or 0 when this is the last. + NextOffset int } // searchScope is one target of a search query's search clause: @@ -89,21 +93,36 @@ const ( fieldDatetime = "datetime" ) +// Record fields a where clause resolves, with the logContent. prefix stripped, +// and the one type every returned field reports. +const ( + fieldData = "data" + fieldID = "id" + fieldType = "type" + fieldSubject = "subject" + fieldSource = "source" + fieldOracleCompartment = "oracle.compartmentid" + fieldOracleLogGroup = "oracle.loggroupid" + fieldOracleLog = "oracle.logid" + fieldOracleIngested = "oracle.ingestedtime" + fieldTypeString = "STRING" +) + // 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"}, + {Name: "datetime", Type: fieldTypeString}, + {Name: "logContent.data", Type: fieldTypeString}, + {Name: "logContent.id", Type: fieldTypeString}, + {Name: "logContent.source", Type: fieldTypeString}, + {Name: "logContent.subject", Type: fieldTypeString}, + {Name: "logContent.time", Type: fieldTypeString}, + {Name: "logContent.type", Type: fieldTypeString}, + {Name: "logContent.oracle.compartmentid", Type: fieldTypeString}, + {Name: "logContent.oracle.loggroupid", Type: fieldTypeString}, + {Name: "logContent.oracle.logid", Type: fieldTypeString}, } // SearchLogs runs a search query over a time range — the loggingsearch data @@ -130,17 +149,27 @@ func (m *Mock) SearchLogs(_ context.Context, req SearchRequest) (*SearchResult, return nil, err } + if req.Offset < 0 { + return nil, cerrors.Newf(cerrors.InvalidArgument, "offset %d must not be negative", req.Offset) + } + 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[:0]} + + if req.Offset < len(matched) { + end := min(req.Offset+limit, len(matched)) + out.Entries = matched[req.Offset:end] + + if end < len(matched) { + out.NextOffset = end + } } - out := &SearchResult{Entries: matched} if req.ReturnFieldInfo { out.Fields = canonicalFields } @@ -236,10 +265,10 @@ func sortEntries(entries []SearchEntry, q *searchQuery) { // //nolint:gochecknoglobals // immutable field lookup table. var entryFields = map[string]struct{}{ - "data": {}, "id": {}, "type": {}, "subject": {}, "source": {}, + fieldData: {}, fieldID: {}, fieldType: {}, fieldSubject: {}, fieldSource: {}, fieldTime: {}, fieldDatetime: {}, - "oracle.compartmentid": {}, "oracle.loggroupid": {}, - "oracle.logid": {}, "oracle.ingestedtime": {}, + fieldOracleCompartment: {}, fieldOracleLogGroup: {}, + fieldOracleLog: {}, fieldOracleIngested: {}, } // resolveField canonicalises a field named in a where clause, rejecting one @@ -256,7 +285,7 @@ func resolveField(field string) (fieldRef, error) { "not a nested path", field) } - return fieldRef{name: "data", jsonKey: key}, nil + return fieldRef{name: fieldData, jsonKey: key}, nil } if _, ok := entryFields[name]; !ok { @@ -287,15 +316,15 @@ func fieldValue(e *LogEntry, g *LogGroup, l *Log, ref fieldRef) string { } switch ref.name { - case "data": + case fieldData: return e.Data - case "id": + case fieldID: return e.ID - case "type": + case fieldType: return e.Type - case "subject": + case fieldSubject: return e.Subject - case "source": + case fieldSource: return e.Source case fieldTime, fieldDatetime: return e.Time.UTC().Format(timeFormat) @@ -307,13 +336,13 @@ func fieldValue(e *LogEntry, g *LogGroup, l *Log, ref fieldRef) string { // 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": + case fieldOracleCompartment: return g.CompartmentID - case "oracle.loggroupid": + case fieldOracleLogGroup: return l.LogGroupID - case "oracle.logid": + case fieldOracleLog: return l.ID - case "oracle.ingestedtime": + case fieldOracleIngested: return e.IngestedTime.UTC().Format(timeFormat) default: return "" diff --git a/providers/oci/logging/snapshot_test.go b/providers/oci/logging/snapshot_test.go index 2d7dfc903..0966db17b 100644 --- a/providers/oci/logging/snapshot_test.go +++ b/providers/oci/logging/snapshot_test.go @@ -25,7 +25,7 @@ func TestSnapshotRestoreRoundTrip(t *testing.T) { 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{{ + require.NoError(t, src.PutLogs(ctx, stdout.ID, validBatches([]ocilogging.LogEntryBatch{{ Source: "host-a", Type: "com.oraclecloud.custom", Subject: "app", @@ -33,7 +33,7 @@ func TestSnapshotRestoreRoundTrip(t *testing.T) { {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) diff --git a/server/oci/logging/group.go b/server/oci/logging/group.go index 3ddd429eb..dc73735cf 100644 --- a/server/oci/logging/group.go +++ b/server/oci/logging/group.go @@ -68,6 +68,10 @@ func (h *Handler) createGroup(w http.ResponseWriter, r *http.Request) { return } + if !h.requireCompartment(w, r, req.CompartmentID) { + return + } + g, err := h.extras.CreateGroup(r.Context(), logprovider.LogGroupSpec{ CompartmentID: req.CompartmentID, DisplayName: req.DisplayName, @@ -100,7 +104,12 @@ func (h *Handler) listGroups(w http.ResponseWriter, r *http.Request) { out = append(out, toLogGroupResponse(&groups[i])) } - ocirest.WriteJSON(w, r, http.StatusOK, paginate(w, r, out)) + page, ok := paginate(w, r, out) + if !ok { + return + } + + ocirest.WriteJSON(w, r, http.StatusOK, page) } func (h *Handler) getGroup(w http.ResponseWriter, r *http.Request, id string) { @@ -168,17 +177,21 @@ func (h *Handler) moveGroup(w http.ResponseWriter, r *http.Request, id string) { return } - if req.TargetCompartmentID == "" { - ocirest.WriteError(w, r, http.StatusBadRequest, codeInvalidParameter, "targetCompartmentId is required") + if req.CompartmentID == "" { + ocirest.WriteError(w, r, http.StatusBadRequest, codeInvalidParameter, "compartmentId is required") + return + } + + if !h.requireCompartment(w, r, req.CompartmentID) { return } - if err := h.extras.MoveGroup(r.Context(), id, req.TargetCompartmentID); err != nil { + if err := h.extras.MoveGroup(r.Context(), id, req.CompartmentID); err != nil { ocirest.WriteDriverError(w, r, err) return } - h.accept(w, r, operationMoveGroup, req.TargetCompartmentID, entityTypeGroup, workrequest.ActionUpdated, id) + h.accept(w, r, operationMoveGroup, req.CompartmentID, entityTypeGroup, workrequest.ActionUpdated, id) } func toLogGroupResponse(g *logprovider.LogGroup) logGroupResponse { diff --git a/server/oci/logging/handler.go b/server/oci/logging/handler.go index c98fa4f9d..6c0ed8bb1 100644 --- a/server/oci/logging/handler.go +++ b/server/oci/logging/handler.go @@ -13,16 +13,17 @@ // POST /20200531/logGroups/{logGroupId}/actions/changeCompartment // POST/GET /20200531/logGroups/{logGroupId}/logs — create, list // GET/PUT/DELETE /20200531/logGroups/{logGroupId}/logs/{logId} +// POST /20200531/logGroups/{logGroupId}/logs/{logId}/actions/changeLogGroup // -// loggingingestion — the data plane, /20200601 -// POST /20200601/logs/{logId}/actions/push — PutLogs +// loggingingestion — the data plane, /20200831 +// POST /20200831/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. +// apart, and Matches claims /logs for /20200831 alone. // // Not emulated: /20200531/unifiedAgentConfigurations and // /20200531/logSavedSearches, which the logging driver has no shape for — the @@ -47,7 +48,7 @@ import ( // The three API version prefixes OCI Logging is published under. const ( versionControl = "20200531" - versionIngestion = "20200601" + versionIngestion = "20200831" versionSearch = "20190909" ) @@ -60,12 +61,11 @@ const ( segSavedSearches = "logSavedSearches" subActions = "actions" actionChangeComp = "changeCompartment" + actionChangeLogGp = "changeLogGroup" actionPush = "push" entityTypeGroup = "loggroup" entityTypeLog = "log" specVersionOCI = "1.0" - fieldTypeString = "STRING" - sourceTypeService = "OCISERVICE" ) // Work request operations the asynchronous mutations record. @@ -73,10 +73,11 @@ const ( operationCreateGroup = "CREATE_LOG_GROUP" operationUpdateGroup = "UPDATE_LOG_GROUP" operationDeleteGroup = "DELETE_LOG_GROUP" - operationMoveGroup = "CHANGE_LOG_GROUP_COMPARTMENT" + operationMoveGroup = "MOVE_LOG_GROUP" operationCreateLog = "CREATE_LOG" operationUpdateLog = "UPDATE_LOG" operationDeleteLog = "DELETE_LOG" + operationMoveLog = "MOVE_LOG" ) // OCI error codes the handler raises itself. @@ -87,8 +88,9 @@ const ( codeNotFound = "NotAuthorizedOrNotFound" ) -// maxPathSegments is /{version}/{collection}/{id}/{sub}/{subId}. -const maxPathSegments = 5 +// maxPathSegments is /{version}/{collection}/{id}/{sub}/{subId}/actions/{action}, +// the shape of an action on a log nested under its group. +const maxPathSegments = 7 // 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 @@ -109,6 +111,7 @@ type Extras interface { 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 + MoveLog(ctx context.Context, groupID, logID, targetGroupID string) error PutLogs(ctx context.Context, logID string, batches []logprovider.LogEntryBatch) error SearchLogs(ctx context.Context, req logprovider.SearchRequest) (*logprovider.SearchResult, error) @@ -118,6 +121,11 @@ type Extras interface { type Handler struct { extras Extras work *workrequest.Store + + // compartmentExists reports whether a compartment OCID exists. It is nil + // unless SetCompartmentChecker wires it from Identity; a nil checker skips + // the check so handlers built without identity keep working. + compartmentExists func(id string) bool } // New returns a Logging handler. work records the asynchronous log group and @@ -128,6 +136,25 @@ func New(l logdriver.Logging, work *workrequest.Store) *Handler { return &Handler{extras: extras, work: work} } +// SetCompartmentChecker wires a compartment-existence check so a create or a +// move into a compartment that does not exist is rejected with 404 +// NotAuthorizedOrNotFound, as real OCI does. When unset (nil) the check is +// skipped, so handlers constructed without identity keep working. +func (h *Handler) SetCompartmentChecker(fn func(id string) bool) { h.compartmentExists = fn } + +// requireCompartment reports whether compartmentID exists; if not it writes +// the OCI 404 NotAuthorizedOrNotFound and returns false. A nil checker (no +// identity wired) is a no-op that allows the request. +func (h *Handler) requireCompartment(w http.ResponseWriter, r *http.Request, compartmentID string) bool { + if h.compartmentExists == nil || compartmentID == "" || h.compartmentExists(compartmentID) { + return true + } + + ocirest.WriteError(w, r, http.StatusNotFound, codeNotFound, compartmentID+" not found") + + return false +} + // route is a parsed Logging path. type route struct { Version string @@ -135,6 +162,10 @@ type route struct { ID string Sub string SubID string + // SubActions and SubAction address an action on a nested resource, as in + // /logGroups/{id}/logs/{logId}/actions/changeLogGroup. + SubActions string + SubAction string } // Matches claims each of the three Logging API surfaces at its own version @@ -248,7 +279,7 @@ func (h *Handler) requireWork(w http.ResponseWriter, r *http.Request) bool { return true } -// parsePath splits /{version}/{collection}[/{id}[/{sub}[/{subId}]]]. +// parsePath splits /{version}/{collection}[/{id}[/{sub}[/{subId}[/actions/{action}]]]]. func parsePath(urlPath string) (route, bool) { parts := strings.Split(strings.Trim(urlPath, "/"), "/") if len(parts) < 2 || len(parts) > maxPathSegments { @@ -275,23 +306,49 @@ func parsePath(urlPath string) (route, bool) { rt.SubID = parts[4] } + if len(parts) > 5 { //nolint:mnd // then an action on the nested resource + rt.SubActions = parts[5] + } + + if len(parts) > 6 { //nolint:mnd // and the action's name + rt.SubAction = parts[6] + } + 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 +// pageOffset reads OCI's opaque page cursor, which CloudEmu mints as the +// offset the next page starts at. An absent cursor is the first page; one +// CloudEmu did not mint is rejected rather than silently restarting at zero, +// which would loop a paginating client forever. +func pageOffset(w http.ResponseWriter, r *http.Request) (int, bool) { + token := ocirest.Page(r) + if token == "" { + return 0, true + } - if token := ocirest.Page(r); token != "" { - if n, err := strconv.Atoi(token); err == nil && n > 0 { - start = n - } + n, err := strconv.Atoi(token) + if err != nil || n < 0 { + ocirest.WriteError(w, r, http.StatusBadRequest, codeInvalidParameter, + "page "+strconv.Quote(token)+" is not a cursor this API returned in opc-next-page") + + return 0, false + } + + return n, true +} + +// paginate applies OCI's limit and page cursor, stamping opc-next-page when +// more items follow. +func paginate[T any](w http.ResponseWriter, r *http.Request, items []T) ([]T, bool) { + start, ok := pageOffset(w, r) + if !ok { + return nil, false } // items[:0] rather than nil: an empty page is [] on the wire, not null. if start >= len(items) { - return items[:0] + return items[:0], true } end := min(start+ocirest.Limit(r), len(items)) @@ -299,7 +356,7 @@ func paginate[T any](w http.ResponseWriter, r *http.Request, items []T) []T { ocirest.SetNextPage(w, strconv.Itoa(end)) } - return items[start:end] + return items[start:end], true } // methodNotAllowed is the response for a verb a collection does not serve. diff --git a/server/oci/logging/handler_test.go b/server/oci/logging/handler_test.go index e523caa83..087f6ca0f 100644 --- a/server/oci/logging/handler_test.go +++ b/server/oci/logging/handler_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strconv" "strings" "testing" "time" @@ -131,16 +132,16 @@ func TestMatches(t *testing.T) { {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}, + // Ingestion plane, /20200831. + {name: "ingestion push", method: http.MethodPost, path: "/20200831/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 ingestion prefix", method: http.MethodPost, path: "/20200831/logGroups", expect: false}, + {name: "search is not on the ingestion prefix", method: http.MethodPost, path: "/20200831/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}, @@ -156,7 +157,10 @@ func TestMatches(t *testing.T) { {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: "too many segments", method: http.MethodGet, + path: "/20200531/logGroups/g/logs/l/actions/changeLogGroup/extra", expect: false, + }, {name: "empty segment", method: http.MethodGet, path: "/20200531//logGroups", expect: false}, } @@ -215,7 +219,7 @@ func TestLogGroupLifecycle(t *testing.T) { 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"}) + map[string]any{"compartmentId": "ocid1.compartment.oc1..moved"}) require.Equal(t, http.StatusAccepted, rec.Code) assert.NotEmpty(t, rec.Header().Get(ocirest.HeaderWorkRequestID)) }) @@ -401,15 +405,16 @@ func TestPutLogs(t *testing.T) { 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) + return do(t, h, http.MethodPost, "/20200831/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", + "source": "host-a", + "type": "custom", + "defaultlogentrytime": "2026-08-08T12:00:00Z", "entries": []any{ map[string]any{"data": "hello", "id": "e1", "time": "2026-08-08T10:00:00Z"}, }, @@ -427,35 +432,38 @@ func TestPutLogs(t *testing.T) { }{ { name: "specversion is required", method: http.MethodPost, - path: "/20200601/logs/" + logID + "/actions/push", + path: "/20200831/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", + path: "/20200831/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"}}}}, + "specversion": "1.0", + "logEntryBatches": []any{map[string]any{ + "source": "host-a", "type": "custom", "defaultlogentrytime": "2026-08-08T12:00:00Z", + "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", + path: "/20200831/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", + path: "/20200831/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, + path: "/20200831/logs", expectCode: http.StatusNotFound, }, { name: "push is POST only", method: http.MethodGet, - path: "/20200601/logs/" + logID + "/actions/push", expectCode: http.StatusMethodNotAllowed, + path: "/20200831/logs/" + logID + "/actions/push", expectCode: http.StatusMethodNotAllowed, }, } @@ -472,11 +480,12 @@ func TestSearchLogs(t *testing.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{ + rec := do(t, h, http.MethodPost, "/20200831/logs/"+logID+"/actions/push", map[string]any{ "specversion": "1.0", "logEntryBatches": []any{map[string]any{ - "source": "host-a", - "type": "custom", + "source": "host-a", + "type": "custom", + "defaultlogentrytime": "2026-08-08T12:00:00Z", "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"}, @@ -663,7 +672,7 @@ func TestDriverWithoutOCICapabilityIs501(t *testing.T) { for _, path := range []string{ "/20200531/logGroups", - "/20200601/logs/l/actions/push", + "/20200831/logs/l/actions/push", "/20190909/search", } { t.Run(path, func(t *testing.T) { @@ -736,9 +745,11 @@ func TestSearchSortAndProvenance(t *testing.T) { 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}}, + rec := do(t, h, http.MethodPost, "/20200831/logs/"+logID+"/actions/push", map[string]any{ + "specversion": "1.0", + "logEntryBatches": []any{map[string]any{ + "source": "host-a", "type": "custom", "defaultlogentrytime": "2026-08-08T12:00:00Z", "entries": entries, + }}, }) require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) } @@ -873,7 +884,7 @@ func TestLogGroupMutations(t *testing.T) { 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}) + map[string]any{"compartmentId": compartmentB}) require.Equal(t, http.StatusAccepted, rec.Code, rec.Body.String()) got := do(t, h, http.MethodGet, "/20200531/logGroups/"+groupID, nil) @@ -916,7 +927,7 @@ func TestLogGroupMutationErrors(t *testing.T) { { name: "move an unknown group", method: http.MethodPost, path: "/20200531/logGroups/" + missing + "/actions/changeCompartment", - body: map[string]any{"targetCompartmentId": compartmentB}, expectCode: http.StatusNotFound, + body: map[string]any{"compartmentId": compartmentB}, expectCode: http.StatusNotFound, }, { name: "move needs a target compartment", method: http.MethodPost, @@ -1019,7 +1030,7 @@ func TestLogMutations(t *testing.T) { assert.InDelta(t, 90, body["retentionDuration"], 0) cfg := body["configuration"].(map[string]any) - assert.Equal(t, "OCISERVICE", cfg["source"].(map[string]any)["sourceType"]) + assert.NotContains(t, cfg["source"].(map[string]any), "sourceType", "a CUSTOM log has no service source") assert.Equal(t, true, cfg["archiving"].(map[string]any)["isEnabled"]) }) @@ -1087,7 +1098,15 @@ func TestRoutingEdges(t *testing.T) { {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, + path: "/20200531/logGroups/" + groupID + "/logs/l/actions/changeLogGroup/x", expectCode: http.StatusBadRequest, + }, + { + name: "an unknown log action", method: http.MethodPost, + path: "/20200531/logGroups/" + groupID + "/logs/l/actions/archive", expectCode: http.StatusNotFound, + }, + { + name: "a log action is POST only", method: http.MethodGet, + path: "/20200531/logGroups/" + groupID + "/logs/l/actions/changeLogGroup", expectCode: http.StatusMethodNotAllowed, }, { name: "unknown API version", method: http.MethodGet, @@ -1137,7 +1156,7 @@ func TestMalformedBodies(t *testing.T) { }, "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"}, + "push": {http.MethodPost, "/20200831/logs/" + logID + "/actions/push"}, "search": {http.MethodPost, "/20190909/search"}, } @@ -1181,3 +1200,270 @@ func TestCreateLogErrors(t *testing.T) { }) } } + +// The wire contract of oci-go-sdk v65, copied from the SDK rather than from this +// handler's constants, so a drift in the handler cannot drift the test with it. +const ( + // logging/logging_loggingmanagement_client.go: client.BasePath. + sdkControlBase = "/20200531" + // loggingingestion/loggingingestion_logging_client.go: client.BasePath. + sdkIngestionBase = "/20200831" + // loggingsearch/loggingsearch_logsearch_client.go: client.BasePath. + sdkSearchBase = "/20190909" +) + +// sdkOperationTypes is logging.OperationTypesEnum: the only values a client's +// GetMappingOperationTypesEnum recognizes on a work request. +// +//nolint:gochecknoglobals // immutable copy of the SDK enum. +var sdkOperationTypes = map[string]bool{ + "CREATE_LOG": true, "UPDATE_LOG": true, "DELETE_LOG": true, "MOVE_LOG": true, + "CREATE_LOG_GROUP": true, "UPDATE_LOG_GROUP": true, "DELETE_LOG_GROUP": true, "MOVE_LOG_GROUP": true, + "CREATE_CONFIGURATION": true, "UPDATE_CONFIGURATION": true, "DELETE_CONFIGURATION": true, + "MOVE_CONFIGURATION": true, +} + +// operationOf returns the operation type of the work request a 202 stamped, +// asserting it is one the SDK defines. +func operationOf(t *testing.T, work *workrequest.Store, rec *httptest.ResponseRecorder) string { + t.Helper() + + require.Equal(t, http.StatusAccepted, rec.Code, rec.Body.String()) + + wr, ok := work.Get(rec.Header().Get(ocirest.HeaderWorkRequestID)) + require.True(t, ok) + assert.True(t, sdkOperationTypes[wr.OperationType], + "%q is not in the SDK's OperationTypesEnum", wr.OperationType) + + return wr.OperationType +} + +// TestSDKContract drives every OCI Logging operation CloudEmu serves through the +// exact path and body shape oci-go-sdk v65 sends. Both shapes earlier tests +// missed — ingestion's API version and ChangeLogGroupCompartmentDetails' +// compartmentId — fail here. +func TestSDKContract(t *testing.T) { + h, work := newHandler(t) + + create := do(t, h, http.MethodPost, sdkControlBase+"/logGroups", map[string]any{ + "compartmentId": compartmentA, "displayName": "app-logs", + }) + assert.Equal(t, "CREATE_LOG_GROUP", operationOf(t, work, create)) + groupID := resourceOf(t, work, create, "loggroup") + otherID := createGroup(t, h, work, "other-logs") + + createdLog := do(t, h, http.MethodPost, sdkControlBase+"/logGroups/"+groupID+"/logs", map[string]any{ + "displayName": "stdout", "logType": "CUSTOM", "isEnabled": true, "retentionDuration": 30, + }) + assert.Equal(t, "CREATE_LOG", operationOf(t, work, createdLog)) + logID := resourceOf(t, work, createdLog, "log") + + t.Run("GetLog carries the tenancy", func(t *testing.T) { + rec := do(t, h, http.MethodGet, sdkControlBase+"/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, "ocid1.tenancy.oc1..aaaaaaaacloudemulocaltenancy", body["tenancyId"]) + assert.NotContains(t, body, "configuration", "a CUSTOM log created without one has no configuration") + }) + + t.Run("PutLogs at the ingestion client's base path", func(t *testing.T) { + path := sdkIngestionBase + "/logs/" + logID + "/actions/push" + require.True(t, h.Matches(httptest.NewRequest(http.MethodPost, path, nil))) + + // PutLogsDetails / LogEntryBatch / LogEntry, every mandatory field set. + rec := do(t, h, http.MethodPost, path, map[string]any{ + "specversion": "1.0", + "logEntryBatches": []any{map[string]any{ + "source": "host-a", "type": "com.example.app", "subject": "app", + "defaultlogentrytime": "2026-08-08T10:00:00Z", + "entries": []any{map[string]any{"id": "e1", "data": "hello", "time": "2026-08-08T10:00:00Z"}}, + }}, + }) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + + found := do(t, h, http.MethodPost, sdkSearchBase+"/search", map[string]any{ + "searchQuery": `search "` + compartmentA + `"`, + "timeStart": "2026-08-08T09:00:00Z", + "timeEnd": "2026-08-08T11:00:00Z", + }) + require.Equal(t, http.StatusOK, found.Code) + assert.Equal(t, []string{"e1"}, resultIDs(t, found), "a follow-up search finds what PutLogs ingested") + }) + + t.Run("the retired ingestion version is not claimed", func(t *testing.T) { + assert.False(t, h.Matches(httptest.NewRequest(http.MethodPost, "/20200601/logs/"+logID+"/actions/push", nil))) + }) + + t.Run("ChangeLogLogGroup moves a log and records MOVE_LOG", func(t *testing.T) { + // ChangeLogLogGroupDetails. + rec := do(t, h, http.MethodPost, + sdkControlBase+"/logGroups/"+groupID+"/logs/"+logID+"/actions/changeLogGroup", + map[string]any{"targetLogGroupId": otherID}) + assert.Equal(t, "MOVE_LOG", operationOf(t, work, rec)) + + got := do(t, h, http.MethodGet, sdkControlBase+"/logGroups/"+otherID+"/logs/"+logID, nil) + assert.Equal(t, http.StatusOK, got.Code, "the log is now addressed through its new group") + }) + + t.Run("ChangeLogGroupCompartment reads compartmentId and records MOVE_LOG_GROUP", func(t *testing.T) { + // ChangeLogGroupCompartmentDetails. + rec := do(t, h, http.MethodPost, sdkControlBase+"/logGroups/"+otherID+"/actions/changeCompartment", + map[string]any{"compartmentId": compartmentB}) + assert.Equal(t, "MOVE_LOG_GROUP", operationOf(t, work, rec)) + + got := do(t, h, http.MethodGet, sdkControlBase+"/logGroups/"+otherID, nil) + + var body map[string]any + require.NoError(t, json.Unmarshal(got.Body.Bytes(), &body)) + assert.Equal(t, compartmentB, body["compartmentId"]) + }) + + t.Run("the old targetCompartmentId body is refused, not accepted", func(t *testing.T) { + rec := do(t, h, http.MethodPost, sdkControlBase+"/logGroups/"+groupID+"/actions/changeCompartment", + map[string]any{"targetCompartmentId": compartmentB}) + require.Equal(t, http.StatusBadRequest, rec.Code) + assert.Contains(t, rec.Body.String(), "compartmentId is required") + }) + + t.Run("deleting a group that holds logs is 409 IncorrectState", func(t *testing.T) { + rec := do(t, h, http.MethodDelete, sdkControlBase+"/logGroups/"+otherID, nil) + require.Equal(t, http.StatusConflict, rec.Code, rec.Body.String()) + assert.Equal(t, "IncorrectState", codeOf(t, rec)) + }) + + t.Run("delete the log, then the emptied group", func(t *testing.T) { + assert.Equal(t, "DELETE_LOG", operationOf(t, work, + do(t, h, http.MethodDelete, sdkControlBase+"/logGroups/"+otherID+"/logs/"+logID, nil))) + assert.Equal(t, "DELETE_LOG_GROUP", operationOf(t, work, + do(t, h, http.MethodDelete, sdkControlBase+"/logGroups/"+otherID, nil))) + }) +} + +func TestWireRejectsWhatOCIRejects(t *testing.T) { + h, work := newHandler(t) + groupID := createGroup(t, h, work, "app-logs") + logID := createLog(t, h, work, groupID, "stdout") + push := "/20200831/logs/" + logID + "/actions/push" + + batch := func(drop string) map[string]any { + b := map[string]any{ + "source": "host-a", "type": "custom", "defaultlogentrytime": "2026-08-08T10:00:00Z", + "entries": []any{map[string]any{"id": "e1", "data": "x"}}, + } + delete(b, drop) + + return map[string]any{"specversion": "1.0", "logEntryBatches": []any{b}} + } + + tests := []struct { + name string + method string + path string + body any + code int + contains string + }{ + {name: "batch missing source", method: http.MethodPost, path: push, body: batch("source"), + code: http.StatusBadRequest, contains: "source is required"}, + {name: "batch missing type", method: http.MethodPost, path: push, body: batch("type"), + code: http.StatusBadRequest, contains: "type is required"}, + {name: "batch missing defaultlogentrytime", method: http.MethodPost, path: push, + body: batch("defaultlogentrytime"), code: http.StatusBadRequest, contains: "defaultlogentrytime is required"}, + {name: "create with retentionDuration 7", method: http.MethodPost, + path: "/20200531/logGroups/" + groupID + "/logs", + body: map[string]any{"displayName": "short", "logType": "CUSTOM", "retentionDuration": 7}, + code: http.StatusBadRequest, contains: "retentionDuration 7 is not valid"}, + {name: "update with retentionDuration 45", method: http.MethodPut, + path: "/20200531/logGroups/" + groupID + "/logs/" + logID, + body: map[string]any{"retentionDuration": 45}, + code: http.StatusBadRequest, contains: "retentionDuration 45 is not valid"}, + {name: "move a log with no target", method: http.MethodPost, + path: "/20200531/logGroups/" + groupID + "/logs/" + logID + "/actions/changeLogGroup", + body: map[string]any{}, code: http.StatusBadRequest, contains: "targetLogGroupId is required"}, + {name: "move a log into an unknown group", method: http.MethodPost, + path: "/20200531/logGroups/" + groupID + "/logs/" + logID + "/actions/changeLogGroup", + body: map[string]any{"targetLogGroupId": "ocid1.loggroup.oc1.iad.missing"}, + code: http.StatusNotFound, contains: "not found"}, + {name: "a list page this API never minted", method: http.MethodGet, + path: "/20200531/logGroups?compartmentId=" + compartmentA + "&page=garbage", + code: http.StatusBadRequest, contains: "is not a cursor"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rec := do(t, h, tc.method, tc.path, tc.body) + require.Equal(t, tc.code, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), tc.contains) + }) + } +} + +func TestSearchPaginates(t *testing.T) { + h, work := newHandler(t) + groupID := createGroup(t, h, work, "app-logs") + logID := createLog(t, h, work, groupID, "stdout") + + entries := make([]any, 0, 5) + for i := range 5 { + entries = append(entries, map[string]any{ + "id": "e" + strconv.Itoa(i), "data": "x", "time": "2026-08-08T10:0" + strconv.Itoa(i) + ":00Z", + }) + } + + rec := do(t, h, http.MethodPost, "/20200831/logs/"+logID+"/actions/push", map[string]any{ + "specversion": "1.0", + "logEntryBatches": []any{map[string]any{ + "source": "host-a", "type": "custom", "defaultlogentrytime": "2026-08-08T10:00:00Z", "entries": entries, + }}, + }) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + + search := func(query string) *httptest.ResponseRecorder { + return do(t, h, http.MethodPost, "/20190909/search"+query, map[string]any{ + "searchQuery": `search "` + compartmentA + `"`, + "timeStart": "2026-08-08T09:00:00Z", + "timeEnd": "2026-08-08T11:00:00Z", + }) + } + + var ( + got []string + page string + ) + + for pages := 0; ; pages++ { + require.Less(t, pages, 5, "pagination must terminate") + + query := "?limit=2" + if page != "" { + query += "&page=" + page + } + + res := search(query) + require.Equal(t, http.StatusOK, res.Code, res.Body.String()) + + got = append(got, resultIDs(t, res)...) + + page = res.Header().Get(ocirest.HeaderNextPage) + if page == "" { + break + } + } + + assert.Equal(t, []string{"e0", "e1", "e2", "e3", "e4"}, got, "every entry, once, in order, across pages") + + t.Run("a page past the end is empty, with no cursor", func(t *testing.T) { + res := search("?limit=2&page=10") + require.Equal(t, http.StatusOK, res.Code) + assert.Empty(t, resultIDs(t, res)) + assert.Empty(t, res.Header().Get(ocirest.HeaderNextPage)) + }) + + t.Run("a page this API never minted is rejected", func(t *testing.T) { + res := search("?page=garbage") + require.Equal(t, http.StatusBadRequest, res.Code) + assert.Contains(t, res.Body.String(), "is not a cursor") + }) +} diff --git a/server/oci/logging/log.go b/server/oci/logging/log.go index 2333c9c71..1bca922a0 100644 --- a/server/oci/logging/log.go +++ b/server/oci/logging/log.go @@ -11,6 +11,11 @@ import ( // 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.SubActions != "" { + h.serveLogAction(w, r, rt) + return + } + if rt.SubID == "" { switch r.Method { case http.MethodPost: @@ -36,6 +41,54 @@ func (h *Handler) serveLogs(w http.ResponseWriter, r *http.Request, rt *route) { } } +// serveLogAction serves the one action OCI defines on a log. +func (h *Handler) serveLogAction(w http.ResponseWriter, r *http.Request, rt *route) { + if rt.SubActions != subActions || rt.SubAction != actionChangeLogGp { + ocirest.WriteError(w, r, http.StatusNotFound, codeNotFound, + "unknown log action "+rt.SubActions+"/"+rt.SubAction) + + return + } + + if r.Method != http.MethodPost { + methodNotAllowed(w, r) + return + } + + h.moveLog(w, r, rt.ID, rt.SubID) +} + +// moveLog moves a log into another log group. +func (h *Handler) moveLog(w http.ResponseWriter, r *http.Request, groupID, logID string) { + if !h.requireWork(w, r) { + return + } + + var req changeLogGroupRequest + + if !ocirest.DecodeJSON(w, r, &req) { + return + } + + if req.TargetLogGroupID == "" { + ocirest.WriteError(w, r, http.StatusBadRequest, codeInvalidParameter, "targetLogGroupId is required") + return + } + + if err := h.extras.MoveLog(r.Context(), groupID, logID, req.TargetLogGroupID); err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + l, err := h.extras.GetLog(r.Context(), req.TargetLogGroupID, logID) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + h.accept(w, r, operationMoveLog, l.CompartmentID, entityTypeLog, workrequest.ActionUpdated, logID) +} + func (h *Handler) createLog(w http.ResponseWriter, r *http.Request, groupID string) { if !h.requireWork(w, r) { return @@ -98,7 +151,12 @@ func (h *Handler) listLogs(w http.ResponseWriter, r *http.Request, groupID strin out = append(out, toLogResponse(&logs[i])) } - ocirest.WriteJSON(w, r, http.StatusOK, paginate(w, r, out)) + page, ok := paginate(w, r, out) + if !ok { + return + } + + ocirest.WriteJSON(w, r, http.StatusOK, page) } func (h *Handler) getLog(w http.ResponseWriter, r *http.Request, groupID, logID string) { @@ -184,6 +242,7 @@ func toLogResponse(l *logprovider.Log) logResponse { ID: l.ID, LogGroupID: l.LogGroupID, CompartmentID: l.CompartmentID, + TenancyID: l.TenancyID, DisplayName: l.DisplayName, LogType: l.LogType, IsEnabled: l.IsEnabled, diff --git a/server/oci/logging/search.go b/server/oci/logging/search.go index af51f40dd..c9adc29f3 100644 --- a/server/oci/logging/search.go +++ b/server/oci/logging/search.go @@ -3,6 +3,7 @@ package logging import ( "encoding/json" "net/http" + "strconv" "time" logprovider "github.com/stackshy/cloudemu/v2/providers/oci/logging" @@ -34,11 +35,17 @@ func (h *Handler) serveSearch(w http.ResponseWriter, r *http.Request) { return } + offset, ok := pageOffset(w, r) + if !ok { + return + } + result, err := h.extras.SearchLogs(r.Context(), logprovider.SearchRequest{ Query: req.SearchQuery, TimeStart: start, TimeEnd: end, Limit: ocirest.Limit(r), + Offset: offset, ReturnFieldInfo: req.IsReturnFieldInfo, }) if err != nil { @@ -46,6 +53,10 @@ func (h *Handler) serveSearch(w http.ResponseWriter, r *http.Request) { return } + if result.NextOffset > 0 { + ocirest.SetNextPage(w, strconv.Itoa(result.NextOffset)) + } + ocirest.WriteJSON(w, r, http.StatusOK, toSearchResponse(result)) } diff --git a/server/oci/logging/types.go b/server/oci/logging/types.go index 78666d21d..27d6b0df6 100644 --- a/server/oci/logging/types.go +++ b/server/oci/logging/types.go @@ -25,8 +25,14 @@ type updateLogGroupRequest struct { DefinedTags definedTags `json:"definedTags,omitempty"` } +// changeCompartmentRequest is ChangeLogGroupCompartmentDetails. type changeCompartmentRequest struct { - TargetCompartmentID string `json:"targetCompartmentId"` + CompartmentID string `json:"compartmentId"` +} + +// changeLogGroupRequest is ChangeLogLogGroupDetails. +type changeLogGroupRequest struct { + TargetLogGroupID string `json:"targetLogGroupId"` } type logGroupResponse struct { @@ -83,6 +89,7 @@ type logResponse struct { ID string `json:"id"` LogGroupID string `json:"logGroupId"` CompartmentID string `json:"compartmentId"` + TenancyID string `json:"tenancyId,omitempty"` DisplayName string `json:"displayName"` LogType string `json:"logType"` IsEnabled bool `json:"isEnabled"` @@ -95,7 +102,7 @@ type logResponse struct { DefinedTags definedTags `json:"definedTags"` } -// Ingestion plane — /20200601. +// Ingestion plane — /20200831. type putLogsEntry struct { Data string `json:"data"` diff --git a/server/oci/logging_compartment_gate_test.go b/server/oci/logging_compartment_gate_test.go new file mode 100644 index 000000000..6f0424173 --- /dev/null +++ b/server/oci/logging_compartment_gate_test.go @@ -0,0 +1,95 @@ +package oci_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stackshy/cloudemu/v2/config" + ociprovider "github.com/stackshy/cloudemu/v2/providers/oci" + ociserver "github.com/stackshy/cloudemu/v2/server/oci" +) + +// TestLogGroupCompartmentGate proves the server wires Identity's compartment +// check into the Logging handler: a log group created or moved into a +// nonexistent compartment is rejected with 404 NotAuthorizedOrNotFound, as +// real OCI does, while the seeded root tenancy compartment succeeds. +func TestLogGroupCompartmentGate(t *testing.T) { + p := ociprovider.New() + + srv := ociserver.New(ociserver.DriversFrom(p)) + + ts := httptest.NewServer(srv) + defer ts.Close() + + post := func(path string, body map[string]any) *http.Response { + raw, err := json.Marshal(body) + require.NoError(t, err) + + resp, err := ts.Client().Post(ts.URL+path, "application/json", bytes.NewReader(raw)) + require.NoError(t, err) + + return resp + } + + codeOf := func(resp *http.Response) string { + var errBody struct { + Code string `json:"code"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&errBody)) + + return errBody.Code + } + + t.Run("create in the root tenancy compartment succeeds", func(t *testing.T) { + resp := post("/20200531/logGroups", map[string]any{ + "compartmentId": config.DefaultTenancyOCID, "displayName": "gate-ok", + }) + defer resp.Body.Close() + + assert.Equal(t, http.StatusAccepted, resp.StatusCode) + }) + + t.Run("create in a nonexistent compartment is 404 NotAuthorizedOrNotFound", func(t *testing.T) { + resp := post("/20200531/logGroups", map[string]any{ + "compartmentId": "ocid1.compartment.oc1..doesnotexist", "displayName": "gate-missing", + }) + defer resp.Body.Close() + + require.Equal(t, http.StatusNotFound, resp.StatusCode) + assert.Equal(t, "NotAuthorizedOrNotFound", codeOf(resp)) + }) + + t.Run("move into a nonexistent compartment is 404 NotAuthorizedOrNotFound", func(t *testing.T) { + created := post("/20200531/logGroups", map[string]any{ + "compartmentId": config.DefaultTenancyOCID, "displayName": "gate-move", + }) + created.Body.Close() + require.Equal(t, http.StatusAccepted, created.StatusCode) + + wr, err := ts.Client().Get(ts.URL + "/20200531/workRequests/" + created.Header.Get("opc-work-request-id")) + require.NoError(t, err) + + defer wr.Body.Close() + + var work struct { + Resources []struct { + Identifier string `json:"identifier"` + } `json:"resources"` + } + require.NoError(t, json.NewDecoder(wr.Body).Decode(&work)) + require.Len(t, work.Resources, 1) + + resp := post("/20200531/logGroups/"+work.Resources[0].Identifier+"/actions/changeCompartment", + map[string]any{"compartmentId": "ocid1.compartment.oc1..doesnotexist"}) + defer resp.Body.Close() + + require.Equal(t, http.StatusNotFound, resp.StatusCode) + assert.Equal(t, "NotAuthorizedOrNotFound", codeOf(resp)) + }) +} diff --git a/server/oci/oci.go b/server/oci/oci.go index b9613b524..3aaf9276b 100644 --- a/server/oci/oci.go +++ b/server/oci/oci.go @@ -96,7 +96,17 @@ func New(d Drivers) *server.Server { } if d.Logging != nil { - srv.Register(ocilogging.New(d.Logging, d.WorkRequests)) + logHandler := ocilogging.New(d.Logging, d.WorkRequests) + + if comps, ok := d.Identity.(identity.Compartments); ok { + logHandler.SetCompartmentChecker(func(id string) bool { + _, err := comps.GetCompartment(context.Background(), id) + + return err == nil + }) + } + + srv.Register(logHandler) } if d.VCN != nil {