Skip to content

Commit c31c95f

Browse files
authored
Merge pull request #4 from taskcluster/feat/taskcluster-log-reader
Add scoped log view
2 parents b229bba + ce3461e commit c31c95f

10 files changed

Lines changed: 313 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
### Added
6+
7+
- Per-environment log-view scoping: `log_view` (with optional `log_bucket`, `log_location`) confines queries to a single Cloud Logging log view instead of the whole project. When `log_view` is unset, behavior is unchanged (project scope).
8+
- `fx-ci-scoped` / `community-tc-scoped` / `staging-scoped` example environments, intended for untrusted agents/containers: a token minted from the narrow `tc-logview-reader` service account can read only TaskCluster's per-namespace tenant log bucket. Under `-v`, the active scope is printed as `Scope: <view resource>`.
9+
10+
### Changed
11+
12+
- `TASKCLUSTER_ROOT_URL` auto-detection now ignores log-view-scoped environments (they share a `root_url` with their broad counterpart). Scoped envs must be selected explicitly with `--env`; auto-detect resolves deterministically to the broad env.
13+
- Result cache keys now include the environment's project and log-view scope, so scoped and broad environments no longer share cache entries.
14+
15+
### Notes
16+
17+
- Infrastructure presets (`k8s.*`, `cloudsql.*`) are not available on `*-scoped` envs — those logs live in other projects/buckets; querying one now returns a clear error (instead of silently hitting the wrong project). Use the broad envs with your own ADC.
18+
319
## v1.3.1 - 2026-05-29
420

521
### Changed

README.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,32 @@ environments:
6464
cluster: "taskcluster-dev"
6565
root_url: "https://tc.dev.taskcluster.mozgcp.net"
6666
# key_path omitted — uses ADC by default
67+
68+
# Scoped envs for untrusted agents — query a single log view, not the project.
69+
fx-ci-scoped:
70+
project_id: "moz-fx-taskcluster-prod"
71+
cluster: "webservices-high-prod"
72+
namespace: "taskcluster-prod"
73+
log_bucket: "gke-taskcluster-prod-log-bucket"
74+
log_location: "global"
75+
log_view: "_AllLogs"
76+
root_url: "https://firefox-ci-tc.services.mozilla.com"
77+
community-tc-scoped:
78+
project_id: "moz-fx-taskcluster-prod"
79+
cluster: "webservices-high-prod"
80+
namespace: "taskcluster-communitytc"
81+
log_bucket: "gke-taskcluster-communitytc-log-bucket"
82+
log_location: "global"
83+
log_view: "_AllLogs"
84+
root_url: "https://community-tc.services.mozilla.com"
85+
staging-scoped:
86+
project_id: "moz-fx-webservices-high-nonpro"
87+
cluster: "webservices-high-nonprod"
88+
namespace: "taskcluster-stage"
89+
log_bucket: "gke-taskcluster-stage-log-bucket"
90+
log_location: "global"
91+
log_view: "_AllLogs"
92+
root_url: "https://stage.taskcluster.nonprod.cloudops.mozgcp.net"
6793
```
6894
6995
### 2. Authenticate to GCP
@@ -106,6 +132,43 @@ When `TC_LOGVIEW_ACCESS_TOKEN` is set it takes priority over `key_path` and ADC.
106132

107133
Re-mint and re-inject the token before it expires for long-running containers. An expired token surfaces as a `401` from the query path with a hint pointing back at the re-mint command.
108134

135+
#### Scoping a token to TaskCluster logs only (`*-scoped` envs)
136+
137+
An access token minted from your own ADC inherits your privileges — too broad for an untrusted agent. To hand an agent a token that can read **only** TaskCluster's logs, mint it from a dedicated, narrowly-scoped reader service account and point tc-logview at a **log view** instead of the whole project.
138+
139+
This is what the `*-scoped` environments are for. They set three optional fields that confine every query to a single Cloud Logging log view:
140+
141+
| Field | Meaning | Default |
142+
|---|---|---|
143+
| `log_view` | View ID; when set, queries are scoped to this view (otherwise project scope) | — (project scope) |
144+
| `log_bucket` | Bucket holding the view | `_Default` |
145+
| `log_location` | Bucket location | `global` |
146+
147+
The `tc-logview-reader` service account is granted `roles/logging.viewAccessor` on exactly that view (a per-namespace tenant log bucket), so a token minted from it can read nothing else:
148+
149+
```bash
150+
# Host: mint a narrow, short-lived token by impersonating the reader SA.
151+
TOKEN=$(gcloud auth print-access-token \
152+
--impersonate-service-account=tc-logview-reader@moz-fx-taskcluster-prod.iam.gserviceaccount.com)
153+
154+
# Agent/container: scoped env queries only the TaskCluster log view.
155+
TC_LOGVIEW_ACCESS_TOKEN=$TOKEN tc-logview query -e fx-ci-scoped --type monitor.error --since 1h
156+
```
157+
158+
The same `tc-logview-reader` service account backs all scoped envs:
159+
160+
| Scoped env | Project | Log bucket |
161+
|---|---|---|
162+
| `fx-ci-scoped` | `moz-fx-taskcluster-prod` | `gke-taskcluster-prod-log-bucket` |
163+
| `community-tc-scoped` | `moz-fx-taskcluster-prod` | `gke-taskcluster-communitytc-log-bucket` |
164+
| `staging-scoped` | `moz-fx-webservices-high-nonpro` | `gke-taskcluster-stage-log-bucket` |
165+
166+
The reader SA lives in the prod project; for `staging-scoped` it's granted `viewAccessor` on the stage bucket in the nonprod project, so the same impersonation command works for all three.
167+
168+
> Infrastructure presets (`k8s.*`, `cloudsql.*`) are **not** available on `*-scoped` envs — those logs live in other projects/buckets the reader SA can't see. Use the broad envs (with your own ADC) for infra queries.
169+
170+
> Auto-detection via `TASKCLUSTER_ROOT_URL` always resolves to the broad env (scoped envs share its `root_url`); select a scoped env explicitly with `-e`.
171+
109172
### 3. Sync references
110173

111174
```bash

cmd/config_init.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,36 @@ environments:
7575
cluster: "taskcluster-dev"
7676
root_url: "https://tc.dev.taskcluster.mozgcp.net"
7777
# key_path omitted — uses ADC by default
78+
79+
# Scoped environments for untrusted agents/containers. These query a single
80+
# Cloud Logging log view (a per-namespace tenant bucket) instead of the whole
81+
# project, so an injected TC_LOGVIEW_ACCESS_TOKEN minted from the narrow
82+
# tc-logview-reader service account can read ONLY TaskCluster service logs.
83+
# Infra/k8s/CloudSQL presets are not available here — use the broad envs above.
84+
fx-ci-scoped:
85+
project_id: "moz-fx-taskcluster-prod"
86+
cluster: "webservices-high-prod"
87+
namespace: "taskcluster-prod"
88+
log_bucket: "gke-taskcluster-prod-log-bucket"
89+
log_location: "global"
90+
log_view: "_AllLogs"
91+
root_url: "https://firefox-ci-tc.services.mozilla.com"
92+
community-tc-scoped:
93+
project_id: "moz-fx-taskcluster-prod"
94+
cluster: "webservices-high-prod"
95+
namespace: "taskcluster-communitytc"
96+
log_bucket: "gke-taskcluster-communitytc-log-bucket"
97+
log_location: "global"
98+
log_view: "_AllLogs"
99+
root_url: "https://community-tc.services.mozilla.com"
100+
staging-scoped:
101+
project_id: "moz-fx-webservices-high-nonpro"
102+
cluster: "webservices-high-nonprod"
103+
namespace: "taskcluster-stage"
104+
log_bucket: "gke-taskcluster-stage-log-bucket"
105+
log_location: "global"
106+
log_view: "_AllLogs"
107+
root_url: "https://stage.taskcluster.nonprod.cloudops.mozgcp.net"
78108
`
79109
if err := os.WriteFile(cfgPath, []byte(example), 0o644); err != nil {
80110
return fmt.Errorf("writing config: %w", err)

cmd/query.go

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,14 @@ func runQuery(cmd *cobra.Command, args []string) error {
9494
}
9595

9696
if preset != nil {
97+
// Infra presets (k8s.*, cloudsql.*) read logs that live outside the scoped
98+
// log view (other projects/buckets), so they are not available on
99+
// log-view-scoped environments. Fail loud rather than silently querying the
100+
// wrong project (or relying on an IAM denial).
101+
if env.LogViewResource() != "" {
102+
return fmt.Errorf("preset %q is not available on log-view-scoped environments (those with log_view set); use a non-scoped environment for k8s/CloudSQL presets", preset.Name)
103+
}
104+
97105
// Preset mode: use preset's filter and field mappings
98106
presetFilter := preset.Filter
99107
skipCluster := false
@@ -136,7 +144,7 @@ func runQuery(cmd *cobra.Command, args []string) error {
136144

137145
// Query, cache, format — same as TC path
138146
resultsCache := cache.New(filepath.Join(config.CacheDir(), "results"), resultsCacheTTL)
139-
cacheKey := resultsCache.Key(env.Cluster, fromTime.Format(time.RFC3339), toTime.Format(time.RFC3339), filterStr)
147+
cacheKey := resultsCache.Key(env.Cluster, env.ProjectID, env.LogViewResource(), fromTime.Format(time.RFC3339), toTime.Format(time.RFC3339), filterStr)
140148

141149
var rawEntries []map[string]interface{}
142150
var totalCount int
@@ -169,7 +177,9 @@ func runQuery(cmd *cobra.Command, args []string) error {
169177
defer client.Close()
170178

171179
logInfo("Querying GCP Cloud Logging...")
172-
result, err := client.Query(ctx, filterStr, queryLimit+queryOffset)
180+
// Presets (k8s/CloudSQL) query at project scope; they are not served
181+
// by a tenant log view, so resourceName is intentionally empty.
182+
result, err := client.Query(ctx, filterStr, "", queryLimit+queryOffset)
173183
if err != nil {
174184
wrapped := fmt.Errorf("querying logs (auth=%s): %w", gcp.AuthModeLabel(auth), err)
175185
if hint := authHint(err, auth); hint != "" {
@@ -291,7 +301,7 @@ func runQuery(cmd *cobra.Command, args []string) error {
291301

292302
// Check results cache
293303
resultsCache := cache.New(filepath.Join(config.CacheDir(), "results"), resultsCacheTTL)
294-
cacheKey := resultsCache.Key(env.Cluster, fromTime.Format(time.RFC3339), toTime.Format(time.RFC3339), filterStr)
304+
cacheKey := resultsCache.Key(env.Cluster, env.ProjectID, env.LogViewResource(), fromTime.Format(time.RFC3339), toTime.Format(time.RFC3339), filterStr)
295305

296306
var rawEntries []map[string]interface{}
297307
var totalCount int
@@ -320,8 +330,11 @@ func runQuery(cmd *cobra.Command, args []string) error {
320330
}
321331
defer client.Close()
322332

333+
if rn := env.LogViewResource(); rn != "" {
334+
logInfo("Scope: %s", rn)
335+
}
323336
logInfo("Querying GCP Cloud Logging...")
324-
result, err := client.Query(ctx, filterStr, queryLimit+queryOffset)
337+
result, err := client.Query(ctx, filterStr, env.LogViewResource(), queryLimit+queryOffset)
325338
if err != nil {
326339
wrapped := fmt.Errorf("querying logs (auth=%s): %w", gcp.AuthModeLabel(auth), err)
327340
if hint := authHint(err, auth); hint != "" {

cmd/root.go

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -124,12 +124,25 @@ func resolveEnv() (*config.Environment, error) {
124124
}
125125
rootURL := os.Getenv("TASKCLUSTER_ROOT_URL")
126126
if rootURL != "" {
127+
// Auto-detection deliberately ignores log-view-scoped environments:
128+
// multiple envs (broad + scoped) can share a root_url, and a scoped env
129+
// uses different credentials/scope. Scoped access must be opted into
130+
// explicitly with --env, so detection resolves to the broad env only.
131+
var scopedMatch string
127132
for name, env := range cfg.Environments {
128-
if env.RootURL == rootURL {
129-
e := env
130-
logInfo("Auto-detected environment: %s", name)
131-
return &e, nil
133+
if env.RootURL != rootURL {
134+
continue
132135
}
136+
if env.LogViewResource() != "" {
137+
scopedMatch = name
138+
continue
139+
}
140+
e := env
141+
logInfo("Auto-detected environment: %s", name)
142+
return &e, nil
143+
}
144+
if scopedMatch != "" {
145+
return nil, fmt.Errorf("TASKCLUSTER_ROOT_URL=%q matches only the scoped environment %q; select it explicitly with --env", rootURL, scopedMatch)
133146
}
134147
return nil, fmt.Errorf("TASKCLUSTER_ROOT_URL=%q does not match any environment", rootURL)
135148
}

cmd/root_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"strings"
66
"testing"
77

8+
"github.com/taskcluster/tc-logview/internal/config"
89
"github.com/taskcluster/tc-logview/internal/gcp"
910
)
1011

@@ -53,6 +54,55 @@ func TestAuthModeMessage(t *testing.T) {
5354
}
5455
}
5556

57+
func TestResolveEnvAutoDetectSkipsScoped(t *testing.T) {
58+
prevCfg, prevFlag := cfg, envFlag
59+
t.Cleanup(func() { cfg, envFlag = prevCfg, prevFlag })
60+
61+
envFlag = ""
62+
t.Setenv("TASKCLUSTER_ROOT_URL", "https://firefox-ci-tc.services.mozilla.com")
63+
cfg = &config.Config{Environments: map[string]config.Environment{
64+
"fx-ci": {
65+
ProjectID: "moz-fx-webservices-high-prod",
66+
RootURL: "https://firefox-ci-tc.services.mozilla.com",
67+
},
68+
"fx-ci-scoped": {
69+
ProjectID: "moz-fx-taskcluster-prod",
70+
RootURL: "https://firefox-ci-tc.services.mozilla.com",
71+
LogBucket: "gke-taskcluster-prod-log-bucket",
72+
LogLocation: "global",
73+
LogView: "_AllLogs",
74+
},
75+
}}
76+
77+
// Map iteration is randomized; a nondeterministic resolveEnv would eventually
78+
// pick the scoped env. Run enough times to catch that.
79+
for i := 0; i < 50; i++ {
80+
env, err := resolveEnv()
81+
if err != nil {
82+
t.Fatalf("resolveEnv: %v", err)
83+
}
84+
if env.ProjectID != "moz-fx-webservices-high-prod" || env.LogViewResource() != "" {
85+
t.Fatalf("auto-detect selected scoped env (project=%s, view=%q); want broad env",
86+
env.ProjectID, env.LogViewResource())
87+
}
88+
}
89+
}
90+
91+
func TestResolveEnvScopedOnlyRequiresExplicit(t *testing.T) {
92+
prevCfg, prevFlag := cfg, envFlag
93+
t.Cleanup(func() { cfg, envFlag = prevCfg, prevFlag })
94+
95+
envFlag = ""
96+
t.Setenv("TASKCLUSTER_ROOT_URL", "https://example.com")
97+
cfg = &config.Config{Environments: map[string]config.Environment{
98+
"only-scoped": {ProjectID: "p", RootURL: "https://example.com", LogView: "_AllLogs"},
99+
}}
100+
101+
if _, err := resolveEnv(); err == nil {
102+
t.Fatal("expected error when only a scoped env matches TASKCLUSTER_ROOT_URL")
103+
}
104+
}
105+
56106
func TestAuthHint(t *testing.T) {
57107
adcHintFragment := "gcloud auth application-default login"
58108
tokenHintFragment := "may be expired"

internal/config/config.go

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,16 @@ import (
1010
)
1111

1212
type Environment struct {
13-
ProjectID string `yaml:"project_id"`
13+
ProjectID string `yaml:"project_id"`
1414
CloudSQLProjectID string `yaml:"cloudsql_project_id,omitempty"`
15-
Cluster string `yaml:"cluster"`
16-
Namespace string `yaml:"namespace,omitempty"`
17-
RootURL string `yaml:"root_url"`
18-
KeyPath string `yaml:"key_path,omitempty"`
19-
CloudSQLInstance string `yaml:"cloudsql_instance,omitempty"`
15+
Cluster string `yaml:"cluster"`
16+
Namespace string `yaml:"namespace,omitempty"`
17+
RootURL string `yaml:"root_url"`
18+
KeyPath string `yaml:"key_path,omitempty"`
19+
CloudSQLInstance string `yaml:"cloudsql_instance,omitempty"`
20+
LogBucket string `yaml:"log_bucket,omitempty"`
21+
LogLocation string `yaml:"log_location,omitempty"`
22+
LogView string `yaml:"log_view,omitempty"`
2023
}
2124

2225
// CloudSQLProject returns the GCP project that holds the CloudSQL instance.
@@ -28,6 +31,26 @@ func (e Environment) CloudSQLProject() string {
2831
return e.ProjectID
2932
}
3033

34+
// LogViewResource returns the fully-qualified Cloud Logging log view resource
35+
// name to scope queries to, or "" to query at project scope (the default,
36+
// unchanged behavior). A non-empty LogView is the trigger; LogBucket defaults
37+
// to "_Default" and LogLocation to "global" when unset.
38+
func (e Environment) LogViewResource() string {
39+
if e.LogView == "" {
40+
return ""
41+
}
42+
bucket := e.LogBucket
43+
if bucket == "" {
44+
bucket = "_Default"
45+
}
46+
location := e.LogLocation
47+
if location == "" {
48+
location = "global"
49+
}
50+
return fmt.Sprintf("projects/%s/locations/%s/buckets/%s/views/%s",
51+
e.ProjectID, location, bucket, e.LogView)
52+
}
53+
3154
type Config struct {
3255
Environments map[string]Environment `yaml:"environments"`
3356
}

0 commit comments

Comments
 (0)