diff --git a/Makefile b/Makefile index 3e2994f36..297460aed 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ GOLANGCI_LINT := $(shell command -v golangci-lint 2>/dev/null || echo $(shell go .DEFAULT_GOAL := help -.PHONY: all build install test test-fast vet lint compat-literals golangci-lint web web-typecheck fmt fmt-check ci ci-full clean help container-sciontool container-scion container-binaries proto proto-check +.PHONY: all build install test test-fast vet lint compat-literals golangci-lint web web-typecheck fmt fmt-check ci ci-full clean help container-sciontool container-scion container-binaries ## all: Build the web frontend, then compile the Go binary with embedded assets all: web install @@ -142,18 +142,6 @@ ci-full: fmt-check web web-typecheck lint compat-literals golangci-lint test-fas @echo "" @echo "CI (full) passed." -## proto: Generate Go code from .proto files -proto: - @echo "Generating proto code..." - @protoc --go_out=. --go_opt=paths=source_relative \ - --go-grpc_out=. --go-grpc_opt=paths=source_relative \ - proto/broker/v1/broker.proto - @echo "Proto generation done." - -## proto-check: Verify generated proto code is up to date -proto-check: proto - @git diff --exit-code proto/ || (echo "Proto generated code is stale. Run 'make proto'" && exit 1) - ## clean: Remove build artifacts clean: @echo "Cleaning..." diff --git a/cmd/build.go b/cmd/build.go new file mode 100644 index 000000000..267fbdfea --- /dev/null +++ b/cmd/build.go @@ -0,0 +1,181 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/GoogleCloudPlatform/scion/pkg/config" + "github.com/GoogleCloudPlatform/scion/pkg/runtime" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +var ( + buildTag string + buildBaseImage string + buildPush bool + buildPlatform string + buildDryRun bool +) + +var buildCmd = &cobra.Command{ + Use: "build ", + Short: "Build a container image from a harness-config Dockerfile", + Long: `Build a container image from a Dockerfile bundled inside a harness-config directory. + +The base image is resolved from the image_registry setting unless --base-image +is provided. After a successful build the harness-config's config.yaml image +field is updated to reference the built image.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + harnessConfigName := args[0] + + hcDir, err := config.FindHarnessConfigDir(harnessConfigName, projectPath) + if err != nil { + return fmt.Errorf("harness-config %q not found: %w", harnessConfigName, err) + } + if hcDir.Path == "" { + return fmt.Errorf("harness-config %q does not have a local directory path", harnessConfigName) + } + + dockerfilePath := filepath.Join(hcDir.Path, "Dockerfile") + if _, err := os.Stat(dockerfilePath); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("harness-config %q does not contain a Dockerfile", harnessConfigName) + } + return fmt.Errorf("cannot access Dockerfile in harness-config %q: %w", harnessConfigName, err) + } + + tag := buildTag + + var settings *config.VersionedSettings + if buildBaseImage == "" || buildPush { + settings, _, err = config.LoadEffectiveSettings(projectPath) + if err != nil { + return fmt.Errorf("failed to load settings: %w", err) + } + } + + baseImage := buildBaseImage + if baseImage == "" { + imageRegistry := "" + if settings != nil { + imageRegistry = settings.ResolveImageRegistry(profile) + } + baseImage = "scion-base:" + tag + if imageRegistry != "" { + baseImage = imageRegistry + "/scion-base:" + tag + } + } + + runtimeBin := runtime.DetectContainerRuntime() + if runtimeBin == "" { + return fmt.Errorf("no container runtime found (tried docker, podman)") + } + + outputImage := harnessConfigName + ":" + tag + if buildPush { + imageRegistry := "" + if settings != nil { + imageRegistry = settings.ResolveImageRegistry(profile) + } + if imageRegistry == "" { + return fmt.Errorf("--push requires image_registry to be configured") + } + outputImage = imageRegistry + "/" + harnessConfigName + ":" + tag + } + + buildArgs := []string{"build", + "--build-arg", "BASE_IMAGE=" + baseImage, + "-t", outputImage, + } + if buildPlatform != "" { + buildArgs = append(buildArgs, "--platform", buildPlatform) + } + buildArgs = append(buildArgs, hcDir.Path) + + if buildDryRun { + fmt.Println(runtimeBin + " " + strings.Join(buildArgs, " ")) + return nil + } + + buildExec := exec.CommandContext(cmd.Context(), runtimeBin, buildArgs...) + buildExec.Stdout = os.Stdout + buildExec.Stderr = os.Stderr + if err := buildExec.Run(); err != nil { + return fmt.Errorf("build failed: %w", err) + } + + if buildPush { + pushExec := exec.CommandContext(cmd.Context(), runtimeBin, "push", outputImage) + pushExec.Stdout = os.Stdout + pushExec.Stderr = os.Stderr + if err := pushExec.Run(); err != nil { + return fmt.Errorf("push failed: %w", err) + } + } + + configPath := filepath.Join(hcDir.Path, "config.yaml") + configData, err := os.ReadFile(configPath) + if err != nil { + return fmt.Errorf("failed to read config.yaml for update: %w", err) + } + var doc yaml.Node + if err := yaml.Unmarshal(configData, &doc); err != nil { + return fmt.Errorf("failed to parse config.yaml: %w", err) + } + if len(doc.Content) > 0 && doc.Content[0].Kind == yaml.MappingNode { + mapping := doc.Content[0] + found := false + for i := 0; i < len(mapping.Content)-1; i += 2 { + if mapping.Content[i].Value == "image" { + mapping.Content[i+1].Value = outputImage + found = true + break + } + } + if !found { + mapping.Content = append(mapping.Content, + &yaml.Node{Kind: yaml.ScalarNode, Value: "image"}, + &yaml.Node{Kind: yaml.ScalarNode, Value: outputImage}, + ) + } + } + updatedData, err := yaml.Marshal(&doc) + if err != nil { + return fmt.Errorf("failed to marshal updated config.yaml: %w", err) + } + if err := os.WriteFile(configPath, updatedData, 0644); err != nil { + return fmt.Errorf("failed to write updated config.yaml: %w", err) + } + fmt.Printf("Updated %s image to %s\n", configPath, outputImage) + + return nil + }, +} + +func init() { + rootCmd.AddCommand(buildCmd) + buildCmd.Flags().StringVar(&buildTag, "tag", "latest", "Image tag") + buildCmd.Flags().StringVar(&buildBaseImage, "base-image", "", "Override the base image (skips image_registry resolution)") + buildCmd.Flags().BoolVar(&buildPush, "push", false, "Push built image to image_registry after building") + buildCmd.Flags().StringVar(&buildPlatform, "platform", "", "Target platform (default: current architecture)") + buildCmd.Flags().BoolVar(&buildDryRun, "dry-run", false, "Show the docker build command without executing") +} diff --git a/cmd/cli_mode.go b/cmd/cli_mode.go index c5ad0c4ff..ea6d877c7 100644 --- a/cmd/cli_mode.go +++ b/cmd/cli_mode.go @@ -86,6 +86,16 @@ var agentAllowed = map[string]bool{ "template.push": true, "template.pull": true, "template.status": true, + "harness-config": true, + "harness-config.list": true, + "harness-config.show": true, + "harness-config.install": true, + "harness-config.sync": true, + "harness-config.push": true, + "harness-config.pull": true, + "harness-config.delete": true, + "harness-config.reset": true, + "harness-config.upgrade": true, } // resolveMode determines the active CLI mode from environment and settings. diff --git a/cmd/cli_mode_test.go b/cmd/cli_mode_test.go index fe3234d17..606ccd25a 100644 --- a/cmd/cli_mode_test.go +++ b/cmd/cli_mode_test.go @@ -276,6 +276,7 @@ func TestApplyModeRestrictions_Agent(t *testing.T) { // These commands should be present in agent mode expected := []string{ "create", "delete", + "harness-config", "harness-config.install", "harness-config.list", "help", "list", "logs", "look", "message", @@ -304,7 +305,7 @@ func TestApplyModeRestrictions_Agent(t *testing.T) { // These should be removed absent := []string{ "attach", "broker", "cdw", "clean", "completion", "config", "doctor", - "grove", "harness-config", "hub", + "grove", "hub", "init", "messages", "restore", "server", "sync", } for _, cmd := range absent { @@ -423,6 +424,9 @@ func TestAgentAllowedList(t *testing.T) { "template", "template.list", "template.show", "template.clone", "template.delete", "template.import", "template.sync", "template.push", "template.pull", "template.status", + "harness-config", "harness-config.list", "harness-config.show", "harness-config.install", + "harness-config.sync", "harness-config.push", "harness-config.pull", + "harness-config.delete", "harness-config.reset", "harness-config.upgrade", } for _, path := range expectedAllowed { assert.True(t, agentAllowed[path], "agentAllowed should contain %s", path) @@ -432,7 +436,6 @@ func TestAgentAllowedList(t *testing.T) { "attach", "restore", "sync", "clean", "cdw", "init", "completion", "config", "doctor", "hub", "messages", "server", "broker", "grove", - "harness-config", "config.set", "config.validate", "config.migrate", "config.list", "config.get", "config.dir", "config.schema", "hub.enable", "hub.disable", "hub.link", "hub.unlink", diff --git a/cmd/sciontool/commands/secret.go b/cmd/sciontool/commands/secret.go index 8af3bfec4..3db24daed 100644 --- a/cmd/sciontool/commands/secret.go +++ b/cmd/sciontool/commands/secret.go @@ -73,13 +73,22 @@ Examples: // Handle @file syntax: read file and base64-encode contents. if strings.HasPrefix(value, "@") { filePath := value[1:] - if strings.HasPrefix(filePath, "~/") { + if filePath == "~" || strings.HasPrefix(filePath, "~/") { home, err := os.UserHomeDir() if err != nil { log.Error("Failed to expand home directory: %v", err) os.Exit(1) } - filePath = home + filePath[1:] + filePath = filepath.Join(home, strings.TrimPrefix(filePath[1:], "/")) + } + info, err := os.Stat(filePath) + if err != nil { + log.Error("Failed to stat file %s: %v", filePath, err) + os.Exit(1) + } + if info.Size() > 64*1024 { + log.Error("File exceeds 64KB limit (%d bytes)", info.Size()) + os.Exit(1) } data, err := os.ReadFile(filePath) if err != nil { diff --git a/cmd/server_foreground.go b/cmd/server_foreground.go index 0dd5b4f61..25d952229 100644 --- a/cmd/server_foreground.go +++ b/cmd/server_foreground.go @@ -44,6 +44,8 @@ import ( "github.com/GoogleCloudPlatform/scion/pkg/harness" "github.com/GoogleCloudPlatform/scion/pkg/hub" "github.com/GoogleCloudPlatform/scion/pkg/observability/dbmetrics" + "github.com/GoogleCloudPlatform/scion/pkg/observability/dispatchmetrics" + "github.com/GoogleCloudPlatform/scion/pkg/observability/hubmetrics" scionplugin "github.com/GoogleCloudPlatform/scion/pkg/plugin" "github.com/GoogleCloudPlatform/scion/pkg/runtime" "github.com/GoogleCloudPlatform/scion/pkg/runtimebroker" @@ -211,6 +213,7 @@ func runServerStart(cmd *cobra.Command, args []string) error { // 11. Start Hub var hubSrv *hub.Server var secretBackend secret.SecretBackend + var hubDBRec dbmetrics.Recorder if enableHub { // Initialize secret backend early so signing keys can be loaded from it // during hub server creation. This prevents the previous bug where @@ -232,13 +235,62 @@ func runServerStart(cmd *cobra.Command, args []string) error { log.Fatalf("Hub server failed to start: %v", hubInitErr) } + // Wire hub OTel metrics export to Cloud Monitoring. + if cfg.Hub.GCPProjectID != "" { + mp, mpErr := hubmetrics.NewMeterProvider(ctx, cfg.Hub.GCPProjectID, + hubmetrics.WithHubID(hubSrv.HubID()), + ) + if mpErr != nil { + log.Printf("WARNING: hub metrics export disabled: %v", mpErr) + } else { + defer func() { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = mp.Shutdown(shutdownCtx) + }() + + dbRec, dbErr := dbmetrics.New(mp) + if dbErr != nil { + log.Printf("WARNING: hub db metrics disabled: %v", dbErr) + } else { + hubDBRec = dbRec + hubSrv.SetDBMetrics(dbRec) + } + + dispRec, dispErr := dispatchmetrics.New(mp) + if dispErr != nil { + log.Printf("WARNING: hub dispatch metrics disabled: %v", dispErr) + } else { + hubSrv.SetDispatchMetrics(dispRec) + } + + if hubSrv.GetBrokerAuthService() != nil { + otelMetrics, otelAuthErr := hub.NewOTelMetricsRecorder(mp) + if otelAuthErr != nil { + log.Printf("WARNING: hub auth metrics OTel export disabled: %v", otelAuthErr) + } else { + hubSrv.SetMetrics(otelMetrics) + } + } + + otelGCP, otelGCPErr := hub.NewOTelGCPTokenMetrics(mp) + if otelGCPErr != nil { + log.Printf("WARNING: hub GCP token metrics OTel export disabled: %v", otelGCPErr) + } else { + hubSrv.SetGCPTokenMetrics(otelGCP) + } + + log.Printf("Hub OTel metrics export enabled (project: %s)", cfg.Hub.GCPProjectID) + } + } + // Wire command bus for cross-node dispatch (B2-4). cmdBus := newCommandBus(ctx, cfg, hubSrv) hubSrv.SetCommandBus(cmdBus) if !enableWeb { // Hub runs its own HTTP server (standalone mode). - eventPub := newEventPublisher(ctx, cfg) + eventPub := newEventPublisher(ctx, cfg, hubDBRec) hubSrv.SetEventPublisher(eventPub) log.Printf("Starting Hub API server on %s:%d", cfg.Hub.Host, cfg.Hub.Port) @@ -266,7 +318,7 @@ func runServerStart(cmd *cobra.Command, args []string) error { // 12. Start Web var webSrv *hub.WebServer if enableWeb { - webSrv = initWebServer(ctx, cfg, hubSrv, devAuthToken, adminEmailList, adminMode, maintenanceMessage, requestLogger) + webSrv = initWebServer(ctx, cfg, hubSrv, devAuthToken, adminEmailList, adminMode, maintenanceMessage, requestLogger, hubDBRec) // In combined mode, start Hub background services now that the // ChannelEventPublisher has been wired by initWebServer. @@ -1167,11 +1219,12 @@ func initHubStorage(ctx context.Context, hubSrv *hub.Server, cfg *config.GlobalC // ChannelEventPublisher. If the Postgres publisher cannot be started it falls // back to the in-process publisher so a single instance still functions, logging // a prominent warning since cross-replica SSE delivery will be unavailable. -func newEventPublisher(ctx context.Context, cfg *config.GlobalConfig) hub.EventPublisher { +func newEventPublisher(ctx context.Context, cfg *config.GlobalConfig, dbRec dbmetrics.Recorder) hub.EventPublisher { if strings.EqualFold(cfg.Database.Driver, "postgres") { - // Metrics export is wired separately (see pkg/observability/dbmetrics); - // use a disabled recorder until a MeterProvider is configured. - pub, err := hub.NewPostgresEventPublisher(ctx, cfg.Database.URL, dbmetrics.NewDisabled(), logging.Subsystem("hub.events")) + if dbRec == nil { + dbRec = dbmetrics.NewDisabled() + } + pub, err := hub.NewPostgresEventPublisher(ctx, cfg.Database.URL, dbRec, logging.Subsystem("hub.events")) if err != nil { log.Printf("WARNING: failed to start Postgres event publisher (%v); falling back to in-process events. Cross-replica SSE will not work.", err) return hub.NewChannelEventPublisher() @@ -1208,7 +1261,7 @@ func newCommandBus(ctx context.Context, cfg *config.GlobalConfig, hubSrv *hub.Se // initWebServer creates and configures the Web server. The provided context is // threaded to the event publisher so that the Postgres LISTEN/NOTIFY goroutine // is cancelled cleanly on shutdown, preventing connection leaks. -func initWebServer(ctx context.Context, cfg *config.GlobalConfig, hubSrv *hub.Server, devAuthToken string, adminEmailList []string, adminMode bool, maintenanceMessage string, requestLogger *slog.Logger) *hub.WebServer { +func initWebServer(ctx context.Context, cfg *config.GlobalConfig, hubSrv *hub.Server, devAuthToken string, adminEmailList []string, adminMode bool, maintenanceMessage string, requestLogger *slog.Logger, dbRec dbmetrics.Recorder) *hub.WebServer { webHost := cfg.Hub.Host if webHost == "" { webHost = "0.0.0.0" @@ -1264,7 +1317,7 @@ func initWebServer(ctx context.Context, cfg *config.GlobalConfig, hubSrv *hub.Se webSrv.SetRequestLogger(requestLogger) // Create shared event publisher for real-time SSE - eventPub := newEventPublisher(ctx, cfg) + eventPub := newEventPublisher(ctx, cfg, dbRec) webSrv.SetEventPublisher(eventPub) // Wire Hub services into WebServer if Hub is enabled @@ -1569,11 +1622,6 @@ func initPluginManager() *scionplugin.Manager { return mgr } - // Set up the gRPC broker factory before loading plugins. - mgr.SetGRPCBrokerFactory(func(address, channel string, log *slog.Logger) (scionplugin.ConfigurableEventBus, error) { - return hub.NewGRPCBrokerAdapter(address, channel, log) - }) - // Convert V1PluginsConfig to plugin.PluginsConfig pluginsCfg := scionplugin.PluginsConfig{ Broker: make(map[string]scionplugin.PluginEntry), @@ -1584,7 +1632,6 @@ func initPluginManager() *scionplugin.Manager { Config: entry.Config, SelfManaged: entry.SelfManaged, Address: entry.Address, - Mode: entry.Mode, } } diff --git a/extras/scion-discord/Dockerfile b/extras/scion-discord/Dockerfile deleted file mode 100644 index c49f90c73..000000000 --- a/extras/scion-discord/Dockerfile +++ /dev/null @@ -1,9 +0,0 @@ -# Build from repo root (extras/scion-discord uses replace directive to ../../) -FROM golang:1.26 AS builder -WORKDIR /src -COPY . . -RUN CGO_ENABLED=0 go build -o /discord-bot ./extras/scion-discord/cmd/scion-plugin-discord - -FROM gcr.io/distroless/static-debian12 -COPY --from=builder /discord-bot /discord-bot -ENTRYPOINT ["/discord-bot", "--standalone"] diff --git a/extras/scion-discord/README.md b/extras/scion-discord/README.md index 434a6c278..b5651711e 100644 --- a/extras/scion-discord/README.md +++ b/extras/scion-discord/README.md @@ -34,7 +34,7 @@ Under the **Bot** tab, scroll to **Privileged Gateway Intents** and enable: Go to the **OAuth2** tab, then **URL Generator**: 1. Select scopes: `bot` and `applications.commands` -2. Select the bot permissions listed below (or use the permissions integer `277562386496`) +2. Select the bot permissions listed below (or use the permissions integer `329101954112`) 3. Copy the generated URL and open it to invite the bot #### Required Bot Permissions @@ -66,7 +66,7 @@ sudo install scion-plugin-discord /usr/local/bin/ ### 4. Configure settings.yaml -Add the Discord plugin to the hub's `settings.yaml`: +Add the Discord plugin to the hub's `settings.yaml` (note that `plugins` MUST be nested under the `server` block): ```yaml server: @@ -75,26 +75,26 @@ server: types: - discord -plugins: - broker: - discord: - config: - bot_token: "your-bot-token" - application_id: "your-application-id" - public_key: "your-public-key" - - # Guild-scoped command registration (instant updates, good for dev). - # Leave empty for global commands (can take up to 1 hour to propagate). - guild_id: "" - - # SQLite database for channel links, user mappings, and state. - # Default: discord.db (relative to hub working directory). - db_path: /var/lib/scion/discord.db - - # Optional tuning. - # send_queue_size: 100 # max queued messages per channel - # send_min_delay: 50ms # minimum delay between sends (rate limiting) - # agent_cache_ttl: 5m # how long to cache agent lists from hub + plugins: + broker: + discord: + config: + bot_token: "your-bot-token" + application_id: "your-application-id" + public_key: "your-public-key" + + # Guild-scoped command registration (instant updates, good for dev). + # Leave empty for global commands (can take up to 1 hour to propagate). + guild_id: "" + + # SQLite database for channel links, user mappings, and state. + # Default: discord.db (relative to hub working directory). + db_path: /var/lib/scion/discord.db + + # Optional tuning. + # send_queue_size: 100 # max queued messages per channel + # send_min_delay: 50ms # minimum delay between sends (rate limiting) + # agent_cache_ttl: 5m # how long to cache agent lists from hub ``` ### 5. Start the Hub @@ -114,6 +114,51 @@ The hub will discover and launch `scion-plugin-discord` as a managed subprocess. 2. **Run `/scion setup`** in any channel → select a project from the list 3. **Register your identity:** run `/scion register` → click the link → authenticate on your hub's profile page (`/profile/discord`) +## Agent-Led Installation and Setup + +If you are using an AI coding assistant or deployment agent (like Antigravity) to set up and configure this plugin on your Scion instance, you can guide the agent with the following instructions: + +### 1. Interactive Information Gathering +An agent should proactively ask the user for: +- **Discord Bot Token:** (e.g. `MTUxNDcwOD...`) +- **Discord Application ID:** (e.g. `1514708...`) +- **Discord Public Key (Optional):** + +Upon receiving the **Application ID**, the agent can automatically construct and output the Discord Server Invitation URL using the required permissions integer `329101954112` (which covers all mandatory permissions, including `Manage Webhooks`): +```text +https://discord.com/api/oauth2/authorize?client_id=&permissions=329101954112&scope=bot%20applications.commands +``` + +### 2. Remote Configuration via gcloud ssh +The agent can automatically configure your remote GCE server: +1. **Identify GCE Instance:** Determine the running instance name, zone, and project ID. +2. **Build and Install Plugin:** Compile the binary locally or directly on the remote VM, and install to `/usr/local/bin/scion-plugin-discord`. +3. **Inject Settings:** Append or modify the YAML configuration inside the remote settings file (located at `/home/scion/.scion/settings.yaml`). +4. **Service Restart & Verification:** Safely restart the service and stream the logs. + +### 3. Agent Prompts +You can copy and paste the following prompt to have an agent execute this installation: + +> **Agent Prompt:** +> Please configure the Discord plugin on our active Scion Hub instance. +> +> 1. Ask me for my Discord Bot Token and Application ID. +> 2. Once I provide the Application ID, generate and output my Discord bot server invite link with permissions set to `329101954112`. +> 3. SSH into the active GCE VM and configure the `/home/scion/.scion/settings.yaml` file: +> - Ensure `- discord` is enabled under `server.message_broker.types`. +> - Add the `server.plugins.broker.discord` block with the provided token and app-id (ensure `plugins` is nested under `server:` and not at the root level). +> - Set `db_path` to `/home/scion/.scion/discord.db`. +> 4. Run `sudo systemctl restart scion-hub` and check the logs via `journalctl` to verify that the message `Discord gateway connected` or `Discord bot ready` is present. + +### 4. Verification Checklist (for the Agent) +The agent should verify the following to confirm a successful installation: +- [ ] `which scion-plugin-discord` returns `/usr/local/bin/scion-plugin-discord`. +- [ ] The SQLite database directory for `db_path` exists and is writable by the `scion` user. +- [ ] `/home/scion/.scion/settings.yaml` is valid YAML and includes the `discord` broker type. +- [ ] The `plugins:` block is properly nested under the `server:` block in `/home/scion/.scion/settings.yaml`. +- [ ] `systemctl is-active scion-hub` returns `active`. + + ## User Guide ### Slash Commands @@ -200,18 +245,18 @@ server: - broker-log - discord -plugins: - broker: - broker-log: - self_managed: true - address: "localhost:9091" - discord: - config: - bot_token: "MTIzNDU2Nzg5.example.token" - application_id: "123456789012345678" - public_key: "abcdef1234567890abcdef1234567890abcdef1234567890" - guild_id: "987654321098765432" - db_path: /var/lib/scion/discord.db + plugins: + broker: + broker-log: + self_managed: true + address: "localhost:9091" + discord: + config: + bot_token: "MTIzNDU2Nzg5.example.token" + application_id: "123456789012345678" + public_key: "abcdef1234567890abcdef1234567890abcdef1234567890" + guild_id: "987654321098765432" + db_path: /var/lib/scion/discord.db ``` ## Architecture @@ -250,3 +295,21 @@ Discord Gateway API - **SQLite state** persists channel links, user mappings, conversation contexts, notification preferences, and pending ask-user callbacks across restarts. - **Send queue** uses per-channel worker goroutines with configurable rate limiting to avoid Discord 429 errors. - **Webhook identity** gives each agent a unique name and RoboHash avatar in Discord, managed per-channel with automatic recreation if deleted. + +## Troubleshooting + +### Disallowed Gateway Intents (Error 4014) + +If the hub logs contain an error similar to: +```text +websocket: close 4014: Disallowed intent(s). +``` +This means the bot has not been granted the required privileged intents in the Discord Developer Portal. + +**Solution:** +1. Navigate to [discord.com/developers/applications](https://discord.com/developers/applications) and select your application. +2. Go to the **Bot** tab on the left-side menu. +3. Scroll down to the **Privileged Gateway Intents** section. +4. Enable both **Server Members Intent** and **Message Content Intent**. +5. Click **Save Changes** and restart your Scion hub server (`sudo systemctl restart scion-hub`). + diff --git a/extras/scion-discord/cmd/scion-plugin-discord/main.go b/extras/scion-discord/cmd/scion-plugin-discord/main.go index f0537e18d..4cbfa5005 100644 --- a/extras/scion-discord/cmd/scion-plugin-discord/main.go +++ b/extras/scion-discord/cmd/scion-plugin-discord/main.go @@ -1,31 +1,19 @@ // scion-plugin-discord is the Discord message broker plugin for scion. // It can run as: // - A go-plugin subprocess (when launched by the scion plugin manager) -// - A standalone gRPC server with HA advisory-lock-based leader election +// - A standalone binary that prints usage information // // Plugin mode is auto-detected via the SCION_PLUGIN magic cookie environment variable. -// Standalone mode is activated via --standalone flag or DISCORD_STANDALONE=true env var. package main import ( - "context" "fmt" "log/slog" - "net" "os" - "os/signal" - "sync/atomic" - "syscall" - "time" "github.com/GoogleCloudPlatform/scion/extras/scion-discord/internal/discord" "github.com/GoogleCloudPlatform/scion/pkg/plugin" - "github.com/GoogleCloudPlatform/scion/pkg/store" - brokerv1 "github.com/GoogleCloudPlatform/scion/proto/broker/v1" goplugin "github.com/hashicorp/go-plugin" - "google.golang.org/grpc" - "google.golang.org/grpc/health" - healthpb "google.golang.org/grpc/health/grpc_health_v1" ) func main() { @@ -35,11 +23,6 @@ func main() { return } - if os.Getenv("DISCORD_STANDALONE") == "true" || hasFlag("--standalone") { - serveStandalone() - return - } - // Otherwise, print usage information. fmt.Println("scion-plugin-discord: Discord message broker plugin for Scion") fmt.Println() @@ -47,22 +30,7 @@ func main() { fmt.Println("It communicates with the Discord Gateway API to provide bidirectional") fmt.Println("messaging between Discord channels and Scion agents.") fmt.Println() - fmt.Println("Usage:") - fmt.Println(" scion-plugin-discord --standalone Run as standalone gRPC server") - fmt.Println() - fmt.Println("Environment variables (standalone mode):") - fmt.Println(" DISCORD_STANDALONE=true Enable standalone mode") - fmt.Println(" DISCORD_BOT_TOKEN (required) Discord bot token") - fmt.Println(" DISCORD_APPLICATION_ID Discord application ID") - fmt.Println(" DISCORD_PUBLIC_KEY Discord public key") - fmt.Println(" DISCORD_GUILD_ID Guild ID for guild-scoped commands") - fmt.Println(" HUB_URL Hub API URL for inbound message delivery") - fmt.Println(" HMAC_KEY Base64-encoded HMAC key") - fmt.Println(" BROKER_ID Broker ID for HMAC signing") - fmt.Println(" DATABASE_URL PostgreSQL connection string") - fmt.Println(" GRPC_LISTEN_ADDRESS gRPC listen address (default :50051)") - fmt.Println() - fmt.Println("Configuration keys (plugin mode):") + fmt.Println("Configuration keys:") fmt.Println(" bot_token (required) Discord bot token") fmt.Println(" application_id Discord application ID (for slash commands)") fmt.Println(" public_key Discord public key (for interaction verification)") @@ -78,15 +46,6 @@ func main() { os.Exit(0) } -func hasFlag(flag string) bool { - for _, arg := range os.Args[1:] { - if arg == flag { - return true - } - } - return false -} - func servePlugin() { log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})) @@ -106,151 +65,3 @@ func servePlugin() { }, }) } - -func serveStandalone() { - log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})) - log.Info("Starting Discord broker in standalone mode") - - botToken := os.Getenv("DISCORD_BOT_TOKEN") - if botToken == "" { - log.Error("DISCORD_BOT_TOKEN is required") - os.Exit(1) - } - - databaseURL := os.Getenv("DATABASE_URL") - if databaseURL == "" { - log.Error("DATABASE_URL is required for standalone mode") - os.Exit(1) - } - - listenAddr := os.Getenv("GRPC_LISTEN_ADDRESS") - if listenAddr == "" { - listenAddr = ":50051" - } - - broker := discord.NewBroker(log) - - config := map[string]string{ - "bot_token": botToken, - "application_id": os.Getenv("DISCORD_APPLICATION_ID"), - "public_key": os.Getenv("DISCORD_PUBLIC_KEY"), - "guild_id": os.Getenv("DISCORD_GUILD_ID"), - "hub_url": os.Getenv("HUB_URL"), - "hmac_key": os.Getenv("HMAC_KEY"), - "broker_id": os.Getenv("BROKER_ID"), - "database_driver": "postgres", - "database_url": databaseURL, - } - - if err := broker.Configure(config); err != nil { - log.Error("Failed to configure broker", "error", err) - os.Exit(1) - } - - lockStore, err := discord.NewPostgresStore(databaseURL) - if err != nil { - log.Error("Failed to open lock store", "error", err) - os.Exit(1) - } - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - lis, err := net.Listen("tcp", listenAddr) - if err != nil { - log.Error("Failed to listen", "address", listenAddr, "error", err) - os.Exit(1) - } - - var lockHeld atomic.Bool - - grpcServer := grpc.NewServer() - brokerv1.RegisterBrokerServiceServer(grpcServer, discord.NewBrokerGRPCServer(broker, log, &lockHeld)) - - healthServer := health.NewServer() - healthpb.RegisterHealthServer(grpcServer, healthServer) - healthServer.SetServingStatus("", healthpb.HealthCheckResponse_NOT_SERVING) - go runAdvisoryLockLoop(ctx, log, lockStore, broker, &lockHeld, healthServer) - - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT) - - go func() { - <-sigCh - log.Info("Received shutdown signal") - cancel() - grpcServer.GracefulStop() - }() - - log.Info("gRPC server listening", "address", listenAddr) - if err := grpcServer.Serve(lis); err != nil { - log.Error("gRPC server failed", "error", err) - } - - // Cleanup after Serve() returns (triggered by GracefulStop or error). - if err := broker.Close(); err != nil { - log.Warn("Error closing broker", "error", err) - } - - if lockHeld.Load() { - if err := lockStore.ReleaseAdvisoryLock(context.Background(), int64(store.LockDiscordGateway)); err != nil { - log.Warn("Error releasing advisory lock", "error", err) - } - } - - if err := lockStore.Close(); err != nil { - log.Warn("Error closing lock store", "error", err) - } -} - -func runAdvisoryLockLoop(ctx context.Context, log *slog.Logger, lockStore discord.Store, broker *discord.DiscordBroker, lockHeld *atomic.Bool, healthServer *health.Server) { - const retryInterval = 30 * time.Second - const pingInterval = 30 * time.Second - - for { - acquired, err := lockStore.TryAdvisoryLock(ctx, int64(store.LockDiscordGateway)) - if err != nil { - if ctx.Err() != nil { - return - } - log.Error("Failed to try advisory lock", "error", err) - } else if acquired { - lockHeld.Store(true) - log.Info("Acquired gateway lock, starting as primary") - if err := broker.Subscribe(">"); err != nil { - log.Error("Failed to subscribe (start gateway)", "error", err) - lockHeld.Store(false) - goto waitRetry - } - healthServer.SetServingStatus("", healthpb.HealthCheckResponse_SERVING) - - // Monitor the lock connection; demote if it drops. - for { - select { - case <-ctx.Done(): - return - case <-time.After(pingInterval): - } - if err := lockStore.PingLockConn(ctx); err != nil { - log.Warn("Lock connection lost, demoting to standby", "error", err) - if closeErr := broker.Close(); closeErr != nil { - log.Warn("Error closing broker after lock loss", "error", closeErr) - } - _ = lockStore.ReleaseAdvisoryLock(context.Background(), int64(store.LockDiscordGateway)) - lockHeld.Store(false) - healthServer.SetServingStatus("", healthpb.HealthCheckResponse_NOT_SERVING) - break - } - } - } else { - log.Info("Another instance holds gateway lock, standing by") - } - - waitRetry: - select { - case <-ctx.Done(): - return - case <-time.After(retryInterval): - } - } -} diff --git a/extras/scion-discord/go.mod b/extras/scion-discord/go.mod index f89348f11..d92a949c2 100644 --- a/extras/scion-discord/go.mod +++ b/extras/scion-discord/go.mod @@ -8,7 +8,6 @@ require ( github.com/hashicorp/go-plugin v1.7.0 github.com/jackc/pgx/v5 v5.10.0 github.com/stretchr/testify v1.11.1 - google.golang.org/grpc v1.80.0 modernc.org/sqlite v1.44.3 ) @@ -39,6 +38,7 @@ require ( golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.35.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/grpc v1.80.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.67.6 // indirect diff --git a/extras/scion-discord/internal/discord/broker.go b/extras/scion-discord/internal/discord/broker.go index 52298b9fb..674ab5a65 100644 --- a/extras/scion-discord/internal/discord/broker.go +++ b/extras/scion-discord/internal/discord/broker.go @@ -154,11 +154,6 @@ func (b *DiscordBroker) Configure(config map[string]string) error { // Phase 1: Bot token configuration. botToken, hasBotToken := config["bot_token"] if hasBotToken && botToken != "" { - if b.session != nil { - _ = b.session.Close() - b.session = nil - } - // Create a discordgo session but do NOT open the gateway yet. // Gateway connection happens on first Subscribe(). session, err := discordgo.New("Bot " + botToken) @@ -455,14 +450,20 @@ func (b *DiscordBroker) Publish(ctx context.Context, topic string, msg *messages return nil } + // Always suppress commentary messages — Discord has no user toggle for this. + if msg != nil && msg.Type == messages.TypeAssistantReply { + b.log.Debug("Filtering assistant-reply message (commentary always suppressed in Discord)") + return nil + } + // Determine whether this message should be sent via webhook (agent identity) // or via the bot API. Webhook routing applies when: // - Sender is an agent (starts with "agent:") - // - Message type is TypeAssistantReply or TypeInstruction + // - Message type is TypeInstruction // State changes and input-needed messages keep the bot identity (embed style). useWebhook := webhooks != nil && strings.HasPrefix(msg.Sender, "agent:") && - (msg.Type == messages.TypeAssistantReply || msg.Type == messages.TypeInstruction) + msg.Type == messages.TypeInstruction // Extract agent slug from sender for webhook username. senderSlug := agentSlug @@ -488,8 +489,7 @@ func (b *DiscordBroker) Publish(ctx context.Context, topic string, msg *messages strings.HasPrefix(msg.Sender, "agent:") && strings.HasPrefix(msg.Recipient, "agent:") isStateChange := msg != nil && msg.Type == messages.TypeStateChange - isAssistantReply := msg != nil && msg.Type == messages.TypeAssistantReply - needsFilter := isAgentToAgent || isStateChange || isAssistantReply + needsFilter := isAgentToAgent || isStateChange // Send to each target channel. var errs []error @@ -505,10 +505,6 @@ func (b *DiscordBroker) Publish(ctx context.Context, topic string, msg *messages b.log.Debug("Filtering state change notification", "channel_id", channelID) continue } - if isAssistantReply && !link.ShowAssistantReply { - b.log.Debug("Filtering assistant-reply message", "channel_id", channelID) - continue - } } } diff --git a/extras/scion-discord/internal/discord/callbacks.go b/extras/scion-discord/internal/discord/callbacks.go index d456cd2f1..0ae78dc79 100644 --- a/extras/scion-discord/internal/discord/callbacks.go +++ b/extras/scion-discord/internal/discord/callbacks.go @@ -190,7 +190,7 @@ func (h *CallbackHandler) saveChannelLink(ctx context.Context, i *discordgo.Inte LinkedBy: linkedBy, LinkedAt: time.Now(), Active: true, - ShowAssistantReply: true, + ShowAssistantReply: false, ShowStateChanges: true, NotifyInGroup: true, } diff --git a/extras/scion-discord/internal/discord/grpc_server.go b/extras/scion-discord/internal/discord/grpc_server.go deleted file mode 100644 index 7a7b4cec8..000000000 --- a/extras/scion-discord/internal/discord/grpc_server.go +++ /dev/null @@ -1,174 +0,0 @@ -package discord - -import ( - "context" - "log/slog" - "sync/atomic" - - brokerv1 "github.com/GoogleCloudPlatform/scion/proto/broker/v1" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - - "github.com/GoogleCloudPlatform/scion/pkg/messages" - "github.com/GoogleCloudPlatform/scion/pkg/plugin" -) - -type brokerGRPCServer struct { - brokerv1.UnimplementedBrokerServiceServer - broker *DiscordBroker - log *slog.Logger - isLeader *atomic.Bool -} - -// NewBrokerGRPCServer creates a gRPC server that delegates to the given DiscordBroker. -// The isLeader flag gates mutating RPCs so that standby instances reject requests. -func NewBrokerGRPCServer(broker *DiscordBroker, log *slog.Logger, isLeader *atomic.Bool) brokerv1.BrokerServiceServer { - if log == nil { - log = slog.Default() - } - return &brokerGRPCServer{ - broker: broker, - log: log, - isLeader: isLeader, - } -} - -func (s *brokerGRPCServer) Configure(_ context.Context, req *brokerv1.ConfigureRequest) (*brokerv1.ConfigureResponse, error) { - if s.isLeader != nil && !s.isLeader.Load() { - return nil, status.Error(codes.Unavailable, "this instance is not the leader") - } - if err := s.broker.Configure(req.GetConfig()); err != nil { - return nil, err - } - return &brokerv1.ConfigureResponse{}, nil -} - -func (s *brokerGRPCServer) Publish(ctx context.Context, req *brokerv1.PublishRequest) (*brokerv1.PublishResponse, error) { - if s.isLeader != nil && !s.isLeader.Load() { - return nil, status.Error(codes.Unavailable, "this instance is not the leader") - } - msg := protoToStructuredMessage(req.GetMessage()) - if err := s.broker.Publish(ctx, req.GetTopic(), msg); err != nil { - return nil, err - } - return &brokerv1.PublishResponse{}, nil -} - -func (s *brokerGRPCServer) Subscribe(_ context.Context, req *brokerv1.SubscribeRequest) (*brokerv1.SubscribeResponse, error) { - if s.isLeader != nil && !s.isLeader.Load() { - return nil, status.Error(codes.Unavailable, "this instance is not the leader") - } - if err := s.broker.Subscribe(req.GetPattern()); err != nil { - return nil, err - } - return &brokerv1.SubscribeResponse{}, nil -} - -func (s *brokerGRPCServer) Unsubscribe(_ context.Context, req *brokerv1.UnsubscribeRequest) (*brokerv1.UnsubscribeResponse, error) { - if err := s.broker.Unsubscribe(req.GetPattern()); err != nil { - return nil, err - } - return &brokerv1.UnsubscribeResponse{}, nil -} - -func (s *brokerGRPCServer) HealthCheck(_ context.Context, _ *brokerv1.HealthCheckRequest) (*brokerv1.HealthCheckResponse, error) { - hs, err := s.broker.HealthCheck() - if err != nil { - return nil, err - } - return &brokerv1.HealthCheckResponse{ - Status: healthStatusToProto(hs), - }, nil -} - -func (s *brokerGRPCServer) GetInfo(_ context.Context, _ *brokerv1.GetInfoRequest) (*brokerv1.GetInfoResponse, error) { - info, err := s.broker.GetInfo() - if err != nil { - return nil, err - } - return &brokerv1.GetInfoResponse{ - Info: pluginInfoToProto(info), - }, nil -} - -// --- Conversion functions --- - -func protoToStructuredMessage(pb *brokerv1.StructuredMessage) *messages.StructuredMessage { - if pb == nil { - return nil - } - return &messages.StructuredMessage{ - Version: int(pb.Version), - Timestamp: pb.Timestamp, - Sender: pb.Sender, - SenderID: pb.SenderId, - Recipient: pb.Recipient, - RecipientID: pb.RecipientId, - Recipients: pb.Recipients, - Msg: pb.Msg, - Type: pb.Type, - Plain: pb.Plain, - Raw: pb.Raw, - Urgent: pb.Urgent, - Broadcasted: pb.Broadcasted, - ObserverOnly: pb.ObserverOnly, - Status: pb.Status, - Attachments: pb.Attachments, - Metadata: pb.Metadata, - Channel: pb.Channel, - ThreadID: pb.ThreadId, - Visibility: pb.Visibility, - } -} - -func structuredMessageToProto(msg *messages.StructuredMessage) *brokerv1.StructuredMessage { - if msg == nil { - return nil - } - return &brokerv1.StructuredMessage{ - Version: int32(msg.Version), - Timestamp: msg.Timestamp, - Sender: msg.Sender, - SenderId: msg.SenderID, - Recipient: msg.Recipient, - RecipientId: msg.RecipientID, - Recipients: msg.Recipients, - Msg: msg.Msg, - Type: msg.Type, - Plain: msg.Plain, - Raw: msg.Raw, - Urgent: msg.Urgent, - Broadcasted: msg.Broadcasted, - ObserverOnly: msg.ObserverOnly, - Status: msg.Status, - Attachments: msg.Attachments, - Metadata: msg.Metadata, - Channel: msg.Channel, - ThreadId: msg.ThreadID, - Visibility: msg.Visibility, - } -} - -func healthStatusToProto(hs *plugin.HealthStatus) *brokerv1.HealthStatus { - if hs == nil { - return nil - } - return &brokerv1.HealthStatus{ - Status: hs.Status, - Message: hs.Message, - Details: hs.Details, - } -} - -func pluginInfoToProto(info *plugin.PluginInfo) *brokerv1.PluginInfo { - if info == nil { - return nil - } - return &brokerv1.PluginInfo{ - Name: info.Name, - Version: info.Version, - MinScionVersion: info.MinScionVersion, - ChannelId: info.ChannelID, - Capabilities: info.Capabilities, - } -} diff --git a/extras/scion-discord/internal/discord/grpc_server_test.go b/extras/scion-discord/internal/discord/grpc_server_test.go deleted file mode 100644 index 7d2ccbf56..000000000 --- a/extras/scion-discord/internal/discord/grpc_server_test.go +++ /dev/null @@ -1,175 +0,0 @@ -package discord - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - brokerv1 "github.com/GoogleCloudPlatform/scion/proto/broker/v1" - - "github.com/GoogleCloudPlatform/scion/pkg/messages" - "github.com/GoogleCloudPlatform/scion/pkg/plugin" -) - -func TestProtoToStructuredMessage_Nil(t *testing.T) { - assert.Nil(t, protoToStructuredMessage(nil)) -} - -func TestStructuredMessageToProto_Nil(t *testing.T) { - assert.Nil(t, structuredMessageToProto(nil)) -} - -func TestStructuredMessageRoundTrip(t *testing.T) { - original := &messages.StructuredMessage{ - Version: 2, - Timestamp: "2026-06-10T12:00:00Z", - Sender: "agent:coder", - SenderID: "sender-123", - Recipient: "user:alice@example.com", - RecipientID: "recipient-456", - Recipients: "group:team", - Msg: "Hello, world!", - Type: messages.TypeAssistantReply, - Plain: true, - Raw: false, - Urgent: true, - Broadcasted: true, - ObserverOnly: false, - Status: "RUNNING", - Attachments: []string{"file1.txt", "file2.png"}, - Metadata: map[string]string{ - "discord_channel_id": "ch-789", - "project_id": "proj-abc", - }, - Channel: "discord", - ThreadID: "thread-001", - Visibility: "public", - } - - pb := structuredMessageToProto(original) - require.NotNil(t, pb) - - roundTripped := protoToStructuredMessage(pb) - require.NotNil(t, roundTripped) - - assert.Equal(t, original.Version, roundTripped.Version) - assert.Equal(t, original.Timestamp, roundTripped.Timestamp) - assert.Equal(t, original.Sender, roundTripped.Sender) - assert.Equal(t, original.SenderID, roundTripped.SenderID) - assert.Equal(t, original.Recipient, roundTripped.Recipient) - assert.Equal(t, original.RecipientID, roundTripped.RecipientID) - assert.Equal(t, original.Recipients, roundTripped.Recipients) - assert.Equal(t, original.Msg, roundTripped.Msg) - assert.Equal(t, original.Type, roundTripped.Type) - assert.Equal(t, original.Plain, roundTripped.Plain) - assert.Equal(t, original.Raw, roundTripped.Raw) - assert.Equal(t, original.Urgent, roundTripped.Urgent) - assert.Equal(t, original.Broadcasted, roundTripped.Broadcasted) - assert.Equal(t, original.ObserverOnly, roundTripped.ObserverOnly) - assert.Equal(t, original.Status, roundTripped.Status) - assert.Equal(t, original.Attachments, roundTripped.Attachments) - assert.Equal(t, original.Metadata, roundTripped.Metadata) - assert.Equal(t, original.Channel, roundTripped.Channel) - assert.Equal(t, original.ThreadID, roundTripped.ThreadID) - assert.Equal(t, original.Visibility, roundTripped.Visibility) -} - -func TestStructuredMessageRoundTrip_ZeroValue(t *testing.T) { - original := &messages.StructuredMessage{} - pb := structuredMessageToProto(original) - require.NotNil(t, pb) - - roundTripped := protoToStructuredMessage(pb) - require.NotNil(t, roundTripped) - - assert.Equal(t, 0, roundTripped.Version) - assert.Empty(t, roundTripped.Msg) - assert.Nil(t, roundTripped.Attachments) - assert.Nil(t, roundTripped.Metadata) -} - -func TestStructuredMessageToProto_FieldMapping(t *testing.T) { - msg := &messages.StructuredMessage{ - SenderID: "sid", - RecipientID: "rid", - ThreadID: "tid", - } - pb := structuredMessageToProto(msg) - assert.Equal(t, "sid", pb.SenderId) - assert.Equal(t, "rid", pb.RecipientId) - assert.Equal(t, "tid", pb.ThreadId) -} - -func TestHealthStatusToProto_Nil(t *testing.T) { - assert.Nil(t, healthStatusToProto(nil)) -} - -func TestHealthStatusToProto(t *testing.T) { - hs := &plugin.HealthStatus{ - Status: "healthy", - Message: "all good", - Details: map[string]string{ - "subscriptions": "3", - "bot_id": "bot-123", - }, - } - pb := healthStatusToProto(hs) - require.NotNil(t, pb) - - assert.Equal(t, "healthy", pb.Status) - assert.Equal(t, "all good", pb.Message) - assert.Equal(t, map[string]string{ - "subscriptions": "3", - "bot_id": "bot-123", - }, pb.Details) -} - -func TestHealthStatusToProto_EmptyDetails(t *testing.T) { - hs := &plugin.HealthStatus{ - Status: "degraded", - Message: "no details", - } - pb := healthStatusToProto(hs) - require.NotNil(t, pb) - assert.Nil(t, pb.Details) -} - -func TestPluginInfoToProto_Nil(t *testing.T) { - assert.Nil(t, pluginInfoToProto(nil)) -} - -func TestPluginInfoToProto(t *testing.T) { - info := &plugin.PluginInfo{ - Name: "discord", - Version: "1.0.0", - MinScionVersion: "0.5.0", - ChannelID: "discord", - Capabilities: []string{"echo-filter", "gateway-websocket"}, - } - pb := pluginInfoToProto(info) - require.NotNil(t, pb) - - assert.Equal(t, "discord", pb.Name) - assert.Equal(t, "1.0.0", pb.Version) - assert.Equal(t, "0.5.0", pb.MinScionVersion) - assert.Equal(t, "discord", pb.ChannelId) - assert.Equal(t, []string{"echo-filter", "gateway-websocket"}, pb.Capabilities) -} - -func TestPluginInfoToProto_Empty(t *testing.T) { - info := &plugin.PluginInfo{} - pb := pluginInfoToProto(info) - require.NotNil(t, pb) - assert.Empty(t, pb.Name) - assert.Nil(t, pb.Capabilities) -} - -func TestNewBrokerGRPCServer(t *testing.T) { - broker := NewBroker(nil) - srv := NewBrokerGRPCServer(broker, nil, nil) - assert.NotNil(t, srv) - - _, ok := srv.(brokerv1.BrokerServiceServer) - assert.True(t, ok, "must implement BrokerServiceServer") -} diff --git a/extras/scion-discord/internal/discord/store.go b/extras/scion-discord/internal/discord/store.go index beca073bf..a97a25b50 100644 --- a/extras/scion-discord/internal/discord/store.go +++ b/extras/scion-discord/internal/discord/store.go @@ -54,11 +54,6 @@ type Store interface { SetNotificationPref(ctx context.Context, pref *NotificationPref) error GetNotificationPrefs(ctx context.Context, discordUserID, projectID string) ([]*NotificationPref, error) - // Advisory locking (for standalone HA leader election) - TryAdvisoryLock(ctx context.Context, key int64) (bool, error) - ReleaseAdvisoryLock(ctx context.Context, key int64) error - PingLockConn(ctx context.Context) error - // Lifecycle Close() error } @@ -179,7 +174,7 @@ CREATE TABLE IF NOT EXISTS channel_links ( linked_at TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 1, show_agent_to_agent INTEGER NOT NULL DEFAULT 0, - show_assistant_reply INTEGER NOT NULL DEFAULT 0, + show_assistant_reply INTEGER NOT NULL DEFAULT 1, show_state_changes INTEGER NOT NULL DEFAULT 1, notify_in_group INTEGER NOT NULL DEFAULT 1, chat_only INTEGER NOT NULL DEFAULT 0 @@ -690,20 +685,6 @@ func scanUserMapping(row *sql.Row) (*DiscordUserMapping, error) { return &m, nil } -// --- Advisory locking (SQLite: no-op, always succeeds) --- - -func (s *sqliteStore) TryAdvisoryLock(_ context.Context, _ int64) (bool, error) { - return true, nil -} - -func (s *sqliteStore) PingLockConn(_ context.Context) error { - return nil -} - -func (s *sqliteStore) ReleaseAdvisoryLock(_ context.Context, _ int64) error { - return nil -} - func boolToInt(b bool) int { if b { return 1 diff --git a/extras/scion-discord/internal/discord/store_postgres.go b/extras/scion-discord/internal/discord/store_postgres.go index c700572c1..dcfce5b18 100644 --- a/extras/scion-discord/internal/discord/store_postgres.go +++ b/extras/scion-discord/internal/discord/store_postgres.go @@ -5,15 +5,12 @@ import ( "database/sql" "encoding/json" "fmt" - "sync" _ "github.com/jackc/pgx/v5/stdlib" ) type postgresStore struct { - db *sql.DB - mu sync.Mutex - lockConn *sql.Conn + db *sql.DB } func NewPostgresStore(databaseURL string) (Store, error) { @@ -47,7 +44,7 @@ CREATE TABLE IF NOT EXISTS discord_channel_links ( linked_at TIMESTAMPTZ NOT NULL, active BOOLEAN NOT NULL DEFAULT TRUE, show_agent_to_agent BOOLEAN NOT NULL DEFAULT FALSE, - show_assistant_reply BOOLEAN NOT NULL DEFAULT FALSE, + show_assistant_reply BOOLEAN NOT NULL DEFAULT TRUE, show_state_changes BOOLEAN NOT NULL DEFAULT TRUE, notify_in_group BOOLEAN NOT NULL DEFAULT TRUE, chat_only BOOLEAN NOT NULL DEFAULT FALSE @@ -436,55 +433,6 @@ func (s *postgresStore) GetNotificationPrefs(ctx context.Context, discordUserID, return prefs, rows.Err() } -// --- Advisory locking --- - -func (s *postgresStore) TryAdvisoryLock(ctx context.Context, key int64) (bool, error) { - s.mu.Lock() - defer s.mu.Unlock() - - if s.lockConn != nil { - return false, fmt.Errorf("advisory lock connection already held") - } - - conn, err := s.db.Conn(ctx) - if err != nil { - return false, err - } - var acquired bool - err = conn.QueryRowContext(ctx, "SELECT pg_try_advisory_lock($1)", key).Scan(&acquired) - if err != nil { - conn.Close() - return false, err - } - if !acquired { - conn.Close() - return false, nil - } - s.lockConn = conn - return true, nil -} - -func (s *postgresStore) PingLockConn(ctx context.Context) error { - s.mu.Lock() - defer s.mu.Unlock() - if s.lockConn == nil { - return fmt.Errorf("no lock connection") - } - return s.lockConn.PingContext(ctx) -} - -func (s *postgresStore) ReleaseAdvisoryLock(ctx context.Context, key int64) error { - s.mu.Lock() - defer s.mu.Unlock() - if s.lockConn == nil { - return nil - } - _, err := s.lockConn.ExecContext(ctx, "SELECT pg_advisory_unlock($1)", key) - s.lockConn.Close() - s.lockConn = nil - return err -} - // --- scan helpers --- func pgScanChannelLink(row *sql.Row) (*ChannelLink, error) { diff --git a/harnesses/codex/capture_auth.py b/harnesses/codex/capture_auth.py new file mode 100644 index 000000000..c49f0351d --- /dev/null +++ b/harnesses/codex/capture_auth.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Codex capture-auth script. + +Scans for credential files on disk and stores them as project-scoped secrets +via `sciontool secret set`. Designed to run after the user authenticates +interactively inside a no-auth agent container. + +Reads credential mappings from inputs/capture-auth-config.json (derived from +the harness config.yaml's auth.types.*.required_files declarations). This +avoids hardcoding paths or key names in the script. + +Exit codes: + 0 = at least one credential captured + 1 = error + 2 = no credentials found (not an error, but nothing was stored) +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +from typing import Any + +EXIT_OK = 0 +EXIT_ERROR = 1 +EXIT_NO_CREDS = 2 + +HARNESS_BUNDLE = os.path.join( + os.environ.get("HOME") or os.path.expanduser("~"), + ".scion", "harness", +) + + +def _expand(path: str) -> str: + return os.path.expanduser(os.path.expandvars(path)) + + +def _load_config(bundle: str) -> list[dict[str, Any]]: + config_path = os.path.join(bundle, "inputs", "capture-auth-config.json") + if not os.path.isfile(config_path): + return [] + with open(config_path, "r", encoding="utf-8") as f: + try: + data = json.load(f) + except (json.JSONDecodeError, OSError): + return [] + creds = data.get("credentials") + if not isinstance(creds, list): + return [] + return creds + + +def _capture_one( + entry: dict[str, Any], force: bool +) -> tuple[bool, str | None]: + """Attempt to capture a single credential. Returns (success, error_msg).""" + key = entry.get("key", "") + source = _expand(entry.get("source", "")) + secret_type = entry.get("type", "file") + target = entry.get("target", "") + + if not key or not source: + return False, f"invalid entry: missing key or source" + + if not os.path.isfile(source): + return False, None + + cmd = [ + "sciontool", "secret", "set", key, f"@{source}", + "--type", secret_type, + "--target", target, + ] + if force: + cmd.append("--force") + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=30, + ) + except FileNotFoundError: + return False, "sciontool not found in PATH" + except subprocess.TimeoutExpired: + return False, f"sciontool timed out for key {key}" + + if result.returncode != 0: + stderr = result.stderr.strip() + return False, f"sciontool failed for {key}: {stderr}" + + return True, None + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Capture auth credentials and store as project secrets" + ) + parser.add_argument( + "--force", + action="store_true", + help="Overwrite existing secrets", + ) + parser.add_argument( + "--bundle", + default=HARNESS_BUNDLE, + help="Path to harness bundle directory", + ) + args = parser.parse_args() + + entries = _load_config(args.bundle) + if not entries: + print( + "capture-auth: no credential mappings found in " + "inputs/capture-auth-config.json", + file=sys.stderr, + ) + return EXIT_NO_CREDS + + captured = 0 + errors = 0 + + for entry in entries: + key = entry.get("key", "") + source = entry.get("source", "") + expanded = _expand(source) if source else "" + + if not expanded or not os.path.isfile(expanded): + print(f"capture-auth: {key}: source not found ({source})") + continue + + ok, err = _capture_one(entry, args.force) + if err: + print(f"capture-auth: {key}: {err}", file=sys.stderr) + errors += 1 + elif ok: + print(f"capture-auth: {key}: captured from {source}") + captured += 1 + + if errors > 0 and captured == 0: + return EXIT_ERROR + + if captured == 0: + print("capture-auth: no credentials found to capture") + return EXIT_NO_CREDS + + print(f"capture-auth: {captured} credential(s) captured successfully") + return EXIT_OK + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/harnesses/codex/config.yaml b/harnesses/codex/config.yaml index d15e5b120..b21a153b6 100644 --- a/harnesses/codex/config.yaml +++ b/harnesses/codex/config.yaml @@ -71,6 +71,12 @@ capabilities: sse: { support: "partial", reason: "Codex maps SSE to its single HTTP server type (url + http_headers)" } streamable_http: { support: "yes" } project_scope: { support: "no", reason: "Project-scoped MCP (.codex/mcp_servers.json) is not implemented yet; project entries are demoted to global" } +no_auth: + behavior: drop-to-shell + message: | + This agent started without credentials. + Run your Codex authentication setup. + Then run: python3 /home/scion/.scion/harness/capture_auth.py auth: default_type: api-key types: @@ -82,6 +88,7 @@ auth: - name: CODEX_AUTH type: file target_suffix: "/.codex/auth.json" + field: CodexAuthFile autodetect: env: CODEX_API_KEY: api-key diff --git a/harnesses/opencode/capture_auth.py b/harnesses/opencode/capture_auth.py new file mode 100644 index 000000000..49ae4794f --- /dev/null +++ b/harnesses/opencode/capture_auth.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""OpenCode capture-auth script. + +Scans for credential files on disk and stores them as project-scoped secrets +via `sciontool secret set`. Designed to run after the user authenticates +interactively inside a no-auth agent container. + +Reads credential mappings from inputs/capture-auth-config.json (derived from +the harness config.yaml's auth.types.*.required_files declarations). This +avoids hardcoding paths or key names in the script. + +Exit codes: + 0 = at least one credential captured + 1 = error + 2 = no credentials found (not an error, but nothing was stored) +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +from typing import Any + +EXIT_OK = 0 +EXIT_ERROR = 1 +EXIT_NO_CREDS = 2 + +HARNESS_BUNDLE = os.path.join( + os.environ.get("HOME") or os.path.expanduser("~"), + ".scion", "harness", +) + + +def _expand(path: str) -> str: + return os.path.expanduser(os.path.expandvars(path)) + + +def _load_config(bundle: str) -> list[dict[str, Any]]: + config_path = os.path.join(bundle, "inputs", "capture-auth-config.json") + if not os.path.isfile(config_path): + return [] + with open(config_path, "r", encoding="utf-8") as f: + try: + data = json.load(f) + except (json.JSONDecodeError, OSError): + return [] + creds = data.get("credentials") + if not isinstance(creds, list): + return [] + return creds + + +def _capture_one( + entry: dict[str, Any], force: bool +) -> tuple[bool, str | None]: + """Attempt to capture a single credential. Returns (success, error_msg).""" + key = entry.get("key", "") + source = _expand(entry.get("source", "")) + secret_type = entry.get("type", "file") + target = entry.get("target", "") + + if not key or not source: + return False, f"invalid entry: missing key or source" + + if not os.path.isfile(source): + return False, None + + cmd = [ + "sciontool", "secret", "set", key, f"@{source}", + "--type", secret_type, + "--target", target, + ] + if force: + cmd.append("--force") + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=30, + ) + except FileNotFoundError: + return False, "sciontool not found in PATH" + except subprocess.TimeoutExpired: + return False, f"sciontool timed out for key {key}" + + if result.returncode != 0: + stderr = result.stderr.strip() + return False, f"sciontool failed for {key}: {stderr}" + + return True, None + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Capture auth credentials and store as project secrets" + ) + parser.add_argument( + "--force", + action="store_true", + help="Overwrite existing secrets", + ) + parser.add_argument( + "--bundle", + default=HARNESS_BUNDLE, + help="Path to harness bundle directory", + ) + args = parser.parse_args() + + entries = _load_config(args.bundle) + if not entries: + print( + "capture-auth: no credential mappings found in " + "inputs/capture-auth-config.json", + file=sys.stderr, + ) + return EXIT_NO_CREDS + + captured = 0 + errors = 0 + + for entry in entries: + key = entry.get("key", "") + source = entry.get("source", "") + expanded = _expand(source) if source else "" + + if not expanded or not os.path.isfile(expanded): + print(f"capture-auth: {key}: source not found ({source})") + continue + + ok, err = _capture_one(entry, args.force) + if err: + print(f"capture-auth: {key}: {err}", file=sys.stderr) + errors += 1 + elif ok: + print(f"capture-auth: {key}: captured from {source}") + captured += 1 + + if errors > 0 and captured == 0: + return EXIT_ERROR + + if captured == 0: + print("capture-auth: no credentials found to capture") + return EXIT_NO_CREDS + + print(f"capture-auth: {captured} credential(s) captured successfully") + return EXIT_OK + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/harnesses/opencode/config.yaml b/harnesses/opencode/config.yaml index 672ce8be8..011054e0c 100644 --- a/harnesses/opencode/config.yaml +++ b/harnesses/opencode/config.yaml @@ -67,6 +67,12 @@ capabilities: sse: { support: "yes" } streamable_http: { support: "yes" } project_scope: { support: "no", reason: "OpenCode does not distinguish project-scoped MCP" } +no_auth: + behavior: drop-to-shell + message: | + This agent started without credentials. + Run your OpenCode authentication setup. + Then run: python3 /home/scion/.scion/harness/capture_auth.py auth: default_type: api-key types: @@ -78,6 +84,7 @@ auth: - name: OPENCODE_AUTH type: file target_suffix: "/.local/share/opencode/auth.json" + field: OpenCodeAuthFile autodetect: env: ANTHROPIC_API_KEY: api-key diff --git a/pkg/agent/caching_skill_resolver_test.go b/pkg/agent/caching_skill_resolver_test.go index 539046129..fb2b7ee63 100644 --- a/pkg/agent/caching_skill_resolver_test.go +++ b/pkg/agent/caching_skill_resolver_test.go @@ -52,7 +52,7 @@ func TestCachingSkillResolver_InjectsCache(t *testing.T) { var capturedCtx context.Context inner := &ctxCapturingResolver{ - inner: &mockResolver{resolved: []ResolvedSkill{{Name: "s", Hash: "h"}}}, + inner: &mockResolver{resolved: []ResolvedSkill{{Name: "s", Hash: "h"}}}, capture: func(ctx context.Context) { capturedCtx = ctx }, } diff --git a/pkg/agent/provision.go b/pkg/agent/provision.go index 17c6d7bc8..aef13921a 100644 --- a/pkg/agent/provision.go +++ b/pkg/agent/provision.go @@ -1144,6 +1144,16 @@ func ProvisionAgent(ctx context.Context, agentName string, templateName string, return "", "", nil, fmt.Errorf("harness provisioning failed: %w", err) } + // Stage capture-auth assets (capture_auth.py + capture-auth-config.json) + // into the harness bundle so they are available at a known path in the + // container. Container-script harnesses stage these during their own + // Provision(); for builtin harnesses this is the only staging opportunity. + if _, isContainerScript := h.(*harness.ContainerScriptHarness); !isContainerScript { + if err := harness.StageCaptureAuthAssets(agentHome, hcDir.Path, hcDir.Config.Auth); err != nil { + fmt.Fprintf(os.Stderr, "Warning: capture-auth asset staging failed: %v\n", err) + } + } + // Reload config to get harness updates (e.g. Env vars injected by harness) reloadTpl := &config.Template{Path: agentDir} if updatedCfg, err := reloadTpl.LoadConfig(); err == nil { diff --git a/pkg/agent/run.go b/pkg/agent/run.go index c2b4699b5..ced5bdbc8 100644 --- a/pkg/agent/run.go +++ b/pkg/agent/run.go @@ -341,6 +341,7 @@ func (m *AgentManager) Start(ctx context.Context, opts api.StartOptions) (*api.A var h api.Harness var harnessConfigRevision string var resolvedImpl string + var noAuthConfig *config.HarnessNoAuthConfig if harnessConfigName != "" { var resolveTemplatePaths []string if opts.Template != "" { @@ -367,6 +368,7 @@ func (m *AgentManager) Start(ctx context.Context, opts api.StartOptions) (*api.A } else { h = resolved.Harness resolvedImpl = resolved.Implementation + noAuthConfig = resolved.Config.NoAuthConfig if resolved.ConfigDir != nil { harnessConfigRevision = config.ComputeHarnessConfigRevision(resolved.ConfigDir.Path) } @@ -883,9 +885,16 @@ func (m *AgentManager) Start(ctx context.Context, opts api.StartOptions) (*api.A } return nil }(), - GitClone: opts.GitClone, - SharedDirs: effectiveSharedDirs, - BrokerMode: opts.BrokerMode, + GitClone: opts.GitClone, + SharedDirs: effectiveSharedDirs, + BrokerMode: opts.BrokerMode, + NoAuth: opts.NoAuth && noAuthConfig != nil && noAuthConfig.Behavior == "drop-to-shell", + NoAuthMessage: func() string { + if opts.NoAuth && noAuthConfig != nil && noAuthConfig.Behavior == "drop-to-shell" { + return noAuthConfig.Message + } + return "" + }(), Debug: util.DebugEnabled(), Resume: opts.Resume, MetadataInterception: hasMetadataInterception(agentEnv), diff --git a/pkg/agent/skill_resolver.go b/pkg/agent/skill_resolver.go index 59f952a40..fe8f3d7aa 100644 --- a/pkg/agent/skill_resolver.go +++ b/pkg/agent/skill_resolver.go @@ -390,7 +390,9 @@ func buildSkillEntry(skill ResolvedSkill, dest, skillsDest string) (*SkillResolu } // populateSkillCache stores downloaded skill files in the cache. -func populateSkillCache(cache interface{ Put(string, map[string][]byte) (string, error) }, skill ResolvedSkill, installedDir string) { +func populateSkillCache(cache interface { + Put(string, map[string][]byte) (string, error) +}, skill ResolvedSkill, installedDir string) { files := make(map[string][]byte, len(skill.Files)) for _, f := range skill.Files { content, err := os.ReadFile(filepath.Join(installedDir, f.Path)) diff --git a/pkg/api/skill_uri.go b/pkg/api/skill_uri.go index 8f3d4b6d7..eaa931137 100644 --- a/pkg/api/skill_uri.go +++ b/pkg/api/skill_uri.go @@ -31,10 +31,10 @@ type SkillURI struct { } const ( - skillURIScheme = "skill://" - defaultRegistry = "scion" - defaultVersion = "latest" - maxSkillNameLen = 64 + skillURIScheme = "skill://" + defaultRegistry = "scion" + defaultVersion = "latest" + maxSkillNameLen = 64 ) var validScopes = map[string]bool{ diff --git a/pkg/api/skill_uri_test.go b/pkg/api/skill_uri_test.go index 354b212ad..7495e17e5 100644 --- a/pkg/api/skill_uri_test.go +++ b/pkg/api/skill_uri_test.go @@ -141,7 +141,7 @@ func TestParseSkillURI_InvalidForms(t *testing.T) { {"", "empty URI"}, {"skill://scion/core/@^1.0", "empty name"}, {"skill://scion/core/My_Skill@1.0", "name not kebab-case"}, - {"skill://scion/grove/team/name@1.0", "grove is not a valid scope"}, + {"skill://scion/invalid-scope/team/name@1.0", "invalid-scope is not a valid scope"}, {"skill://scion/core/name@", "empty version after @"}, {"skill://scion/unknown-scope/name@1.0", "unrecognized scope keyword"}, {"../traversal", "path traversal in bare name"}, diff --git a/pkg/config/harness_config.go b/pkg/config/harness_config.go index 5745df0b5..2e2d550d9 100644 --- a/pkg/config/harness_config.go +++ b/pkg/config/harness_config.go @@ -271,7 +271,7 @@ func mapEmbedFileToHarnessConfigPath(targetDir, homeDir, configDir, fileName str } func isHarnessConfigRootSupportFile(relPath string) bool { - if relPath == "provision.py" || relPath == "dialect.yaml" { + if relPath == "provision.py" || relPath == "dialect.yaml" || relPath == "capture_auth.py" { return true } for _, prefix := range []string{"schema/", "schemas/", "examples/", "tests/fixtures/"} { @@ -346,7 +346,6 @@ func ComputeHarnessConfigRevision(dirPath string) string { } var hashes []fileHash skipBasenames := map[string]bool{ - "Dockerfile": true, "cloudbuild.yaml": true, "README.md": true, ".gitkeep": true, diff --git a/pkg/config/harness_config_test.go b/pkg/config/harness_config_test.go index fef66bbda..4db66725e 100644 --- a/pkg/config/harness_config_test.go +++ b/pkg/config/harness_config_test.go @@ -510,7 +510,7 @@ func TestComputeHarnessConfigRevision_SkipsNonRuntimeFiles(t *testing.T) { t.Fatal("expected non-empty revision") } - for _, skip := range []string{"Dockerfile", "cloudbuild.yaml", "README.md", ".gitkeep"} { + for _, skip := range []string{"cloudbuild.yaml", "README.md", ".gitkeep"} { if err := os.WriteFile(filepath.Join(dir, skip), []byte("should be ignored"), 0644); err != nil { t.Fatal(err) } @@ -520,6 +520,14 @@ func TestComputeHarnessConfigRevision_SkipsNonRuntimeFiles(t *testing.T) { t.Errorf("adding non-runtime files changed revision: %s -> %s", baseRev, afterSkipped) } + if err := os.WriteFile(filepath.Join(dir, "Dockerfile"), []byte("FROM scratch"), 0644); err != nil { + t.Fatal(err) + } + afterDockerfile := ComputeHarnessConfigRevision(dir) + if afterDockerfile == afterSkipped { + t.Error("adding Dockerfile should change revision") + } + if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte("harness: opencode\nimage: new\n"), 0644); err != nil { t.Fatal(err) } diff --git a/pkg/config/schemas/settings-v1.schema.json b/pkg/config/schemas/settings-v1.schema.json index ceb9f5d22..af7e542ee 100644 --- a/pkg/config/schemas/settings-v1.schema.json +++ b/pkg/config/schemas/settings-v1.schema.json @@ -269,8 +269,8 @@ }, "auth_selected_type": { "type": "string", - "enum": ["api-key", "oauth-token", "auth-file", "vertex-ai"], - "description": "Authentication mechanism to use (e.g., api-key, oauth-token, vertex-ai, auth-file)." + "enum": ["api-key", "oauth-token", "auth-file", "vertex-ai", "none"], + "description": "Authentication mechanism to use (e.g., api-key, oauth-token, vertex-ai, auth-file, none)." }, "secrets": { "type": "array", @@ -332,6 +332,10 @@ "$ref": "#/$defs/harnessMCPMapping", "description": "Declarative mapping for translating universal mcp_servers into the harness's native MCP config (used by scion_harness.apply_mcp_servers_simple). Harnesses with bespoke MCP formats (e.g. OpenCode) leave this empty and translate themselves in provision.py." }, + "no_auth": { + "$ref": "#/$defs/harnessNoAuthConfig", + "description": "Behavior when an agent starts without credentials (NoAuth mode)." + }, "dialect": { "type": "object", "description": "Optional hook dialect metadata.", @@ -340,6 +344,21 @@ }, "additionalProperties": false }, + "harnessNoAuthConfig": { + "type": "object", + "properties": { + "behavior": { + "type": "string", + "enum": ["drop-to-shell", "show-setup-instructions", "run-setup-wizard"], + "description": "What the harness should do when no credentials are provided." + }, + "message": { + "type": "string", + "description": "Message to display to the user in no-auth mode." + } + }, + "additionalProperties": false + }, "harnessMCPMapping": { "type": "object", "properties": { @@ -533,7 +552,8 @@ "items": { "type": "string" } }, "skipped_when_gcp_service_account_assigned": { "type": "boolean" }, - "required": { "type": "boolean" } + "required": { "type": "boolean" }, + "field": { "type": "string" } }, "additionalProperties": false }, diff --git a/pkg/config/settings_v1.go b/pkg/config/settings_v1.go index 401e9ca39..7d1f7aed6 100644 --- a/pkg/config/settings_v1.go +++ b/pkg/config/settings_v1.go @@ -720,6 +720,7 @@ type HarnessConfigEntry struct { EnvTemplate map[string]string `json:"env_template,omitempty" yaml:"env_template,omitempty" koanf:"env_template"` Capabilities *api.HarnessAdvancedCapabilities `json:"capabilities,omitempty" yaml:"capabilities,omitempty" koanf:"capabilities"` Auth *HarnessAuthMetadata `json:"auth,omitempty" yaml:"auth,omitempty" koanf:"auth"` + NoAuthConfig *HarnessNoAuthConfig `json:"no_auth,omitempty" yaml:"no_auth,omitempty" koanf:"no_auth"` MCP *HarnessMCPConfig `json:"mcp,omitempty" yaml:"mcp,omitempty" koanf:"mcp"` Dialect map[string]interface{} `json:"dialect,omitempty" yaml:"dialect,omitempty" koanf:"dialect"` } @@ -768,6 +769,11 @@ type HarnessAuthFileRequirement struct { // TargetSuffix is the in-container projection target suffix. Used // together with the broker's home dir resolution, e.g. "/.claude/.credentials.json". TargetSuffix string `json:"target_suffix,omitempty" yaml:"target_suffix,omitempty" koanf:"target_suffix"` + // Field maps this file requirement to the corresponding AuthConfig + // struct field name (e.g. "ClaudeAuthFile"). Used by + // OverlayFileSecretsFromConfig to set auth fields without hardcoded + // switch statements. + Field string `json:"field,omitempty" yaml:"field,omitempty" koanf:"field"` // AlternativeEnvKeys lists env vars that satisfy this file requirement // in lieu of the file itself (e.g. GOOGLE_APPLICATION_CREDENTIALS for // gcloud-adc). @@ -792,6 +798,12 @@ type HarnessAuthAutodetect struct { Files map[string]string `json:"files,omitempty" yaml:"files,omitempty" koanf:"files"` } +// HarnessNoAuthConfig defines harness behavior when an agent starts without credentials. +type HarnessNoAuthConfig struct { + Behavior string `json:"behavior,omitempty" yaml:"behavior,omitempty" koanf:"behavior"` + Message string `json:"message,omitempty" yaml:"message,omitempty" koanf:"message"` +} + // HarnessMCPConfig is the declarative mapping that lets a harness's // container-side provisioner translate the universal mcp_servers map into the // harness's native MCP config without bespoke per-harness Python. Used by diff --git a/pkg/ent/client.go b/pkg/ent/client.go index 505a2b67c..66bf0b72b 100644 --- a/pkg/ent/client.go +++ b/pkg/ent/client.go @@ -23,7 +23,6 @@ import ( "github.com/GoogleCloudPlatform/scion/pkg/ent/brokerdispatch" "github.com/GoogleCloudPlatform/scion/pkg/ent/brokerjointoken" "github.com/GoogleCloudPlatform/scion/pkg/ent/brokersecret" - "github.com/GoogleCloudPlatform/scion/pkg/ent/discordpendinglink" "github.com/GoogleCloudPlatform/scion/pkg/ent/envvar" "github.com/GoogleCloudPlatform/scion/pkg/ent/gcpserviceaccount" "github.com/GoogleCloudPlatform/scion/pkg/ent/githubinstallation" @@ -73,8 +72,6 @@ type Client struct { BrokerJoinToken *BrokerJoinTokenClient // BrokerSecret is the client for interacting with the BrokerSecret builders. BrokerSecret *BrokerSecretClient - // DiscordPendingLink is the client for interacting with the DiscordPendingLink builders. - DiscordPendingLink *DiscordPendingLinkClient // EnvVar is the client for interacting with the EnvVar builders. EnvVar *EnvVarClient // GCPServiceAccount is the client for interacting with the GCPServiceAccount builders. @@ -149,7 +146,6 @@ func (c *Client) init() { c.BrokerDispatch = NewBrokerDispatchClient(c.config) c.BrokerJoinToken = NewBrokerJoinTokenClient(c.config) c.BrokerSecret = NewBrokerSecretClient(c.config) - c.DiscordPendingLink = NewDiscordPendingLinkClient(c.config) c.EnvVar = NewEnvVarClient(c.config) c.GCPServiceAccount = NewGCPServiceAccountClient(c.config) c.GithubInstallation = NewGithubInstallationClient(c.config) @@ -277,7 +273,6 @@ func (c *Client) Tx(ctx context.Context) (*Tx, error) { BrokerDispatch: NewBrokerDispatchClient(cfg), BrokerJoinToken: NewBrokerJoinTokenClient(cfg), BrokerSecret: NewBrokerSecretClient(cfg), - DiscordPendingLink: NewDiscordPendingLinkClient(cfg), EnvVar: NewEnvVarClient(cfg), GCPServiceAccount: NewGCPServiceAccountClient(cfg), GithubInstallation: NewGithubInstallationClient(cfg), @@ -332,7 +327,6 @@ func (c *Client) BeginTx(ctx context.Context, opts *sql.TxOptions) (*Tx, error) BrokerDispatch: NewBrokerDispatchClient(cfg), BrokerJoinToken: NewBrokerJoinTokenClient(cfg), BrokerSecret: NewBrokerSecretClient(cfg), - DiscordPendingLink: NewDiscordPendingLinkClient(cfg), EnvVar: NewEnvVarClient(cfg), GCPServiceAccount: NewGCPServiceAccountClient(cfg), GithubInstallation: NewGithubInstallationClient(cfg), @@ -391,9 +385,9 @@ func (c *Client) Close() error { func (c *Client) Use(hooks ...Hook) { for _, n := range []interface{ Use(...Hook) }{ c.AccessPolicy, c.Agent, c.AllowListEntry, c.ApiKey, c.BrokerDispatch, - c.BrokerJoinToken, c.BrokerSecret, c.DiscordPendingLink, c.EnvVar, - c.GCPServiceAccount, c.GithubInstallation, c.Group, c.GroupMembership, - c.HarnessConfig, c.InviteCode, c.LifecycleHook, c.LifecycleHookAgentPhase, + c.BrokerJoinToken, c.BrokerSecret, c.EnvVar, c.GCPServiceAccount, + c.GithubInstallation, c.Group, c.GroupMembership, c.HarnessConfig, + c.InviteCode, c.LifecycleHook, c.LifecycleHookAgentPhase, c.MaintenanceOperation, c.MaintenanceOperationRun, c.Message, c.Notification, c.NotificationSubscription, c.PolicyBinding, c.Project, c.ProjectContributor, c.ProjectSyncState, c.RuntimeBroker, c.Schedule, c.ScheduledEvent, c.Secret, @@ -409,9 +403,9 @@ func (c *Client) Use(hooks ...Hook) { func (c *Client) Intercept(interceptors ...Interceptor) { for _, n := range []interface{ Intercept(...Interceptor) }{ c.AccessPolicy, c.Agent, c.AllowListEntry, c.ApiKey, c.BrokerDispatch, - c.BrokerJoinToken, c.BrokerSecret, c.DiscordPendingLink, c.EnvVar, - c.GCPServiceAccount, c.GithubInstallation, c.Group, c.GroupMembership, - c.HarnessConfig, c.InviteCode, c.LifecycleHook, c.LifecycleHookAgentPhase, + c.BrokerJoinToken, c.BrokerSecret, c.EnvVar, c.GCPServiceAccount, + c.GithubInstallation, c.Group, c.GroupMembership, c.HarnessConfig, + c.InviteCode, c.LifecycleHook, c.LifecycleHookAgentPhase, c.MaintenanceOperation, c.MaintenanceOperationRun, c.Message, c.Notification, c.NotificationSubscription, c.PolicyBinding, c.Project, c.ProjectContributor, c.ProjectSyncState, c.RuntimeBroker, c.Schedule, c.ScheduledEvent, c.Secret, @@ -439,8 +433,6 @@ func (c *Client) Mutate(ctx context.Context, m Mutation) (Value, error) { return c.BrokerJoinToken.mutate(ctx, m) case *BrokerSecretMutation: return c.BrokerSecret.mutate(ctx, m) - case *DiscordPendingLinkMutation: - return c.DiscordPendingLink.mutate(ctx, m) case *EnvVarMutation: return c.EnvVar.mutate(ctx, m) case *GCPServiceAccountMutation: @@ -1497,139 +1489,6 @@ func (c *BrokerSecretClient) mutate(ctx context.Context, m *BrokerSecretMutation } } -// DiscordPendingLinkClient is a client for the DiscordPendingLink schema. -type DiscordPendingLinkClient struct { - config -} - -// NewDiscordPendingLinkClient returns a client for the DiscordPendingLink from the given config. -func NewDiscordPendingLinkClient(c config) *DiscordPendingLinkClient { - return &DiscordPendingLinkClient{config: c} -} - -// Use adds a list of mutation hooks to the hooks stack. -// A call to `Use(f, g, h)` equals to `discordpendinglink.Hooks(f(g(h())))`. -func (c *DiscordPendingLinkClient) Use(hooks ...Hook) { - c.hooks.DiscordPendingLink = append(c.hooks.DiscordPendingLink, hooks...) -} - -// Intercept adds a list of query interceptors to the interceptors stack. -// A call to `Intercept(f, g, h)` equals to `discordpendinglink.Intercept(f(g(h())))`. -func (c *DiscordPendingLinkClient) Intercept(interceptors ...Interceptor) { - c.inters.DiscordPendingLink = append(c.inters.DiscordPendingLink, interceptors...) -} - -// Create returns a builder for creating a DiscordPendingLink entity. -func (c *DiscordPendingLinkClient) Create() *DiscordPendingLinkCreate { - mutation := newDiscordPendingLinkMutation(c.config, OpCreate) - return &DiscordPendingLinkCreate{config: c.config, hooks: c.Hooks(), mutation: mutation} -} - -// CreateBulk returns a builder for creating a bulk of DiscordPendingLink entities. -func (c *DiscordPendingLinkClient) CreateBulk(builders ...*DiscordPendingLinkCreate) *DiscordPendingLinkCreateBulk { - return &DiscordPendingLinkCreateBulk{config: c.config, builders: builders} -} - -// MapCreateBulk creates a bulk creation builder from the given slice. For each item in the slice, the function creates -// a builder and applies setFunc on it. -func (c *DiscordPendingLinkClient) MapCreateBulk(slice any, setFunc func(*DiscordPendingLinkCreate, int)) *DiscordPendingLinkCreateBulk { - rv := reflect.ValueOf(slice) - if rv.Kind() != reflect.Slice { - return &DiscordPendingLinkCreateBulk{err: fmt.Errorf("calling to DiscordPendingLinkClient.MapCreateBulk with wrong type %T, need slice", slice)} - } - builders := make([]*DiscordPendingLinkCreate, rv.Len()) - for i := 0; i < rv.Len(); i++ { - builders[i] = c.Create() - setFunc(builders[i], i) - } - return &DiscordPendingLinkCreateBulk{config: c.config, builders: builders} -} - -// Update returns an update builder for DiscordPendingLink. -func (c *DiscordPendingLinkClient) Update() *DiscordPendingLinkUpdate { - mutation := newDiscordPendingLinkMutation(c.config, OpUpdate) - return &DiscordPendingLinkUpdate{config: c.config, hooks: c.Hooks(), mutation: mutation} -} - -// UpdateOne returns an update builder for the given entity. -func (c *DiscordPendingLinkClient) UpdateOne(_m *DiscordPendingLink) *DiscordPendingLinkUpdateOne { - mutation := newDiscordPendingLinkMutation(c.config, OpUpdateOne, withDiscordPendingLink(_m)) - return &DiscordPendingLinkUpdateOne{config: c.config, hooks: c.Hooks(), mutation: mutation} -} - -// UpdateOneID returns an update builder for the given id. -func (c *DiscordPendingLinkClient) UpdateOneID(id uuid.UUID) *DiscordPendingLinkUpdateOne { - mutation := newDiscordPendingLinkMutation(c.config, OpUpdateOne, withDiscordPendingLinkID(id)) - return &DiscordPendingLinkUpdateOne{config: c.config, hooks: c.Hooks(), mutation: mutation} -} - -// Delete returns a delete builder for DiscordPendingLink. -func (c *DiscordPendingLinkClient) Delete() *DiscordPendingLinkDelete { - mutation := newDiscordPendingLinkMutation(c.config, OpDelete) - return &DiscordPendingLinkDelete{config: c.config, hooks: c.Hooks(), mutation: mutation} -} - -// DeleteOne returns a builder for deleting the given entity. -func (c *DiscordPendingLinkClient) DeleteOne(_m *DiscordPendingLink) *DiscordPendingLinkDeleteOne { - return c.DeleteOneID(_m.ID) -} - -// DeleteOneID returns a builder for deleting the given entity by its id. -func (c *DiscordPendingLinkClient) DeleteOneID(id uuid.UUID) *DiscordPendingLinkDeleteOne { - builder := c.Delete().Where(discordpendinglink.ID(id)) - builder.mutation.id = &id - builder.mutation.op = OpDeleteOne - return &DiscordPendingLinkDeleteOne{builder} -} - -// Query returns a query builder for DiscordPendingLink. -func (c *DiscordPendingLinkClient) Query() *DiscordPendingLinkQuery { - return &DiscordPendingLinkQuery{ - config: c.config, - ctx: &QueryContext{Type: TypeDiscordPendingLink}, - inters: c.Interceptors(), - } -} - -// Get returns a DiscordPendingLink entity by its id. -func (c *DiscordPendingLinkClient) Get(ctx context.Context, id uuid.UUID) (*DiscordPendingLink, error) { - return c.Query().Where(discordpendinglink.ID(id)).Only(ctx) -} - -// GetX is like Get, but panics if an error occurs. -func (c *DiscordPendingLinkClient) GetX(ctx context.Context, id uuid.UUID) *DiscordPendingLink { - obj, err := c.Get(ctx, id) - if err != nil { - panic(err) - } - return obj -} - -// Hooks returns the client hooks. -func (c *DiscordPendingLinkClient) Hooks() []Hook { - return c.hooks.DiscordPendingLink -} - -// Interceptors returns the client interceptors. -func (c *DiscordPendingLinkClient) Interceptors() []Interceptor { - return c.inters.DiscordPendingLink -} - -func (c *DiscordPendingLinkClient) mutate(ctx context.Context, m *DiscordPendingLinkMutation) (Value, error) { - switch m.Op() { - case OpCreate: - return (&DiscordPendingLinkCreate{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx) - case OpUpdate: - return (&DiscordPendingLinkUpdate{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx) - case OpUpdateOne: - return (&DiscordPendingLinkUpdateOne{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx) - case OpDelete, OpDeleteOne: - return (&DiscordPendingLinkDelete{config: c.config, hooks: c.Hooks(), mutation: m}).Exec(ctx) - default: - return nil, fmt.Errorf("ent: unknown DiscordPendingLink mutation op: %q", m.Op()) - } -} - // EnvVarClient is a client for the EnvVar schema. type EnvVarClient struct { config @@ -5614,22 +5473,20 @@ func (c *UserAccessTokenClient) mutate(ctx context.Context, m *UserAccessTokenMu type ( hooks struct { AccessPolicy, Agent, AllowListEntry, ApiKey, BrokerDispatch, BrokerJoinToken, - BrokerSecret, DiscordPendingLink, EnvVar, GCPServiceAccount, - GithubInstallation, Group, GroupMembership, HarnessConfig, InviteCode, - LifecycleHook, LifecycleHookAgentPhase, MaintenanceOperation, - MaintenanceOperationRun, Message, Notification, NotificationSubscription, - PolicyBinding, Project, ProjectContributor, ProjectSyncState, RuntimeBroker, - Schedule, ScheduledEvent, Secret, Skill, SkillVersion, SubscriptionTemplate, - Template, User, UserAccessToken []ent.Hook + BrokerSecret, EnvVar, GCPServiceAccount, GithubInstallation, Group, + GroupMembership, HarnessConfig, InviteCode, LifecycleHook, + LifecycleHookAgentPhase, MaintenanceOperation, MaintenanceOperationRun, + Message, Notification, NotificationSubscription, PolicyBinding, Project, + ProjectContributor, ProjectSyncState, RuntimeBroker, Schedule, ScheduledEvent, + Secret, Skill, SkillVersion, SubscriptionTemplate, Template, User, UserAccessToken []ent.Hook } inters struct { AccessPolicy, Agent, AllowListEntry, ApiKey, BrokerDispatch, BrokerJoinToken, - BrokerSecret, DiscordPendingLink, EnvVar, GCPServiceAccount, - GithubInstallation, Group, GroupMembership, HarnessConfig, InviteCode, - LifecycleHook, LifecycleHookAgentPhase, MaintenanceOperation, - MaintenanceOperationRun, Message, Notification, NotificationSubscription, - PolicyBinding, Project, ProjectContributor, ProjectSyncState, RuntimeBroker, - Schedule, ScheduledEvent, Secret, Skill, SkillVersion, SubscriptionTemplate, - Template, User, UserAccessToken []ent.Interceptor + BrokerSecret, EnvVar, GCPServiceAccount, GithubInstallation, Group, + GroupMembership, HarnessConfig, InviteCode, LifecycleHook, + LifecycleHookAgentPhase, MaintenanceOperation, MaintenanceOperationRun, + Message, Notification, NotificationSubscription, PolicyBinding, Project, + ProjectContributor, ProjectSyncState, RuntimeBroker, Schedule, ScheduledEvent, + Secret, Skill, SkillVersion, SubscriptionTemplate, Template, User, UserAccessToken []ent.Interceptor } ) diff --git a/pkg/ent/discordpendinglink.go b/pkg/ent/discordpendinglink.go deleted file mode 100644 index 1b34af8d6..000000000 --- a/pkg/ent/discordpendinglink.go +++ /dev/null @@ -1,173 +0,0 @@ -// Code generated by ent, DO NOT EDIT. - -package ent - -import ( - "fmt" - "strings" - "time" - - "entgo.io/ent" - "entgo.io/ent/dialect/sql" - "github.com/GoogleCloudPlatform/scion/pkg/ent/discordpendinglink" - "github.com/google/uuid" -) - -// DiscordPendingLink is the model entity for the DiscordPendingLink schema. -type DiscordPendingLink struct { - config `json:"-"` - // ID of the ent. - ID uuid.UUID `json:"id,omitempty"` - // Code holds the value of the "code" field. - Code string `json:"code,omitempty"` - // DiscordUserID holds the value of the "discord_user_id" field. - DiscordUserID string `json:"discord_user_id,omitempty"` - // Status holds the value of the "status" field. - Status string `json:"status,omitempty"` - // UserID holds the value of the "user_id" field. - UserID string `json:"user_id,omitempty"` - // UserEmail holds the value of the "user_email" field. - UserEmail string `json:"user_email,omitempty"` - // ExpiresAt holds the value of the "expires_at" field. - ExpiresAt time.Time `json:"expires_at,omitempty"` - // CreatedAt holds the value of the "created_at" field. - CreatedAt time.Time `json:"created_at,omitempty"` - selectValues sql.SelectValues -} - -// scanValues returns the types for scanning values from sql.Rows. -func (*DiscordPendingLink) scanValues(columns []string) ([]any, error) { - values := make([]any, len(columns)) - for i := range columns { - switch columns[i] { - case discordpendinglink.FieldCode, discordpendinglink.FieldDiscordUserID, discordpendinglink.FieldStatus, discordpendinglink.FieldUserID, discordpendinglink.FieldUserEmail: - values[i] = new(sql.NullString) - case discordpendinglink.FieldExpiresAt, discordpendinglink.FieldCreatedAt: - values[i] = new(sql.NullTime) - case discordpendinglink.FieldID: - values[i] = new(uuid.UUID) - default: - values[i] = new(sql.UnknownType) - } - } - return values, nil -} - -// assignValues assigns the values that were returned from sql.Rows (after scanning) -// to the DiscordPendingLink fields. -func (_m *DiscordPendingLink) assignValues(columns []string, values []any) error { - if m, n := len(values), len(columns); m < n { - return fmt.Errorf("mismatch number of scan values: %d != %d", m, n) - } - for i := range columns { - switch columns[i] { - case discordpendinglink.FieldID: - if value, ok := values[i].(*uuid.UUID); !ok { - return fmt.Errorf("unexpected type %T for field id", values[i]) - } else if value != nil { - _m.ID = *value - } - case discordpendinglink.FieldCode: - if value, ok := values[i].(*sql.NullString); !ok { - return fmt.Errorf("unexpected type %T for field code", values[i]) - } else if value.Valid { - _m.Code = value.String - } - case discordpendinglink.FieldDiscordUserID: - if value, ok := values[i].(*sql.NullString); !ok { - return fmt.Errorf("unexpected type %T for field discord_user_id", values[i]) - } else if value.Valid { - _m.DiscordUserID = value.String - } - case discordpendinglink.FieldStatus: - if value, ok := values[i].(*sql.NullString); !ok { - return fmt.Errorf("unexpected type %T for field status", values[i]) - } else if value.Valid { - _m.Status = value.String - } - case discordpendinglink.FieldUserID: - if value, ok := values[i].(*sql.NullString); !ok { - return fmt.Errorf("unexpected type %T for field user_id", values[i]) - } else if value.Valid { - _m.UserID = value.String - } - case discordpendinglink.FieldUserEmail: - if value, ok := values[i].(*sql.NullString); !ok { - return fmt.Errorf("unexpected type %T for field user_email", values[i]) - } else if value.Valid { - _m.UserEmail = value.String - } - case discordpendinglink.FieldExpiresAt: - if value, ok := values[i].(*sql.NullTime); !ok { - return fmt.Errorf("unexpected type %T for field expires_at", values[i]) - } else if value.Valid { - _m.ExpiresAt = value.Time - } - case discordpendinglink.FieldCreatedAt: - if value, ok := values[i].(*sql.NullTime); !ok { - return fmt.Errorf("unexpected type %T for field created_at", values[i]) - } else if value.Valid { - _m.CreatedAt = value.Time - } - default: - _m.selectValues.Set(columns[i], values[i]) - } - } - return nil -} - -// Value returns the ent.Value that was dynamically selected and assigned to the DiscordPendingLink. -// This includes values selected through modifiers, order, etc. -func (_m *DiscordPendingLink) Value(name string) (ent.Value, error) { - return _m.selectValues.Get(name) -} - -// Update returns a builder for updating this DiscordPendingLink. -// Note that you need to call DiscordPendingLink.Unwrap() before calling this method if this DiscordPendingLink -// was returned from a transaction, and the transaction was committed or rolled back. -func (_m *DiscordPendingLink) Update() *DiscordPendingLinkUpdateOne { - return NewDiscordPendingLinkClient(_m.config).UpdateOne(_m) -} - -// Unwrap unwraps the DiscordPendingLink entity that was returned from a transaction after it was closed, -// so that all future queries will be executed through the driver which created the transaction. -func (_m *DiscordPendingLink) Unwrap() *DiscordPendingLink { - _tx, ok := _m.config.driver.(*txDriver) - if !ok { - panic("ent: DiscordPendingLink is not a transactional entity") - } - _m.config.driver = _tx.drv - return _m -} - -// String implements the fmt.Stringer. -func (_m *DiscordPendingLink) String() string { - var builder strings.Builder - builder.WriteString("DiscordPendingLink(") - builder.WriteString(fmt.Sprintf("id=%v, ", _m.ID)) - builder.WriteString("code=") - builder.WriteString(_m.Code) - builder.WriteString(", ") - builder.WriteString("discord_user_id=") - builder.WriteString(_m.DiscordUserID) - builder.WriteString(", ") - builder.WriteString("status=") - builder.WriteString(_m.Status) - builder.WriteString(", ") - builder.WriteString("user_id=") - builder.WriteString(_m.UserID) - builder.WriteString(", ") - builder.WriteString("user_email=") - builder.WriteString(_m.UserEmail) - builder.WriteString(", ") - builder.WriteString("expires_at=") - builder.WriteString(_m.ExpiresAt.Format(time.ANSIC)) - builder.WriteString(", ") - builder.WriteString("created_at=") - builder.WriteString(_m.CreatedAt.Format(time.ANSIC)) - builder.WriteByte(')') - return builder.String() -} - -// DiscordPendingLinks is a parsable slice of DiscordPendingLink. -type DiscordPendingLinks []*DiscordPendingLink diff --git a/pkg/ent/discordpendinglink/discordpendinglink.go b/pkg/ent/discordpendinglink/discordpendinglink.go deleted file mode 100644 index 20b716f32..000000000 --- a/pkg/ent/discordpendinglink/discordpendinglink.go +++ /dev/null @@ -1,115 +0,0 @@ -// Code generated by ent, DO NOT EDIT. - -package discordpendinglink - -import ( - "time" - - "entgo.io/ent/dialect/sql" - "github.com/google/uuid" -) - -const ( - // Label holds the string label denoting the discordpendinglink type in the database. - Label = "discord_pending_link" - // FieldID holds the string denoting the id field in the database. - FieldID = "id" - // FieldCode holds the string denoting the code field in the database. - FieldCode = "code" - // FieldDiscordUserID holds the string denoting the discord_user_id field in the database. - FieldDiscordUserID = "discord_user_id" - // FieldStatus holds the string denoting the status field in the database. - FieldStatus = "status" - // FieldUserID holds the string denoting the user_id field in the database. - FieldUserID = "user_id" - // FieldUserEmail holds the string denoting the user_email field in the database. - FieldUserEmail = "user_email" - // FieldExpiresAt holds the string denoting the expires_at field in the database. - FieldExpiresAt = "expires_at" - // FieldCreatedAt holds the string denoting the created_at field in the database. - FieldCreatedAt = "created_at" - // Table holds the table name of the discordpendinglink in the database. - Table = "discord_pending_links" -) - -// Columns holds all SQL columns for discordpendinglink fields. -var Columns = []string{ - FieldID, - FieldCode, - FieldDiscordUserID, - FieldStatus, - FieldUserID, - FieldUserEmail, - FieldExpiresAt, - FieldCreatedAt, -} - -// ValidColumn reports if the column name is valid (part of the table columns). -func ValidColumn(column string) bool { - for i := range Columns { - if column == Columns[i] { - return true - } - } - return false -} - -var ( - // CodeValidator is a validator for the "code" field. It is called by the builders before save. - CodeValidator func(string) error - // DiscordUserIDValidator is a validator for the "discord_user_id" field. It is called by the builders before save. - DiscordUserIDValidator func(string) error - // DefaultStatus holds the default value on creation for the "status" field. - DefaultStatus string - // DefaultUserID holds the default value on creation for the "user_id" field. - DefaultUserID string - // DefaultUserEmail holds the default value on creation for the "user_email" field. - DefaultUserEmail string - // DefaultCreatedAt holds the default value on creation for the "created_at" field. - DefaultCreatedAt func() time.Time - // DefaultID holds the default value on creation for the "id" field. - DefaultID func() uuid.UUID -) - -// OrderOption defines the ordering options for the DiscordPendingLink queries. -type OrderOption func(*sql.Selector) - -// ByID orders the results by the id field. -func ByID(opts ...sql.OrderTermOption) OrderOption { - return sql.OrderByField(FieldID, opts...).ToFunc() -} - -// ByCode orders the results by the code field. -func ByCode(opts ...sql.OrderTermOption) OrderOption { - return sql.OrderByField(FieldCode, opts...).ToFunc() -} - -// ByDiscordUserID orders the results by the discord_user_id field. -func ByDiscordUserID(opts ...sql.OrderTermOption) OrderOption { - return sql.OrderByField(FieldDiscordUserID, opts...).ToFunc() -} - -// ByStatus orders the results by the status field. -func ByStatus(opts ...sql.OrderTermOption) OrderOption { - return sql.OrderByField(FieldStatus, opts...).ToFunc() -} - -// ByUserID orders the results by the user_id field. -func ByUserID(opts ...sql.OrderTermOption) OrderOption { - return sql.OrderByField(FieldUserID, opts...).ToFunc() -} - -// ByUserEmail orders the results by the user_email field. -func ByUserEmail(opts ...sql.OrderTermOption) OrderOption { - return sql.OrderByField(FieldUserEmail, opts...).ToFunc() -} - -// ByExpiresAt orders the results by the expires_at field. -func ByExpiresAt(opts ...sql.OrderTermOption) OrderOption { - return sql.OrderByField(FieldExpiresAt, opts...).ToFunc() -} - -// ByCreatedAt orders the results by the created_at field. -func ByCreatedAt(opts ...sql.OrderTermOption) OrderOption { - return sql.OrderByField(FieldCreatedAt, opts...).ToFunc() -} diff --git a/pkg/ent/discordpendinglink/where.go b/pkg/ent/discordpendinglink/where.go deleted file mode 100644 index e80ab3b66..000000000 --- a/pkg/ent/discordpendinglink/where.go +++ /dev/null @@ -1,511 +0,0 @@ -// Code generated by ent, DO NOT EDIT. - -package discordpendinglink - -import ( - "time" - - "entgo.io/ent/dialect/sql" - "github.com/GoogleCloudPlatform/scion/pkg/ent/predicate" - "github.com/google/uuid" -) - -// ID filters vertices based on their ID field. -func ID(id uuid.UUID) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldEQ(FieldID, id)) -} - -// IDEQ applies the EQ predicate on the ID field. -func IDEQ(id uuid.UUID) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldEQ(FieldID, id)) -} - -// IDNEQ applies the NEQ predicate on the ID field. -func IDNEQ(id uuid.UUID) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldNEQ(FieldID, id)) -} - -// IDIn applies the In predicate on the ID field. -func IDIn(ids ...uuid.UUID) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldIn(FieldID, ids...)) -} - -// IDNotIn applies the NotIn predicate on the ID field. -func IDNotIn(ids ...uuid.UUID) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldNotIn(FieldID, ids...)) -} - -// IDGT applies the GT predicate on the ID field. -func IDGT(id uuid.UUID) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldGT(FieldID, id)) -} - -// IDGTE applies the GTE predicate on the ID field. -func IDGTE(id uuid.UUID) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldGTE(FieldID, id)) -} - -// IDLT applies the LT predicate on the ID field. -func IDLT(id uuid.UUID) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldLT(FieldID, id)) -} - -// IDLTE applies the LTE predicate on the ID field. -func IDLTE(id uuid.UUID) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldLTE(FieldID, id)) -} - -// Code applies equality check predicate on the "code" field. It's identical to CodeEQ. -func Code(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldEQ(FieldCode, v)) -} - -// DiscordUserID applies equality check predicate on the "discord_user_id" field. It's identical to DiscordUserIDEQ. -func DiscordUserID(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldEQ(FieldDiscordUserID, v)) -} - -// Status applies equality check predicate on the "status" field. It's identical to StatusEQ. -func Status(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldEQ(FieldStatus, v)) -} - -// UserID applies equality check predicate on the "user_id" field. It's identical to UserIDEQ. -func UserID(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldEQ(FieldUserID, v)) -} - -// UserEmail applies equality check predicate on the "user_email" field. It's identical to UserEmailEQ. -func UserEmail(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldEQ(FieldUserEmail, v)) -} - -// ExpiresAt applies equality check predicate on the "expires_at" field. It's identical to ExpiresAtEQ. -func ExpiresAt(v time.Time) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldEQ(FieldExpiresAt, v)) -} - -// CreatedAt applies equality check predicate on the "created_at" field. It's identical to CreatedAtEQ. -func CreatedAt(v time.Time) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldEQ(FieldCreatedAt, v)) -} - -// CodeEQ applies the EQ predicate on the "code" field. -func CodeEQ(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldEQ(FieldCode, v)) -} - -// CodeNEQ applies the NEQ predicate on the "code" field. -func CodeNEQ(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldNEQ(FieldCode, v)) -} - -// CodeIn applies the In predicate on the "code" field. -func CodeIn(vs ...string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldIn(FieldCode, vs...)) -} - -// CodeNotIn applies the NotIn predicate on the "code" field. -func CodeNotIn(vs ...string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldNotIn(FieldCode, vs...)) -} - -// CodeGT applies the GT predicate on the "code" field. -func CodeGT(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldGT(FieldCode, v)) -} - -// CodeGTE applies the GTE predicate on the "code" field. -func CodeGTE(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldGTE(FieldCode, v)) -} - -// CodeLT applies the LT predicate on the "code" field. -func CodeLT(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldLT(FieldCode, v)) -} - -// CodeLTE applies the LTE predicate on the "code" field. -func CodeLTE(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldLTE(FieldCode, v)) -} - -// CodeContains applies the Contains predicate on the "code" field. -func CodeContains(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldContains(FieldCode, v)) -} - -// CodeHasPrefix applies the HasPrefix predicate on the "code" field. -func CodeHasPrefix(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldHasPrefix(FieldCode, v)) -} - -// CodeHasSuffix applies the HasSuffix predicate on the "code" field. -func CodeHasSuffix(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldHasSuffix(FieldCode, v)) -} - -// CodeEqualFold applies the EqualFold predicate on the "code" field. -func CodeEqualFold(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldEqualFold(FieldCode, v)) -} - -// CodeContainsFold applies the ContainsFold predicate on the "code" field. -func CodeContainsFold(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldContainsFold(FieldCode, v)) -} - -// DiscordUserIDEQ applies the EQ predicate on the "discord_user_id" field. -func DiscordUserIDEQ(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldEQ(FieldDiscordUserID, v)) -} - -// DiscordUserIDNEQ applies the NEQ predicate on the "discord_user_id" field. -func DiscordUserIDNEQ(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldNEQ(FieldDiscordUserID, v)) -} - -// DiscordUserIDIn applies the In predicate on the "discord_user_id" field. -func DiscordUserIDIn(vs ...string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldIn(FieldDiscordUserID, vs...)) -} - -// DiscordUserIDNotIn applies the NotIn predicate on the "discord_user_id" field. -func DiscordUserIDNotIn(vs ...string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldNotIn(FieldDiscordUserID, vs...)) -} - -// DiscordUserIDGT applies the GT predicate on the "discord_user_id" field. -func DiscordUserIDGT(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldGT(FieldDiscordUserID, v)) -} - -// DiscordUserIDGTE applies the GTE predicate on the "discord_user_id" field. -func DiscordUserIDGTE(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldGTE(FieldDiscordUserID, v)) -} - -// DiscordUserIDLT applies the LT predicate on the "discord_user_id" field. -func DiscordUserIDLT(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldLT(FieldDiscordUserID, v)) -} - -// DiscordUserIDLTE applies the LTE predicate on the "discord_user_id" field. -func DiscordUserIDLTE(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldLTE(FieldDiscordUserID, v)) -} - -// DiscordUserIDContains applies the Contains predicate on the "discord_user_id" field. -func DiscordUserIDContains(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldContains(FieldDiscordUserID, v)) -} - -// DiscordUserIDHasPrefix applies the HasPrefix predicate on the "discord_user_id" field. -func DiscordUserIDHasPrefix(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldHasPrefix(FieldDiscordUserID, v)) -} - -// DiscordUserIDHasSuffix applies the HasSuffix predicate on the "discord_user_id" field. -func DiscordUserIDHasSuffix(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldHasSuffix(FieldDiscordUserID, v)) -} - -// DiscordUserIDEqualFold applies the EqualFold predicate on the "discord_user_id" field. -func DiscordUserIDEqualFold(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldEqualFold(FieldDiscordUserID, v)) -} - -// DiscordUserIDContainsFold applies the ContainsFold predicate on the "discord_user_id" field. -func DiscordUserIDContainsFold(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldContainsFold(FieldDiscordUserID, v)) -} - -// StatusEQ applies the EQ predicate on the "status" field. -func StatusEQ(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldEQ(FieldStatus, v)) -} - -// StatusNEQ applies the NEQ predicate on the "status" field. -func StatusNEQ(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldNEQ(FieldStatus, v)) -} - -// StatusIn applies the In predicate on the "status" field. -func StatusIn(vs ...string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldIn(FieldStatus, vs...)) -} - -// StatusNotIn applies the NotIn predicate on the "status" field. -func StatusNotIn(vs ...string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldNotIn(FieldStatus, vs...)) -} - -// StatusGT applies the GT predicate on the "status" field. -func StatusGT(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldGT(FieldStatus, v)) -} - -// StatusGTE applies the GTE predicate on the "status" field. -func StatusGTE(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldGTE(FieldStatus, v)) -} - -// StatusLT applies the LT predicate on the "status" field. -func StatusLT(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldLT(FieldStatus, v)) -} - -// StatusLTE applies the LTE predicate on the "status" field. -func StatusLTE(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldLTE(FieldStatus, v)) -} - -// StatusContains applies the Contains predicate on the "status" field. -func StatusContains(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldContains(FieldStatus, v)) -} - -// StatusHasPrefix applies the HasPrefix predicate on the "status" field. -func StatusHasPrefix(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldHasPrefix(FieldStatus, v)) -} - -// StatusHasSuffix applies the HasSuffix predicate on the "status" field. -func StatusHasSuffix(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldHasSuffix(FieldStatus, v)) -} - -// StatusEqualFold applies the EqualFold predicate on the "status" field. -func StatusEqualFold(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldEqualFold(FieldStatus, v)) -} - -// StatusContainsFold applies the ContainsFold predicate on the "status" field. -func StatusContainsFold(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldContainsFold(FieldStatus, v)) -} - -// UserIDEQ applies the EQ predicate on the "user_id" field. -func UserIDEQ(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldEQ(FieldUserID, v)) -} - -// UserIDNEQ applies the NEQ predicate on the "user_id" field. -func UserIDNEQ(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldNEQ(FieldUserID, v)) -} - -// UserIDIn applies the In predicate on the "user_id" field. -func UserIDIn(vs ...string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldIn(FieldUserID, vs...)) -} - -// UserIDNotIn applies the NotIn predicate on the "user_id" field. -func UserIDNotIn(vs ...string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldNotIn(FieldUserID, vs...)) -} - -// UserIDGT applies the GT predicate on the "user_id" field. -func UserIDGT(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldGT(FieldUserID, v)) -} - -// UserIDGTE applies the GTE predicate on the "user_id" field. -func UserIDGTE(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldGTE(FieldUserID, v)) -} - -// UserIDLT applies the LT predicate on the "user_id" field. -func UserIDLT(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldLT(FieldUserID, v)) -} - -// UserIDLTE applies the LTE predicate on the "user_id" field. -func UserIDLTE(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldLTE(FieldUserID, v)) -} - -// UserIDContains applies the Contains predicate on the "user_id" field. -func UserIDContains(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldContains(FieldUserID, v)) -} - -// UserIDHasPrefix applies the HasPrefix predicate on the "user_id" field. -func UserIDHasPrefix(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldHasPrefix(FieldUserID, v)) -} - -// UserIDHasSuffix applies the HasSuffix predicate on the "user_id" field. -func UserIDHasSuffix(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldHasSuffix(FieldUserID, v)) -} - -// UserIDEqualFold applies the EqualFold predicate on the "user_id" field. -func UserIDEqualFold(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldEqualFold(FieldUserID, v)) -} - -// UserIDContainsFold applies the ContainsFold predicate on the "user_id" field. -func UserIDContainsFold(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldContainsFold(FieldUserID, v)) -} - -// UserEmailEQ applies the EQ predicate on the "user_email" field. -func UserEmailEQ(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldEQ(FieldUserEmail, v)) -} - -// UserEmailNEQ applies the NEQ predicate on the "user_email" field. -func UserEmailNEQ(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldNEQ(FieldUserEmail, v)) -} - -// UserEmailIn applies the In predicate on the "user_email" field. -func UserEmailIn(vs ...string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldIn(FieldUserEmail, vs...)) -} - -// UserEmailNotIn applies the NotIn predicate on the "user_email" field. -func UserEmailNotIn(vs ...string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldNotIn(FieldUserEmail, vs...)) -} - -// UserEmailGT applies the GT predicate on the "user_email" field. -func UserEmailGT(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldGT(FieldUserEmail, v)) -} - -// UserEmailGTE applies the GTE predicate on the "user_email" field. -func UserEmailGTE(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldGTE(FieldUserEmail, v)) -} - -// UserEmailLT applies the LT predicate on the "user_email" field. -func UserEmailLT(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldLT(FieldUserEmail, v)) -} - -// UserEmailLTE applies the LTE predicate on the "user_email" field. -func UserEmailLTE(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldLTE(FieldUserEmail, v)) -} - -// UserEmailContains applies the Contains predicate on the "user_email" field. -func UserEmailContains(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldContains(FieldUserEmail, v)) -} - -// UserEmailHasPrefix applies the HasPrefix predicate on the "user_email" field. -func UserEmailHasPrefix(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldHasPrefix(FieldUserEmail, v)) -} - -// UserEmailHasSuffix applies the HasSuffix predicate on the "user_email" field. -func UserEmailHasSuffix(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldHasSuffix(FieldUserEmail, v)) -} - -// UserEmailEqualFold applies the EqualFold predicate on the "user_email" field. -func UserEmailEqualFold(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldEqualFold(FieldUserEmail, v)) -} - -// UserEmailContainsFold applies the ContainsFold predicate on the "user_email" field. -func UserEmailContainsFold(v string) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldContainsFold(FieldUserEmail, v)) -} - -// ExpiresAtEQ applies the EQ predicate on the "expires_at" field. -func ExpiresAtEQ(v time.Time) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldEQ(FieldExpiresAt, v)) -} - -// ExpiresAtNEQ applies the NEQ predicate on the "expires_at" field. -func ExpiresAtNEQ(v time.Time) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldNEQ(FieldExpiresAt, v)) -} - -// ExpiresAtIn applies the In predicate on the "expires_at" field. -func ExpiresAtIn(vs ...time.Time) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldIn(FieldExpiresAt, vs...)) -} - -// ExpiresAtNotIn applies the NotIn predicate on the "expires_at" field. -func ExpiresAtNotIn(vs ...time.Time) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldNotIn(FieldExpiresAt, vs...)) -} - -// ExpiresAtGT applies the GT predicate on the "expires_at" field. -func ExpiresAtGT(v time.Time) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldGT(FieldExpiresAt, v)) -} - -// ExpiresAtGTE applies the GTE predicate on the "expires_at" field. -func ExpiresAtGTE(v time.Time) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldGTE(FieldExpiresAt, v)) -} - -// ExpiresAtLT applies the LT predicate on the "expires_at" field. -func ExpiresAtLT(v time.Time) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldLT(FieldExpiresAt, v)) -} - -// ExpiresAtLTE applies the LTE predicate on the "expires_at" field. -func ExpiresAtLTE(v time.Time) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldLTE(FieldExpiresAt, v)) -} - -// CreatedAtEQ applies the EQ predicate on the "created_at" field. -func CreatedAtEQ(v time.Time) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldEQ(FieldCreatedAt, v)) -} - -// CreatedAtNEQ applies the NEQ predicate on the "created_at" field. -func CreatedAtNEQ(v time.Time) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldNEQ(FieldCreatedAt, v)) -} - -// CreatedAtIn applies the In predicate on the "created_at" field. -func CreatedAtIn(vs ...time.Time) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldIn(FieldCreatedAt, vs...)) -} - -// CreatedAtNotIn applies the NotIn predicate on the "created_at" field. -func CreatedAtNotIn(vs ...time.Time) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldNotIn(FieldCreatedAt, vs...)) -} - -// CreatedAtGT applies the GT predicate on the "created_at" field. -func CreatedAtGT(v time.Time) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldGT(FieldCreatedAt, v)) -} - -// CreatedAtGTE applies the GTE predicate on the "created_at" field. -func CreatedAtGTE(v time.Time) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldGTE(FieldCreatedAt, v)) -} - -// CreatedAtLT applies the LT predicate on the "created_at" field. -func CreatedAtLT(v time.Time) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldLT(FieldCreatedAt, v)) -} - -// CreatedAtLTE applies the LTE predicate on the "created_at" field. -func CreatedAtLTE(v time.Time) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.FieldLTE(FieldCreatedAt, v)) -} - -// And groups predicates with the AND operator between them. -func And(predicates ...predicate.DiscordPendingLink) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.AndPredicates(predicates...)) -} - -// Or groups predicates with the OR operator between them. -func Or(predicates ...predicate.DiscordPendingLink) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.OrPredicates(predicates...)) -} - -// Not applies the not operator on the given predicate. -func Not(p predicate.DiscordPendingLink) predicate.DiscordPendingLink { - return predicate.DiscordPendingLink(sql.NotPredicates(p)) -} diff --git a/pkg/ent/discordpendinglink_create.go b/pkg/ent/discordpendinglink_create.go deleted file mode 100644 index 124d0f992..000000000 --- a/pkg/ent/discordpendinglink_create.go +++ /dev/null @@ -1,851 +0,0 @@ -// Code generated by ent, DO NOT EDIT. - -package ent - -import ( - "context" - "errors" - "fmt" - "time" - - "entgo.io/ent/dialect" - "entgo.io/ent/dialect/sql" - "entgo.io/ent/dialect/sql/sqlgraph" - "entgo.io/ent/schema/field" - "github.com/GoogleCloudPlatform/scion/pkg/ent/discordpendinglink" - "github.com/google/uuid" -) - -// DiscordPendingLinkCreate is the builder for creating a DiscordPendingLink entity. -type DiscordPendingLinkCreate struct { - config - mutation *DiscordPendingLinkMutation - hooks []Hook - conflict []sql.ConflictOption -} - -// SetCode sets the "code" field. -func (_c *DiscordPendingLinkCreate) SetCode(v string) *DiscordPendingLinkCreate { - _c.mutation.SetCode(v) - return _c -} - -// SetDiscordUserID sets the "discord_user_id" field. -func (_c *DiscordPendingLinkCreate) SetDiscordUserID(v string) *DiscordPendingLinkCreate { - _c.mutation.SetDiscordUserID(v) - return _c -} - -// SetStatus sets the "status" field. -func (_c *DiscordPendingLinkCreate) SetStatus(v string) *DiscordPendingLinkCreate { - _c.mutation.SetStatus(v) - return _c -} - -// SetNillableStatus sets the "status" field if the given value is not nil. -func (_c *DiscordPendingLinkCreate) SetNillableStatus(v *string) *DiscordPendingLinkCreate { - if v != nil { - _c.SetStatus(*v) - } - return _c -} - -// SetUserID sets the "user_id" field. -func (_c *DiscordPendingLinkCreate) SetUserID(v string) *DiscordPendingLinkCreate { - _c.mutation.SetUserID(v) - return _c -} - -// SetNillableUserID sets the "user_id" field if the given value is not nil. -func (_c *DiscordPendingLinkCreate) SetNillableUserID(v *string) *DiscordPendingLinkCreate { - if v != nil { - _c.SetUserID(*v) - } - return _c -} - -// SetUserEmail sets the "user_email" field. -func (_c *DiscordPendingLinkCreate) SetUserEmail(v string) *DiscordPendingLinkCreate { - _c.mutation.SetUserEmail(v) - return _c -} - -// SetNillableUserEmail sets the "user_email" field if the given value is not nil. -func (_c *DiscordPendingLinkCreate) SetNillableUserEmail(v *string) *DiscordPendingLinkCreate { - if v != nil { - _c.SetUserEmail(*v) - } - return _c -} - -// SetExpiresAt sets the "expires_at" field. -func (_c *DiscordPendingLinkCreate) SetExpiresAt(v time.Time) *DiscordPendingLinkCreate { - _c.mutation.SetExpiresAt(v) - return _c -} - -// SetCreatedAt sets the "created_at" field. -func (_c *DiscordPendingLinkCreate) SetCreatedAt(v time.Time) *DiscordPendingLinkCreate { - _c.mutation.SetCreatedAt(v) - return _c -} - -// SetNillableCreatedAt sets the "created_at" field if the given value is not nil. -func (_c *DiscordPendingLinkCreate) SetNillableCreatedAt(v *time.Time) *DiscordPendingLinkCreate { - if v != nil { - _c.SetCreatedAt(*v) - } - return _c -} - -// SetID sets the "id" field. -func (_c *DiscordPendingLinkCreate) SetID(v uuid.UUID) *DiscordPendingLinkCreate { - _c.mutation.SetID(v) - return _c -} - -// SetNillableID sets the "id" field if the given value is not nil. -func (_c *DiscordPendingLinkCreate) SetNillableID(v *uuid.UUID) *DiscordPendingLinkCreate { - if v != nil { - _c.SetID(*v) - } - return _c -} - -// Mutation returns the DiscordPendingLinkMutation object of the builder. -func (_c *DiscordPendingLinkCreate) Mutation() *DiscordPendingLinkMutation { - return _c.mutation -} - -// Save creates the DiscordPendingLink in the database. -func (_c *DiscordPendingLinkCreate) Save(ctx context.Context) (*DiscordPendingLink, error) { - _c.defaults() - return withHooks(ctx, _c.sqlSave, _c.mutation, _c.hooks) -} - -// SaveX calls Save and panics if Save returns an error. -func (_c *DiscordPendingLinkCreate) SaveX(ctx context.Context) *DiscordPendingLink { - v, err := _c.Save(ctx) - if err != nil { - panic(err) - } - return v -} - -// Exec executes the query. -func (_c *DiscordPendingLinkCreate) Exec(ctx context.Context) error { - _, err := _c.Save(ctx) - return err -} - -// ExecX is like Exec, but panics if an error occurs. -func (_c *DiscordPendingLinkCreate) ExecX(ctx context.Context) { - if err := _c.Exec(ctx); err != nil { - panic(err) - } -} - -// defaults sets the default values of the builder before save. -func (_c *DiscordPendingLinkCreate) defaults() { - if _, ok := _c.mutation.Status(); !ok { - v := discordpendinglink.DefaultStatus - _c.mutation.SetStatus(v) - } - if _, ok := _c.mutation.UserID(); !ok { - v := discordpendinglink.DefaultUserID - _c.mutation.SetUserID(v) - } - if _, ok := _c.mutation.UserEmail(); !ok { - v := discordpendinglink.DefaultUserEmail - _c.mutation.SetUserEmail(v) - } - if _, ok := _c.mutation.CreatedAt(); !ok { - v := discordpendinglink.DefaultCreatedAt() - _c.mutation.SetCreatedAt(v) - } - if _, ok := _c.mutation.ID(); !ok { - v := discordpendinglink.DefaultID() - _c.mutation.SetID(v) - } -} - -// check runs all checks and user-defined validators on the builder. -func (_c *DiscordPendingLinkCreate) check() error { - if _, ok := _c.mutation.Code(); !ok { - return &ValidationError{Name: "code", err: errors.New(`ent: missing required field "DiscordPendingLink.code"`)} - } - if v, ok := _c.mutation.Code(); ok { - if err := discordpendinglink.CodeValidator(v); err != nil { - return &ValidationError{Name: "code", err: fmt.Errorf(`ent: validator failed for field "DiscordPendingLink.code": %w`, err)} - } - } - if _, ok := _c.mutation.DiscordUserID(); !ok { - return &ValidationError{Name: "discord_user_id", err: errors.New(`ent: missing required field "DiscordPendingLink.discord_user_id"`)} - } - if v, ok := _c.mutation.DiscordUserID(); ok { - if err := discordpendinglink.DiscordUserIDValidator(v); err != nil { - return &ValidationError{Name: "discord_user_id", err: fmt.Errorf(`ent: validator failed for field "DiscordPendingLink.discord_user_id": %w`, err)} - } - } - if _, ok := _c.mutation.Status(); !ok { - return &ValidationError{Name: "status", err: errors.New(`ent: missing required field "DiscordPendingLink.status"`)} - } - if _, ok := _c.mutation.UserID(); !ok { - return &ValidationError{Name: "user_id", err: errors.New(`ent: missing required field "DiscordPendingLink.user_id"`)} - } - if _, ok := _c.mutation.UserEmail(); !ok { - return &ValidationError{Name: "user_email", err: errors.New(`ent: missing required field "DiscordPendingLink.user_email"`)} - } - if _, ok := _c.mutation.ExpiresAt(); !ok { - return &ValidationError{Name: "expires_at", err: errors.New(`ent: missing required field "DiscordPendingLink.expires_at"`)} - } - if _, ok := _c.mutation.CreatedAt(); !ok { - return &ValidationError{Name: "created_at", err: errors.New(`ent: missing required field "DiscordPendingLink.created_at"`)} - } - return nil -} - -func (_c *DiscordPendingLinkCreate) sqlSave(ctx context.Context) (*DiscordPendingLink, error) { - if err := _c.check(); err != nil { - return nil, err - } - _node, _spec := _c.createSpec() - if err := sqlgraph.CreateNode(ctx, _c.driver, _spec); err != nil { - if sqlgraph.IsConstraintError(err) { - err = &ConstraintError{msg: err.Error(), wrap: err} - } - return nil, err - } - if _spec.ID.Value != nil { - if id, ok := _spec.ID.Value.(*uuid.UUID); ok { - _node.ID = *id - } else if err := _node.ID.Scan(_spec.ID.Value); err != nil { - return nil, err - } - } - _c.mutation.id = &_node.ID - _c.mutation.done = true - return _node, nil -} - -func (_c *DiscordPendingLinkCreate) createSpec() (*DiscordPendingLink, *sqlgraph.CreateSpec) { - var ( - _node = &DiscordPendingLink{config: _c.config} - _spec = sqlgraph.NewCreateSpec(discordpendinglink.Table, sqlgraph.NewFieldSpec(discordpendinglink.FieldID, field.TypeUUID)) - ) - _spec.OnConflict = _c.conflict - if id, ok := _c.mutation.ID(); ok { - _node.ID = id - _spec.ID.Value = &id - } - if value, ok := _c.mutation.Code(); ok { - _spec.SetField(discordpendinglink.FieldCode, field.TypeString, value) - _node.Code = value - } - if value, ok := _c.mutation.DiscordUserID(); ok { - _spec.SetField(discordpendinglink.FieldDiscordUserID, field.TypeString, value) - _node.DiscordUserID = value - } - if value, ok := _c.mutation.Status(); ok { - _spec.SetField(discordpendinglink.FieldStatus, field.TypeString, value) - _node.Status = value - } - if value, ok := _c.mutation.UserID(); ok { - _spec.SetField(discordpendinglink.FieldUserID, field.TypeString, value) - _node.UserID = value - } - if value, ok := _c.mutation.UserEmail(); ok { - _spec.SetField(discordpendinglink.FieldUserEmail, field.TypeString, value) - _node.UserEmail = value - } - if value, ok := _c.mutation.ExpiresAt(); ok { - _spec.SetField(discordpendinglink.FieldExpiresAt, field.TypeTime, value) - _node.ExpiresAt = value - } - if value, ok := _c.mutation.CreatedAt(); ok { - _spec.SetField(discordpendinglink.FieldCreatedAt, field.TypeTime, value) - _node.CreatedAt = value - } - return _node, _spec -} - -// OnConflict allows configuring the `ON CONFLICT` / `ON DUPLICATE KEY` clause -// of the `INSERT` statement. For example: -// -// client.DiscordPendingLink.Create(). -// SetCode(v). -// OnConflict( -// // Update the row with the new values -// // the was proposed for insertion. -// sql.ResolveWithNewValues(), -// ). -// // Override some of the fields with custom -// // update values. -// Update(func(u *ent.DiscordPendingLinkUpsert) { -// SetCode(v+v). -// }). -// Exec(ctx) -func (_c *DiscordPendingLinkCreate) OnConflict(opts ...sql.ConflictOption) *DiscordPendingLinkUpsertOne { - _c.conflict = opts - return &DiscordPendingLinkUpsertOne{ - create: _c, - } -} - -// OnConflictColumns calls `OnConflict` and configures the columns -// as conflict target. Using this option is equivalent to using: -// -// client.DiscordPendingLink.Create(). -// OnConflict(sql.ConflictColumns(columns...)). -// Exec(ctx) -func (_c *DiscordPendingLinkCreate) OnConflictColumns(columns ...string) *DiscordPendingLinkUpsertOne { - _c.conflict = append(_c.conflict, sql.ConflictColumns(columns...)) - return &DiscordPendingLinkUpsertOne{ - create: _c, - } -} - -type ( - // DiscordPendingLinkUpsertOne is the builder for "upsert"-ing - // one DiscordPendingLink node. - DiscordPendingLinkUpsertOne struct { - create *DiscordPendingLinkCreate - } - - // DiscordPendingLinkUpsert is the "OnConflict" setter. - DiscordPendingLinkUpsert struct { - *sql.UpdateSet - } -) - -// SetCode sets the "code" field. -func (u *DiscordPendingLinkUpsert) SetCode(v string) *DiscordPendingLinkUpsert { - u.Set(discordpendinglink.FieldCode, v) - return u -} - -// UpdateCode sets the "code" field to the value that was provided on create. -func (u *DiscordPendingLinkUpsert) UpdateCode() *DiscordPendingLinkUpsert { - u.SetExcluded(discordpendinglink.FieldCode) - return u -} - -// SetDiscordUserID sets the "discord_user_id" field. -func (u *DiscordPendingLinkUpsert) SetDiscordUserID(v string) *DiscordPendingLinkUpsert { - u.Set(discordpendinglink.FieldDiscordUserID, v) - return u -} - -// UpdateDiscordUserID sets the "discord_user_id" field to the value that was provided on create. -func (u *DiscordPendingLinkUpsert) UpdateDiscordUserID() *DiscordPendingLinkUpsert { - u.SetExcluded(discordpendinglink.FieldDiscordUserID) - return u -} - -// SetStatus sets the "status" field. -func (u *DiscordPendingLinkUpsert) SetStatus(v string) *DiscordPendingLinkUpsert { - u.Set(discordpendinglink.FieldStatus, v) - return u -} - -// UpdateStatus sets the "status" field to the value that was provided on create. -func (u *DiscordPendingLinkUpsert) UpdateStatus() *DiscordPendingLinkUpsert { - u.SetExcluded(discordpendinglink.FieldStatus) - return u -} - -// SetUserID sets the "user_id" field. -func (u *DiscordPendingLinkUpsert) SetUserID(v string) *DiscordPendingLinkUpsert { - u.Set(discordpendinglink.FieldUserID, v) - return u -} - -// UpdateUserID sets the "user_id" field to the value that was provided on create. -func (u *DiscordPendingLinkUpsert) UpdateUserID() *DiscordPendingLinkUpsert { - u.SetExcluded(discordpendinglink.FieldUserID) - return u -} - -// SetUserEmail sets the "user_email" field. -func (u *DiscordPendingLinkUpsert) SetUserEmail(v string) *DiscordPendingLinkUpsert { - u.Set(discordpendinglink.FieldUserEmail, v) - return u -} - -// UpdateUserEmail sets the "user_email" field to the value that was provided on create. -func (u *DiscordPendingLinkUpsert) UpdateUserEmail() *DiscordPendingLinkUpsert { - u.SetExcluded(discordpendinglink.FieldUserEmail) - return u -} - -// SetExpiresAt sets the "expires_at" field. -func (u *DiscordPendingLinkUpsert) SetExpiresAt(v time.Time) *DiscordPendingLinkUpsert { - u.Set(discordpendinglink.FieldExpiresAt, v) - return u -} - -// UpdateExpiresAt sets the "expires_at" field to the value that was provided on create. -func (u *DiscordPendingLinkUpsert) UpdateExpiresAt() *DiscordPendingLinkUpsert { - u.SetExcluded(discordpendinglink.FieldExpiresAt) - return u -} - -// UpdateNewValues updates the mutable fields using the new values that were set on create except the ID field. -// Using this option is equivalent to using: -// -// client.DiscordPendingLink.Create(). -// OnConflict( -// sql.ResolveWithNewValues(), -// sql.ResolveWith(func(u *sql.UpdateSet) { -// u.SetIgnore(discordpendinglink.FieldID) -// }), -// ). -// Exec(ctx) -func (u *DiscordPendingLinkUpsertOne) UpdateNewValues() *DiscordPendingLinkUpsertOne { - u.create.conflict = append(u.create.conflict, sql.ResolveWithNewValues()) - u.create.conflict = append(u.create.conflict, sql.ResolveWith(func(s *sql.UpdateSet) { - if _, exists := u.create.mutation.ID(); exists { - s.SetIgnore(discordpendinglink.FieldID) - } - if _, exists := u.create.mutation.CreatedAt(); exists { - s.SetIgnore(discordpendinglink.FieldCreatedAt) - } - })) - return u -} - -// Ignore sets each column to itself in case of conflict. -// Using this option is equivalent to using: -// -// client.DiscordPendingLink.Create(). -// OnConflict(sql.ResolveWithIgnore()). -// Exec(ctx) -func (u *DiscordPendingLinkUpsertOne) Ignore() *DiscordPendingLinkUpsertOne { - u.create.conflict = append(u.create.conflict, sql.ResolveWithIgnore()) - return u -} - -// DoNothing configures the conflict_action to `DO NOTHING`. -// Supported only by SQLite and PostgreSQL. -func (u *DiscordPendingLinkUpsertOne) DoNothing() *DiscordPendingLinkUpsertOne { - u.create.conflict = append(u.create.conflict, sql.DoNothing()) - return u -} - -// Update allows overriding fields `UPDATE` values. See the DiscordPendingLinkCreate.OnConflict -// documentation for more info. -func (u *DiscordPendingLinkUpsertOne) Update(set func(*DiscordPendingLinkUpsert)) *DiscordPendingLinkUpsertOne { - u.create.conflict = append(u.create.conflict, sql.ResolveWith(func(update *sql.UpdateSet) { - set(&DiscordPendingLinkUpsert{UpdateSet: update}) - })) - return u -} - -// SetCode sets the "code" field. -func (u *DiscordPendingLinkUpsertOne) SetCode(v string) *DiscordPendingLinkUpsertOne { - return u.Update(func(s *DiscordPendingLinkUpsert) { - s.SetCode(v) - }) -} - -// UpdateCode sets the "code" field to the value that was provided on create. -func (u *DiscordPendingLinkUpsertOne) UpdateCode() *DiscordPendingLinkUpsertOne { - return u.Update(func(s *DiscordPendingLinkUpsert) { - s.UpdateCode() - }) -} - -// SetDiscordUserID sets the "discord_user_id" field. -func (u *DiscordPendingLinkUpsertOne) SetDiscordUserID(v string) *DiscordPendingLinkUpsertOne { - return u.Update(func(s *DiscordPendingLinkUpsert) { - s.SetDiscordUserID(v) - }) -} - -// UpdateDiscordUserID sets the "discord_user_id" field to the value that was provided on create. -func (u *DiscordPendingLinkUpsertOne) UpdateDiscordUserID() *DiscordPendingLinkUpsertOne { - return u.Update(func(s *DiscordPendingLinkUpsert) { - s.UpdateDiscordUserID() - }) -} - -// SetStatus sets the "status" field. -func (u *DiscordPendingLinkUpsertOne) SetStatus(v string) *DiscordPendingLinkUpsertOne { - return u.Update(func(s *DiscordPendingLinkUpsert) { - s.SetStatus(v) - }) -} - -// UpdateStatus sets the "status" field to the value that was provided on create. -func (u *DiscordPendingLinkUpsertOne) UpdateStatus() *DiscordPendingLinkUpsertOne { - return u.Update(func(s *DiscordPendingLinkUpsert) { - s.UpdateStatus() - }) -} - -// SetUserID sets the "user_id" field. -func (u *DiscordPendingLinkUpsertOne) SetUserID(v string) *DiscordPendingLinkUpsertOne { - return u.Update(func(s *DiscordPendingLinkUpsert) { - s.SetUserID(v) - }) -} - -// UpdateUserID sets the "user_id" field to the value that was provided on create. -func (u *DiscordPendingLinkUpsertOne) UpdateUserID() *DiscordPendingLinkUpsertOne { - return u.Update(func(s *DiscordPendingLinkUpsert) { - s.UpdateUserID() - }) -} - -// SetUserEmail sets the "user_email" field. -func (u *DiscordPendingLinkUpsertOne) SetUserEmail(v string) *DiscordPendingLinkUpsertOne { - return u.Update(func(s *DiscordPendingLinkUpsert) { - s.SetUserEmail(v) - }) -} - -// UpdateUserEmail sets the "user_email" field to the value that was provided on create. -func (u *DiscordPendingLinkUpsertOne) UpdateUserEmail() *DiscordPendingLinkUpsertOne { - return u.Update(func(s *DiscordPendingLinkUpsert) { - s.UpdateUserEmail() - }) -} - -// SetExpiresAt sets the "expires_at" field. -func (u *DiscordPendingLinkUpsertOne) SetExpiresAt(v time.Time) *DiscordPendingLinkUpsertOne { - return u.Update(func(s *DiscordPendingLinkUpsert) { - s.SetExpiresAt(v) - }) -} - -// UpdateExpiresAt sets the "expires_at" field to the value that was provided on create. -func (u *DiscordPendingLinkUpsertOne) UpdateExpiresAt() *DiscordPendingLinkUpsertOne { - return u.Update(func(s *DiscordPendingLinkUpsert) { - s.UpdateExpiresAt() - }) -} - -// Exec executes the query. -func (u *DiscordPendingLinkUpsertOne) Exec(ctx context.Context) error { - if len(u.create.conflict) == 0 { - return errors.New("ent: missing options for DiscordPendingLinkCreate.OnConflict") - } - return u.create.Exec(ctx) -} - -// ExecX is like Exec, but panics if an error occurs. -func (u *DiscordPendingLinkUpsertOne) ExecX(ctx context.Context) { - if err := u.create.Exec(ctx); err != nil { - panic(err) - } -} - -// Exec executes the UPSERT query and returns the inserted/updated ID. -func (u *DiscordPendingLinkUpsertOne) ID(ctx context.Context) (id uuid.UUID, err error) { - if u.create.driver.Dialect() == dialect.MySQL { - // In case of "ON CONFLICT", there is no way to get back non-numeric ID - // fields from the database since MySQL does not support the RETURNING clause. - return id, errors.New("ent: DiscordPendingLinkUpsertOne.ID is not supported by MySQL driver. Use DiscordPendingLinkUpsertOne.Exec instead") - } - node, err := u.create.Save(ctx) - if err != nil { - return id, err - } - return node.ID, nil -} - -// IDX is like ID, but panics if an error occurs. -func (u *DiscordPendingLinkUpsertOne) IDX(ctx context.Context) uuid.UUID { - id, err := u.ID(ctx) - if err != nil { - panic(err) - } - return id -} - -// DiscordPendingLinkCreateBulk is the builder for creating many DiscordPendingLink entities in bulk. -type DiscordPendingLinkCreateBulk struct { - config - err error - builders []*DiscordPendingLinkCreate - conflict []sql.ConflictOption -} - -// Save creates the DiscordPendingLink entities in the database. -func (_c *DiscordPendingLinkCreateBulk) Save(ctx context.Context) ([]*DiscordPendingLink, error) { - if _c.err != nil { - return nil, _c.err - } - specs := make([]*sqlgraph.CreateSpec, len(_c.builders)) - nodes := make([]*DiscordPendingLink, len(_c.builders)) - mutators := make([]Mutator, len(_c.builders)) - for i := range _c.builders { - func(i int, root context.Context) { - builder := _c.builders[i] - builder.defaults() - var mut Mutator = MutateFunc(func(ctx context.Context, m Mutation) (Value, error) { - mutation, ok := m.(*DiscordPendingLinkMutation) - if !ok { - return nil, fmt.Errorf("unexpected mutation type %T", m) - } - if err := builder.check(); err != nil { - return nil, err - } - builder.mutation = mutation - var err error - nodes[i], specs[i] = builder.createSpec() - if i < len(mutators)-1 { - _, err = mutators[i+1].Mutate(root, _c.builders[i+1].mutation) - } else { - spec := &sqlgraph.BatchCreateSpec{Nodes: specs} - spec.OnConflict = _c.conflict - // Invoke the actual operation on the latest mutation in the chain. - if err = sqlgraph.BatchCreate(ctx, _c.driver, spec); err != nil { - if sqlgraph.IsConstraintError(err) { - err = &ConstraintError{msg: err.Error(), wrap: err} - } - } - } - if err != nil { - return nil, err - } - mutation.id = &nodes[i].ID - mutation.done = true - return nodes[i], nil - }) - for i := len(builder.hooks) - 1; i >= 0; i-- { - mut = builder.hooks[i](mut) - } - mutators[i] = mut - }(i, ctx) - } - if len(mutators) > 0 { - if _, err := mutators[0].Mutate(ctx, _c.builders[0].mutation); err != nil { - return nil, err - } - } - return nodes, nil -} - -// SaveX is like Save, but panics if an error occurs. -func (_c *DiscordPendingLinkCreateBulk) SaveX(ctx context.Context) []*DiscordPendingLink { - v, err := _c.Save(ctx) - if err != nil { - panic(err) - } - return v -} - -// Exec executes the query. -func (_c *DiscordPendingLinkCreateBulk) Exec(ctx context.Context) error { - _, err := _c.Save(ctx) - return err -} - -// ExecX is like Exec, but panics if an error occurs. -func (_c *DiscordPendingLinkCreateBulk) ExecX(ctx context.Context) { - if err := _c.Exec(ctx); err != nil { - panic(err) - } -} - -// OnConflict allows configuring the `ON CONFLICT` / `ON DUPLICATE KEY` clause -// of the `INSERT` statement. For example: -// -// client.DiscordPendingLink.CreateBulk(builders...). -// OnConflict( -// // Update the row with the new values -// // the was proposed for insertion. -// sql.ResolveWithNewValues(), -// ). -// // Override some of the fields with custom -// // update values. -// Update(func(u *ent.DiscordPendingLinkUpsert) { -// SetCode(v+v). -// }). -// Exec(ctx) -func (_c *DiscordPendingLinkCreateBulk) OnConflict(opts ...sql.ConflictOption) *DiscordPendingLinkUpsertBulk { - _c.conflict = opts - return &DiscordPendingLinkUpsertBulk{ - create: _c, - } -} - -// OnConflictColumns calls `OnConflict` and configures the columns -// as conflict target. Using this option is equivalent to using: -// -// client.DiscordPendingLink.Create(). -// OnConflict(sql.ConflictColumns(columns...)). -// Exec(ctx) -func (_c *DiscordPendingLinkCreateBulk) OnConflictColumns(columns ...string) *DiscordPendingLinkUpsertBulk { - _c.conflict = append(_c.conflict, sql.ConflictColumns(columns...)) - return &DiscordPendingLinkUpsertBulk{ - create: _c, - } -} - -// DiscordPendingLinkUpsertBulk is the builder for "upsert"-ing -// a bulk of DiscordPendingLink nodes. -type DiscordPendingLinkUpsertBulk struct { - create *DiscordPendingLinkCreateBulk -} - -// UpdateNewValues updates the mutable fields using the new values that -// were set on create. Using this option is equivalent to using: -// -// client.DiscordPendingLink.Create(). -// OnConflict( -// sql.ResolveWithNewValues(), -// sql.ResolveWith(func(u *sql.UpdateSet) { -// u.SetIgnore(discordpendinglink.FieldID) -// }), -// ). -// Exec(ctx) -func (u *DiscordPendingLinkUpsertBulk) UpdateNewValues() *DiscordPendingLinkUpsertBulk { - u.create.conflict = append(u.create.conflict, sql.ResolveWithNewValues()) - u.create.conflict = append(u.create.conflict, sql.ResolveWith(func(s *sql.UpdateSet) { - for _, b := range u.create.builders { - if _, exists := b.mutation.ID(); exists { - s.SetIgnore(discordpendinglink.FieldID) - } - if _, exists := b.mutation.CreatedAt(); exists { - s.SetIgnore(discordpendinglink.FieldCreatedAt) - } - } - })) - return u -} - -// Ignore sets each column to itself in case of conflict. -// Using this option is equivalent to using: -// -// client.DiscordPendingLink.Create(). -// OnConflict(sql.ResolveWithIgnore()). -// Exec(ctx) -func (u *DiscordPendingLinkUpsertBulk) Ignore() *DiscordPendingLinkUpsertBulk { - u.create.conflict = append(u.create.conflict, sql.ResolveWithIgnore()) - return u -} - -// DoNothing configures the conflict_action to `DO NOTHING`. -// Supported only by SQLite and PostgreSQL. -func (u *DiscordPendingLinkUpsertBulk) DoNothing() *DiscordPendingLinkUpsertBulk { - u.create.conflict = append(u.create.conflict, sql.DoNothing()) - return u -} - -// Update allows overriding fields `UPDATE` values. See the DiscordPendingLinkCreateBulk.OnConflict -// documentation for more info. -func (u *DiscordPendingLinkUpsertBulk) Update(set func(*DiscordPendingLinkUpsert)) *DiscordPendingLinkUpsertBulk { - u.create.conflict = append(u.create.conflict, sql.ResolveWith(func(update *sql.UpdateSet) { - set(&DiscordPendingLinkUpsert{UpdateSet: update}) - })) - return u -} - -// SetCode sets the "code" field. -func (u *DiscordPendingLinkUpsertBulk) SetCode(v string) *DiscordPendingLinkUpsertBulk { - return u.Update(func(s *DiscordPendingLinkUpsert) { - s.SetCode(v) - }) -} - -// UpdateCode sets the "code" field to the value that was provided on create. -func (u *DiscordPendingLinkUpsertBulk) UpdateCode() *DiscordPendingLinkUpsertBulk { - return u.Update(func(s *DiscordPendingLinkUpsert) { - s.UpdateCode() - }) -} - -// SetDiscordUserID sets the "discord_user_id" field. -func (u *DiscordPendingLinkUpsertBulk) SetDiscordUserID(v string) *DiscordPendingLinkUpsertBulk { - return u.Update(func(s *DiscordPendingLinkUpsert) { - s.SetDiscordUserID(v) - }) -} - -// UpdateDiscordUserID sets the "discord_user_id" field to the value that was provided on create. -func (u *DiscordPendingLinkUpsertBulk) UpdateDiscordUserID() *DiscordPendingLinkUpsertBulk { - return u.Update(func(s *DiscordPendingLinkUpsert) { - s.UpdateDiscordUserID() - }) -} - -// SetStatus sets the "status" field. -func (u *DiscordPendingLinkUpsertBulk) SetStatus(v string) *DiscordPendingLinkUpsertBulk { - return u.Update(func(s *DiscordPendingLinkUpsert) { - s.SetStatus(v) - }) -} - -// UpdateStatus sets the "status" field to the value that was provided on create. -func (u *DiscordPendingLinkUpsertBulk) UpdateStatus() *DiscordPendingLinkUpsertBulk { - return u.Update(func(s *DiscordPendingLinkUpsert) { - s.UpdateStatus() - }) -} - -// SetUserID sets the "user_id" field. -func (u *DiscordPendingLinkUpsertBulk) SetUserID(v string) *DiscordPendingLinkUpsertBulk { - return u.Update(func(s *DiscordPendingLinkUpsert) { - s.SetUserID(v) - }) -} - -// UpdateUserID sets the "user_id" field to the value that was provided on create. -func (u *DiscordPendingLinkUpsertBulk) UpdateUserID() *DiscordPendingLinkUpsertBulk { - return u.Update(func(s *DiscordPendingLinkUpsert) { - s.UpdateUserID() - }) -} - -// SetUserEmail sets the "user_email" field. -func (u *DiscordPendingLinkUpsertBulk) SetUserEmail(v string) *DiscordPendingLinkUpsertBulk { - return u.Update(func(s *DiscordPendingLinkUpsert) { - s.SetUserEmail(v) - }) -} - -// UpdateUserEmail sets the "user_email" field to the value that was provided on create. -func (u *DiscordPendingLinkUpsertBulk) UpdateUserEmail() *DiscordPendingLinkUpsertBulk { - return u.Update(func(s *DiscordPendingLinkUpsert) { - s.UpdateUserEmail() - }) -} - -// SetExpiresAt sets the "expires_at" field. -func (u *DiscordPendingLinkUpsertBulk) SetExpiresAt(v time.Time) *DiscordPendingLinkUpsertBulk { - return u.Update(func(s *DiscordPendingLinkUpsert) { - s.SetExpiresAt(v) - }) -} - -// UpdateExpiresAt sets the "expires_at" field to the value that was provided on create. -func (u *DiscordPendingLinkUpsertBulk) UpdateExpiresAt() *DiscordPendingLinkUpsertBulk { - return u.Update(func(s *DiscordPendingLinkUpsert) { - s.UpdateExpiresAt() - }) -} - -// Exec executes the query. -func (u *DiscordPendingLinkUpsertBulk) Exec(ctx context.Context) error { - if u.create.err != nil { - return u.create.err - } - for i, b := range u.create.builders { - if len(b.conflict) != 0 { - return fmt.Errorf("ent: OnConflict was set for builder %d. Set it on the DiscordPendingLinkCreateBulk instead", i) - } - } - if len(u.create.conflict) == 0 { - return errors.New("ent: missing options for DiscordPendingLinkCreateBulk.OnConflict") - } - return u.create.Exec(ctx) -} - -// ExecX is like Exec, but panics if an error occurs. -func (u *DiscordPendingLinkUpsertBulk) ExecX(ctx context.Context) { - if err := u.create.Exec(ctx); err != nil { - panic(err) - } -} diff --git a/pkg/ent/discordpendinglink_delete.go b/pkg/ent/discordpendinglink_delete.go deleted file mode 100644 index 779100bf3..000000000 --- a/pkg/ent/discordpendinglink_delete.go +++ /dev/null @@ -1,88 +0,0 @@ -// Code generated by ent, DO NOT EDIT. - -package ent - -import ( - "context" - - "entgo.io/ent/dialect/sql" - "entgo.io/ent/dialect/sql/sqlgraph" - "entgo.io/ent/schema/field" - "github.com/GoogleCloudPlatform/scion/pkg/ent/discordpendinglink" - "github.com/GoogleCloudPlatform/scion/pkg/ent/predicate" -) - -// DiscordPendingLinkDelete is the builder for deleting a DiscordPendingLink entity. -type DiscordPendingLinkDelete struct { - config - hooks []Hook - mutation *DiscordPendingLinkMutation -} - -// Where appends a list predicates to the DiscordPendingLinkDelete builder. -func (_d *DiscordPendingLinkDelete) Where(ps ...predicate.DiscordPendingLink) *DiscordPendingLinkDelete { - _d.mutation.Where(ps...) - return _d -} - -// Exec executes the deletion query and returns how many vertices were deleted. -func (_d *DiscordPendingLinkDelete) Exec(ctx context.Context) (int, error) { - return withHooks(ctx, _d.sqlExec, _d.mutation, _d.hooks) -} - -// ExecX is like Exec, but panics if an error occurs. -func (_d *DiscordPendingLinkDelete) ExecX(ctx context.Context) int { - n, err := _d.Exec(ctx) - if err != nil { - panic(err) - } - return n -} - -func (_d *DiscordPendingLinkDelete) sqlExec(ctx context.Context) (int, error) { - _spec := sqlgraph.NewDeleteSpec(discordpendinglink.Table, sqlgraph.NewFieldSpec(discordpendinglink.FieldID, field.TypeUUID)) - if ps := _d.mutation.predicates; len(ps) > 0 { - _spec.Predicate = func(selector *sql.Selector) { - for i := range ps { - ps[i](selector) - } - } - } - affected, err := sqlgraph.DeleteNodes(ctx, _d.driver, _spec) - if err != nil && sqlgraph.IsConstraintError(err) { - err = &ConstraintError{msg: err.Error(), wrap: err} - } - _d.mutation.done = true - return affected, err -} - -// DiscordPendingLinkDeleteOne is the builder for deleting a single DiscordPendingLink entity. -type DiscordPendingLinkDeleteOne struct { - _d *DiscordPendingLinkDelete -} - -// Where appends a list predicates to the DiscordPendingLinkDelete builder. -func (_d *DiscordPendingLinkDeleteOne) Where(ps ...predicate.DiscordPendingLink) *DiscordPendingLinkDeleteOne { - _d._d.mutation.Where(ps...) - return _d -} - -// Exec executes the deletion query. -func (_d *DiscordPendingLinkDeleteOne) Exec(ctx context.Context) error { - n, err := _d._d.Exec(ctx) - switch { - case err != nil: - return err - case n == 0: - return &NotFoundError{discordpendinglink.Label} - default: - return nil - } -} - -// ExecX is like Exec, but panics if an error occurs. -func (_d *DiscordPendingLinkDeleteOne) ExecX(ctx context.Context) { - if err := _d.Exec(ctx); err != nil { - panic(err) - } -} diff --git a/pkg/ent/discordpendinglink_query.go b/pkg/ent/discordpendinglink_query.go deleted file mode 100644 index bb7f57fb9..000000000 --- a/pkg/ent/discordpendinglink_query.go +++ /dev/null @@ -1,565 +0,0 @@ -// Code generated by ent, DO NOT EDIT. - -package ent - -import ( - "context" - "fmt" - "math" - - "entgo.io/ent" - "entgo.io/ent/dialect" - "entgo.io/ent/dialect/sql" - "entgo.io/ent/dialect/sql/sqlgraph" - "entgo.io/ent/schema/field" - "github.com/GoogleCloudPlatform/scion/pkg/ent/discordpendinglink" - "github.com/GoogleCloudPlatform/scion/pkg/ent/predicate" - "github.com/google/uuid" -) - -// DiscordPendingLinkQuery is the builder for querying DiscordPendingLink entities. -type DiscordPendingLinkQuery struct { - config - ctx *QueryContext - order []discordpendinglink.OrderOption - inters []Interceptor - predicates []predicate.DiscordPendingLink - modifiers []func(*sql.Selector) - // intermediate query (i.e. traversal path). - sql *sql.Selector - path func(context.Context) (*sql.Selector, error) -} - -// Where adds a new predicate for the DiscordPendingLinkQuery builder. -func (_q *DiscordPendingLinkQuery) Where(ps ...predicate.DiscordPendingLink) *DiscordPendingLinkQuery { - _q.predicates = append(_q.predicates, ps...) - return _q -} - -// Limit the number of records to be returned by this query. -func (_q *DiscordPendingLinkQuery) Limit(limit int) *DiscordPendingLinkQuery { - _q.ctx.Limit = &limit - return _q -} - -// Offset to start from. -func (_q *DiscordPendingLinkQuery) Offset(offset int) *DiscordPendingLinkQuery { - _q.ctx.Offset = &offset - return _q -} - -// Unique configures the query builder to filter duplicate records on query. -// By default, unique is set to true, and can be disabled using this method. -func (_q *DiscordPendingLinkQuery) Unique(unique bool) *DiscordPendingLinkQuery { - _q.ctx.Unique = &unique - return _q -} - -// Order specifies how the records should be ordered. -func (_q *DiscordPendingLinkQuery) Order(o ...discordpendinglink.OrderOption) *DiscordPendingLinkQuery { - _q.order = append(_q.order, o...) - return _q -} - -// First returns the first DiscordPendingLink entity from the query. -// Returns a *NotFoundError when no DiscordPendingLink was found. -func (_q *DiscordPendingLinkQuery) First(ctx context.Context) (*DiscordPendingLink, error) { - nodes, err := _q.Limit(1).All(setContextOp(ctx, _q.ctx, ent.OpQueryFirst)) - if err != nil { - return nil, err - } - if len(nodes) == 0 { - return nil, &NotFoundError{discordpendinglink.Label} - } - return nodes[0], nil -} - -// FirstX is like First, but panics if an error occurs. -func (_q *DiscordPendingLinkQuery) FirstX(ctx context.Context) *DiscordPendingLink { - node, err := _q.First(ctx) - if err != nil && !IsNotFound(err) { - panic(err) - } - return node -} - -// FirstID returns the first DiscordPendingLink ID from the query. -// Returns a *NotFoundError when no DiscordPendingLink ID was found. -func (_q *DiscordPendingLinkQuery) FirstID(ctx context.Context) (id uuid.UUID, err error) { - var ids []uuid.UUID - if ids, err = _q.Limit(1).IDs(setContextOp(ctx, _q.ctx, ent.OpQueryFirstID)); err != nil { - return - } - if len(ids) == 0 { - err = &NotFoundError{discordpendinglink.Label} - return - } - return ids[0], nil -} - -// FirstIDX is like FirstID, but panics if an error occurs. -func (_q *DiscordPendingLinkQuery) FirstIDX(ctx context.Context) uuid.UUID { - id, err := _q.FirstID(ctx) - if err != nil && !IsNotFound(err) { - panic(err) - } - return id -} - -// Only returns a single DiscordPendingLink entity found by the query, ensuring it only returns one. -// Returns a *NotSingularError when more than one DiscordPendingLink entity is found. -// Returns a *NotFoundError when no DiscordPendingLink entities are found. -func (_q *DiscordPendingLinkQuery) Only(ctx context.Context) (*DiscordPendingLink, error) { - nodes, err := _q.Limit(2).All(setContextOp(ctx, _q.ctx, ent.OpQueryOnly)) - if err != nil { - return nil, err - } - switch len(nodes) { - case 1: - return nodes[0], nil - case 0: - return nil, &NotFoundError{discordpendinglink.Label} - default: - return nil, &NotSingularError{discordpendinglink.Label} - } -} - -// OnlyX is like Only, but panics if an error occurs. -func (_q *DiscordPendingLinkQuery) OnlyX(ctx context.Context) *DiscordPendingLink { - node, err := _q.Only(ctx) - if err != nil { - panic(err) - } - return node -} - -// OnlyID is like Only, but returns the only DiscordPendingLink ID in the query. -// Returns a *NotSingularError when more than one DiscordPendingLink ID is found. -// Returns a *NotFoundError when no entities are found. -func (_q *DiscordPendingLinkQuery) OnlyID(ctx context.Context) (id uuid.UUID, err error) { - var ids []uuid.UUID - if ids, err = _q.Limit(2).IDs(setContextOp(ctx, _q.ctx, ent.OpQueryOnlyID)); err != nil { - return - } - switch len(ids) { - case 1: - id = ids[0] - case 0: - err = &NotFoundError{discordpendinglink.Label} - default: - err = &NotSingularError{discordpendinglink.Label} - } - return -} - -// OnlyIDX is like OnlyID, but panics if an error occurs. -func (_q *DiscordPendingLinkQuery) OnlyIDX(ctx context.Context) uuid.UUID { - id, err := _q.OnlyID(ctx) - if err != nil { - panic(err) - } - return id -} - -// All executes the query and returns a list of DiscordPendingLinks. -func (_q *DiscordPendingLinkQuery) All(ctx context.Context) ([]*DiscordPendingLink, error) { - ctx = setContextOp(ctx, _q.ctx, ent.OpQueryAll) - if err := _q.prepareQuery(ctx); err != nil { - return nil, err - } - qr := querierAll[[]*DiscordPendingLink, *DiscordPendingLinkQuery]() - return withInterceptors[[]*DiscordPendingLink](ctx, _q, qr, _q.inters) -} - -// AllX is like All, but panics if an error occurs. -func (_q *DiscordPendingLinkQuery) AllX(ctx context.Context) []*DiscordPendingLink { - nodes, err := _q.All(ctx) - if err != nil { - panic(err) - } - return nodes -} - -// IDs executes the query and returns a list of DiscordPendingLink IDs. -func (_q *DiscordPendingLinkQuery) IDs(ctx context.Context) (ids []uuid.UUID, err error) { - if _q.ctx.Unique == nil && _q.path != nil { - _q.Unique(true) - } - ctx = setContextOp(ctx, _q.ctx, ent.OpQueryIDs) - if err = _q.Select(discordpendinglink.FieldID).Scan(ctx, &ids); err != nil { - return nil, err - } - return ids, nil -} - -// IDsX is like IDs, but panics if an error occurs. -func (_q *DiscordPendingLinkQuery) IDsX(ctx context.Context) []uuid.UUID { - ids, err := _q.IDs(ctx) - if err != nil { - panic(err) - } - return ids -} - -// Count returns the count of the given query. -func (_q *DiscordPendingLinkQuery) Count(ctx context.Context) (int, error) { - ctx = setContextOp(ctx, _q.ctx, ent.OpQueryCount) - if err := _q.prepareQuery(ctx); err != nil { - return 0, err - } - return withInterceptors[int](ctx, _q, querierCount[*DiscordPendingLinkQuery](), _q.inters) -} - -// CountX is like Count, but panics if an error occurs. -func (_q *DiscordPendingLinkQuery) CountX(ctx context.Context) int { - count, err := _q.Count(ctx) - if err != nil { - panic(err) - } - return count -} - -// Exist returns true if the query has elements in the graph. -func (_q *DiscordPendingLinkQuery) Exist(ctx context.Context) (bool, error) { - ctx = setContextOp(ctx, _q.ctx, ent.OpQueryExist) - switch _, err := _q.FirstID(ctx); { - case IsNotFound(err): - return false, nil - case err != nil: - return false, fmt.Errorf("ent: check existence: %w", err) - default: - return true, nil - } -} - -// ExistX is like Exist, but panics if an error occurs. -func (_q *DiscordPendingLinkQuery) ExistX(ctx context.Context) bool { - exist, err := _q.Exist(ctx) - if err != nil { - panic(err) - } - return exist -} - -// Clone returns a duplicate of the DiscordPendingLinkQuery builder, including all associated steps. It can be -// used to prepare common query builders and use them differently after the clone is made. -func (_q *DiscordPendingLinkQuery) Clone() *DiscordPendingLinkQuery { - if _q == nil { - return nil - } - return &DiscordPendingLinkQuery{ - config: _q.config, - ctx: _q.ctx.Clone(), - order: append([]discordpendinglink.OrderOption{}, _q.order...), - inters: append([]Interceptor{}, _q.inters...), - predicates: append([]predicate.DiscordPendingLink{}, _q.predicates...), - // clone intermediate query. - sql: _q.sql.Clone(), - path: _q.path, - } -} - -// GroupBy is used to group vertices by one or more fields/columns. -// It is often used with aggregate functions, like: count, max, mean, min, sum. -// -// Example: -// -// var v []struct { -// Code string `json:"code,omitempty"` -// Count int `json:"count,omitempty"` -// } -// -// client.DiscordPendingLink.Query(). -// GroupBy(discordpendinglink.FieldCode). -// Aggregate(ent.Count()). -// Scan(ctx, &v) -func (_q *DiscordPendingLinkQuery) GroupBy(field string, fields ...string) *DiscordPendingLinkGroupBy { - _q.ctx.Fields = append([]string{field}, fields...) - grbuild := &DiscordPendingLinkGroupBy{build: _q} - grbuild.flds = &_q.ctx.Fields - grbuild.label = discordpendinglink.Label - grbuild.scan = grbuild.Scan - return grbuild -} - -// Select allows the selection one or more fields/columns for the given query, -// instead of selecting all fields in the entity. -// -// Example: -// -// var v []struct { -// Code string `json:"code,omitempty"` -// } -// -// client.DiscordPendingLink.Query(). -// Select(discordpendinglink.FieldCode). -// Scan(ctx, &v) -func (_q *DiscordPendingLinkQuery) Select(fields ...string) *DiscordPendingLinkSelect { - _q.ctx.Fields = append(_q.ctx.Fields, fields...) - sbuild := &DiscordPendingLinkSelect{DiscordPendingLinkQuery: _q} - sbuild.label = discordpendinglink.Label - sbuild.flds, sbuild.scan = &_q.ctx.Fields, sbuild.Scan - return sbuild -} - -// Aggregate returns a DiscordPendingLinkSelect configured with the given aggregations. -func (_q *DiscordPendingLinkQuery) Aggregate(fns ...AggregateFunc) *DiscordPendingLinkSelect { - return _q.Select().Aggregate(fns...) -} - -func (_q *DiscordPendingLinkQuery) prepareQuery(ctx context.Context) error { - for _, inter := range _q.inters { - if inter == nil { - return fmt.Errorf("ent: uninitialized interceptor (forgotten import ent/runtime?)") - } - if trv, ok := inter.(Traverser); ok { - if err := trv.Traverse(ctx, _q); err != nil { - return err - } - } - } - for _, f := range _q.ctx.Fields { - if !discordpendinglink.ValidColumn(f) { - return &ValidationError{Name: f, err: fmt.Errorf("ent: invalid field %q for query", f)} - } - } - if _q.path != nil { - prev, err := _q.path(ctx) - if err != nil { - return err - } - _q.sql = prev - } - return nil -} - -func (_q *DiscordPendingLinkQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*DiscordPendingLink, error) { - var ( - nodes = []*DiscordPendingLink{} - _spec = _q.querySpec() - ) - _spec.ScanValues = func(columns []string) ([]any, error) { - return (*DiscordPendingLink).scanValues(nil, columns) - } - _spec.Assign = func(columns []string, values []any) error { - node := &DiscordPendingLink{config: _q.config} - nodes = append(nodes, node) - return node.assignValues(columns, values) - } - if len(_q.modifiers) > 0 { - _spec.Modifiers = _q.modifiers - } - for i := range hooks { - hooks[i](ctx, _spec) - } - if err := sqlgraph.QueryNodes(ctx, _q.driver, _spec); err != nil { - return nil, err - } - if len(nodes) == 0 { - return nodes, nil - } - return nodes, nil -} - -func (_q *DiscordPendingLinkQuery) sqlCount(ctx context.Context) (int, error) { - _spec := _q.querySpec() - if len(_q.modifiers) > 0 { - _spec.Modifiers = _q.modifiers - } - _spec.Node.Columns = _q.ctx.Fields - if len(_q.ctx.Fields) > 0 { - _spec.Unique = _q.ctx.Unique != nil && *_q.ctx.Unique - } - return sqlgraph.CountNodes(ctx, _q.driver, _spec) -} - -func (_q *DiscordPendingLinkQuery) querySpec() *sqlgraph.QuerySpec { - _spec := sqlgraph.NewQuerySpec(discordpendinglink.Table, discordpendinglink.Columns, sqlgraph.NewFieldSpec(discordpendinglink.FieldID, field.TypeUUID)) - _spec.From = _q.sql - if unique := _q.ctx.Unique; unique != nil { - _spec.Unique = *unique - } else if _q.path != nil { - _spec.Unique = true - } - if fields := _q.ctx.Fields; len(fields) > 0 { - _spec.Node.Columns = make([]string, 0, len(fields)) - _spec.Node.Columns = append(_spec.Node.Columns, discordpendinglink.FieldID) - for i := range fields { - if fields[i] != discordpendinglink.FieldID { - _spec.Node.Columns = append(_spec.Node.Columns, fields[i]) - } - } - } - if ps := _q.predicates; len(ps) > 0 { - _spec.Predicate = func(selector *sql.Selector) { - for i := range ps { - ps[i](selector) - } - } - } - if limit := _q.ctx.Limit; limit != nil { - _spec.Limit = *limit - } - if offset := _q.ctx.Offset; offset != nil { - _spec.Offset = *offset - } - if ps := _q.order; len(ps) > 0 { - _spec.Order = func(selector *sql.Selector) { - for i := range ps { - ps[i](selector) - } - } - } - return _spec -} - -func (_q *DiscordPendingLinkQuery) sqlQuery(ctx context.Context) *sql.Selector { - builder := sql.Dialect(_q.driver.Dialect()) - t1 := builder.Table(discordpendinglink.Table) - columns := _q.ctx.Fields - if len(columns) == 0 { - columns = discordpendinglink.Columns - } - selector := builder.Select(t1.Columns(columns...)...).From(t1) - if _q.sql != nil { - selector = _q.sql - selector.Select(selector.Columns(columns...)...) - } - if _q.ctx.Unique != nil && *_q.ctx.Unique { - selector.Distinct() - } - for _, m := range _q.modifiers { - m(selector) - } - for _, p := range _q.predicates { - p(selector) - } - for _, p := range _q.order { - p(selector) - } - if offset := _q.ctx.Offset; offset != nil { - // limit is mandatory for offset clause. We start - // with default value, and override it below if needed. - selector.Offset(*offset).Limit(math.MaxInt32) - } - if limit := _q.ctx.Limit; limit != nil { - selector.Limit(*limit) - } - return selector -} - -// ForUpdate locks the selected rows against concurrent updates, and prevent them from being -// updated, deleted or "selected ... for update" by other sessions, until the transaction is -// either committed or rolled-back. -func (_q *DiscordPendingLinkQuery) ForUpdate(opts ...sql.LockOption) *DiscordPendingLinkQuery { - if _q.driver.Dialect() == dialect.Postgres { - _q.Unique(false) - } - _q.modifiers = append(_q.modifiers, func(s *sql.Selector) { - s.ForUpdate(opts...) - }) - return _q -} - -// ForShare behaves similarly to ForUpdate, except that it acquires a shared mode lock -// on any rows that are read. Other sessions can read the rows, but cannot modify them -// until your transaction commits. -func (_q *DiscordPendingLinkQuery) ForShare(opts ...sql.LockOption) *DiscordPendingLinkQuery { - if _q.driver.Dialect() == dialect.Postgres { - _q.Unique(false) - } - _q.modifiers = append(_q.modifiers, func(s *sql.Selector) { - s.ForShare(opts...) - }) - return _q -} - -// DiscordPendingLinkGroupBy is the group-by builder for DiscordPendingLink entities. -type DiscordPendingLinkGroupBy struct { - selector - build *DiscordPendingLinkQuery -} - -// Aggregate adds the given aggregation functions to the group-by query. -func (_g *DiscordPendingLinkGroupBy) Aggregate(fns ...AggregateFunc) *DiscordPendingLinkGroupBy { - _g.fns = append(_g.fns, fns...) - return _g -} - -// Scan applies the selector query and scans the result into the given value. -func (_g *DiscordPendingLinkGroupBy) Scan(ctx context.Context, v any) error { - ctx = setContextOp(ctx, _g.build.ctx, ent.OpQueryGroupBy) - if err := _g.build.prepareQuery(ctx); err != nil { - return err - } - return scanWithInterceptors[*DiscordPendingLinkQuery, *DiscordPendingLinkGroupBy](ctx, _g.build, _g, _g.build.inters, v) -} - -func (_g *DiscordPendingLinkGroupBy) sqlScan(ctx context.Context, root *DiscordPendingLinkQuery, v any) error { - selector := root.sqlQuery(ctx).Select() - aggregation := make([]string, 0, len(_g.fns)) - for _, fn := range _g.fns { - aggregation = append(aggregation, fn(selector)) - } - if len(selector.SelectedColumns()) == 0 { - columns := make([]string, 0, len(*_g.flds)+len(_g.fns)) - for _, f := range *_g.flds { - columns = append(columns, selector.C(f)) - } - columns = append(columns, aggregation...) - selector.Select(columns...) - } - selector.GroupBy(selector.Columns(*_g.flds...)...) - if err := selector.Err(); err != nil { - return err - } - rows := &sql.Rows{} - query, args := selector.Query() - if err := _g.build.driver.Query(ctx, query, args, rows); err != nil { - return err - } - defer rows.Close() - return sql.ScanSlice(rows, v) -} - -// DiscordPendingLinkSelect is the builder for selecting fields of DiscordPendingLink entities. -type DiscordPendingLinkSelect struct { - *DiscordPendingLinkQuery - selector -} - -// Aggregate adds the given aggregation functions to the selector query. -func (_s *DiscordPendingLinkSelect) Aggregate(fns ...AggregateFunc) *DiscordPendingLinkSelect { - _s.fns = append(_s.fns, fns...) - return _s -} - -// Scan applies the selector query and scans the result into the given value. -func (_s *DiscordPendingLinkSelect) Scan(ctx context.Context, v any) error { - ctx = setContextOp(ctx, _s.ctx, ent.OpQuerySelect) - if err := _s.prepareQuery(ctx); err != nil { - return err - } - return scanWithInterceptors[*DiscordPendingLinkQuery, *DiscordPendingLinkSelect](ctx, _s.DiscordPendingLinkQuery, _s, _s.inters, v) -} - -func (_s *DiscordPendingLinkSelect) sqlScan(ctx context.Context, root *DiscordPendingLinkQuery, v any) error { - selector := root.sqlQuery(ctx) - aggregation := make([]string, 0, len(_s.fns)) - for _, fn := range _s.fns { - aggregation = append(aggregation, fn(selector)) - } - switch n := len(*_s.selector.flds); { - case n == 0 && len(aggregation) > 0: - selector.Select(aggregation...) - case n != 0 && len(aggregation) > 0: - selector.AppendSelect(aggregation...) - } - rows := &sql.Rows{} - query, args := selector.Query() - if err := _s.driver.Query(ctx, query, args, rows); err != nil { - return err - } - defer rows.Close() - return sql.ScanSlice(rows, v) -} diff --git a/pkg/ent/discordpendinglink_update.go b/pkg/ent/discordpendinglink_update.go deleted file mode 100644 index 659a0462a..000000000 --- a/pkg/ent/discordpendinglink_update.go +++ /dev/null @@ -1,416 +0,0 @@ -// Code generated by ent, DO NOT EDIT. - -package ent - -import ( - "context" - "errors" - "fmt" - "time" - - "entgo.io/ent/dialect/sql" - "entgo.io/ent/dialect/sql/sqlgraph" - "entgo.io/ent/schema/field" - "github.com/GoogleCloudPlatform/scion/pkg/ent/discordpendinglink" - "github.com/GoogleCloudPlatform/scion/pkg/ent/predicate" -) - -// DiscordPendingLinkUpdate is the builder for updating DiscordPendingLink entities. -type DiscordPendingLinkUpdate struct { - config - hooks []Hook - mutation *DiscordPendingLinkMutation -} - -// Where appends a list predicates to the DiscordPendingLinkUpdate builder. -func (_u *DiscordPendingLinkUpdate) Where(ps ...predicate.DiscordPendingLink) *DiscordPendingLinkUpdate { - _u.mutation.Where(ps...) - return _u -} - -// SetCode sets the "code" field. -func (_u *DiscordPendingLinkUpdate) SetCode(v string) *DiscordPendingLinkUpdate { - _u.mutation.SetCode(v) - return _u -} - -// SetNillableCode sets the "code" field if the given value is not nil. -func (_u *DiscordPendingLinkUpdate) SetNillableCode(v *string) *DiscordPendingLinkUpdate { - if v != nil { - _u.SetCode(*v) - } - return _u -} - -// SetDiscordUserID sets the "discord_user_id" field. -func (_u *DiscordPendingLinkUpdate) SetDiscordUserID(v string) *DiscordPendingLinkUpdate { - _u.mutation.SetDiscordUserID(v) - return _u -} - -// SetNillableDiscordUserID sets the "discord_user_id" field if the given value is not nil. -func (_u *DiscordPendingLinkUpdate) SetNillableDiscordUserID(v *string) *DiscordPendingLinkUpdate { - if v != nil { - _u.SetDiscordUserID(*v) - } - return _u -} - -// SetStatus sets the "status" field. -func (_u *DiscordPendingLinkUpdate) SetStatus(v string) *DiscordPendingLinkUpdate { - _u.mutation.SetStatus(v) - return _u -} - -// SetNillableStatus sets the "status" field if the given value is not nil. -func (_u *DiscordPendingLinkUpdate) SetNillableStatus(v *string) *DiscordPendingLinkUpdate { - if v != nil { - _u.SetStatus(*v) - } - return _u -} - -// SetUserID sets the "user_id" field. -func (_u *DiscordPendingLinkUpdate) SetUserID(v string) *DiscordPendingLinkUpdate { - _u.mutation.SetUserID(v) - return _u -} - -// SetNillableUserID sets the "user_id" field if the given value is not nil. -func (_u *DiscordPendingLinkUpdate) SetNillableUserID(v *string) *DiscordPendingLinkUpdate { - if v != nil { - _u.SetUserID(*v) - } - return _u -} - -// SetUserEmail sets the "user_email" field. -func (_u *DiscordPendingLinkUpdate) SetUserEmail(v string) *DiscordPendingLinkUpdate { - _u.mutation.SetUserEmail(v) - return _u -} - -// SetNillableUserEmail sets the "user_email" field if the given value is not nil. -func (_u *DiscordPendingLinkUpdate) SetNillableUserEmail(v *string) *DiscordPendingLinkUpdate { - if v != nil { - _u.SetUserEmail(*v) - } - return _u -} - -// SetExpiresAt sets the "expires_at" field. -func (_u *DiscordPendingLinkUpdate) SetExpiresAt(v time.Time) *DiscordPendingLinkUpdate { - _u.mutation.SetExpiresAt(v) - return _u -} - -// SetNillableExpiresAt sets the "expires_at" field if the given value is not nil. -func (_u *DiscordPendingLinkUpdate) SetNillableExpiresAt(v *time.Time) *DiscordPendingLinkUpdate { - if v != nil { - _u.SetExpiresAt(*v) - } - return _u -} - -// Mutation returns the DiscordPendingLinkMutation object of the builder. -func (_u *DiscordPendingLinkUpdate) Mutation() *DiscordPendingLinkMutation { - return _u.mutation -} - -// Save executes the query and returns the number of nodes affected by the update operation. -func (_u *DiscordPendingLinkUpdate) Save(ctx context.Context) (int, error) { - return withHooks(ctx, _u.sqlSave, _u.mutation, _u.hooks) -} - -// SaveX is like Save, but panics if an error occurs. -func (_u *DiscordPendingLinkUpdate) SaveX(ctx context.Context) int { - affected, err := _u.Save(ctx) - if err != nil { - panic(err) - } - return affected -} - -// Exec executes the query. -func (_u *DiscordPendingLinkUpdate) Exec(ctx context.Context) error { - _, err := _u.Save(ctx) - return err -} - -// ExecX is like Exec, but panics if an error occurs. -func (_u *DiscordPendingLinkUpdate) ExecX(ctx context.Context) { - if err := _u.Exec(ctx); err != nil { - panic(err) - } -} - -// check runs all checks and user-defined validators on the builder. -func (_u *DiscordPendingLinkUpdate) check() error { - if v, ok := _u.mutation.Code(); ok { - if err := discordpendinglink.CodeValidator(v); err != nil { - return &ValidationError{Name: "code", err: fmt.Errorf(`ent: validator failed for field "DiscordPendingLink.code": %w`, err)} - } - } - if v, ok := _u.mutation.DiscordUserID(); ok { - if err := discordpendinglink.DiscordUserIDValidator(v); err != nil { - return &ValidationError{Name: "discord_user_id", err: fmt.Errorf(`ent: validator failed for field "DiscordPendingLink.discord_user_id": %w`, err)} - } - } - return nil -} - -func (_u *DiscordPendingLinkUpdate) sqlSave(ctx context.Context) (_node int, err error) { - if err := _u.check(); err != nil { - return _node, err - } - _spec := sqlgraph.NewUpdateSpec(discordpendinglink.Table, discordpendinglink.Columns, sqlgraph.NewFieldSpec(discordpendinglink.FieldID, field.TypeUUID)) - if ps := _u.mutation.predicates; len(ps) > 0 { - _spec.Predicate = func(selector *sql.Selector) { - for i := range ps { - ps[i](selector) - } - } - } - if value, ok := _u.mutation.Code(); ok { - _spec.SetField(discordpendinglink.FieldCode, field.TypeString, value) - } - if value, ok := _u.mutation.DiscordUserID(); ok { - _spec.SetField(discordpendinglink.FieldDiscordUserID, field.TypeString, value) - } - if value, ok := _u.mutation.Status(); ok { - _spec.SetField(discordpendinglink.FieldStatus, field.TypeString, value) - } - if value, ok := _u.mutation.UserID(); ok { - _spec.SetField(discordpendinglink.FieldUserID, field.TypeString, value) - } - if value, ok := _u.mutation.UserEmail(); ok { - _spec.SetField(discordpendinglink.FieldUserEmail, field.TypeString, value) - } - if value, ok := _u.mutation.ExpiresAt(); ok { - _spec.SetField(discordpendinglink.FieldExpiresAt, field.TypeTime, value) - } - if _node, err = sqlgraph.UpdateNodes(ctx, _u.driver, _spec); err != nil { - if _, ok := err.(*sqlgraph.NotFoundError); ok { - err = &NotFoundError{discordpendinglink.Label} - } else if sqlgraph.IsConstraintError(err) { - err = &ConstraintError{msg: err.Error(), wrap: err} - } - return 0, err - } - _u.mutation.done = true - return _node, nil -} - -// DiscordPendingLinkUpdateOne is the builder for updating a single DiscordPendingLink entity. -type DiscordPendingLinkUpdateOne struct { - config - fields []string - hooks []Hook - mutation *DiscordPendingLinkMutation -} - -// SetCode sets the "code" field. -func (_u *DiscordPendingLinkUpdateOne) SetCode(v string) *DiscordPendingLinkUpdateOne { - _u.mutation.SetCode(v) - return _u -} - -// SetNillableCode sets the "code" field if the given value is not nil. -func (_u *DiscordPendingLinkUpdateOne) SetNillableCode(v *string) *DiscordPendingLinkUpdateOne { - if v != nil { - _u.SetCode(*v) - } - return _u -} - -// SetDiscordUserID sets the "discord_user_id" field. -func (_u *DiscordPendingLinkUpdateOne) SetDiscordUserID(v string) *DiscordPendingLinkUpdateOne { - _u.mutation.SetDiscordUserID(v) - return _u -} - -// SetNillableDiscordUserID sets the "discord_user_id" field if the given value is not nil. -func (_u *DiscordPendingLinkUpdateOne) SetNillableDiscordUserID(v *string) *DiscordPendingLinkUpdateOne { - if v != nil { - _u.SetDiscordUserID(*v) - } - return _u -} - -// SetStatus sets the "status" field. -func (_u *DiscordPendingLinkUpdateOne) SetStatus(v string) *DiscordPendingLinkUpdateOne { - _u.mutation.SetStatus(v) - return _u -} - -// SetNillableStatus sets the "status" field if the given value is not nil. -func (_u *DiscordPendingLinkUpdateOne) SetNillableStatus(v *string) *DiscordPendingLinkUpdateOne { - if v != nil { - _u.SetStatus(*v) - } - return _u -} - -// SetUserID sets the "user_id" field. -func (_u *DiscordPendingLinkUpdateOne) SetUserID(v string) *DiscordPendingLinkUpdateOne { - _u.mutation.SetUserID(v) - return _u -} - -// SetNillableUserID sets the "user_id" field if the given value is not nil. -func (_u *DiscordPendingLinkUpdateOne) SetNillableUserID(v *string) *DiscordPendingLinkUpdateOne { - if v != nil { - _u.SetUserID(*v) - } - return _u -} - -// SetUserEmail sets the "user_email" field. -func (_u *DiscordPendingLinkUpdateOne) SetUserEmail(v string) *DiscordPendingLinkUpdateOne { - _u.mutation.SetUserEmail(v) - return _u -} - -// SetNillableUserEmail sets the "user_email" field if the given value is not nil. -func (_u *DiscordPendingLinkUpdateOne) SetNillableUserEmail(v *string) *DiscordPendingLinkUpdateOne { - if v != nil { - _u.SetUserEmail(*v) - } - return _u -} - -// SetExpiresAt sets the "expires_at" field. -func (_u *DiscordPendingLinkUpdateOne) SetExpiresAt(v time.Time) *DiscordPendingLinkUpdateOne { - _u.mutation.SetExpiresAt(v) - return _u -} - -// SetNillableExpiresAt sets the "expires_at" field if the given value is not nil. -func (_u *DiscordPendingLinkUpdateOne) SetNillableExpiresAt(v *time.Time) *DiscordPendingLinkUpdateOne { - if v != nil { - _u.SetExpiresAt(*v) - } - return _u -} - -// Mutation returns the DiscordPendingLinkMutation object of the builder. -func (_u *DiscordPendingLinkUpdateOne) Mutation() *DiscordPendingLinkMutation { - return _u.mutation -} - -// Where appends a list predicates to the DiscordPendingLinkUpdate builder. -func (_u *DiscordPendingLinkUpdateOne) Where(ps ...predicate.DiscordPendingLink) *DiscordPendingLinkUpdateOne { - _u.mutation.Where(ps...) - return _u -} - -// Select allows selecting one or more fields (columns) of the returned entity. -// The default is selecting all fields defined in the entity schema. -func (_u *DiscordPendingLinkUpdateOne) Select(field string, fields ...string) *DiscordPendingLinkUpdateOne { - _u.fields = append([]string{field}, fields...) - return _u -} - -// Save executes the query and returns the updated DiscordPendingLink entity. -func (_u *DiscordPendingLinkUpdateOne) Save(ctx context.Context) (*DiscordPendingLink, error) { - return withHooks(ctx, _u.sqlSave, _u.mutation, _u.hooks) -} - -// SaveX is like Save, but panics if an error occurs. -func (_u *DiscordPendingLinkUpdateOne) SaveX(ctx context.Context) *DiscordPendingLink { - node, err := _u.Save(ctx) - if err != nil { - panic(err) - } - return node -} - -// Exec executes the query on the entity. -func (_u *DiscordPendingLinkUpdateOne) Exec(ctx context.Context) error { - _, err := _u.Save(ctx) - return err -} - -// ExecX is like Exec, but panics if an error occurs. -func (_u *DiscordPendingLinkUpdateOne) ExecX(ctx context.Context) { - if err := _u.Exec(ctx); err != nil { - panic(err) - } -} - -// check runs all checks and user-defined validators on the builder. -func (_u *DiscordPendingLinkUpdateOne) check() error { - if v, ok := _u.mutation.Code(); ok { - if err := discordpendinglink.CodeValidator(v); err != nil { - return &ValidationError{Name: "code", err: fmt.Errorf(`ent: validator failed for field "DiscordPendingLink.code": %w`, err)} - } - } - if v, ok := _u.mutation.DiscordUserID(); ok { - if err := discordpendinglink.DiscordUserIDValidator(v); err != nil { - return &ValidationError{Name: "discord_user_id", err: fmt.Errorf(`ent: validator failed for field "DiscordPendingLink.discord_user_id": %w`, err)} - } - } - return nil -} - -func (_u *DiscordPendingLinkUpdateOne) sqlSave(ctx context.Context) (_node *DiscordPendingLink, err error) { - if err := _u.check(); err != nil { - return _node, err - } - _spec := sqlgraph.NewUpdateSpec(discordpendinglink.Table, discordpendinglink.Columns, sqlgraph.NewFieldSpec(discordpendinglink.FieldID, field.TypeUUID)) - id, ok := _u.mutation.ID() - if !ok { - return nil, &ValidationError{Name: "id", err: errors.New(`ent: missing "DiscordPendingLink.id" for update`)} - } - _spec.Node.ID.Value = id - if fields := _u.fields; len(fields) > 0 { - _spec.Node.Columns = make([]string, 0, len(fields)) - _spec.Node.Columns = append(_spec.Node.Columns, discordpendinglink.FieldID) - for _, f := range fields { - if !discordpendinglink.ValidColumn(f) { - return nil, &ValidationError{Name: f, err: fmt.Errorf("ent: invalid field %q for query", f)} - } - if f != discordpendinglink.FieldID { - _spec.Node.Columns = append(_spec.Node.Columns, f) - } - } - } - if ps := _u.mutation.predicates; len(ps) > 0 { - _spec.Predicate = func(selector *sql.Selector) { - for i := range ps { - ps[i](selector) - } - } - } - if value, ok := _u.mutation.Code(); ok { - _spec.SetField(discordpendinglink.FieldCode, field.TypeString, value) - } - if value, ok := _u.mutation.DiscordUserID(); ok { - _spec.SetField(discordpendinglink.FieldDiscordUserID, field.TypeString, value) - } - if value, ok := _u.mutation.Status(); ok { - _spec.SetField(discordpendinglink.FieldStatus, field.TypeString, value) - } - if value, ok := _u.mutation.UserID(); ok { - _spec.SetField(discordpendinglink.FieldUserID, field.TypeString, value) - } - if value, ok := _u.mutation.UserEmail(); ok { - _spec.SetField(discordpendinglink.FieldUserEmail, field.TypeString, value) - } - if value, ok := _u.mutation.ExpiresAt(); ok { - _spec.SetField(discordpendinglink.FieldExpiresAt, field.TypeTime, value) - } - _node = &DiscordPendingLink{config: _u.config} - _spec.Assign = _node.assignValues - _spec.ScanValues = _node.scanValues - if err = sqlgraph.UpdateNode(ctx, _u.driver, _spec); err != nil { - if _, ok := err.(*sqlgraph.NotFoundError); ok { - err = &NotFoundError{discordpendinglink.Label} - } else if sqlgraph.IsConstraintError(err) { - err = &ConstraintError{msg: err.Error(), wrap: err} - } - return nil, err - } - _u.mutation.done = true - return _node, nil -} diff --git a/pkg/ent/ent.go b/pkg/ent/ent.go index 09568ca20..2b154769b 100644 --- a/pkg/ent/ent.go +++ b/pkg/ent/ent.go @@ -19,7 +19,6 @@ import ( "github.com/GoogleCloudPlatform/scion/pkg/ent/brokerdispatch" "github.com/GoogleCloudPlatform/scion/pkg/ent/brokerjointoken" "github.com/GoogleCloudPlatform/scion/pkg/ent/brokersecret" - "github.com/GoogleCloudPlatform/scion/pkg/ent/discordpendinglink" "github.com/GoogleCloudPlatform/scion/pkg/ent/envvar" "github.com/GoogleCloudPlatform/scion/pkg/ent/gcpserviceaccount" "github.com/GoogleCloudPlatform/scion/pkg/ent/githubinstallation" @@ -115,7 +114,6 @@ func checkColumn(t, c string) error { brokerdispatch.Table: brokerdispatch.ValidColumn, brokerjointoken.Table: brokerjointoken.ValidColumn, brokersecret.Table: brokersecret.ValidColumn, - discordpendinglink.Table: discordpendinglink.ValidColumn, envvar.Table: envvar.ValidColumn, gcpserviceaccount.Table: gcpserviceaccount.ValidColumn, githubinstallation.Table: githubinstallation.ValidColumn, diff --git a/pkg/ent/hook/hook.go b/pkg/ent/hook/hook.go index c83d185da..ad2280765 100644 --- a/pkg/ent/hook/hook.go +++ b/pkg/ent/hook/hook.go @@ -93,18 +93,6 @@ func (f BrokerSecretFunc) Mutate(ctx context.Context, m ent.Mutation) (ent.Value return nil, fmt.Errorf("unexpected mutation type %T. expect *ent.BrokerSecretMutation", m) } -// The DiscordPendingLinkFunc type is an adapter to allow the use of ordinary -// function as DiscordPendingLink mutator. -type DiscordPendingLinkFunc func(context.Context, *ent.DiscordPendingLinkMutation) (ent.Value, error) - -// Mutate calls f(ctx, m). -func (f DiscordPendingLinkFunc) Mutate(ctx context.Context, m ent.Mutation) (ent.Value, error) { - if mv, ok := m.(*ent.DiscordPendingLinkMutation); ok { - return f(ctx, mv) - } - return nil, fmt.Errorf("unexpected mutation type %T. expect *ent.DiscordPendingLinkMutation", m) -} - // The EnvVarFunc type is an adapter to allow the use of ordinary // function as EnvVar mutator. type EnvVarFunc func(context.Context, *ent.EnvVarMutation) (ent.Value, error) diff --git a/pkg/ent/migrate/schema.go b/pkg/ent/migrate/schema.go index 24734891b..19d9254b2 100644 --- a/pkg/ent/migrate/schema.go +++ b/pkg/ent/migrate/schema.go @@ -211,35 +211,6 @@ var ( Columns: BrokerSecretsColumns, PrimaryKey: []*schema.Column{BrokerSecretsColumns[0]}, } - // DiscordPendingLinksColumns holds the columns for the "discord_pending_links" table. - DiscordPendingLinksColumns = []*schema.Column{ - {Name: "id", Type: field.TypeUUID}, - {Name: "code", Type: field.TypeString, Unique: true}, - {Name: "discord_user_id", Type: field.TypeString}, - {Name: "status", Type: field.TypeString, Default: "pending"}, - {Name: "user_id", Type: field.TypeString, Default: ""}, - {Name: "user_email", Type: field.TypeString, Default: ""}, - {Name: "expires_at", Type: field.TypeTime}, - {Name: "created_at", Type: field.TypeTime}, - } - // DiscordPendingLinksTable holds the schema information for the "discord_pending_links" table. - DiscordPendingLinksTable = &schema.Table{ - Name: "discord_pending_links", - Columns: DiscordPendingLinksColumns, - PrimaryKey: []*schema.Column{DiscordPendingLinksColumns[0]}, - Indexes: []*schema.Index{ - { - Name: "discordpendinglink_expires_at", - Unique: false, - Columns: []*schema.Column{DiscordPendingLinksColumns[6]}, - }, - { - Name: "discordpendinglink_discord_user_id", - Unique: false, - Columns: []*schema.Column{DiscordPendingLinksColumns[2]}, - }, - }, - } // EnvVarsColumns holds the columns for the "env_vars" table. EnvVarsColumns = []*schema.Column{ {Name: "id", Type: field.TypeUUID}, @@ -1205,7 +1176,6 @@ var ( BrokerDispatchTable, BrokerJoinTokensTable, BrokerSecretsTable, - DiscordPendingLinksTable, EnvVarsTable, GcpServiceAccountsTable, GithubInstallationsTable, @@ -1255,9 +1225,6 @@ func init() { BrokerSecretsTable.Annotation = &entsql.Annotation{ Table: "broker_secrets", } - DiscordPendingLinksTable.Annotation = &entsql.Annotation{ - Table: "discord_pending_links", - } EnvVarsTable.Annotation = &entsql.Annotation{ Table: "env_vars", } diff --git a/pkg/ent/mutation.go b/pkg/ent/mutation.go index f347a9be5..0d7f90da1 100644 --- a/pkg/ent/mutation.go +++ b/pkg/ent/mutation.go @@ -18,7 +18,6 @@ import ( "github.com/GoogleCloudPlatform/scion/pkg/ent/brokerdispatch" "github.com/GoogleCloudPlatform/scion/pkg/ent/brokerjointoken" "github.com/GoogleCloudPlatform/scion/pkg/ent/brokersecret" - "github.com/GoogleCloudPlatform/scion/pkg/ent/discordpendinglink" "github.com/GoogleCloudPlatform/scion/pkg/ent/envvar" "github.com/GoogleCloudPlatform/scion/pkg/ent/gcpserviceaccount" "github.com/GoogleCloudPlatform/scion/pkg/ent/githubinstallation" @@ -68,7 +67,6 @@ const ( TypeBrokerDispatch = "BrokerDispatch" TypeBrokerJoinToken = "BrokerJoinToken" TypeBrokerSecret = "BrokerSecret" - TypeDiscordPendingLink = "DiscordPendingLink" TypeEnvVar = "EnvVar" TypeGCPServiceAccount = "GCPServiceAccount" TypeGithubInstallation = "GithubInstallation" @@ -8285,662 +8283,6 @@ func (m *BrokerSecretMutation) ResetEdge(name string) error { return fmt.Errorf("unknown BrokerSecret edge %s", name) } -// DiscordPendingLinkMutation represents an operation that mutates the DiscordPendingLink nodes in the graph. -type DiscordPendingLinkMutation struct { - config - op Op - typ string - id *uuid.UUID - code *string - discord_user_id *string - status *string - user_id *string - user_email *string - expires_at *time.Time - created_at *time.Time - clearedFields map[string]struct{} - done bool - oldValue func(context.Context) (*DiscordPendingLink, error) - predicates []predicate.DiscordPendingLink -} - -var _ ent.Mutation = (*DiscordPendingLinkMutation)(nil) - -// discordpendinglinkOption allows management of the mutation configuration using functional options. -type discordpendinglinkOption func(*DiscordPendingLinkMutation) - -// newDiscordPendingLinkMutation creates new mutation for the DiscordPendingLink entity. -func newDiscordPendingLinkMutation(c config, op Op, opts ...discordpendinglinkOption) *DiscordPendingLinkMutation { - m := &DiscordPendingLinkMutation{ - config: c, - op: op, - typ: TypeDiscordPendingLink, - clearedFields: make(map[string]struct{}), - } - for _, opt := range opts { - opt(m) - } - return m -} - -// withDiscordPendingLinkID sets the ID field of the mutation. -func withDiscordPendingLinkID(id uuid.UUID) discordpendinglinkOption { - return func(m *DiscordPendingLinkMutation) { - var ( - err error - once sync.Once - value *DiscordPendingLink - ) - m.oldValue = func(ctx context.Context) (*DiscordPendingLink, error) { - once.Do(func() { - if m.done { - err = errors.New("querying old values post mutation is not allowed") - } else { - value, err = m.Client().DiscordPendingLink.Get(ctx, id) - } - }) - return value, err - } - m.id = &id - } -} - -// withDiscordPendingLink sets the old DiscordPendingLink of the mutation. -func withDiscordPendingLink(node *DiscordPendingLink) discordpendinglinkOption { - return func(m *DiscordPendingLinkMutation) { - m.oldValue = func(context.Context) (*DiscordPendingLink, error) { - return node, nil - } - m.id = &node.ID - } -} - -// Client returns a new `ent.Client` from the mutation. If the mutation was -// executed in a transaction (ent.Tx), a transactional client is returned. -func (m DiscordPendingLinkMutation) Client() *Client { - client := &Client{config: m.config} - client.init() - return client -} - -// Tx returns an `ent.Tx` for mutations that were executed in transactions; -// it returns an error otherwise. -func (m DiscordPendingLinkMutation) Tx() (*Tx, error) { - if _, ok := m.driver.(*txDriver); !ok { - return nil, errors.New("ent: mutation is not running in a transaction") - } - tx := &Tx{config: m.config} - tx.init() - return tx, nil -} - -// SetID sets the value of the id field. Note that this -// operation is only accepted on creation of DiscordPendingLink entities. -func (m *DiscordPendingLinkMutation) SetID(id uuid.UUID) { - m.id = &id -} - -// ID returns the ID value in the mutation. Note that the ID is only available -// if it was provided to the builder or after it was returned from the database. -func (m *DiscordPendingLinkMutation) ID() (id uuid.UUID, exists bool) { - if m.id == nil { - return - } - return *m.id, true -} - -// IDs queries the database and returns the entity ids that match the mutation's predicate. -// That means, if the mutation is applied within a transaction with an isolation level such -// as sql.LevelSerializable, the returned ids match the ids of the rows that will be updated -// or updated by the mutation. -func (m *DiscordPendingLinkMutation) IDs(ctx context.Context) ([]uuid.UUID, error) { - switch { - case m.op.Is(OpUpdateOne | OpDeleteOne): - id, exists := m.ID() - if exists { - return []uuid.UUID{id}, nil - } - fallthrough - case m.op.Is(OpUpdate | OpDelete): - return m.Client().DiscordPendingLink.Query().Where(m.predicates...).IDs(ctx) - default: - return nil, fmt.Errorf("IDs is not allowed on %s operations", m.op) - } -} - -// SetCode sets the "code" field. -func (m *DiscordPendingLinkMutation) SetCode(s string) { - m.code = &s -} - -// Code returns the value of the "code" field in the mutation. -func (m *DiscordPendingLinkMutation) Code() (r string, exists bool) { - v := m.code - if v == nil { - return - } - return *v, true -} - -// OldCode returns the old "code" field's value of the DiscordPendingLink entity. -// If the DiscordPendingLink object wasn't provided to the builder, the object is fetched from the database. -// An error is returned if the mutation operation is not UpdateOne, or the database query fails. -func (m *DiscordPendingLinkMutation) OldCode(ctx context.Context) (v string, err error) { - if !m.op.Is(OpUpdateOne) { - return v, errors.New("OldCode is only allowed on UpdateOne operations") - } - if m.id == nil || m.oldValue == nil { - return v, errors.New("OldCode requires an ID field in the mutation") - } - oldValue, err := m.oldValue(ctx) - if err != nil { - return v, fmt.Errorf("querying old value for OldCode: %w", err) - } - return oldValue.Code, nil -} - -// ResetCode resets all changes to the "code" field. -func (m *DiscordPendingLinkMutation) ResetCode() { - m.code = nil -} - -// SetDiscordUserID sets the "discord_user_id" field. -func (m *DiscordPendingLinkMutation) SetDiscordUserID(s string) { - m.discord_user_id = &s -} - -// DiscordUserID returns the value of the "discord_user_id" field in the mutation. -func (m *DiscordPendingLinkMutation) DiscordUserID() (r string, exists bool) { - v := m.discord_user_id - if v == nil { - return - } - return *v, true -} - -// OldDiscordUserID returns the old "discord_user_id" field's value of the DiscordPendingLink entity. -// If the DiscordPendingLink object wasn't provided to the builder, the object is fetched from the database. -// An error is returned if the mutation operation is not UpdateOne, or the database query fails. -func (m *DiscordPendingLinkMutation) OldDiscordUserID(ctx context.Context) (v string, err error) { - if !m.op.Is(OpUpdateOne) { - return v, errors.New("OldDiscordUserID is only allowed on UpdateOne operations") - } - if m.id == nil || m.oldValue == nil { - return v, errors.New("OldDiscordUserID requires an ID field in the mutation") - } - oldValue, err := m.oldValue(ctx) - if err != nil { - return v, fmt.Errorf("querying old value for OldDiscordUserID: %w", err) - } - return oldValue.DiscordUserID, nil -} - -// ResetDiscordUserID resets all changes to the "discord_user_id" field. -func (m *DiscordPendingLinkMutation) ResetDiscordUserID() { - m.discord_user_id = nil -} - -// SetStatus sets the "status" field. -func (m *DiscordPendingLinkMutation) SetStatus(s string) { - m.status = &s -} - -// Status returns the value of the "status" field in the mutation. -func (m *DiscordPendingLinkMutation) Status() (r string, exists bool) { - v := m.status - if v == nil { - return - } - return *v, true -} - -// OldStatus returns the old "status" field's value of the DiscordPendingLink entity. -// If the DiscordPendingLink object wasn't provided to the builder, the object is fetched from the database. -// An error is returned if the mutation operation is not UpdateOne, or the database query fails. -func (m *DiscordPendingLinkMutation) OldStatus(ctx context.Context) (v string, err error) { - if !m.op.Is(OpUpdateOne) { - return v, errors.New("OldStatus is only allowed on UpdateOne operations") - } - if m.id == nil || m.oldValue == nil { - return v, errors.New("OldStatus requires an ID field in the mutation") - } - oldValue, err := m.oldValue(ctx) - if err != nil { - return v, fmt.Errorf("querying old value for OldStatus: %w", err) - } - return oldValue.Status, nil -} - -// ResetStatus resets all changes to the "status" field. -func (m *DiscordPendingLinkMutation) ResetStatus() { - m.status = nil -} - -// SetUserID sets the "user_id" field. -func (m *DiscordPendingLinkMutation) SetUserID(s string) { - m.user_id = &s -} - -// UserID returns the value of the "user_id" field in the mutation. -func (m *DiscordPendingLinkMutation) UserID() (r string, exists bool) { - v := m.user_id - if v == nil { - return - } - return *v, true -} - -// OldUserID returns the old "user_id" field's value of the DiscordPendingLink entity. -// If the DiscordPendingLink object wasn't provided to the builder, the object is fetched from the database. -// An error is returned if the mutation operation is not UpdateOne, or the database query fails. -func (m *DiscordPendingLinkMutation) OldUserID(ctx context.Context) (v string, err error) { - if !m.op.Is(OpUpdateOne) { - return v, errors.New("OldUserID is only allowed on UpdateOne operations") - } - if m.id == nil || m.oldValue == nil { - return v, errors.New("OldUserID requires an ID field in the mutation") - } - oldValue, err := m.oldValue(ctx) - if err != nil { - return v, fmt.Errorf("querying old value for OldUserID: %w", err) - } - return oldValue.UserID, nil -} - -// ResetUserID resets all changes to the "user_id" field. -func (m *DiscordPendingLinkMutation) ResetUserID() { - m.user_id = nil -} - -// SetUserEmail sets the "user_email" field. -func (m *DiscordPendingLinkMutation) SetUserEmail(s string) { - m.user_email = &s -} - -// UserEmail returns the value of the "user_email" field in the mutation. -func (m *DiscordPendingLinkMutation) UserEmail() (r string, exists bool) { - v := m.user_email - if v == nil { - return - } - return *v, true -} - -// OldUserEmail returns the old "user_email" field's value of the DiscordPendingLink entity. -// If the DiscordPendingLink object wasn't provided to the builder, the object is fetched from the database. -// An error is returned if the mutation operation is not UpdateOne, or the database query fails. -func (m *DiscordPendingLinkMutation) OldUserEmail(ctx context.Context) (v string, err error) { - if !m.op.Is(OpUpdateOne) { - return v, errors.New("OldUserEmail is only allowed on UpdateOne operations") - } - if m.id == nil || m.oldValue == nil { - return v, errors.New("OldUserEmail requires an ID field in the mutation") - } - oldValue, err := m.oldValue(ctx) - if err != nil { - return v, fmt.Errorf("querying old value for OldUserEmail: %w", err) - } - return oldValue.UserEmail, nil -} - -// ResetUserEmail resets all changes to the "user_email" field. -func (m *DiscordPendingLinkMutation) ResetUserEmail() { - m.user_email = nil -} - -// SetExpiresAt sets the "expires_at" field. -func (m *DiscordPendingLinkMutation) SetExpiresAt(t time.Time) { - m.expires_at = &t -} - -// ExpiresAt returns the value of the "expires_at" field in the mutation. -func (m *DiscordPendingLinkMutation) ExpiresAt() (r time.Time, exists bool) { - v := m.expires_at - if v == nil { - return - } - return *v, true -} - -// OldExpiresAt returns the old "expires_at" field's value of the DiscordPendingLink entity. -// If the DiscordPendingLink object wasn't provided to the builder, the object is fetched from the database. -// An error is returned if the mutation operation is not UpdateOne, or the database query fails. -func (m *DiscordPendingLinkMutation) OldExpiresAt(ctx context.Context) (v time.Time, err error) { - if !m.op.Is(OpUpdateOne) { - return v, errors.New("OldExpiresAt is only allowed on UpdateOne operations") - } - if m.id == nil || m.oldValue == nil { - return v, errors.New("OldExpiresAt requires an ID field in the mutation") - } - oldValue, err := m.oldValue(ctx) - if err != nil { - return v, fmt.Errorf("querying old value for OldExpiresAt: %w", err) - } - return oldValue.ExpiresAt, nil -} - -// ResetExpiresAt resets all changes to the "expires_at" field. -func (m *DiscordPendingLinkMutation) ResetExpiresAt() { - m.expires_at = nil -} - -// SetCreatedAt sets the "created_at" field. -func (m *DiscordPendingLinkMutation) SetCreatedAt(t time.Time) { - m.created_at = &t -} - -// CreatedAt returns the value of the "created_at" field in the mutation. -func (m *DiscordPendingLinkMutation) CreatedAt() (r time.Time, exists bool) { - v := m.created_at - if v == nil { - return - } - return *v, true -} - -// OldCreatedAt returns the old "created_at" field's value of the DiscordPendingLink entity. -// If the DiscordPendingLink object wasn't provided to the builder, the object is fetched from the database. -// An error is returned if the mutation operation is not UpdateOne, or the database query fails. -func (m *DiscordPendingLinkMutation) OldCreatedAt(ctx context.Context) (v time.Time, err error) { - if !m.op.Is(OpUpdateOne) { - return v, errors.New("OldCreatedAt is only allowed on UpdateOne operations") - } - if m.id == nil || m.oldValue == nil { - return v, errors.New("OldCreatedAt requires an ID field in the mutation") - } - oldValue, err := m.oldValue(ctx) - if err != nil { - return v, fmt.Errorf("querying old value for OldCreatedAt: %w", err) - } - return oldValue.CreatedAt, nil -} - -// ResetCreatedAt resets all changes to the "created_at" field. -func (m *DiscordPendingLinkMutation) ResetCreatedAt() { - m.created_at = nil -} - -// Where appends a list predicates to the DiscordPendingLinkMutation builder. -func (m *DiscordPendingLinkMutation) Where(ps ...predicate.DiscordPendingLink) { - m.predicates = append(m.predicates, ps...) -} - -// WhereP appends storage-level predicates to the DiscordPendingLinkMutation builder. Using this method, -// users can use type-assertion to append predicates that do not depend on any generated package. -func (m *DiscordPendingLinkMutation) WhereP(ps ...func(*sql.Selector)) { - p := make([]predicate.DiscordPendingLink, len(ps)) - for i := range ps { - p[i] = ps[i] - } - m.Where(p...) -} - -// Op returns the operation name. -func (m *DiscordPendingLinkMutation) Op() Op { - return m.op -} - -// SetOp allows setting the mutation operation. -func (m *DiscordPendingLinkMutation) SetOp(op Op) { - m.op = op -} - -// Type returns the node type of this mutation (DiscordPendingLink). -func (m *DiscordPendingLinkMutation) Type() string { - return m.typ -} - -// Fields returns all fields that were changed during this mutation. Note that in -// order to get all numeric fields that were incremented/decremented, call -// AddedFields(). -func (m *DiscordPendingLinkMutation) Fields() []string { - fields := make([]string, 0, 7) - if m.code != nil { - fields = append(fields, discordpendinglink.FieldCode) - } - if m.discord_user_id != nil { - fields = append(fields, discordpendinglink.FieldDiscordUserID) - } - if m.status != nil { - fields = append(fields, discordpendinglink.FieldStatus) - } - if m.user_id != nil { - fields = append(fields, discordpendinglink.FieldUserID) - } - if m.user_email != nil { - fields = append(fields, discordpendinglink.FieldUserEmail) - } - if m.expires_at != nil { - fields = append(fields, discordpendinglink.FieldExpiresAt) - } - if m.created_at != nil { - fields = append(fields, discordpendinglink.FieldCreatedAt) - } - return fields -} - -// Field returns the value of a field with the given name. The second boolean -// return value indicates that this field was not set, or was not defined in the -// schema. -func (m *DiscordPendingLinkMutation) Field(name string) (ent.Value, bool) { - switch name { - case discordpendinglink.FieldCode: - return m.Code() - case discordpendinglink.FieldDiscordUserID: - return m.DiscordUserID() - case discordpendinglink.FieldStatus: - return m.Status() - case discordpendinglink.FieldUserID: - return m.UserID() - case discordpendinglink.FieldUserEmail: - return m.UserEmail() - case discordpendinglink.FieldExpiresAt: - return m.ExpiresAt() - case discordpendinglink.FieldCreatedAt: - return m.CreatedAt() - } - return nil, false -} - -// OldField returns the old value of the field from the database. An error is -// returned if the mutation operation is not UpdateOne, or the query to the -// database failed. -func (m *DiscordPendingLinkMutation) OldField(ctx context.Context, name string) (ent.Value, error) { - switch name { - case discordpendinglink.FieldCode: - return m.OldCode(ctx) - case discordpendinglink.FieldDiscordUserID: - return m.OldDiscordUserID(ctx) - case discordpendinglink.FieldStatus: - return m.OldStatus(ctx) - case discordpendinglink.FieldUserID: - return m.OldUserID(ctx) - case discordpendinglink.FieldUserEmail: - return m.OldUserEmail(ctx) - case discordpendinglink.FieldExpiresAt: - return m.OldExpiresAt(ctx) - case discordpendinglink.FieldCreatedAt: - return m.OldCreatedAt(ctx) - } - return nil, fmt.Errorf("unknown DiscordPendingLink field %s", name) -} - -// SetField sets the value of a field with the given name. It returns an error if -// the field is not defined in the schema, or if the type mismatched the field -// type. -func (m *DiscordPendingLinkMutation) SetField(name string, value ent.Value) error { - switch name { - case discordpendinglink.FieldCode: - v, ok := value.(string) - if !ok { - return fmt.Errorf("unexpected type %T for field %s", value, name) - } - m.SetCode(v) - return nil - case discordpendinglink.FieldDiscordUserID: - v, ok := value.(string) - if !ok { - return fmt.Errorf("unexpected type %T for field %s", value, name) - } - m.SetDiscordUserID(v) - return nil - case discordpendinglink.FieldStatus: - v, ok := value.(string) - if !ok { - return fmt.Errorf("unexpected type %T for field %s", value, name) - } - m.SetStatus(v) - return nil - case discordpendinglink.FieldUserID: - v, ok := value.(string) - if !ok { - return fmt.Errorf("unexpected type %T for field %s", value, name) - } - m.SetUserID(v) - return nil - case discordpendinglink.FieldUserEmail: - v, ok := value.(string) - if !ok { - return fmt.Errorf("unexpected type %T for field %s", value, name) - } - m.SetUserEmail(v) - return nil - case discordpendinglink.FieldExpiresAt: - v, ok := value.(time.Time) - if !ok { - return fmt.Errorf("unexpected type %T for field %s", value, name) - } - m.SetExpiresAt(v) - return nil - case discordpendinglink.FieldCreatedAt: - v, ok := value.(time.Time) - if !ok { - return fmt.Errorf("unexpected type %T for field %s", value, name) - } - m.SetCreatedAt(v) - return nil - } - return fmt.Errorf("unknown DiscordPendingLink field %s", name) -} - -// AddedFields returns all numeric fields that were incremented/decremented during -// this mutation. -func (m *DiscordPendingLinkMutation) AddedFields() []string { - return nil -} - -// AddedField returns the numeric value that was incremented/decremented on a field -// with the given name. The second boolean return value indicates that this field -// was not set, or was not defined in the schema. -func (m *DiscordPendingLinkMutation) AddedField(name string) (ent.Value, bool) { - return nil, false -} - -// AddField adds the value to the field with the given name. It returns an error if -// the field is not defined in the schema, or if the type mismatched the field -// type. -func (m *DiscordPendingLinkMutation) AddField(name string, value ent.Value) error { - switch name { - } - return fmt.Errorf("unknown DiscordPendingLink numeric field %s", name) -} - -// ClearedFields returns all nullable fields that were cleared during this -// mutation. -func (m *DiscordPendingLinkMutation) ClearedFields() []string { - return nil -} - -// FieldCleared returns a boolean indicating if a field with the given name was -// cleared in this mutation. -func (m *DiscordPendingLinkMutation) FieldCleared(name string) bool { - _, ok := m.clearedFields[name] - return ok -} - -// ClearField clears the value of the field with the given name. It returns an -// error if the field is not defined in the schema. -func (m *DiscordPendingLinkMutation) ClearField(name string) error { - return fmt.Errorf("unknown DiscordPendingLink nullable field %s", name) -} - -// ResetField resets all changes in the mutation for the field with the given name. -// It returns an error if the field is not defined in the schema. -func (m *DiscordPendingLinkMutation) ResetField(name string) error { - switch name { - case discordpendinglink.FieldCode: - m.ResetCode() - return nil - case discordpendinglink.FieldDiscordUserID: - m.ResetDiscordUserID() - return nil - case discordpendinglink.FieldStatus: - m.ResetStatus() - return nil - case discordpendinglink.FieldUserID: - m.ResetUserID() - return nil - case discordpendinglink.FieldUserEmail: - m.ResetUserEmail() - return nil - case discordpendinglink.FieldExpiresAt: - m.ResetExpiresAt() - return nil - case discordpendinglink.FieldCreatedAt: - m.ResetCreatedAt() - return nil - } - return fmt.Errorf("unknown DiscordPendingLink field %s", name) -} - -// AddedEdges returns all edge names that were set/added in this mutation. -func (m *DiscordPendingLinkMutation) AddedEdges() []string { - edges := make([]string, 0, 0) - return edges -} - -// AddedIDs returns all IDs (to other nodes) that were added for the given edge -// name in this mutation. -func (m *DiscordPendingLinkMutation) AddedIDs(name string) []ent.Value { - return nil -} - -// RemovedEdges returns all edge names that were removed in this mutation. -func (m *DiscordPendingLinkMutation) RemovedEdges() []string { - edges := make([]string, 0, 0) - return edges -} - -// RemovedIDs returns all IDs (to other nodes) that were removed for the edge with -// the given name in this mutation. -func (m *DiscordPendingLinkMutation) RemovedIDs(name string) []ent.Value { - return nil -} - -// ClearedEdges returns all edge names that were cleared in this mutation. -func (m *DiscordPendingLinkMutation) ClearedEdges() []string { - edges := make([]string, 0, 0) - return edges -} - -// EdgeCleared returns a boolean which indicates if the edge with the given name -// was cleared in this mutation. -func (m *DiscordPendingLinkMutation) EdgeCleared(name string) bool { - return false -} - -// ClearEdge clears the value of the edge with the given name. It returns an error -// if that edge is not defined in the schema. -func (m *DiscordPendingLinkMutation) ClearEdge(name string) error { - return fmt.Errorf("unknown DiscordPendingLink unique edge %s", name) -} - -// ResetEdge resets all changes to the edge with the given name in this mutation. -// It returns an error if the edge is not defined in the schema. -func (m *DiscordPendingLinkMutation) ResetEdge(name string) error { - return fmt.Errorf("unknown DiscordPendingLink edge %s", name) -} - // EnvVarMutation represents an operation that mutates the EnvVar nodes in the graph. type EnvVarMutation struct { config diff --git a/pkg/ent/predicate/predicate.go b/pkg/ent/predicate/predicate.go index bd407a71c..01e9e0d33 100644 --- a/pkg/ent/predicate/predicate.go +++ b/pkg/ent/predicate/predicate.go @@ -27,9 +27,6 @@ type BrokerJoinToken func(*sql.Selector) // BrokerSecret is the predicate function for brokersecret builders. type BrokerSecret func(*sql.Selector) -// DiscordPendingLink is the predicate function for discordpendinglink builders. -type DiscordPendingLink func(*sql.Selector) - // EnvVar is the predicate function for envvar builders. type EnvVar func(*sql.Selector) diff --git a/pkg/ent/runtime.go b/pkg/ent/runtime.go index a58d05f0c..537df6bad 100644 --- a/pkg/ent/runtime.go +++ b/pkg/ent/runtime.go @@ -12,7 +12,6 @@ import ( "github.com/GoogleCloudPlatform/scion/pkg/ent/brokerdispatch" "github.com/GoogleCloudPlatform/scion/pkg/ent/brokerjointoken" "github.com/GoogleCloudPlatform/scion/pkg/ent/brokersecret" - "github.com/GoogleCloudPlatform/scion/pkg/ent/discordpendinglink" "github.com/GoogleCloudPlatform/scion/pkg/ent/envvar" "github.com/GoogleCloudPlatform/scion/pkg/ent/gcpserviceaccount" "github.com/GoogleCloudPlatform/scion/pkg/ent/githubinstallation" @@ -229,36 +228,6 @@ func init() { brokersecretDescCreated := brokersecretFields[6].Descriptor() // brokersecret.DefaultCreated holds the default value on creation for the created field. brokersecret.DefaultCreated = brokersecretDescCreated.Default.(func() time.Time) - discordpendinglinkFields := schema.DiscordPendingLink{}.Fields() - _ = discordpendinglinkFields - // discordpendinglinkDescCode is the schema descriptor for code field. - discordpendinglinkDescCode := discordpendinglinkFields[1].Descriptor() - // discordpendinglink.CodeValidator is a validator for the "code" field. It is called by the builders before save. - discordpendinglink.CodeValidator = discordpendinglinkDescCode.Validators[0].(func(string) error) - // discordpendinglinkDescDiscordUserID is the schema descriptor for discord_user_id field. - discordpendinglinkDescDiscordUserID := discordpendinglinkFields[2].Descriptor() - // discordpendinglink.DiscordUserIDValidator is a validator for the "discord_user_id" field. It is called by the builders before save. - discordpendinglink.DiscordUserIDValidator = discordpendinglinkDescDiscordUserID.Validators[0].(func(string) error) - // discordpendinglinkDescStatus is the schema descriptor for status field. - discordpendinglinkDescStatus := discordpendinglinkFields[3].Descriptor() - // discordpendinglink.DefaultStatus holds the default value on creation for the status field. - discordpendinglink.DefaultStatus = discordpendinglinkDescStatus.Default.(string) - // discordpendinglinkDescUserID is the schema descriptor for user_id field. - discordpendinglinkDescUserID := discordpendinglinkFields[4].Descriptor() - // discordpendinglink.DefaultUserID holds the default value on creation for the user_id field. - discordpendinglink.DefaultUserID = discordpendinglinkDescUserID.Default.(string) - // discordpendinglinkDescUserEmail is the schema descriptor for user_email field. - discordpendinglinkDescUserEmail := discordpendinglinkFields[5].Descriptor() - // discordpendinglink.DefaultUserEmail holds the default value on creation for the user_email field. - discordpendinglink.DefaultUserEmail = discordpendinglinkDescUserEmail.Default.(string) - // discordpendinglinkDescCreatedAt is the schema descriptor for created_at field. - discordpendinglinkDescCreatedAt := discordpendinglinkFields[7].Descriptor() - // discordpendinglink.DefaultCreatedAt holds the default value on creation for the created_at field. - discordpendinglink.DefaultCreatedAt = discordpendinglinkDescCreatedAt.Default.(func() time.Time) - // discordpendinglinkDescID is the schema descriptor for id field. - discordpendinglinkDescID := discordpendinglinkFields[0].Descriptor() - // discordpendinglink.DefaultID holds the default value on creation for the id field. - discordpendinglink.DefaultID = discordpendinglinkDescID.Default.(func() uuid.UUID) envvarFields := schema.EnvVar{}.Fields() _ = envvarFields // envvarDescKey is the schema descriptor for key field. diff --git a/pkg/ent/schema/discordpendinglink.go b/pkg/ent/schema/discordpendinglink.go deleted file mode 100644 index 06d0d22c9..000000000 --- a/pkg/ent/schema/discordpendinglink.go +++ /dev/null @@ -1,72 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package schema - -import ( - "time" - - "entgo.io/ent" - "entgo.io/ent/dialect/entsql" - "entgo.io/ent/schema" - "entgo.io/ent/schema/field" - "entgo.io/ent/schema/index" - "github.com/google/uuid" -) - -// DiscordPendingLink holds the schema definition for the DiscordPendingLink -// entity. It stores pending Discord account link codes so that a code -// registered on one hub instance can be verified on another. -type DiscordPendingLink struct { - ent.Schema -} - -// Fields of the DiscordPendingLink. -func (DiscordPendingLink) Fields() []ent.Field { - return []ent.Field{ - field.UUID("id", uuid.UUID{}). - Default(uuid.New). - Immutable(), - field.String("code"). - Unique(). - NotEmpty(), - field.String("discord_user_id"). - NotEmpty(), - field.String("status"). - Default("pending"), - field.String("user_id"). - Default(""), - field.String("user_email"). - Default(""), - field.Time("expires_at"), - field.Time("created_at"). - Default(time.Now). - Immutable(), - } -} - -// Indexes of the DiscordPendingLink. -func (DiscordPendingLink) Indexes() []ent.Index { - return []ent.Index{ - index.Fields("expires_at"), - index.Fields("discord_user_id"), - } -} - -// Annotations of the DiscordPendingLink. -func (DiscordPendingLink) Annotations() []schema.Annotation { - return []schema.Annotation{ - entsql.Annotation{Table: "discord_pending_links"}, - } -} diff --git a/pkg/ent/tx.go b/pkg/ent/tx.go index 3a0ee459b..0d9e925a0 100644 --- a/pkg/ent/tx.go +++ b/pkg/ent/tx.go @@ -26,8 +26,6 @@ type Tx struct { BrokerJoinToken *BrokerJoinTokenClient // BrokerSecret is the client for interacting with the BrokerSecret builders. BrokerSecret *BrokerSecretClient - // DiscordPendingLink is the client for interacting with the DiscordPendingLink builders. - DiscordPendingLink *DiscordPendingLinkClient // EnvVar is the client for interacting with the EnvVar builders. EnvVar *EnvVarClient // GCPServiceAccount is the client for interacting with the GCPServiceAccount builders. @@ -222,7 +220,6 @@ func (tx *Tx) init() { tx.BrokerDispatch = NewBrokerDispatchClient(tx.config) tx.BrokerJoinToken = NewBrokerJoinTokenClient(tx.config) tx.BrokerSecret = NewBrokerSecretClient(tx.config) - tx.DiscordPendingLink = NewDiscordPendingLinkClient(tx.config) tx.EnvVar = NewEnvVarClient(tx.config) tx.GCPServiceAccount = NewGCPServiceAccountClient(tx.config) tx.GithubInstallation = NewGithubInstallationClient(tx.config) diff --git a/pkg/harness/auth.go b/pkg/harness/auth.go index a5a4b433c..ae8383ccb 100644 --- a/pkg/harness/auth.go +++ b/pkg/harness/auth.go @@ -151,6 +151,80 @@ func OverlayFileSecrets(auth *api.AuthConfig, secrets []api.ResolvedSecret) { } } +// OverlayFileSecretsFromConfig is the config-driven counterpart of +// OverlayFileSecrets. It reads field mappings from the harness config's +// auth.types entries and sets the corresponding AuthConfig fields. When a +// secret's Name matches a declared field mapping, the config-driven path is +// used. For secrets that don't match any declared Name, it falls back to +// target-path-suffix matching (preserving backward compatibility with secrets +// created before field mappings were added to config.yaml). +func OverlayFileSecretsFromConfig(auth *api.AuthConfig, secrets []api.ResolvedSecret, authMeta *config.HarnessAuthMetadata) { + fieldMap := buildFieldMap(authMeta) + + for _, s := range secrets { + if s.Type != "file" { + continue + } + if fieldName, ok := fieldMap[s.Name]; ok && fieldName != "" { + setAuthConfigField(auth, fieldName, s.Target) + continue + } + // Fallback: match by target path suffix for backward compat + setAuthConfigFieldByTargetSuffix(auth, s.Target) + } +} + +// buildFieldMap collects secret-name -> AuthConfig field mappings from all +// auth types declared in the harness config. +func buildFieldMap(authMeta *config.HarnessAuthMetadata) map[string]string { + m := make(map[string]string) + if authMeta == nil { + return m + } + for _, authType := range authMeta.Types { + for _, rf := range authType.RequiredFiles { + if rf.Name != "" && rf.Field != "" { + m[rf.Name] = rf.Field + } + } + } + return m +} + +// setAuthConfigField sets the named field on AuthConfig to the given value. +// Field names must match AuthConfig struct fields exactly. +func setAuthConfigField(auth *api.AuthConfig, field, value string) { + switch field { + case "GoogleAppCredentials": + auth.GoogleAppCredentials = value + case "OAuthCreds": + auth.OAuthCreds = value + case "CodexAuthFile": + auth.CodexAuthFile = value + case "OpenCodeAuthFile": + auth.OpenCodeAuthFile = value + case "ClaudeAuthFile": + auth.ClaudeAuthFile = value + } +} + +// setAuthConfigFieldByTargetSuffix matches a file secret's target path to an +// AuthConfig field using the same suffix rules as the original OverlayFileSecrets. +func setAuthConfigFieldByTargetSuffix(auth *api.AuthConfig, target string) { + switch { + case strings.HasSuffix(target, "/application_default_credentials.json"): + auth.GoogleAppCredentials = target + case strings.HasSuffix(target, "/oauth_creds.json"): + auth.OAuthCreds = target + case strings.HasSuffix(target, "/.codex/auth.json"): + auth.CodexAuthFile = target + case strings.HasSuffix(target, "/opencode/auth.json"): + auth.OpenCodeAuthFile = target + case strings.HasSuffix(target, "/.claude/.credentials.json"): + auth.ClaudeAuthFile = target + } +} + // OverlaySettings applies settings-based overrides to an AuthConfig. // It reads AuthSelectedType from scion-agent.json (top-level), which is // populated from scion's settings chain during provisioning. diff --git a/pkg/harness/auth_test.go b/pkg/harness/auth_test.go index bdcbbe402..b908a437b 100644 --- a/pkg/harness/auth_test.go +++ b/pkg/harness/auth_test.go @@ -15,12 +15,14 @@ package harness import ( + "encoding/json" "os" "path/filepath" "strings" "testing" "github.com/GoogleCloudPlatform/scion/pkg/api" + "github.com/GoogleCloudPlatform/scion/pkg/config" ) func TestGatherAuth_EnvVars(t *testing.T) { @@ -978,3 +980,201 @@ func TestOverlayFileSecrets(t *testing.T) { }) } } + +func TestOverlayFileSecretsFromConfig(t *testing.T) { + claudeAuthMeta := &config.HarnessAuthMetadata{ + DefaultType: "api-key", + Types: map[string]config.HarnessAuthTypeMetadata{ + "auth-file": { + RequiredFiles: []config.HarnessAuthFileRequirement{ + {Name: "CLAUDE_AUTH", Type: "file", TargetSuffix: "/.claude/.credentials.json", Field: "ClaudeAuthFile"}, + }, + }, + "vertex-ai": { + RequiredFiles: []config.HarnessAuthFileRequirement{ + {Name: "gcloud-adc", Type: "file", TargetSuffix: "", Field: "GoogleAppCredentials", Required: true}, + }, + }, + }, + } + + tests := []struct { + name string + meta *config.HarnessAuthMetadata + secrets []api.ResolvedSecret + check func(t *testing.T, auth api.AuthConfig) + }{ + { + name: "config-driven field mapping for CLAUDE_AUTH", + meta: claudeAuthMeta, + secrets: []api.ResolvedSecret{ + {Name: "CLAUDE_AUTH", Type: "file", Target: "/home/agent/.claude/.credentials.json"}, + }, + check: func(t *testing.T, auth api.AuthConfig) { + if auth.ClaudeAuthFile != "/home/agent/.claude/.credentials.json" { + t.Errorf("ClaudeAuthFile = %q, want credentials path", auth.ClaudeAuthFile) + } + }, + }, + { + name: "fallback to target suffix for unknown secret name", + meta: claudeAuthMeta, + secrets: []api.ResolvedSecret{ + {Name: "my-custom-claude-creds", Type: "file", Target: "/home/agent/.claude/.credentials.json"}, + }, + check: func(t *testing.T, auth api.AuthConfig) { + if auth.ClaudeAuthFile != "/home/agent/.claude/.credentials.json" { + t.Errorf("ClaudeAuthFile = %q, want credentials path from suffix fallback", auth.ClaudeAuthFile) + } + }, + }, + { + name: "config-driven matches hardcoded behavior", + meta: claudeAuthMeta, + secrets: []api.ResolvedSecret{ + {Name: "CLAUDE_AUTH", Type: "file", Target: "/home/agent/.claude/.credentials.json"}, + }, + check: func(t *testing.T, auth api.AuthConfig) { + hardcoded := api.AuthConfig{} + OverlayFileSecrets(&hardcoded, []api.ResolvedSecret{ + {Name: "CLAUDE_AUTH", Type: "file", Target: "/home/agent/.claude/.credentials.json"}, + }) + if auth.ClaudeAuthFile != hardcoded.ClaudeAuthFile { + t.Errorf("config-driven ClaudeAuthFile = %q, hardcoded = %q", auth.ClaudeAuthFile, hardcoded.ClaudeAuthFile) + } + }, + }, + { + name: "non-file secrets are skipped", + meta: claudeAuthMeta, + secrets: []api.ResolvedSecret{ + {Name: "CLAUDE_AUTH", Type: "environment", Target: "CLAUDE_AUTH", Value: "some-value"}, + }, + check: func(t *testing.T, auth api.AuthConfig) { + if auth.ClaudeAuthFile != "" { + t.Errorf("ClaudeAuthFile = %q, want empty (env-type should be skipped)", auth.ClaudeAuthFile) + } + }, + }, + { + name: "nil auth metadata falls back to suffix matching", + meta: nil, + secrets: []api.ResolvedSecret{ + {Name: "CLAUDE_AUTH", Type: "file", Target: "/home/agent/.claude/.credentials.json"}, + }, + check: func(t *testing.T, auth api.AuthConfig) { + if auth.ClaudeAuthFile != "/home/agent/.claude/.credentials.json" { + t.Errorf("ClaudeAuthFile = %q, want credentials path from suffix fallback", auth.ClaudeAuthFile) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + auth := api.AuthConfig{} + OverlayFileSecretsFromConfig(&auth, tt.secrets, tt.meta) + tt.check(t, auth) + }) + } +} + +func TestStageCaptureAuthAssets(t *testing.T) { + authMeta := &config.HarnessAuthMetadata{ + Types: map[string]config.HarnessAuthTypeMetadata{ + "auth-file": { + RequiredFiles: []config.HarnessAuthFileRequirement{ + {Name: "CLAUDE_AUTH", Type: "file", TargetSuffix: "/.claude/.credentials.json", Field: "ClaudeAuthFile"}, + }, + }, + "vertex-ai": { + RequiredFiles: []config.HarnessAuthFileRequirement{ + {Name: "gcloud-adc", Type: "file", TargetSuffix: "", Field: "GoogleAppCredentials"}, + }, + }, + }, + } + + t.Run("stages capture-auth-config.json from auth metadata", func(t *testing.T) { + agentHome := t.TempDir() + configDir := t.TempDir() + + if err := os.WriteFile(filepath.Join(configDir, "capture_auth.py"), []byte("#!/usr/bin/env python3\n"), 0644); err != nil { + t.Fatal(err) + } + + if err := StageCaptureAuthAssets(agentHome, configDir, authMeta); err != nil { + t.Fatalf("StageCaptureAuthAssets failed: %v", err) + } + + scriptPath := filepath.Join(agentHome, ".scion", "harness", "capture_auth.py") + if _, err := os.Stat(scriptPath); err != nil { + t.Errorf("capture_auth.py not staged: %v", err) + } + + configPath := filepath.Join(agentHome, ".scion", "harness", "inputs", "capture-auth-config.json") + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("capture-auth-config.json not staged: %v", err) + } + + var payload map[string]interface{} + if err := json.Unmarshal(data, &payload); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + creds, ok := payload["credentials"].([]interface{}) + if !ok { + t.Fatal("credentials field missing or not an array") + } + + // Only CLAUDE_AUTH has a TargetSuffix, so only it should appear + if len(creds) != 1 { + t.Fatalf("expected 1 credential entry, got %d", len(creds)) + } + + entry := creds[0].(map[string]interface{}) + if entry["key"] != "CLAUDE_AUTH" { + t.Errorf("key = %q, want CLAUDE_AUTH", entry["key"]) + } + if entry["source"] != "~/.claude/.credentials.json" { + t.Errorf("source = %q, want ~/.claude/.credentials.json", entry["source"]) + } + }) + + t.Run("no-op with nil auth metadata", func(t *testing.T) { + agentHome := t.TempDir() + configDir := t.TempDir() + + if err := StageCaptureAuthAssets(agentHome, configDir, nil); err != nil { + t.Fatalf("StageCaptureAuthAssets failed: %v", err) + } + + configPath := filepath.Join(agentHome, ".scion", "harness", "inputs", "capture-auth-config.json") + if _, err := os.Stat(configPath); !os.IsNotExist(err) { + t.Error("expected no capture-auth-config.json with nil auth metadata") + } + }) + + t.Run("script is executable", func(t *testing.T) { + agentHome := t.TempDir() + configDir := t.TempDir() + + if err := os.WriteFile(filepath.Join(configDir, "capture_auth.py"), []byte("#!/usr/bin/env python3\n"), 0644); err != nil { + t.Fatal(err) + } + + if err := StageCaptureAuthAssets(agentHome, configDir, authMeta); err != nil { + t.Fatal(err) + } + + scriptPath := filepath.Join(agentHome, ".scion", "harness", "capture_auth.py") + info, err := os.Stat(scriptPath) + if err != nil { + t.Fatal(err) + } + if info.Mode()&0111 == 0 { + t.Error("capture_auth.py should be executable") + } + }) +} diff --git a/pkg/harness/claude/embeds/capture_auth.py b/pkg/harness/claude/embeds/capture_auth.py new file mode 100644 index 000000000..de7c543ed --- /dev/null +++ b/pkg/harness/claude/embeds/capture_auth.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Claude capture-auth script. + +Scans for credential files on disk and stores them as project-scoped secrets +via `sciontool secret set`. Designed to run after the user authenticates +interactively (e.g. `claude login`) inside a no-auth agent container. + +Reads credential mappings from inputs/capture-auth-config.json (derived from +the harness config.yaml's auth.types.*.required_files declarations). This +avoids hardcoding paths or key names in the script. + +Exit codes: + 0 = at least one credential captured + 1 = error + 2 = no credentials found (not an error, but nothing was stored) +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +from typing import Any + +EXIT_OK = 0 +EXIT_ERROR = 1 +EXIT_NO_CREDS = 2 + +HARNESS_BUNDLE = os.path.join( + os.environ.get("HOME") or os.path.expanduser("~"), + ".scion", "harness", +) + + +def _expand(path: str) -> str: + return os.path.expanduser(os.path.expandvars(path)) + + +def _load_config(bundle: str) -> list[dict[str, Any]]: + config_path = os.path.join(bundle, "inputs", "capture-auth-config.json") + if not os.path.isfile(config_path): + return [] + with open(config_path, "r", encoding="utf-8") as f: + try: + data = json.load(f) + except (json.JSONDecodeError, OSError): + return [] + creds = data.get("credentials") + if not isinstance(creds, list): + return [] + return creds + + +def _capture_one( + entry: dict[str, Any], force: bool +) -> tuple[bool, str | None]: + """Attempt to capture a single credential. Returns (success, error_msg).""" + key = entry.get("key", "") + source = _expand(entry.get("source", "")) + secret_type = entry.get("type", "file") + target = entry.get("target", "") + + if not key or not source: + return False, f"invalid entry: missing key or source" + + if not os.path.isfile(source): + return False, None + + cmd = [ + "sciontool", "secret", "set", key, f"@{source}", + "--type", secret_type, + "--target", target, + ] + if force: + cmd.append("--force") + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=30, + ) + except FileNotFoundError: + return False, "sciontool not found in PATH" + except subprocess.TimeoutExpired: + return False, f"sciontool timed out for key {key}" + + if result.returncode != 0: + stderr = result.stderr.strip() + return False, f"sciontool failed for {key}: {stderr}" + + return True, None + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Capture auth credentials and store as project secrets" + ) + parser.add_argument( + "--force", + action="store_true", + help="Overwrite existing secrets", + ) + parser.add_argument( + "--bundle", + default=HARNESS_BUNDLE, + help="Path to harness bundle directory", + ) + args = parser.parse_args() + + entries = _load_config(args.bundle) + if not entries: + print( + "capture-auth: no credential mappings found in " + "inputs/capture-auth-config.json", + file=sys.stderr, + ) + return EXIT_NO_CREDS + + captured = 0 + errors = 0 + + for entry in entries: + key = entry.get("key", "") + source = entry.get("source", "") + expanded = _expand(source) if source else "" + + if not expanded or not os.path.isfile(expanded): + print(f"capture-auth: {key}: source not found ({source})") + continue + + ok, err = _capture_one(entry, args.force) + if err: + print(f"capture-auth: {key}: {err}", file=sys.stderr) + errors += 1 + elif ok: + print(f"capture-auth: {key}: captured from {source}") + captured += 1 + + if errors > 0 and captured == 0: + return EXIT_ERROR + + if captured == 0: + print("capture-auth: no credentials found to capture") + return EXIT_NO_CREDS + + print(f"capture-auth: {captured} credential(s) captured successfully") + return EXIT_OK + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pkg/harness/claude/embeds/config.yaml b/pkg/harness/claude/embeds/config.yaml index f7c440617..205cddc4f 100644 --- a/pkg/harness/claude/embeds/config.yaml +++ b/pkg/harness/claude/embeds/config.yaml @@ -52,6 +52,12 @@ capabilities: auth_file: { support: "yes" } oauth_token: { support: "yes" } vertex_ai: { support: "yes" } +no_auth: + behavior: drop-to-shell + message: | + This agent started without credentials. + Run: claude login + Then run: python3 /home/scion/.scion/harness/capture_auth.py auth: default_type: api-key types: @@ -66,6 +72,7 @@ auth: - name: CLAUDE_AUTH type: file target_suffix: "/.claude/.credentials.json" + field: ClaudeAuthFile vertex-ai: required_env: - any_of: ["GOOGLE_CLOUD_PROJECT"] @@ -74,6 +81,7 @@ auth: - name: gcloud-adc type: file description: "Google Cloud Application Default Credentials (ADC) file for vertex-ai authentication" + field: GoogleAppCredentials alternative_env_keys: ["GOOGLE_APPLICATION_CREDENTIALS"] skipped_when_gcp_service_account_assigned: true required: true diff --git a/pkg/harness/container_script_harness.go b/pkg/harness/container_script_harness.go index 6ead07d8f..6efdc39c6 100644 --- a/pkg/harness/container_script_harness.go +++ b/pkg/harness/container_script_harness.go @@ -22,6 +22,7 @@ import ( "io" "os" "path/filepath" + "sort" "strings" "github.com/GoogleCloudPlatform/scion/pkg/api" @@ -339,6 +340,11 @@ func (c *ContainerScriptHarness) Provision(ctx context.Context, agentName, agent } } + // Stage capture_auth.py and capture-auth-config.json into the bundle. + if err := c.stageCaptureAuthConfig(agentHome); err != nil { + return fmt.Errorf("stage capture-auth assets: %w", err) + } + // Copy dialect.yaml if present. dialectSrc := filepath.Join(c.configDirPath, "dialect.yaml") if fileExistsHelper(dialectSrc) { @@ -528,6 +534,13 @@ func (c *ContainerScriptHarness) ApplyTelemetrySettings(agentHome string, teleme return c.stageInputFile(agentHome, "telemetry.json", data) } +// stageCaptureAuthConfig delegates to the shared StageCaptureAuthAssets +// helper to generate inputs/capture-auth-config.json from the harness +// config's auth.types.*.required_files declarations. +func (c *ContainerScriptHarness) stageCaptureAuthConfig(agentHome string) error { + return StageCaptureAuthAssets(agentHome, c.configDirPath, c.entry.Auth) +} + // stageInputFile writes content under agent_home/.scion/harness/inputs/. // Inputs are not secrets; mode 0644 is fine. func (c *ContainerScriptHarness) stageInputFile(agentHome, name string, content []byte) error { @@ -629,3 +642,87 @@ func expandEnvTemplate(value, agentName, agentHome, unixUsername string) string } return out } + +// StageCaptureAuthAssets stages capture_auth.py and its config file into the +// harness bundle directory at agentHome/.scion/harness/. This is a shared +// helper called by both container-script and builtin harness Provision methods +// so the capture script is available at a known path in the container. +// +// configDirPath is the harness-config directory containing capture_auth.py. +// authMeta provides the required_files declarations used to generate the +// capture-auth-config.json input. +func StageCaptureAuthAssets(agentHome, configDirPath string, authMeta *config.HarnessAuthMetadata) error { + bundleDir := filepath.Join(agentHome, ".scion", "harness") + inputsDir := filepath.Join(bundleDir, "inputs") + + for _, dir := range []string{bundleDir, inputsDir} { + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("create dir %q: %w", dir, err) + } + } + + captureAuthSrc := filepath.Join(configDirPath, "capture_auth.py") + if fileExistsHelper(captureAuthSrc) { + dst := filepath.Join(bundleDir, "capture_auth.py") + if err := copyHarnessConfigFile(captureAuthSrc, dst); err != nil { + return fmt.Errorf("stage capture_auth.py: %w", err) + } + if err := os.Chmod(dst, 0755); err != nil { + return fmt.Errorf("chmod capture_auth.py: %w", err) + } + } + + if authMeta == nil || len(authMeta.Types) == 0 { + return nil + } + + type credEntry struct { + Key string `json:"key"` + Source string `json:"source"` + Type string `json:"type"` + Target string `json:"target"` + } + + var creds []credEntry + for _, authType := range authMeta.Types { + for _, rf := range authType.RequiredFiles { + // Entries with empty TargetSuffix (e.g. gcloud-adc) are intentionally + // excluded — these credentials come from well-known system paths and don't + // use the suffix-based source derivation. + if rf.Name == "" || rf.TargetSuffix == "" { + continue + } + fileType := rf.Type + if fileType == "" { + fileType = "file" + } + suffix := rf.TargetSuffix + if !strings.HasPrefix(suffix, "/") { + suffix = "/" + suffix + } + source := "~" + suffix + creds = append(creds, credEntry{ + Key: rf.Name, + Source: source, + Type: fileType, + Target: source, + }) + } + } + + if len(creds) == 0 { + return nil + } + + sort.Slice(creds, func(i, j int) bool { return creds[i].Key < creds[j].Key }) + + payload := map[string]interface{}{ + "schema_version": 1, + "credentials": creds, + } + data, err := json.MarshalIndent(payload, "", " ") + if err != nil { + return fmt.Errorf("marshal capture-auth config: %w", err) + } + return os.WriteFile(filepath.Join(inputsDir, "capture-auth-config.json"), data, 0644) +} diff --git a/pkg/harness/gemini/embeds/capture_auth.py b/pkg/harness/gemini/embeds/capture_auth.py new file mode 100644 index 000000000..c7f10eb83 --- /dev/null +++ b/pkg/harness/gemini/embeds/capture_auth.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Gemini capture-auth script. + +Scans for credential files on disk and stores them as project-scoped secrets +via `sciontool secret set`. Designed to run after the user authenticates +interactively inside a no-auth agent container. + +Reads credential mappings from inputs/capture-auth-config.json (derived from +the harness config.yaml's auth.types.*.required_files declarations). This +avoids hardcoding paths or key names in the script. + +Exit codes: + 0 = at least one credential captured + 1 = error + 2 = no credentials found (not an error, but nothing was stored) +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +from typing import Any + +EXIT_OK = 0 +EXIT_ERROR = 1 +EXIT_NO_CREDS = 2 + +HARNESS_BUNDLE = os.path.join( + os.environ.get("HOME") or os.path.expanduser("~"), + ".scion", "harness", +) + + +def _expand(path: str) -> str: + return os.path.expanduser(os.path.expandvars(path)) + + +def _load_config(bundle: str) -> list[dict[str, Any]]: + config_path = os.path.join(bundle, "inputs", "capture-auth-config.json") + if not os.path.isfile(config_path): + return [] + with open(config_path, "r", encoding="utf-8") as f: + try: + data = json.load(f) + except (json.JSONDecodeError, OSError): + return [] + creds = data.get("credentials") + if not isinstance(creds, list): + return [] + return creds + + +def _capture_one( + entry: dict[str, Any], force: bool +) -> tuple[bool, str | None]: + """Attempt to capture a single credential. Returns (success, error_msg).""" + key = entry.get("key", "") + source = _expand(entry.get("source", "")) + secret_type = entry.get("type", "file") + target = entry.get("target", "") + + if not key or not source: + return False, f"invalid entry: missing key or source" + + if not os.path.isfile(source): + return False, None + + cmd = [ + "sciontool", "secret", "set", key, f"@{source}", + "--type", secret_type, + "--target", target, + ] + if force: + cmd.append("--force") + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=30, + ) + except FileNotFoundError: + return False, "sciontool not found in PATH" + except subprocess.TimeoutExpired: + return False, f"sciontool timed out for key {key}" + + if result.returncode != 0: + stderr = result.stderr.strip() + return False, f"sciontool failed for {key}: {stderr}" + + return True, None + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Capture auth credentials and store as project secrets" + ) + parser.add_argument( + "--force", + action="store_true", + help="Overwrite existing secrets", + ) + parser.add_argument( + "--bundle", + default=HARNESS_BUNDLE, + help="Path to harness bundle directory", + ) + args = parser.parse_args() + + entries = _load_config(args.bundle) + if not entries: + print( + "capture-auth: no credential mappings found in " + "inputs/capture-auth-config.json", + file=sys.stderr, + ) + return EXIT_NO_CREDS + + captured = 0 + errors = 0 + + for entry in entries: + key = entry.get("key", "") + source = entry.get("source", "") + expanded = _expand(source) if source else "" + + if not expanded or not os.path.isfile(expanded): + print(f"capture-auth: {key}: source not found ({source})") + continue + + ok, err = _capture_one(entry, args.force) + if err: + print(f"capture-auth: {key}: {err}", file=sys.stderr) + errors += 1 + elif ok: + print(f"capture-auth: {key}: captured from {source}") + captured += 1 + + if errors > 0 and captured == 0: + return EXIT_ERROR + + if captured == 0: + print("capture-auth: no credentials found to capture") + return EXIT_NO_CREDS + + print(f"capture-auth: {captured} credential(s) captured successfully") + return EXIT_OK + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pkg/harness/gemini/embeds/config.yaml b/pkg/harness/gemini/embeds/config.yaml index 2ba82d634..1ee301529 100644 --- a/pkg/harness/gemini/embeds/config.yaml +++ b/pkg/harness/gemini/embeds/config.yaml @@ -51,6 +51,12 @@ capabilities: auth_file: { support: "yes" } oauth_token: { support: "no" } vertex_ai: { support: "yes" } +no_auth: + behavior: drop-to-shell + message: | + This agent started without credentials. + Run your Gemini authentication setup. + Then run: python3 /home/scion/.scion/harness/capture_auth.py auth: default_type: api-key types: @@ -63,6 +69,7 @@ auth: type: file description: "Gemini personal OAuth credentials file" target_suffix: "/.gemini/oauth_creds.json" + field: OAuthCreds vertex-ai: required_env: - any_of: ["GOOGLE_CLOUD_PROJECT"] @@ -71,6 +78,7 @@ auth: - name: gcloud-adc type: file description: "Google Cloud Application Default Credentials (ADC) file for vertex-ai authentication" + field: GoogleAppCredentials alternative_env_keys: ["GOOGLE_APPLICATION_CREDENTIALS"] skipped_when_gcp_service_account_assigned: true required: true diff --git a/pkg/hub/discord_link.go b/pkg/hub/discord_link.go index cbc10b3d5..b0f67caf3 100644 --- a/pkg/hub/discord_link.go +++ b/pkg/hub/discord_link.go @@ -15,26 +15,30 @@ package hub import ( - "context" - "errors" "log/slog" "net" "net/http" "strings" "sync" "time" - - "github.com/GoogleCloudPlatform/scion/pkg/store" ) const discordLinkCodeTTL = 15 * time.Minute +// discordPendingLink holds state for a pending Discord account linking. +type discordPendingLink struct { + Code string + DiscordUserID string + ExpiresAt time.Time + Status string // "pending", "confirmed" + UserID string + UserEmail string +} + // DiscordLinkService manages pending Discord account link codes. -// Pending codes are stored in the database so that a code registered on one -// hub instance can be verified on another (HA mode). -// IP-based rate limiting remains in-memory (per-instance by design). type DiscordLinkService struct { - store store.DiscordPendingLinkStore + mu sync.Mutex + pending map[string]*discordPendingLink // code → pending link verifyMu sync.Mutex verifyLimiters map[string]*tokenBucket // IP → token bucket @@ -43,93 +47,88 @@ type DiscordLinkService struct { done chan struct{} } -// NewDiscordLinkService creates a new DiscordLinkService backed by the given -// store and starts a background goroutine that periodically removes expired -// entries. -func NewDiscordLinkService(s store.DiscordPendingLinkStore) *DiscordLinkService { - svc := &DiscordLinkService{ - store: s, +// NewDiscordLinkService creates a new DiscordLinkService and starts +// a background goroutine that periodically removes expired entries. +func NewDiscordLinkService() *DiscordLinkService { + s := &DiscordLinkService{ + pending: make(map[string]*discordPendingLink), verifyLimiters: make(map[string]*tokenBucket), done: make(chan struct{}), } - go svc.cleanupLoop() - return svc + go s.cleanupLoop() + return s } // RegisterCode stores a pending link code from the Discord plugin. func (s *DiscordLinkService) RegisterCode(code, discordUserID string) { - ctx := context.Background() - upperCode := strings.ToUpper(code) + s.mu.Lock() + defer s.mu.Unlock() // Remove any existing pending code for this discord user. - if err := s.store.DeleteDiscordPendingLinksByDiscordUser(ctx, discordUserID); err != nil { - slog.Error("discord link: failed to delete existing links", "error", err) + for c, p := range s.pending { + if p.DiscordUserID == discordUserID { + delete(s.pending, c) + } } - link := &store.DiscordPendingLink{ - Code: upperCode, + s.pending[strings.ToUpper(code)] = &discordPendingLink{ + Code: strings.ToUpper(code), DiscordUserID: discordUserID, - Status: "pending", ExpiresAt: time.Now().Add(discordLinkCodeTTL), - } - if err := s.store.CreateDiscordPendingLink(ctx, link); err != nil { - slog.Error("discord link: failed to create pending link", "error", err) + Status: "pending", } } // VerifyCode attempts to confirm a pending link code with the given user. // Returns the discordUserID on success, or empty string with a reason. -func (s *DiscordLinkService) VerifyCode(ctx context.Context, code, userID, userEmail string) (discordUserID string, err string) { - upperCode := strings.ToUpper(code) +func (s *DiscordLinkService) VerifyCode(code, userID, userEmail string) (discordUserID string, err string) { + s.mu.Lock() + defer s.mu.Unlock() - link, dbErr := s.store.GetDiscordPendingLinkByCode(ctx, upperCode) - if dbErr != nil { - if errors.Is(dbErr, store.ErrNotFound) { - return "", "code_not_found" - } - slog.Error("discord link: failed to get pending link", "error", dbErr) + p, ok := s.pending[strings.ToUpper(code)] + if !ok { return "", "code_not_found" } - if time.Now().After(link.ExpiresAt) { - _ = s.store.DeleteDiscordPendingLink(ctx, upperCode) + if time.Now().After(p.ExpiresAt) { + delete(s.pending, strings.ToUpper(code)) return "", "code_expired" } - if link.Status == "confirmed" { - return link.DiscordUserID, "" + if p.Status == "confirmed" { + return p.DiscordUserID, "" } - link.Status = "confirmed" - link.UserID = userID - link.UserEmail = userEmail - if dbErr := s.store.UpdateDiscordPendingLink(ctx, link); dbErr != nil { - if errors.Is(dbErr, store.ErrVersionConflict) { - return "", "code_not_found" - } - slog.Error("discord link: failed to update pending link", "error", dbErr) - return "", "code_not_found" - } - return link.DiscordUserID, "" + p.Status = "confirmed" + p.UserID = userID + p.UserEmail = userEmail + return p.DiscordUserID, "" } // GetStatusByDiscordUser returns the linking status for a given Discord user ID. func (s *DiscordLinkService) GetStatusByDiscordUser(discordUserID string) (status, userID, userEmail string) { - ctx := context.Background() + s.mu.Lock() + defer s.mu.Unlock() - link, err := s.store.GetDiscordPendingLinkByDiscordUser(ctx, discordUserID) - if err != nil { - return "not_found", "", "" - } - if time.Now().After(link.ExpiresAt) { - return "expired", "", "" + for _, p := range s.pending { + if p.DiscordUserID == discordUserID { + if time.Now().After(p.ExpiresAt) { + return "expired", "", "" + } + return p.Status, p.UserID, p.UserEmail + } } - return link.Status, link.UserID, link.UserEmail + return "not_found", "", "" } // ConsumePending removes a confirmed entry so it isn't returned again. func (s *DiscordLinkService) ConsumePending(discordUserID string) { - ctx := context.Background() - if err := s.store.DeleteDiscordPendingLinksByDiscordUser(ctx, discordUserID); err != nil { - slog.Error("discord link: failed to consume pending link", "error", err) + s.mu.Lock() + defer s.mu.Unlock() + + for code, p := range s.pending { + if p.DiscordUserID == discordUserID { + delete(s.pending, code) + return + } } } @@ -178,16 +177,17 @@ func (s *DiscordLinkService) cleanupLoop() { case <-s.done: return case <-ticker.C: - ctx := context.Background() + now := time.Now() - if n, err := s.store.DeleteExpiredDiscordPendingLinks(ctx); err != nil { - slog.Error("discord link: failed to cleanup expired links", "error", err) - } else if n > 0 { - slog.Debug("discord link: cleaned up expired links", "count", n) + s.mu.Lock() + for code, p := range s.pending { + if now.After(p.ExpiresAt) { + delete(s.pending, code) + } } + s.mu.Unlock() // Clean up stale verify rate limiter entries. - now := time.Now() s.verifyMu.Lock() cutoff := now.Add(-30 * time.Minute) for ip, b := range s.verifyLimiters { @@ -288,7 +288,7 @@ func (s *Server) handleDiscordLinkVerify(w http.ResponseWriter, r *http.Request) return } - discordUserID, errReason := s.discordLinkService.VerifyCode(r.Context(), req.Code, user.ID(), user.Email()) + discordUserID, errReason := s.discordLinkService.VerifyCode(req.Code, user.ID(), user.Email()) if errReason != "" { switch errReason { case "code_not_found": diff --git a/pkg/hub/discord_link_test.go b/pkg/hub/discord_link_test.go deleted file mode 100644 index 3d2166b64..000000000 --- a/pkg/hub/discord_link_test.go +++ /dev/null @@ -1,240 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package hub - -import ( - "context" - "sync" - "testing" - "time" - - "github.com/GoogleCloudPlatform/scion/pkg/store" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// memDiscordPendingLinkStore is an in-memory implementation of -// store.DiscordPendingLinkStore for testing the DiscordLinkService without -// a database. -type memDiscordPendingLinkStore struct { - mu sync.Mutex - links map[string]*store.DiscordPendingLink // code → link - seq int -} - -func newMemDiscordPendingLinkStore() *memDiscordPendingLinkStore { - return &memDiscordPendingLinkStore{ - links: make(map[string]*store.DiscordPendingLink), - } -} - -func (m *memDiscordPendingLinkStore) CreateDiscordPendingLink(_ context.Context, link *store.DiscordPendingLink) error { - m.mu.Lock() - defer m.mu.Unlock() - if _, exists := m.links[link.Code]; exists { - return store.ErrAlreadyExists - } - m.seq++ - if link.ID == "" { - link.ID = "mem-" + link.Code - } - m.links[link.Code] = link - return nil -} - -func (m *memDiscordPendingLinkStore) GetDiscordPendingLinkByCode(_ context.Context, code string) (*store.DiscordPendingLink, error) { - m.mu.Lock() - defer m.mu.Unlock() - l, ok := m.links[code] - if !ok { - return nil, store.ErrNotFound - } - return l, nil -} - -func (m *memDiscordPendingLinkStore) GetDiscordPendingLinkByDiscordUser(_ context.Context, discordUserID string) (*store.DiscordPendingLink, error) { - m.mu.Lock() - defer m.mu.Unlock() - for _, l := range m.links { - if l.DiscordUserID == discordUserID { - return l, nil - } - } - return nil, store.ErrNotFound -} - -func (m *memDiscordPendingLinkStore) UpdateDiscordPendingLink(_ context.Context, link *store.DiscordPendingLink) error { - m.mu.Lock() - defer m.mu.Unlock() - for code, l := range m.links { - if l.ID == link.ID { - link.Code = code - m.links[code] = link - return nil - } - } - return store.ErrNotFound -} - -func (m *memDiscordPendingLinkStore) DeleteDiscordPendingLink(_ context.Context, code string) error { - m.mu.Lock() - defer m.mu.Unlock() - delete(m.links, code) - return nil -} - -func (m *memDiscordPendingLinkStore) DeleteDiscordPendingLinksByDiscordUser(_ context.Context, discordUserID string) error { - m.mu.Lock() - defer m.mu.Unlock() - for code, l := range m.links { - if l.DiscordUserID == discordUserID { - delete(m.links, code) - } - } - return nil -} - -func (m *memDiscordPendingLinkStore) DeleteExpiredDiscordPendingLinks(_ context.Context) (int, error) { - m.mu.Lock() - defer m.mu.Unlock() - n := 0 - now := time.Now() - for code, l := range m.links { - if now.After(l.ExpiresAt) { - delete(m.links, code) - n++ - } - } - return n, nil -} - -func newTestDiscordLinkService() (*DiscordLinkService, *memDiscordPendingLinkStore) { - ms := newMemDiscordPendingLinkStore() - svc := NewDiscordLinkService(ms) - return svc, ms -} - -func TestDiscordLinkService_RegisterAndVerify(t *testing.T) { - svc, _ := newTestDiscordLinkService() - defer svc.Close() - - svc.RegisterCode("abc123", "discord-user-1") - - discordUserID, errReason := svc.VerifyCode(context.Background(), "ABC123", "user-1", "user1@example.com") - assert.Empty(t, errReason) - assert.Equal(t, "discord-user-1", discordUserID) -} - -func TestDiscordLinkService_VerifyNotFound(t *testing.T) { - svc, _ := newTestDiscordLinkService() - defer svc.Close() - - _, errReason := svc.VerifyCode(context.Background(), "NONEXISTENT", "user-1", "user1@example.com") - assert.Equal(t, "code_not_found", errReason) -} - -func TestDiscordLinkService_VerifyExpired(t *testing.T) { - svc, ms := newTestDiscordLinkService() - defer svc.Close() - - svc.RegisterCode("exp001", "discord-user-2") - - // Manually expire the link. - ms.mu.Lock() - ms.links["EXP001"].ExpiresAt = time.Now().Add(-1 * time.Minute) - ms.mu.Unlock() - - _, errReason := svc.VerifyCode(context.Background(), "EXP001", "user-2", "user2@example.com") - assert.Equal(t, "code_expired", errReason) -} - -func TestDiscordLinkService_RegisterReplacesExisting(t *testing.T) { - svc, _ := newTestDiscordLinkService() - defer svc.Close() - - svc.RegisterCode("first", "discord-user-3") - svc.RegisterCode("second", "discord-user-3") - - // First code should be gone. - _, errReason := svc.VerifyCode(context.Background(), "FIRST", "user-3", "user3@example.com") - assert.Equal(t, "code_not_found", errReason) - - // Second code should work. - discordUserID, errReason := svc.VerifyCode(context.Background(), "SECOND", "user-3", "user3@example.com") - assert.Empty(t, errReason) - assert.Equal(t, "discord-user-3", discordUserID) -} - -func TestDiscordLinkService_GetStatusByDiscordUser(t *testing.T) { - svc, _ := newTestDiscordLinkService() - defer svc.Close() - - // Not found before registration. - status, _, _ := svc.GetStatusByDiscordUser("discord-user-4") - assert.Equal(t, "not_found", status) - - svc.RegisterCode("stat001", "discord-user-4") - - // Pending after registration. - status, _, _ = svc.GetStatusByDiscordUser("discord-user-4") - assert.Equal(t, "pending", status) - - // Confirmed after verification. - svc.VerifyCode(context.Background(), "STAT001", "user-4", "user4@example.com") - status, userID, userEmail := svc.GetStatusByDiscordUser("discord-user-4") - assert.Equal(t, "confirmed", status) - assert.Equal(t, "user-4", userID) - assert.Equal(t, "user4@example.com", userEmail) -} - -func TestDiscordLinkService_ConsumePending(t *testing.T) { - svc, _ := newTestDiscordLinkService() - defer svc.Close() - - svc.RegisterCode("con001", "discord-user-5") - svc.VerifyCode(context.Background(), "CON001", "user-5", "user5@example.com") - svc.ConsumePending("discord-user-5") - - status, _, _ := svc.GetStatusByDiscordUser("discord-user-5") - assert.Equal(t, "not_found", status) -} - -func TestDiscordLinkService_AllowVerify_RateLimit(t *testing.T) { - svc, _ := newTestDiscordLinkService() - defer svc.Close() - - ip := "192.0.2.1" - // Should allow the first verifyBurst attempts. - for i := 0; i < verifyBurst; i++ { - require.True(t, svc.AllowVerify(ip), "attempt %d should be allowed", i) - } - // Next attempt should be rate limited. - assert.False(t, svc.AllowVerify(ip)) -} - -func TestDiscordLinkService_VerifyAlreadyConfirmed(t *testing.T) { - svc, _ := newTestDiscordLinkService() - defer svc.Close() - - svc.RegisterCode("conf001", "discord-user-6") - discordUserID, errReason := svc.VerifyCode(context.Background(), "CONF001", "user-6", "user6@example.com") - assert.Empty(t, errReason) - assert.Equal(t, "discord-user-6", discordUserID) - - // Verify again should return the confirmed discord user ID. - discordUserID, errReason = svc.VerifyCode(context.Background(), "CONF001", "user-other", "other@example.com") - assert.Empty(t, errReason) - assert.Equal(t, "discord-user-6", discordUserID) -} diff --git a/pkg/hub/gcp_metrics.go b/pkg/hub/gcp_metrics.go index 0c91c4a4c..0cbcd0077 100644 --- a/pkg/hub/gcp_metrics.go +++ b/pkg/hub/gcp_metrics.go @@ -20,6 +20,14 @@ import ( "time" ) +// GCPTokenMetricsRecorder is the interface for recording GCP token metrics. +type GCPTokenMetricsRecorder interface { + RecordAccessTokenRequest(success bool, latency time.Duration) + RecordIDTokenRequest(success bool, latency time.Duration) + RecordRateLimitRejection() + GetSnapshot() *GCPTokenMetricsSnapshot +} + // GCPTokenMetrics tracks metrics for GCP token operations. type GCPTokenMetrics struct { // Access token counters diff --git a/pkg/hub/grpc_broker_adapter.go b/pkg/hub/grpc_broker_adapter.go deleted file mode 100644 index 2d4908dad..000000000 --- a/pkg/hub/grpc_broker_adapter.go +++ /dev/null @@ -1,329 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package hub - -import ( - "context" - "fmt" - "log/slog" - "sync" - "time" - - "github.com/GoogleCloudPlatform/scion/pkg/eventbus" - "github.com/GoogleCloudPlatform/scion/pkg/messages" - brokerv1 "github.com/GoogleCloudPlatform/scion/proto/broker/v1" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" - "google.golang.org/grpc/keepalive" -) - -// GRPCBrokerAdapter implements eventbus.EventBus by forwarding calls over gRPC -// to a standalone broker service. -type GRPCBrokerAdapter struct { - client brokerv1.BrokerServiceClient - conn *grpc.ClientConn - address string - channel string - log *slog.Logger - - mu sync.RWMutex - subs map[string]eventbus.EventHandler - closed bool - lastReconnectAt time.Time -} - -// NewGRPCBrokerAdapter dials the broker gRPC service at address and returns an -// adapter that satisfies eventbus.EventBus. -func NewGRPCBrokerAdapter(address string, channel string, log *slog.Logger) (*GRPCBrokerAdapter, error) { - conn, err := grpc.NewClient(address, - grpc.WithTransportCredentials(insecure.NewCredentials()), - grpc.WithKeepaliveParams(keepalive.ClientParameters{ - Time: 30 * time.Second, - Timeout: 10 * time.Second, - PermitWithoutStream: true, - }), - ) - if err != nil { - return nil, fmt.Errorf("grpc dial %s: %w", address, err) - } - - return &GRPCBrokerAdapter{ - client: brokerv1.NewBrokerServiceClient(conn), - conn: conn, - address: address, - channel: channel, - log: log.With("component", "grpc-broker-adapter", "address", address), - subs: make(map[string]eventbus.EventHandler), - }, nil -} - -// Configure sends a configuration map to the remote broker via gRPC. -func (a *GRPCBrokerAdapter) Configure(config map[string]string) error { - a.mu.RLock() - if a.closed { - a.mu.RUnlock() - return fmt.Errorf("adapter is closed") - } - client := a.client - a.mu.RUnlock() - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - _, err := client.Configure(ctx, &brokerv1.ConfigureRequest{Config: config}) - cancel() - if err != nil { - a.log.Warn("Configure failed, attempting reconnect", "error", err) - if reconnErr := a.tryReconnect(); reconnErr != nil { - return fmt.Errorf("configure failed: %w (reconnect also failed: %v)", err, reconnErr) - } - a.mu.RLock() - client = a.client - a.mu.RUnlock() - ctx2, cancel2 := context.WithTimeout(context.Background(), 10*time.Second) - _, err = client.Configure(ctx2, &brokerv1.ConfigureRequest{Config: config}) - cancel2() - } - return err -} - -// Publish converts the StructuredMessage to its proto representation and sends -// it to the remote broker via gRPC. -func (a *GRPCBrokerAdapter) Publish(ctx context.Context, topic string, msg *messages.StructuredMessage) error { - a.mu.RLock() - if a.closed { - a.mu.RUnlock() - return fmt.Errorf("adapter is closed") - } - client := a.client - a.mu.RUnlock() - - req := structuredMessageToPublishRequest(topic, msg) - _, err := client.Publish(ctx, req) - if err != nil { - a.log.Warn("Publish failed", "topic", topic, "error", err) - if reconnErr := a.tryReconnect(); reconnErr != nil { - return fmt.Errorf("publish failed: %w (reconnect also failed: %v)", err, reconnErr) - } - a.mu.RLock() - client = a.client - a.mu.RUnlock() - _, err = client.Publish(ctx, req) - } - return err -} - -// Subscribe stores the handler locally and tells the remote broker to start -// listening for the given pattern. Inbound delivery is via HTTP POST to the -// hub API, not via the handler callback. -func (a *GRPCBrokerAdapter) Subscribe(pattern string, handler eventbus.EventHandler) (eventbus.Subscription, error) { - a.mu.Lock() - defer a.mu.Unlock() - - if a.closed { - return nil, fmt.Errorf("adapter is closed") - } - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - _, err := a.client.Subscribe(ctx, &brokerv1.SubscribeRequest{Pattern: pattern}) - cancel() - if err != nil { - a.log.Warn("Subscribe failed, attempting reconnect", "pattern", pattern, "error", err) - a.mu.Unlock() - reconnErr := a.tryReconnect() - a.mu.Lock() - if reconnErr != nil { - return nil, fmt.Errorf("subscribe failed: %w (reconnect also failed: %v)", err, reconnErr) - } - ctx2, cancel2 := context.WithTimeout(context.Background(), 10*time.Second) - _, err = a.client.Subscribe(ctx2, &brokerv1.SubscribeRequest{Pattern: pattern}) - cancel2() - if err != nil { - return nil, err - } - } - - a.subs[pattern] = handler - return &grpcSubscription{adapter: a, pattern: pattern}, nil -} - -// Close shuts down the gRPC connection. -func (a *GRPCBrokerAdapter) Close() error { - a.mu.Lock() - defer a.mu.Unlock() - a.closed = true - return a.conn.Close() -} - -// tryReconnect re-establishes the gRPC connection and re-subscribes all active -// patterns on the new connection. It guards against thundering-herd reconnects -// by skipping if another goroutine already reconnected or if the last reconnect -// was within the past 5 seconds. -func (a *GRPCBrokerAdapter) tryReconnect() error { - a.mu.Lock() - if time.Since(a.lastReconnectAt) < 5*time.Second { - a.mu.Unlock() - a.log.Debug("Skipping reconnect, another goroutine reconnected recently") - return nil - } - failedConn := a.conn - a.mu.Unlock() - - a.log.Info("Attempting reconnect to broker service") - - conn, err := grpc.NewClient(a.address, - grpc.WithTransportCredentials(insecure.NewCredentials()), - grpc.WithKeepaliveParams(keepalive.ClientParameters{ - Time: 30 * time.Second, - Timeout: 10 * time.Second, - PermitWithoutStream: true, - }), - ) - if err != nil { - return fmt.Errorf("reconnect dial failed: %w", err) - } - - a.mu.Lock() - if a.conn != failedConn { - a.mu.Unlock() - _ = conn.Close() - a.log.Debug("Skipping reconnect, connection already replaced by another goroutine") - return nil - } - oldConn := a.conn - a.conn = conn - a.client = brokerv1.NewBrokerServiceClient(conn) - client := a.client - a.lastReconnectAt = time.Now() - patterns := make([]string, 0, len(a.subs)) - for p := range a.subs { - patterns = append(patterns, p) - } - a.mu.Unlock() - - if oldConn != nil { - _ = oldConn.Close() - } - - for _, pattern := range patterns { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - if _, subErr := client.Subscribe(ctx, &brokerv1.SubscribeRequest{Pattern: pattern}); subErr != nil { - a.log.Warn("Failed to re-subscribe after reconnect", "pattern", pattern, "error", subErr) - } - cancel() - } - - a.log.Info("Successfully reconnected to broker service", "resubscribed", len(patterns)) - return nil -} - -// structuredMessageToPublishRequest converts a messages.StructuredMessage and -// topic into a brokerv1.PublishRequest. -func structuredMessageToPublishRequest(topic string, msg *messages.StructuredMessage) *brokerv1.PublishRequest { - if msg == nil { - return &brokerv1.PublishRequest{Topic: topic} - } - return &brokerv1.PublishRequest{ - Topic: topic, - Message: structuredMessageToProto(msg), - } -} - -func structuredMessageToProto(msg *messages.StructuredMessage) *brokerv1.StructuredMessage { - pm := &brokerv1.StructuredMessage{ - Version: int32(msg.Version), - Timestamp: msg.Timestamp, - Sender: msg.Sender, - SenderId: msg.SenderID, - Recipient: msg.Recipient, - RecipientId: msg.RecipientID, - Recipients: msg.Recipients, - Msg: msg.Msg, - Type: msg.Type, - Plain: msg.Plain, - Raw: msg.Raw, - Urgent: msg.Urgent, - Broadcasted: msg.Broadcasted, - ObserverOnly: msg.ObserverOnly, - Status: msg.Status, - Channel: msg.Channel, - ThreadId: msg.ThreadID, - Visibility: msg.Visibility, - } - if len(msg.Attachments) > 0 { - pm.Attachments = make([]string, len(msg.Attachments)) - copy(pm.Attachments, msg.Attachments) - } - if len(msg.Metadata) > 0 { - pm.Metadata = make(map[string]string, len(msg.Metadata)) - for k, v := range msg.Metadata { - pm.Metadata[k] = v - } - } - return pm -} - -// protoToStructuredMessage converts a brokerv1.StructuredMessage back to the -// internal messages.StructuredMessage type. -func protoToStructuredMessage(pm *brokerv1.StructuredMessage) *messages.StructuredMessage { - if pm == nil { - return nil - } - msg := &messages.StructuredMessage{ - Version: int(pm.Version), - Timestamp: pm.Timestamp, - Sender: pm.Sender, - SenderID: pm.SenderId, - Recipient: pm.Recipient, - RecipientID: pm.RecipientId, - Recipients: pm.Recipients, - Msg: pm.Msg, - Type: pm.Type, - Plain: pm.Plain, - Raw: pm.Raw, - Urgent: pm.Urgent, - Broadcasted: pm.Broadcasted, - ObserverOnly: pm.ObserverOnly, - Status: pm.Status, - Channel: pm.Channel, - ThreadID: pm.ThreadId, - Visibility: pm.Visibility, - } - if len(pm.Attachments) > 0 { - msg.Attachments = make([]string, len(pm.Attachments)) - copy(msg.Attachments, pm.Attachments) - } - if len(pm.Metadata) > 0 { - msg.Metadata = make(map[string]string, len(pm.Metadata)) - for k, v := range pm.Metadata { - msg.Metadata[k] = v - } - } - return msg -} - -// grpcSubscription implements eventbus.Subscription for the GRPCBrokerAdapter. -type grpcSubscription struct { - adapter *GRPCBrokerAdapter - pattern string -} - -func (s *grpcSubscription) Unsubscribe() error { - s.adapter.mu.Lock() - defer s.adapter.mu.Unlock() - delete(s.adapter.subs, s.pattern) - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - _, err := s.adapter.client.Unsubscribe(ctx, &brokerv1.UnsubscribeRequest{Pattern: s.pattern}) - return err -} diff --git a/pkg/hub/grpc_broker_adapter_test.go b/pkg/hub/grpc_broker_adapter_test.go deleted file mode 100644 index b9373512f..000000000 --- a/pkg/hub/grpc_broker_adapter_test.go +++ /dev/null @@ -1,308 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package hub - -import ( - "context" - "log/slog" - "net" - "testing" - - "github.com/GoogleCloudPlatform/scion/pkg/eventbus" - "github.com/GoogleCloudPlatform/scion/pkg/messages" - brokerv1 "github.com/GoogleCloudPlatform/scion/proto/broker/v1" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" -) - -// fakeBrokerServer records calls for assertions. -type fakeBrokerServer struct { - brokerv1.UnimplementedBrokerServiceServer - published []*brokerv1.PublishRequest - subscribed []string - unsubscribed []string -} - -func (s *fakeBrokerServer) Publish(_ context.Context, req *brokerv1.PublishRequest) (*brokerv1.PublishResponse, error) { - s.published = append(s.published, req) - return &brokerv1.PublishResponse{}, nil -} - -func (s *fakeBrokerServer) Subscribe(_ context.Context, req *brokerv1.SubscribeRequest) (*brokerv1.SubscribeResponse, error) { - s.subscribed = append(s.subscribed, req.GetPattern()) - return &brokerv1.SubscribeResponse{}, nil -} - -func (s *fakeBrokerServer) Unsubscribe(_ context.Context, req *brokerv1.UnsubscribeRequest) (*brokerv1.UnsubscribeResponse, error) { - s.unsubscribed = append(s.unsubscribed, req.GetPattern()) - return &brokerv1.UnsubscribeResponse{}, nil -} - -func startFakeServer(t *testing.T) (*fakeBrokerServer, string) { - t.Helper() - lis, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatal(err) - } - srv := grpc.NewServer() - fake := &fakeBrokerServer{} - brokerv1.RegisterBrokerServiceServer(srv, fake) - go func() { _ = srv.Serve(lis) }() - t.Cleanup(srv.GracefulStop) - return fake, lis.Addr().String() -} - -func newTestAdapter(t *testing.T, addr string) *GRPCBrokerAdapter { - t.Helper() - conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials())) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = conn.Close() }) - return &GRPCBrokerAdapter{ - client: brokerv1.NewBrokerServiceClient(conn), - conn: conn, - address: addr, - channel: "test-channel", - log: slog.Default(), - subs: make(map[string]eventbus.EventHandler), - } -} - -func TestGRPCBrokerAdapter_Publish(t *testing.T) { - fake, addr := startFakeServer(t) - adapter := newTestAdapter(t, addr) - - msg := &messages.StructuredMessage{ - Version: 1, - Sender: "alice", - SenderID: "a1", - Recipient: "bob", - Msg: "hello", - Type: "instruction", - Plain: true, - Channel: "test-channel", - Metadata: map[string]string{"key": "val"}, - Attachments: []string{"att1"}, - } - - if err := adapter.Publish(context.Background(), "chat.message", msg); err != nil { - t.Fatalf("Publish: %v", err) - } - - if len(fake.published) != 1 { - t.Fatalf("expected 1 publish, got %d", len(fake.published)) - } - req := fake.published[0] - if req.Topic != "chat.message" { - t.Errorf("topic = %q, want %q", req.Topic, "chat.message") - } - pm := req.Message - if pm.Sender != "alice" { - t.Errorf("sender = %q, want %q", pm.Sender, "alice") - } - if pm.SenderId != "a1" { - t.Errorf("sender_id = %q, want %q", pm.SenderId, "a1") - } - if pm.Msg != "hello" { - t.Errorf("msg = %q, want %q", pm.Msg, "hello") - } - if !pm.Plain { - t.Error("plain should be true") - } - if pm.Channel != "test-channel" { - t.Errorf("channel = %q, want %q", pm.Channel, "test-channel") - } - if pm.Metadata["key"] != "val" { - t.Errorf("metadata[key] = %q, want %q", pm.Metadata["key"], "val") - } - if len(pm.Attachments) != 1 || pm.Attachments[0] != "att1" { - t.Errorf("attachments = %v, want [att1]", pm.Attachments) - } -} - -func TestGRPCBrokerAdapter_Subscribe(t *testing.T) { - fake, addr := startFakeServer(t) - adapter := newTestAdapter(t, addr) - - handler := func(_ context.Context, _ string, _ *messages.StructuredMessage) {} - sub, err := adapter.Subscribe(">", handler) - if err != nil { - t.Fatalf("Subscribe: %v", err) - } - - if len(fake.subscribed) != 1 || fake.subscribed[0] != ">" { - t.Fatalf("expected subscribe with pattern '>', got %v", fake.subscribed) - } - - adapter.mu.RLock() - _, tracked := adapter.subs[">"] - adapter.mu.RUnlock() - if !tracked { - t.Error("handler not tracked in subs map") - } - - if err := sub.Unsubscribe(); err != nil { - t.Fatalf("Unsubscribe: %v", err) - } - if len(fake.unsubscribed) != 1 || fake.unsubscribed[0] != ">" { - t.Fatalf("expected unsubscribe with pattern '>', got %v", fake.unsubscribed) - } - - adapter.mu.RLock() - _, stillTracked := adapter.subs[">"] - adapter.mu.RUnlock() - if stillTracked { - t.Error("handler still tracked after Unsubscribe") - } -} - -func TestGRPCBrokerAdapter_Close(t *testing.T) { - _, addr := startFakeServer(t) - adapter := newTestAdapter(t, addr) - - if err := adapter.Close(); err != nil { - t.Fatalf("Close: %v", err) - } - - adapter.mu.RLock() - closed := adapter.closed - adapter.mu.RUnlock() - if !closed { - t.Error("expected closed flag to be set") - } - - if err := adapter.Publish(context.Background(), "t", &messages.StructuredMessage{}); err == nil { - t.Error("expected error publishing on closed adapter") - } -} - -func TestStructuredMessageConversion_RoundTrip(t *testing.T) { - orig := &messages.StructuredMessage{ - Version: 1, - Timestamp: "2026-01-01T00:00:00Z", - Sender: "alice", - SenderID: "a1", - Recipient: "bob", - RecipientID: "b2", - Recipients: "bob,carol", - Msg: "test message", - Type: "instruction", - Plain: true, - Raw: false, - Urgent: true, - Broadcasted: false, - ObserverOnly: true, - Status: "active", - Attachments: []string{"file1.txt", "file2.txt"}, - Metadata: map[string]string{"k1": "v1", "k2": "v2"}, - Channel: "discord", - ThreadID: "thread-123", - Visibility: "normal", - } - - proto := structuredMessageToProto(orig) - back := protoToStructuredMessage(proto) - - if back.Version != orig.Version { - t.Errorf("Version: got %d, want %d", back.Version, orig.Version) - } - if back.Timestamp != orig.Timestamp { - t.Errorf("Timestamp: got %q, want %q", back.Timestamp, orig.Timestamp) - } - if back.Sender != orig.Sender { - t.Errorf("Sender: got %q, want %q", back.Sender, orig.Sender) - } - if back.SenderID != orig.SenderID { - t.Errorf("SenderID: got %q, want %q", back.SenderID, orig.SenderID) - } - if back.Recipient != orig.Recipient { - t.Errorf("Recipient: got %q, want %q", back.Recipient, orig.Recipient) - } - if back.RecipientID != orig.RecipientID { - t.Errorf("RecipientID: got %q, want %q", back.RecipientID, orig.RecipientID) - } - if back.Recipients != orig.Recipients { - t.Errorf("Recipients: got %q, want %q", back.Recipients, orig.Recipients) - } - if back.Msg != orig.Msg { - t.Errorf("Msg: got %q, want %q", back.Msg, orig.Msg) - } - if back.Type != orig.Type { - t.Errorf("Type: got %q, want %q", back.Type, orig.Type) - } - if back.Plain != orig.Plain { - t.Errorf("Plain: got %v, want %v", back.Plain, orig.Plain) - } - if back.Raw != orig.Raw { - t.Errorf("Raw: got %v, want %v", back.Raw, orig.Raw) - } - if back.Urgent != orig.Urgent { - t.Errorf("Urgent: got %v, want %v", back.Urgent, orig.Urgent) - } - if back.Broadcasted != orig.Broadcasted { - t.Errorf("Broadcasted: got %v, want %v", back.Broadcasted, orig.Broadcasted) - } - if back.ObserverOnly != orig.ObserverOnly { - t.Errorf("ObserverOnly: got %v, want %v", back.ObserverOnly, orig.ObserverOnly) - } - if back.Status != orig.Status { - t.Errorf("Status: got %q, want %q", back.Status, orig.Status) - } - if back.Channel != orig.Channel { - t.Errorf("Channel: got %q, want %q", back.Channel, orig.Channel) - } - if back.ThreadID != orig.ThreadID { - t.Errorf("ThreadID: got %q, want %q", back.ThreadID, orig.ThreadID) - } - if back.Visibility != orig.Visibility { - t.Errorf("Visibility: got %q, want %q", back.Visibility, orig.Visibility) - } - if len(back.Attachments) != len(orig.Attachments) { - t.Fatalf("Attachments length: got %d, want %d", len(back.Attachments), len(orig.Attachments)) - } - for i, a := range orig.Attachments { - if back.Attachments[i] != a { - t.Errorf("Attachments[%d]: got %q, want %q", i, back.Attachments[i], a) - } - } - if len(back.Metadata) != len(orig.Metadata) { - t.Fatalf("Metadata length: got %d, want %d", len(back.Metadata), len(orig.Metadata)) - } - for k, v := range orig.Metadata { - if back.Metadata[k] != v { - t.Errorf("Metadata[%q]: got %q, want %q", k, back.Metadata[k], v) - } - } -} - -func TestProtoToStructuredMessage_Nil(t *testing.T) { - if got := protoToStructuredMessage(nil); got != nil { - t.Errorf("expected nil, got %v", got) - } -} - -func TestPublishRequest_NilMessage(t *testing.T) { - req := structuredMessageToPublishRequest("topic", nil) - if req.Topic != "topic" { - t.Errorf("topic = %q, want %q", req.Topic, "topic") - } - if req.Message != nil { - t.Errorf("expected nil message, got %v", req.Message) - } -} - -// Verify interface compliance at compile time. -var _ eventbus.EventBus = (*GRPCBrokerAdapter)(nil) diff --git a/pkg/hub/handlers.go b/pkg/hub/handlers.go index f85bcad85..200c499db 100644 --- a/pkg/hub/handlers.go +++ b/pkg/hub/handlers.go @@ -16,6 +16,7 @@ package hub import ( "context" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -217,6 +218,9 @@ type CreateAgentRequest struct { // rather than create a brand-new one. When true and a stopped agent with // the same name exists, the Hub recovers it instead of creating fresh. Resume bool `json:"resume,omitempty"` + // NoAuth indicates the agent should start with zero injected credentials. + // When true, the Hub skips secret resolution and the broker skips credential injection. + NoAuth bool `json:"noAuth,omitempty"` // GCPIdentity specifies the GCP identity assignment for the agent. // Controls metadata server behavior and optional service account binding. GCPIdentity *GCPIdentityAssignment `json:"gcp_identity,omitempty"` @@ -7743,6 +7747,12 @@ func (s *Server) setSecret(w http.ResponseWriter, r *http.Request, key string) { return } + decoded, err := base64.StdEncoding.DecodeString(req.Value) + if err != nil { + http.Error(w, "value must be base64-encoded", http.StatusBadRequest) + return + } + // Validate and default secret type secretType := req.Type if secretType == "" { @@ -7767,6 +7777,10 @@ func (s *Server) setSecret(w http.ResponseWriter, r *http.Request, key string) { // Validate file-specific constraints if secretType == store.SecretTypeFile { + if strings.Contains(target, "..") { + http.Error(w, "target path must not contain '..'", http.StatusBadRequest) + return + } if !strings.HasPrefix(target, "/") && !strings.HasPrefix(target, "~/") { ValidationError(w, "file secret target must be an absolute path (or start with ~/)", map[string]interface{}{ "field": "target", @@ -7774,13 +7788,9 @@ func (s *Server) setSecret(w http.ResponseWriter, r *http.Request, key string) { }) return } - // Enforce 64 KiB limit for file secrets - if len(req.Value) > 64*1024 { - ValidationError(w, "file secret value exceeds 64 KiB limit", map[string]interface{}{ - "field": "value", - "limit": "65536 bytes", - "size": len(req.Value), - }) + const maxSecretSize = 64 * 1024 + if len(decoded) > maxSecretSize { + http.Error(w, "secret value exceeds 64KB limit", http.StatusBadRequest) return } } @@ -7956,6 +7966,12 @@ func (s *Server) handleAgentSecrets(w http.ResponseWriter, r *http.Request, agen return } + decoded, err := base64.StdEncoding.DecodeString(req.Value) + if err != nil { + http.Error(w, "value must be base64-encoded", http.StatusBadRequest) + return + } + // Validate and default secret type. secretType := req.Type if secretType == "" { @@ -7980,6 +7996,10 @@ func (s *Server) handleAgentSecrets(w http.ResponseWriter, r *http.Request, agen // Validate file-specific constraints. if secretType == store.SecretTypeFile { + if strings.Contains(target, "..") { + http.Error(w, "target path must not contain '..'", http.StatusBadRequest) + return + } if !strings.HasPrefix(target, "/") && !strings.HasPrefix(target, "~/") { ValidationError(w, "file secret target must be an absolute path (or start with ~/)", map[string]interface{}{ "field": "target", @@ -7987,12 +8007,9 @@ func (s *Server) handleAgentSecrets(w http.ResponseWriter, r *http.Request, agen }) return } - if (len(req.Value) * 3 / 4) > 64*1024 { - ValidationError(w, "file secret value exceeds 64 KiB limit", map[string]interface{}{ - "field": "value", - "limit": "65536 bytes", - "size": len(req.Value) * 3 / 4, - }) + const maxSecretSize = 64 * 1024 + if len(decoded) > maxSecretSize { + http.Error(w, "secret value exceeds 64KB limit", http.StatusBadRequest) return } } @@ -8445,6 +8462,11 @@ func (s *Server) handleProjectSecretByKey(w http.ResponseWriter, r *http.Request ValidationError(w, "value is required", nil) return } + decoded, err := base64.StdEncoding.DecodeString(req.Value) + if err != nil { + http.Error(w, "value must be base64-encoded", http.StatusBadRequest) + return + } secretType := req.Type if secretType == "" { secretType = store.SecretTypeEnvironment @@ -8460,12 +8482,17 @@ func (s *Server) handleProjectSecretByKey(w http.ResponseWriter, r *http.Request target = key } if secretType == store.SecretTypeFile { + if strings.Contains(target, "..") { + http.Error(w, "target path must not contain '..'", http.StatusBadRequest) + return + } if !strings.HasPrefix(target, "/") && !strings.HasPrefix(target, "~/") { ValidationError(w, "file secret target must be an absolute path (or start with ~/)", map[string]interface{}{"field": "target", "value": target}) return } - if len(req.Value) > 64*1024 { - ValidationError(w, "file secret value exceeds 64 KiB limit", map[string]interface{}{"field": "value", "limit": "65536 bytes", "size": len(req.Value)}) + const maxSecretSize = 64 * 1024 + if len(decoded) > maxSecretSize { + http.Error(w, "secret value exceeds 64KB limit", http.StatusBadRequest) return } } @@ -9070,6 +9097,11 @@ func (s *Server) handleBrokerSecretByKey(w http.ResponseWriter, r *http.Request, ValidationError(w, "value is required", nil) return } + decoded, err := base64.StdEncoding.DecodeString(req.Value) + if err != nil { + http.Error(w, "value must be base64-encoded", http.StatusBadRequest) + return + } secretType := req.Type if secretType == "" { secretType = store.SecretTypeEnvironment @@ -9085,12 +9117,17 @@ func (s *Server) handleBrokerSecretByKey(w http.ResponseWriter, r *http.Request, target = key } if secretType == store.SecretTypeFile { + if strings.Contains(target, "..") { + http.Error(w, "target path must not contain '..'", http.StatusBadRequest) + return + } if !strings.HasPrefix(target, "/") && !strings.HasPrefix(target, "~/") { ValidationError(w, "file secret target must be an absolute path (or start with ~/)", map[string]interface{}{"field": "target", "value": target}) return } - if len(req.Value) > 64*1024 { - ValidationError(w, "file secret value exceeds 64 KiB limit", map[string]interface{}{"field": "value", "limit": "65536 bytes", "size": len(req.Value)}) + const maxSecretSize = 64 * 1024 + if len(decoded) > maxSecretSize { + http.Error(w, "secret value exceeds 64KB limit", http.StatusBadRequest) return } } @@ -9192,6 +9229,8 @@ func (s *Server) buildAppliedConfig(req CreateAgentRequest, harnessConfig string CreatorName: creatorName, } + ac.NoAuth = req.NoAuth + if req.Config != nil { ac.Image = req.Config.Image ac.Env = req.Config.Env @@ -9212,6 +9251,10 @@ func (s *Server) buildAppliedConfig(req CreateAgentRequest, harnessConfig string ac.InlineConfig = req.Config } + if ac.HarnessAuth == "none" { + ac.NoAuth = true + } + return ac } diff --git a/pkg/hub/handlers_test_login.go b/pkg/hub/handlers_test_login.go index f6c9e981e..8b16dbf42 100644 --- a/pkg/hub/handlers_test_login.go +++ b/pkg/hub/handlers_test_login.go @@ -16,6 +16,7 @@ package hub import ( "encoding/json" + "errors" "log/slog" "net/http" "strings" @@ -76,6 +77,8 @@ func (ws *WebServer) handleTestLogin(w http.ResponseWriter, r *http.Request) { return } + r.Body = http.MaxBytesReader(w, r.Body, 4096) + var req TestLoginRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request body", http.StatusBadRequest) @@ -87,6 +90,11 @@ func (ws *WebServer) handleTestLogin(w http.ResponseWriter, r *http.Request) { return } + if !strings.Contains(req.Email, "@") { + http.Error(w, "email must contain @", http.StatusBadRequest) + return + } + switch req.Role { case "admin", "member", "viewer": case "": @@ -96,6 +104,7 @@ func (ws *WebServer) handleTestLogin(w http.ResponseWriter, r *http.Request) { return } + displayNameProvided := req.DisplayName != "" if req.DisplayName == "" { req.DisplayName = req.Email } @@ -104,6 +113,11 @@ func (ws *WebServer) handleTestLogin(w http.ResponseWriter, r *http.Request) { // Find or create user user, err := ws.store.GetUserByEmail(ctx, req.Email) + if err != nil && !errors.Is(err, store.ErrNotFound) { + slog.Error("test-login: failed to look up user", "email", req.Email, "error", err) + http.Error(w, "failed to look up user", http.StatusInternalServerError) + return + } if err != nil { user = &store.User{ ID: generateID(), @@ -122,7 +136,7 @@ func (ws *WebServer) handleTestLogin(w http.ResponseWriter, r *http.Request) { } else { user.LastLogin = time.Now() user.Role = req.Role - if req.DisplayName != user.Email { + if displayNameProvided { user.DisplayName = req.DisplayName } if err := ws.store.UpdateUser(ctx, user); err != nil { diff --git a/pkg/hub/handlers_test_login_test.go b/pkg/hub/handlers_test_login_test.go index 63f4b22fc..7e56bee13 100644 --- a/pkg/hub/handlers_test_login_test.go +++ b/pkg/hub/handlers_test_login_test.go @@ -33,7 +33,8 @@ import ( type testLoginStore struct { store.Store - users map[string]*store.User + users map[string]*store.User + errOnLookup error } func newTestLoginStore() *testLoginStore { @@ -41,10 +42,13 @@ func newTestLoginStore() *testLoginStore { } func (s *testLoginStore) GetUserByEmail(_ context.Context, email string) (*store.User, error) { + if s.errOnLookup != nil { + return nil, s.errOnLookup + } if u, ok := s.users[email]; ok { return u, nil } - return nil, fmt.Errorf("user not found") + return nil, store.ErrNotFound } func (s *testLoginStore) CreateUser(_ context.Context, user *store.User) error { @@ -177,6 +181,38 @@ func TestHandleTestLogin_MissingEmail(t *testing.T) { assert.Equal(t, http.StatusBadRequest, rec.Code) } +func TestHandleTestLogin_InvalidEmail(t *testing.T) { + ws, tokenSvc := newTestLoginWebServer(t, true) + + body := `{"email":"nope","role":"admin"}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/test-login", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", testLoginAuthHeader(t, tokenSvc)) + rec := httptest.NewRecorder() + + ws.handleTestLogin(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Contains(t, rec.Body.String(), "email must contain @") +} + +func TestHandleTestLogin_DBError(t *testing.T) { + ws, tokenSvc := newTestLoginWebServer(t, true) + mockStore := ws.store.(*testLoginStore) + mockStore.errOnLookup = fmt.Errorf("connection refused") + + body := `{"email":"test@example.com","role":"admin"}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/test-login", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", testLoginAuthHeader(t, tokenSvc)) + rec := httptest.NewRecorder() + + ws.handleTestLogin(rec, req) + + assert.Equal(t, http.StatusInternalServerError, rec.Code) + assert.Contains(t, rec.Body.String(), "failed to look up user") +} + func TestHandleTestLogin_InvalidRole(t *testing.T) { ws, tokenSvc := newTestLoginWebServer(t, true) diff --git a/pkg/hub/httpdispatcher.go b/pkg/hub/httpdispatcher.go index 45801703c..2a321e7a6 100644 --- a/pkg/hub/httpdispatcher.go +++ b/pkg/hub/httpdispatcher.go @@ -412,32 +412,44 @@ func (d *HTTPAgentDispatcher) buildCreateRequest(ctx context.Context, agent *sto } } - // Resolve type-aware secrets from all applicable scopes - resolvedSecrets, err := d.resolveSecrets(ctx, agent) - if err != nil { + // Propagate no-auth intent from the agent's applied config. + noAuth := agent.AppliedConfig != nil && agent.AppliedConfig.NoAuth + if noAuth { + req.NoAuth = true + req.ResolvedSecrets = nil if d.debug { - d.log.Warn("Failed to resolve secrets", "agent_id", agent.ID, "error", err) - } - // Continue without secrets rather than failing agent creation - } else if len(resolvedSecrets) > 0 { - req.ResolvedSecrets = resolvedSecrets - if d.debug { - d.log.Debug("Resolved secrets for agent", "count", len(resolvedSecrets)) + d.log.Debug("NoAuth enabled: skipping secret resolution", "agent_id", agent.ID) } + } - // Inject environment-type secrets into ResolvedEnv so the broker - // receives them as plain env vars for auth resolution. This mirrors - // DispatchAgentStart which merges env-type secrets into resolvedEnv - // before dispatching. Without this, the broker's auth pipeline - // relies solely on buildAuthEnvOverlay in run.go, which may not - // see secrets if they are only in ResolvedSecrets. - if req.ResolvedEnv == nil { - req.ResolvedEnv = make(map[string]string) - } - for _, s := range resolvedSecrets { - if (s.Type == "environment" || s.Type == "") && s.Target != "" { - if existing, exists := req.ResolvedEnv[s.Target]; !exists || existing == "" { - req.ResolvedEnv[s.Target] = s.Value + // Resolve type-aware secrets from all applicable scopes + if !noAuth { + resolvedSecrets, err := d.resolveSecrets(ctx, agent) + if err != nil { + if d.debug { + d.log.Warn("Failed to resolve secrets", "agent_id", agent.ID, "error", err) + } + // Continue without secrets rather than failing agent creation + } else if len(resolvedSecrets) > 0 { + req.ResolvedSecrets = resolvedSecrets + if d.debug { + d.log.Debug("Resolved secrets for agent", "count", len(resolvedSecrets)) + } + + // Inject environment-type secrets into ResolvedEnv so the broker + // receives them as plain env vars for auth resolution. This mirrors + // DispatchAgentStart which merges env-type secrets into resolvedEnv + // before dispatching. Without this, the broker's auth pipeline + // relies solely on buildAuthEnvOverlay in run.go, which may not + // see secrets if they are only in ResolvedSecrets. + if req.ResolvedEnv == nil { + req.ResolvedEnv = make(map[string]string) + } + for _, s := range resolvedSecrets { + if (s.Type == "environment" || s.Type == "") && s.Target != "" { + if existing, exists := req.ResolvedEnv[s.Target]; !exists || existing == "" { + req.ResolvedEnv[s.Target] = s.Value + } } } } @@ -515,7 +527,7 @@ func (d *HTTPAgentDispatcher) buildCreateRequest(ctx context.Context, agent *sto d.log.Debug("buildCreateRequest: env resolution summary", "configEnvCount", configEnvCount, "storageEnvCount", len(envFromStorage), - "resolvedSecretsCount", len(resolvedSecrets), + "resolvedSecretsCount", len(req.ResolvedSecrets), "totalResolvedEnvCount", len(req.ResolvedEnv), ) } diff --git a/pkg/hub/httpdispatcher_test.go b/pkg/hub/httpdispatcher_test.go index 49a714540..92ba0b540 100644 --- a/pkg/hub/httpdispatcher_test.go +++ b/pkg/hub/httpdispatcher_test.go @@ -29,6 +29,7 @@ import ( "github.com/GoogleCloudPlatform/scion/pkg/agent/state" "github.com/GoogleCloudPlatform/scion/pkg/api" "github.com/GoogleCloudPlatform/scion/pkg/messages" + "github.com/GoogleCloudPlatform/scion/pkg/secret" "github.com/GoogleCloudPlatform/scion/pkg/store" ) @@ -3269,3 +3270,104 @@ func TestBuildCreateRequest_GitHubAppTokenWhenNoUserToken(t *testing.T) { t.Error("expected GitHub App minter to be called when no user GITHUB_TOKEN exists") } } + +// mockSecretBackend is a test implementation of secret.SecretBackend that +// returns a fixed set of secrets from Resolve. +type mockSecretBackend struct { + secrets []secret.SecretWithValue +} + +func (m *mockSecretBackend) Get(ctx context.Context, name, scope, scopeID string) (*secret.SecretWithValue, error) { + return nil, nil +} +func (m *mockSecretBackend) Set(ctx context.Context, input *secret.SetSecretInput) (bool, *secret.SecretMeta, error) { + return false, nil, nil +} +func (m *mockSecretBackend) Delete(ctx context.Context, name, scope, scopeID string) error { + return nil +} +func (m *mockSecretBackend) List(ctx context.Context, filter secret.Filter) ([]secret.SecretMeta, error) { + return nil, nil +} +func (m *mockSecretBackend) GetMeta(ctx context.Context, name, scope, scopeID string) (*secret.SecretMeta, error) { + return nil, nil +} +func (m *mockSecretBackend) Resolve(ctx context.Context, userID, projectID, brokerID string, opts *secret.ResolveOpts) ([]secret.SecretWithValue, error) { + return m.secrets, nil +} +func (m *mockSecretBackend) HubID() string { return "test-hub" } + +func TestBuildCreateRequest_NoAuth_SkipsSecrets(t *testing.T) { + ctx := context.Background() + memStore := createTestStore(t) + + broker := &store.RuntimeBroker{ + ID: tid("host-1"), + Name: "test-host", + Slug: "test-host", + Endpoint: "http://localhost:9800", + Status: store.BrokerStatusOnline, + } + if err := memStore.CreateRuntimeBroker(ctx, broker); err != nil { + t.Fatalf("failed to create runtime broker: %v", err) + } + + mockClient := &mockRuntimeBrokerClient{} + dispatcher := NewHTTPAgentDispatcherWithClient(memStore, mockClient, false, slog.Default()) + dispatcher.SetSecretBackend(&mockSecretBackend{ + secrets: []secret.SecretWithValue{ + {SecretMeta: secret.SecretMeta{Name: "CLAUDE_AUTH", SecretType: "file", Target: "~/.claude/.credentials.json"}, Value: "secret-data"}, + {SecretMeta: secret.SecretMeta{Name: "API_KEY", SecretType: "environment", Target: "API_KEY"}, Value: "key-value"}, + }, + }) + + t.Run("NoAuth=true skips secret resolution", func(t *testing.T) { + agent := &store.Agent{ + ID: tid("agent-1"), + Name: "noauth-agent", + Slug: "noauth-agent", + OwnerID: tid("user-1"), + RuntimeBrokerID: tid("host-1"), + AppliedConfig: &store.AgentAppliedConfig{NoAuth: true}, + } + + req, err := dispatcher.buildCreateRequest(ctx, agent, "TestNoAuth") + if err != nil { + t.Fatalf("buildCreateRequest failed: %v", err) + } + + if !req.NoAuth { + t.Error("expected req.NoAuth to be true") + } + if len(req.ResolvedSecrets) != 0 { + t.Errorf("expected no resolved secrets with NoAuth, got %d", len(req.ResolvedSecrets)) + } + // Env-type secrets should not have been injected into ResolvedEnv + if v, ok := req.ResolvedEnv["API_KEY"]; ok && v != "" { + t.Errorf("expected API_KEY to not be injected into ResolvedEnv with NoAuth, got %q", v) + } + }) + + t.Run("NoAuth=false resolves secrets normally", func(t *testing.T) { + agent := &store.Agent{ + ID: tid("agent-2"), + Name: "auth-agent", + Slug: "auth-agent", + OwnerID: tid("user-1"), + RuntimeBrokerID: tid("host-1"), + AppliedConfig: &store.AgentAppliedConfig{}, + } + + req, err := dispatcher.buildCreateRequest(ctx, agent, "TestWithAuth") + if err != nil { + t.Fatalf("buildCreateRequest failed: %v", err) + } + + if req.NoAuth { + t.Error("expected req.NoAuth to be false") + } + if len(req.ResolvedSecrets) != 2 { + t.Errorf("expected 2 resolved secrets, got %d", len(req.ResolvedSecrets)) + } + }) +} diff --git a/pkg/hub/maintenance_executors.go b/pkg/hub/maintenance_executors.go index 7ec561299..cb303e3f4 100644 --- a/pkg/hub/maintenance_executors.go +++ b/pkg/hub/maintenance_executors.go @@ -25,6 +25,7 @@ import ( "runtime" "strings" + scionruntime "github.com/GoogleCloudPlatform/scion/pkg/runtime" "github.com/GoogleCloudPlatform/scion/pkg/secret" "github.com/GoogleCloudPlatform/scion/pkg/store" "github.com/GoogleCloudPlatform/scion/pkg/util/logging" @@ -172,7 +173,7 @@ func (e *PullImagesExecutor) Run(ctx context.Context, logger io.Writer, params m runtimeBin := e.runtimeBin if runtimeBin == "" { - runtimeBin = detectContainerRuntime() + runtimeBin = scionruntime.DetectContainerRuntime() } if runtimeBin == "" { return fmt.Errorf("no container runtime found (tried docker, podman)") @@ -220,16 +221,6 @@ func (e *PullImagesExecutor) Run(ctx context.Context, logger io.Writer, params m return nil } -// detectContainerRuntime finds an available container CLI on the system. -func detectContainerRuntime() string { - for _, bin := range []string{"docker", "podman"} { - if p, err := exec.LookPath(bin); err == nil && p != "" { - return bin - } - } - return "" -} - // RebuildServerExecutor rebuilds the server binary from git and restarts via systemd. type RebuildServerExecutor struct { repoPath string // path to scion source checkout diff --git a/pkg/hub/otel_gcp_metrics.go b/pkg/hub/otel_gcp_metrics.go new file mode 100644 index 000000000..cbca83d7a --- /dev/null +++ b/pkg/hub/otel_gcp_metrics.go @@ -0,0 +1,125 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package hub + +import ( + "context" + "fmt" + "time" + + "go.opentelemetry.io/otel/metric" +) + +// OTelGCPTokenMetrics implements GCPTokenMetricsRecorder using OTel +// instruments for Cloud Monitoring export. It embeds a GCPTokenMetrics for +// the /api/metrics JSON snapshot endpoint (dual-write). +type OTelGCPTokenMetrics struct { + accessRequests metric.Int64Counter + accessSuccesses metric.Int64Counter + accessFailures metric.Int64Counter + idRequests metric.Int64Counter + idSuccesses metric.Int64Counter + idFailures metric.Int64Counter + rateLimitRejects metric.Int64Counter + iamDuration metric.Float64Histogram + + snap *GCPTokenMetrics +} + +var _ GCPTokenMetricsRecorder = (*OTelGCPTokenMetrics)(nil) + +// NewOTelGCPTokenMetrics creates an OTel-backed GCP token metrics recorder. +func NewOTelGCPTokenMetrics(mp metric.MeterProvider) (*OTelGCPTokenMetrics, error) { + m := mp.Meter(instrumentationScope) + r := &OTelGCPTokenMetrics{snap: NewGCPTokenMetrics()} + + var err error + + if r.accessRequests, err = m.Int64Counter("scion.hub.gcp.token.access.requests", + metric.WithUnit("{request}"), + ); err != nil { + return nil, fmt.Errorf("creating gcp.token.access.requests counter: %w", err) + } + if r.accessSuccesses, err = m.Int64Counter("scion.hub.gcp.token.access.successes", + metric.WithUnit("{request}"), + ); err != nil { + return nil, fmt.Errorf("creating gcp.token.access.successes counter: %w", err) + } + if r.accessFailures, err = m.Int64Counter("scion.hub.gcp.token.access.failures", + metric.WithUnit("{request}"), + ); err != nil { + return nil, fmt.Errorf("creating gcp.token.access.failures counter: %w", err) + } + if r.idRequests, err = m.Int64Counter("scion.hub.gcp.token.identity.requests", + metric.WithUnit("{request}"), + ); err != nil { + return nil, fmt.Errorf("creating gcp.token.identity.requests counter: %w", err) + } + if r.idSuccesses, err = m.Int64Counter("scion.hub.gcp.token.identity.successes", + metric.WithUnit("{request}"), + ); err != nil { + return nil, fmt.Errorf("creating gcp.token.identity.successes counter: %w", err) + } + if r.idFailures, err = m.Int64Counter("scion.hub.gcp.token.identity.failures", + metric.WithUnit("{request}"), + ); err != nil { + return nil, fmt.Errorf("creating gcp.token.identity.failures counter: %w", err) + } + if r.rateLimitRejects, err = m.Int64Counter("scion.hub.gcp.token.ratelimit.rejections", + metric.WithUnit("{rejection}"), + ); err != nil { + return nil, fmt.Errorf("creating gcp.token.ratelimit.rejections counter: %w", err) + } + if r.iamDuration, err = m.Float64Histogram("scion.hub.gcp.iam.duration", + metric.WithUnit("ms"), + ); err != nil { + return nil, fmt.Errorf("creating gcp.iam.duration histogram: %w", err) + } + + return r, nil +} + +func (r *OTelGCPTokenMetrics) RecordAccessTokenRequest(success bool, latency time.Duration) { + ctx := context.Background() + r.accessRequests.Add(ctx, 1) + if success { + r.accessSuccesses.Add(ctx, 1) + } else { + r.accessFailures.Add(ctx, 1) + } + r.iamDuration.Record(ctx, float64(latency.Milliseconds())) + r.snap.RecordAccessTokenRequest(success, latency) +} + +func (r *OTelGCPTokenMetrics) RecordIDTokenRequest(success bool, latency time.Duration) { + ctx := context.Background() + r.idRequests.Add(ctx, 1) + if success { + r.idSuccesses.Add(ctx, 1) + } else { + r.idFailures.Add(ctx, 1) + } + r.iamDuration.Record(ctx, float64(latency.Milliseconds())) + r.snap.RecordIDTokenRequest(success, latency) +} + +func (r *OTelGCPTokenMetrics) RecordRateLimitRejection() { + r.rateLimitRejects.Add(context.Background(), 1) + r.snap.RecordRateLimitRejection() +} + +func (r *OTelGCPTokenMetrics) GetSnapshot() *GCPTokenMetricsSnapshot { + return r.snap.GetSnapshot() +} diff --git a/pkg/hub/otel_gcp_metrics_test.go b/pkg/hub/otel_gcp_metrics_test.go new file mode 100644 index 000000000..59d14248b --- /dev/null +++ b/pkg/hub/otel_gcp_metrics_test.go @@ -0,0 +1,185 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package hub + +import ( + "context" + "testing" + "time" + + "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" +) + +var _ GCPTokenMetricsRecorder = (*OTelGCPTokenMetrics)(nil) + +func newTestGCPRecorder(t *testing.T) (*OTelGCPTokenMetrics, *metric.ManualReader) { + t.Helper() + reader := metric.NewManualReader() + mp := metric.NewMeterProvider(metric.WithReader(reader)) + t.Cleanup(func() { _ = mp.Shutdown(context.Background()) }) + + rec, err := NewOTelGCPTokenMetrics(mp) + if err != nil { + t.Fatalf("NewOTelGCPTokenMetrics: %v", err) + } + return rec, reader +} + +func collectGCPMetrics(t *testing.T, reader *metric.ManualReader) map[string]metricdata.Metrics { + t.Helper() + var rm metricdata.ResourceMetrics + if err := reader.Collect(context.Background(), &rm); err != nil { + t.Fatalf("collecting metrics: %v", err) + } + result := make(map[string]metricdata.Metrics) + for _, sm := range rm.ScopeMetrics { + for _, m := range sm.Metrics { + result[m.Name] = m + } + } + return result +} + +func gcpSumCounter(m metricdata.Metrics) int64 { + sum, ok := m.Data.(metricdata.Sum[int64]) + if !ok { + return 0 + } + var total int64 + for _, dp := range sum.DataPoints { + total += dp.Value + } + return total +} + +func TestOTelGCPRecordAccessTokenRequest(t *testing.T) { + rec, reader := newTestGCPRecorder(t) + + rec.RecordAccessTokenRequest(true, 30*time.Millisecond) + rec.RecordAccessTokenRequest(false, 50*time.Millisecond) + + metrics := collectGCPMetrics(t, reader) + + if got := gcpSumCounter(metrics["scion.hub.gcp.token.access.requests"]); got != 2 { + t.Errorf("access.requests = %d, want 2", got) + } + if got := gcpSumCounter(metrics["scion.hub.gcp.token.access.successes"]); got != 1 { + t.Errorf("access.successes = %d, want 1", got) + } + if got := gcpSumCounter(metrics["scion.hub.gcp.token.access.failures"]); got != 1 { + t.Errorf("access.failures = %d, want 1", got) + } + + snap := rec.GetSnapshot() + if snap.AccessTokenRequests != 2 { + t.Errorf("snapshot AccessTokenRequests = %d, want 2", snap.AccessTokenRequests) + } + if snap.AccessTokenSuccesses != 1 { + t.Errorf("snapshot AccessTokenSuccesses = %d, want 1", snap.AccessTokenSuccesses) + } + if snap.AccessTokenFailures != 1 { + t.Errorf("snapshot AccessTokenFailures = %d, want 1", snap.AccessTokenFailures) + } +} + +func TestOTelGCPRecordIDTokenRequest(t *testing.T) { + rec, reader := newTestGCPRecorder(t) + + rec.RecordIDTokenRequest(true, 20*time.Millisecond) + rec.RecordIDTokenRequest(false, 40*time.Millisecond) + + metrics := collectGCPMetrics(t, reader) + + if got := gcpSumCounter(metrics["scion.hub.gcp.token.identity.requests"]); got != 2 { + t.Errorf("identity.requests = %d, want 2", got) + } + if got := gcpSumCounter(metrics["scion.hub.gcp.token.identity.successes"]); got != 1 { + t.Errorf("identity.successes = %d, want 1", got) + } + if got := gcpSumCounter(metrics["scion.hub.gcp.token.identity.failures"]); got != 1 { + t.Errorf("identity.failures = %d, want 1", got) + } + + snap := rec.GetSnapshot() + if snap.IDTokenRequests != 2 { + t.Errorf("snapshot IDTokenRequests = %d, want 2", snap.IDTokenRequests) + } + if snap.IDTokenSuccesses != 1 { + t.Errorf("snapshot IDTokenSuccesses = %d, want 1", snap.IDTokenSuccesses) + } + if snap.IDTokenFailures != 1 { + t.Errorf("snapshot IDTokenFailures = %d, want 1", snap.IDTokenFailures) + } +} + +func TestOTelGCPRecordRateLimitRejection(t *testing.T) { + rec, reader := newTestGCPRecorder(t) + + rec.RecordRateLimitRejection() + rec.RecordRateLimitRejection() + + metrics := collectGCPMetrics(t, reader) + + if got := gcpSumCounter(metrics["scion.hub.gcp.token.ratelimit.rejections"]); got != 2 { + t.Errorf("ratelimit.rejections = %d, want 2", got) + } + + snap := rec.GetSnapshot() + if snap.RateLimitRejections != 2 { + t.Errorf("snapshot RateLimitRejections = %d, want 2", snap.RateLimitRejections) + } +} + +func TestOTelGCPIAMDurationHistogram(t *testing.T) { + rec, reader := newTestGCPRecorder(t) + + rec.RecordAccessTokenRequest(true, 42*time.Millisecond) + + metrics := collectGCPMetrics(t, reader) + m, ok := metrics["scion.hub.gcp.iam.duration"] + if !ok { + t.Fatal("scion.hub.gcp.iam.duration not found") + } + hist, ok := m.Data.(metricdata.Histogram[float64]) + if !ok { + t.Fatal("iam.duration is not a histogram") + } + if len(hist.DataPoints) == 0 { + t.Fatal("histogram has no data points") + } + if hist.DataPoints[0].Sum <= 0 { + t.Errorf("histogram sum = %f, want > 0", hist.DataPoints[0].Sum) + } +} + +func TestOTelGCPGetSnapshot(t *testing.T) { + rec, _ := newTestGCPRecorder(t) + + rec.RecordAccessTokenRequest(true, 10*time.Millisecond) + rec.RecordIDTokenRequest(false, 20*time.Millisecond) + rec.RecordRateLimitRejection() + + snap := rec.GetSnapshot() + if snap.AccessTokenRequests != 1 { + t.Errorf("AccessTokenRequests = %d, want 1", snap.AccessTokenRequests) + } + if snap.IDTokenRequests != 1 { + t.Errorf("IDTokenRequests = %d, want 1", snap.IDTokenRequests) + } + if snap.RateLimitRejections != 1 { + t.Errorf("RateLimitRejections = %d, want 1", snap.RateLimitRejections) + } +} diff --git a/pkg/hub/otel_metrics.go b/pkg/hub/otel_metrics.go new file mode 100644 index 000000000..751697289 --- /dev/null +++ b/pkg/hub/otel_metrics.go @@ -0,0 +1,174 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package hub + +import ( + "context" + "fmt" + "time" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" +) + +const instrumentationScope = "github.com/GoogleCloudPlatform/scion/pkg/hub" + +// OTelMetricsRecorder implements MetricsRecorder using OTel instruments for +// Cloud Monitoring export. It embeds a BrokerAuthMetrics for the /api/metrics +// JSON snapshot endpoint (dual-write). +type OTelMetricsRecorder struct { + authAttempts metric.Int64Counter + authSuccesses metric.Int64Counter + authFailures metric.Int64Counter + authDuration metric.Float64Histogram + registrations metric.Int64Counter + joins metric.Int64Counter + joinFailures metric.Int64Counter + rotations metric.Int64Counter + dispatchAttempts metric.Int64Counter + dispatchFailures metric.Int64Counter + connectedBrokers metric.Int64Gauge + + snap *BrokerAuthMetrics +} + +var _ MetricsRecorder = (*OTelMetricsRecorder)(nil) + +// NewOTelMetricsRecorder creates an OTel-backed MetricsRecorder. All +// instruments are registered under the hub instrumentation scope. +func NewOTelMetricsRecorder(mp metric.MeterProvider) (*OTelMetricsRecorder, error) { + m := mp.Meter(instrumentationScope) + r := &OTelMetricsRecorder{snap: NewBrokerAuthMetrics()} + + var err error + + if r.authAttempts, err = m.Int64Counter("scion.hub.auth.attempts", + metric.WithUnit("{attempt}"), + ); err != nil { + return nil, fmt.Errorf("creating auth.attempts counter: %w", err) + } + if r.authSuccesses, err = m.Int64Counter("scion.hub.auth.successes", + metric.WithUnit("{attempt}"), + ); err != nil { + return nil, fmt.Errorf("creating auth.successes counter: %w", err) + } + if r.authFailures, err = m.Int64Counter("scion.hub.auth.failures", + metric.WithUnit("{attempt}"), + ); err != nil { + return nil, fmt.Errorf("creating auth.failures counter: %w", err) + } + if r.authDuration, err = m.Float64Histogram("scion.hub.auth.duration", + metric.WithUnit("ms"), + ); err != nil { + return nil, fmt.Errorf("creating auth.duration histogram: %w", err) + } + if r.registrations, err = m.Int64Counter("scion.hub.registration.count", + metric.WithUnit("{registration}"), + ); err != nil { + return nil, fmt.Errorf("creating registration.count counter: %w", err) + } + if r.joins, err = m.Int64Counter("scion.hub.join.attempts", + metric.WithUnit("{attempt}"), + ); err != nil { + return nil, fmt.Errorf("creating join.attempts counter: %w", err) + } + if r.joinFailures, err = m.Int64Counter("scion.hub.join.failures", + metric.WithUnit("{attempt}"), + ); err != nil { + return nil, fmt.Errorf("creating join.failures counter: %w", err) + } + if r.rotations, err = m.Int64Counter("scion.hub.rotation.count", + metric.WithUnit("{rotation}"), + ); err != nil { + return nil, fmt.Errorf("creating rotation.count counter: %w", err) + } + if r.dispatchAttempts, err = m.Int64Counter("scion.hub.dispatch.attempts", + metric.WithUnit("{attempt}"), + ); err != nil { + return nil, fmt.Errorf("creating dispatch.attempts counter: %w", err) + } + if r.dispatchFailures, err = m.Int64Counter("scion.hub.dispatch.failures", + metric.WithUnit("{attempt}"), + ); err != nil { + return nil, fmt.Errorf("creating dispatch.failures counter: %w", err) + } + if r.connectedBrokers, err = m.Int64Gauge("scion.hub.brokers.connected", + metric.WithUnit("{broker}"), + ); err != nil { + return nil, fmt.Errorf("creating brokers.connected gauge: %w", err) + } + + return r, nil +} + +func (r *OTelMetricsRecorder) RecordAuthAttempt(brokerID string, success bool, latency time.Duration) { + ctx := context.Background() + attrs := metric.WithAttributes(attribute.String("broker_id", brokerID)) + r.authAttempts.Add(ctx, 1, attrs) + if success { + r.authSuccesses.Add(ctx, 1, attrs) + } else { + r.authFailures.Add(ctx, 1, attrs) + } + r.authDuration.Record(ctx, float64(latency.Milliseconds()), attrs) + r.snap.RecordAuthAttempt(brokerID, success, latency) +} + +func (r *OTelMetricsRecorder) RecordRegistration(brokerID string) { + ctx := context.Background() + attrs := metric.WithAttributes(attribute.String("broker_id", brokerID)) + r.registrations.Add(ctx, 1, attrs) + r.snap.RecordRegistration(brokerID) +} + +func (r *OTelMetricsRecorder) RecordJoin(brokerID string, success bool) { + ctx := context.Background() + attrs := metric.WithAttributes(attribute.String("broker_id", brokerID)) + r.joins.Add(ctx, 1, attrs) + if !success { + r.joinFailures.Add(ctx, 1, attrs) + } + r.snap.RecordJoin(brokerID, success) +} + +func (r *OTelMetricsRecorder) RecordRotation(brokerID string) { + ctx := context.Background() + attrs := metric.WithAttributes(attribute.String("broker_id", brokerID)) + r.rotations.Add(ctx, 1, attrs) + r.snap.RecordRotation(brokerID) +} + +func (r *OTelMetricsRecorder) RecordDispatch(brokerID string, operation string, success bool, latency time.Duration) { + ctx := context.Background() + attrs := metric.WithAttributes( + attribute.String("broker_id", brokerID), + attribute.String("operation", operation), + ) + r.dispatchAttempts.Add(ctx, 1, attrs) + if !success { + r.dispatchFailures.Add(ctx, 1, attrs) + } + r.snap.RecordDispatch(brokerID, operation, success, latency) +} + +func (r *OTelMetricsRecorder) SetConnectedBrokers(count int64) { + ctx := context.Background() + r.connectedBrokers.Record(ctx, count) + r.snap.SetConnectedBrokers(count) +} + +func (r *OTelMetricsRecorder) GetSnapshot() *MetricsSnapshot { + return r.snap.GetSnapshot() +} diff --git a/pkg/hub/otel_metrics_test.go b/pkg/hub/otel_metrics_test.go new file mode 100644 index 000000000..212cd40f9 --- /dev/null +++ b/pkg/hub/otel_metrics_test.go @@ -0,0 +1,224 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package hub + +import ( + "context" + "testing" + "time" + + "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" +) + +var _ MetricsRecorder = (*OTelMetricsRecorder)(nil) + +func newTestRecorder(t *testing.T) (*OTelMetricsRecorder, *metric.ManualReader) { + t.Helper() + reader := metric.NewManualReader() + mp := metric.NewMeterProvider(metric.WithReader(reader)) + t.Cleanup(func() { _ = mp.Shutdown(context.Background()) }) + + rec, err := NewOTelMetricsRecorder(mp) + if err != nil { + t.Fatalf("NewOTelMetricsRecorder: %v", err) + } + return rec, reader +} + +func collectMetrics(t *testing.T, reader *metric.ManualReader) map[string]metricdata.Metrics { + t.Helper() + var rm metricdata.ResourceMetrics + if err := reader.Collect(context.Background(), &rm); err != nil { + t.Fatalf("collecting metrics: %v", err) + } + result := make(map[string]metricdata.Metrics) + for _, sm := range rm.ScopeMetrics { + for _, m := range sm.Metrics { + result[m.Name] = m + } + } + return result +} + +func sumCounter(m metricdata.Metrics) int64 { + sum, ok := m.Data.(metricdata.Sum[int64]) + if !ok { + return 0 + } + var total int64 + for _, dp := range sum.DataPoints { + total += dp.Value + } + return total +} + +func TestOTelRecordAuthAttempt(t *testing.T) { + rec, reader := newTestRecorder(t) + + rec.RecordAuthAttempt("broker-1", true, 50*time.Millisecond) + rec.RecordAuthAttempt("broker-1", false, 100*time.Millisecond) + + metrics := collectMetrics(t, reader) + + if got := sumCounter(metrics["scion.hub.auth.attempts"]); got != 2 { + t.Errorf("auth.attempts = %d, want 2", got) + } + if got := sumCounter(metrics["scion.hub.auth.successes"]); got != 1 { + t.Errorf("auth.successes = %d, want 1", got) + } + if got := sumCounter(metrics["scion.hub.auth.failures"]); got != 1 { + t.Errorf("auth.failures = %d, want 1", got) + } + + snap := rec.GetSnapshot() + if snap.AuthAttempts != 2 { + t.Errorf("snapshot AuthAttempts = %d, want 2", snap.AuthAttempts) + } + if snap.AuthSuccesses != 1 { + t.Errorf("snapshot AuthSuccesses = %d, want 1", snap.AuthSuccesses) + } + if snap.AuthFailures != 1 { + t.Errorf("snapshot AuthFailures = %d, want 1", snap.AuthFailures) + } +} + +func TestOTelAuthDurationHistogram(t *testing.T) { + rec, reader := newTestRecorder(t) + + rec.RecordAuthAttempt("broker-1", true, 42*time.Millisecond) + + metrics := collectMetrics(t, reader) + m, ok := metrics["scion.hub.auth.duration"] + if !ok { + t.Fatal("scion.hub.auth.duration not found") + } + hist, ok := m.Data.(metricdata.Histogram[float64]) + if !ok { + t.Fatal("auth.duration is not a histogram") + } + if len(hist.DataPoints) == 0 { + t.Fatal("histogram has no data points") + } + if hist.DataPoints[0].Sum <= 0 { + t.Errorf("histogram sum = %f, want > 0", hist.DataPoints[0].Sum) + } +} + +func TestOTelRecordRegistration(t *testing.T) { + rec, reader := newTestRecorder(t) + + rec.RecordRegistration("broker-1") + rec.RecordRegistration("broker-2") + + metrics := collectMetrics(t, reader) + if got := sumCounter(metrics["scion.hub.registration.count"]); got != 2 { + t.Errorf("registration.count = %d, want 2", got) + } + + snap := rec.GetSnapshot() + if snap.Registrations != 2 { + t.Errorf("snapshot Registrations = %d, want 2", snap.Registrations) + } +} + +func TestOTelRecordJoin(t *testing.T) { + rec, reader := newTestRecorder(t) + + rec.RecordJoin("broker-1", true) + rec.RecordJoin("broker-2", false) + + metrics := collectMetrics(t, reader) + if got := sumCounter(metrics["scion.hub.join.attempts"]); got != 2 { + t.Errorf("join.attempts = %d, want 2", got) + } + if got := sumCounter(metrics["scion.hub.join.failures"]); got != 1 { + t.Errorf("join.failures = %d, want 1", got) + } + + snap := rec.GetSnapshot() + if snap.Joins != 2 { + t.Errorf("snapshot Joins = %d, want 2", snap.Joins) + } + if snap.JoinFailures != 1 { + t.Errorf("snapshot JoinFailures = %d, want 1", snap.JoinFailures) + } +} + +func TestOTelRecordRotation(t *testing.T) { + rec, reader := newTestRecorder(t) + + rec.RecordRotation("broker-1") + + metrics := collectMetrics(t, reader) + if got := sumCounter(metrics["scion.hub.rotation.count"]); got != 1 { + t.Errorf("rotation.count = %d, want 1", got) + } + + snap := rec.GetSnapshot() + if snap.Rotations != 1 { + t.Errorf("snapshot Rotations = %d, want 1", snap.Rotations) + } +} + +func TestOTelRecordDispatch(t *testing.T) { + rec, reader := newTestRecorder(t) + + rec.RecordDispatch("broker-1", "create", true, 10*time.Millisecond) + rec.RecordDispatch("broker-1", "create", false, 20*time.Millisecond) + + metrics := collectMetrics(t, reader) + if got := sumCounter(metrics["scion.hub.dispatch.attempts"]); got != 2 { + t.Errorf("dispatch.attempts = %d, want 2", got) + } + if got := sumCounter(metrics["scion.hub.dispatch.failures"]); got != 1 { + t.Errorf("dispatch.failures = %d, want 1", got) + } + + snap := rec.GetSnapshot() + if snap.DispatchAttempts != 2 { + t.Errorf("snapshot DispatchAttempts = %d, want 2", snap.DispatchAttempts) + } + if snap.DispatchFailures != 1 { + t.Errorf("snapshot DispatchFailures = %d, want 1", snap.DispatchFailures) + } +} + +func TestOTelSetConnectedBrokers(t *testing.T) { + rec, reader := newTestRecorder(t) + + rec.SetConnectedBrokers(5) + + metrics := collectMetrics(t, reader) + m, ok := metrics["scion.hub.brokers.connected"] + if !ok { + t.Fatal("scion.hub.brokers.connected not found") + } + gauge, ok := m.Data.(metricdata.Gauge[int64]) + if !ok { + t.Fatal("brokers.connected is not a gauge") + } + if len(gauge.DataPoints) == 0 { + t.Fatal("gauge has no data points") + } + if gauge.DataPoints[0].Value != 5 { + t.Errorf("gauge value = %d, want 5", gauge.DataPoints[0].Value) + } + + snap := rec.GetSnapshot() + if snap.ConnectedBrokers != 5 { + t.Errorf("snapshot ConnectedBrokers = %d, want 5", snap.ConnectedBrokers) + } +} diff --git a/pkg/hub/server.go b/pkg/hub/server.go index 5ba6d86f3..21faeca77 100644 --- a/pkg/hub/server.go +++ b/pkg/hub/server.go @@ -390,6 +390,8 @@ type RemoteCreateAgentRequest struct { // CreatorName is the human-readable identity of who created this agent. // Injected as the SCION_CREATOR environment variable in the agent container. CreatorName string `json:"creatorName,omitempty"` + // NoAuth indicates the agent should start without any injected credentials. + NoAuth bool `json:"noAuth,omitempty"` // Attach indicates the agent should start in interactive attach mode (not detached). Attach bool `json:"attach,omitempty"` // ProvisionOnly indicates the agent should be provisioned (dirs, worktree, templates) @@ -602,7 +604,7 @@ type Server struct { gcpTokenRateLimiter *GCPTokenRateLimiter // GCP token metrics tracker (nil = disabled) - gcpTokenMetrics *GCPTokenMetrics + gcpTokenMetrics GCPTokenMetricsRecorder // Database connection-pool / notify metrics recorder (P0-5). Defaults to a // disabled no-op recorder; SetDBMetrics wires a real exporter. Drives the @@ -746,8 +748,8 @@ func New(cfg ServerConfig, s store.Store) (*Server, error) { // Initialize Telegram link service srv.telegramLinkService = NewTelegramLinkService() - // Initialize Discord link service (DB-backed for HA) - srv.discordLinkService = NewDiscordLinkService(s) + // Initialize Discord link service + srv.discordLinkService = NewDiscordLinkService() // Initialize OAuth service if configured if cfg.OAuthConfig.IsConfigured() { @@ -1472,6 +1474,13 @@ func (s *Server) SetDispatchMetrics(rec dispatchmetrics.Recorder) { s.dispatchMetrics = rec } +// SetGCPTokenMetrics wires the GCP token metrics recorder. +func (s *Server) SetGCPTokenMetrics(m GCPTokenMetricsRecorder) { + s.mu.Lock() + defer s.mu.Unlock() + s.gcpTokenMetrics = m +} + // GetMaintenanceState returns the runtime maintenance state. func (s *Server) GetMaintenanceState() *MaintenanceState { return s.maintenance diff --git a/pkg/hub/skill_handlers.go b/pkg/hub/skill_handlers.go index 3dcef79cc..cd9dcaf2a 100644 --- a/pkg/hub/skill_handlers.go +++ b/pkg/hub/skill_handlers.go @@ -59,7 +59,7 @@ type SkillWithCapabilities struct { // PublishVersionRequest is the request body for creating a skill version. type PublishVersionRequest struct { - Version string `json:"version"` + Version string `json:"version"` Files []FileUploadRequest `json:"files,omitempty"` } @@ -779,7 +779,7 @@ func (s *Server) handleSkillUpload(w http.ResponseWriter, r *http.Request, skill } var req struct { - Version string `json:"version"` + Version string `json:"version"` Files []FileUploadRequest `json:"files"` } if err := readJSON(r, &req); err != nil { diff --git a/pkg/hubclient/skills.go b/pkg/hubclient/skills.go index cafa9aedc..681ad9b7f 100644 --- a/pkg/hubclient/skills.go +++ b/pkg/hubclient/skills.go @@ -160,7 +160,7 @@ type UpdateSkillRequest struct { // PublishVersionRequest is the request for creating a skill version. type PublishVersionRequest struct { - Version string `json:"version"` + Version string `json:"version"` Files []FileUploadRequest `json:"files,omitempty"` } @@ -172,7 +172,7 @@ type PublishVersionResponse struct { // FinalizeSkillVersionRequest is the request for finalizing a skill version. type FinalizeSkillVersionRequest struct { - Version string `json:"version"` + Version string `json:"version"` Manifest *SkillManifest `json:"manifest"` } @@ -201,7 +201,7 @@ type ResolveSkillRef struct { // ResolveSkillsResponse is the response for batch skill resolution. type ResolveSkillsResponse struct { - Resolved []ResolvedSkill `json:"resolved"` + Resolved []ResolvedSkill `json:"resolved"` Errors []ResolveSkillError `json:"errors,omitempty"` } @@ -343,7 +343,7 @@ func (s *skillService) FinalizeVersion(ctx context.Context, skillID string, req // RequestUploadURLs requests signed upload URLs for a skill version's files. func (s *skillService) RequestUploadURLs(ctx context.Context, skillID string, version string, files []FileUploadRequest) (*UploadResponse, error) { req := struct { - Version string `json:"version"` + Version string `json:"version"` Files []FileUploadRequest `json:"files"` }{ Version: version, diff --git a/pkg/observability/hubmetrics/hubmetrics.go b/pkg/observability/hubmetrics/hubmetrics.go new file mode 100644 index 000000000..5d3228d1d --- /dev/null +++ b/pkg/observability/hubmetrics/hubmetrics.go @@ -0,0 +1,146 @@ +/* +Copyright 2026 The Scion Authors. +*/ + +// Package hubmetrics creates the OpenTelemetry MeterProvider used by hub-side +// metric recorders (dbmetrics, dispatchmetrics). It exports directly to GCP +// Cloud Monitoring via Application Default Credentials. +package hubmetrics + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + mexporter "github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/sdk/instrumentation" + "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/resource" + semconv "go.opentelemetry.io/otel/semconv/v1.26.0" +) + +const defaultExportInterval = 60 * time.Second + +// MetricGroup identifies a logical group of hub metrics that can be +// independently enabled or disabled. +type MetricGroup struct { + EnvVar string + NamePattern string +} + +var metricGroups = []MetricGroup{ + {EnvVar: "SCION_METRICS_DB_NOTIFY", NamePattern: "scion.db.notify.*"}, + {EnvVar: "SCION_METRICS_DB_POOL", NamePattern: "scion.db.pool.*"}, + {EnvVar: "SCION_METRICS_DISPATCH", NamePattern: "scion.dispatch.*"}, + {EnvVar: "SCION_METRICS_HUB_AUTH", NamePattern: "scion.hub.auth.*"}, + {EnvVar: "SCION_METRICS_HUB_AUTH", NamePattern: "scion.hub.registration.*"}, + {EnvVar: "SCION_METRICS_HUB_AUTH", NamePattern: "scion.hub.join.*"}, + {EnvVar: "SCION_METRICS_HUB_AUTH", NamePattern: "scion.hub.rotation.*"}, + {EnvVar: "SCION_METRICS_HUB_AUTH", NamePattern: "scion.hub.brokers.*"}, + {EnvVar: "SCION_METRICS_HUB_AUTH", NamePattern: "scion.hub.dispatch.*"}, + {EnvVar: "SCION_METRICS_HUB_GCP", NamePattern: "scion.hub.gcp.*"}, +} + +// Option configures the MeterProvider. +type Option func(*options) + +type options struct { + exportInterval time.Duration + hubID string +} + +// WithExportInterval sets the periodic reader interval. Defaults to 60s. +func WithExportInterval(d time.Duration) Option { + return func(o *options) { o.exportInterval = d } +} + +// WithHubID sets the scion.hub.id resource attribute. +func WithHubID(id string) Option { + return func(o *options) { o.hubID = id } +} + +// NewMeterProvider creates an OTel SDK MeterProvider that exports to GCP Cloud +// Monitoring. It uses Application Default Credentials (workload identity on +// Cloud Run, attached SA on GCE). +// +// If gcpProjectID is empty, an error is returned — callers should fall back to +// disabled recorders. +func NewMeterProvider(ctx context.Context, gcpProjectID string, opts ...Option) (*metric.MeterProvider, error) { + if gcpProjectID == "" { + return nil, fmt.Errorf("GCP project ID is required for hub metrics export") + } + + o := &options{exportInterval: defaultExportInterval} + for _, fn := range opts { + fn(o) + } + + exporter, err := mexporter.New(mexporter.WithProjectID(gcpProjectID)) + if err != nil { + return nil, fmt.Errorf("creating GCP metric exporter: %w", err) + } + + resAttrs := []attribute.KeyValue{ + semconv.ServiceName("scion-hub"), + } + if o.hubID != "" { + resAttrs = append(resAttrs, attribute.String("scion.hub.id", o.hubID)) + } + if envHubID := os.Getenv("SCION_HUB_ID"); envHubID != "" && o.hubID == "" { + resAttrs = append(resAttrs, attribute.String("scion.hub.id", envHubID)) + } + + res, err := resource.New(ctx, + resource.WithAttributes(resAttrs...), + ) + if err != nil { + return nil, fmt.Errorf("creating OTel resource: %w", err) + } + + mpOpts := []metric.Option{ + metric.WithResource(res), + metric.WithReader(metric.NewPeriodicReader(exporter, + metric.WithInterval(o.exportInterval), + )), + } + + mpOpts = append(mpOpts, groupDropViews()...) + + return metric.NewMeterProvider(mpOpts...), nil +} + +// groupDropViews returns OTel View options that drop instruments belonging to +// disabled metric groups. A group is disabled when its env var is set to +// "false" or "0". All groups are enabled by default. +func groupDropViews() []metric.Option { + var opts []metric.Option + for _, g := range metricGroups { + if isGroupDisabled(g.EnvVar) { + opts = append(opts, metric.WithView(metric.NewView( + metric.Instrument{Name: g.NamePattern}, + metric.Stream{Aggregation: metric.AggregationDrop{}}, + ))) + } + } + return opts +} + +func isGroupDisabled(envVar string) bool { + v := strings.ToLower(strings.TrimSpace(os.Getenv(envVar))) + return v == "false" || v == "0" +} + +// GroupScopes returns the instrumentation scopes for each metric group, useful +// for testing and documentation. +func GroupScopes() []MetricGroup { + return append([]MetricGroup(nil), metricGroups...) +} + +// InstrumentationScope returns a scope matching the dbmetrics or +// dispatchmetrics package, useful for building Views in tests. +func InstrumentationScope(name string) instrumentation.Scope { + return instrumentation.Scope{Name: name} +} diff --git a/pkg/observability/hubmetrics/hubmetrics_test.go b/pkg/observability/hubmetrics/hubmetrics_test.go new file mode 100644 index 000000000..68567f0a4 --- /dev/null +++ b/pkg/observability/hubmetrics/hubmetrics_test.go @@ -0,0 +1,172 @@ +/* +Copyright 2026 The Scion Authors. +*/ + +package hubmetrics + +import ( + "context" + "os" + "testing" + + "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" + + "github.com/GoogleCloudPlatform/scion/pkg/observability/dbmetrics" + "github.com/GoogleCloudPlatform/scion/pkg/observability/dispatchmetrics" +) + +func TestNewMeterProviderEmptyProjectID(t *testing.T) { + _, err := NewMeterProvider(context.Background(), "") + if err == nil { + t.Fatal("expected error for empty project ID") + } +} + +func TestGroupDropViewsAllEnabled(t *testing.T) { + for _, g := range metricGroups { + if err := os.Unsetenv(g.EnvVar); err != nil { + t.Fatalf("Unsetenv(%s): %v", g.EnvVar, err) + } + } + views := groupDropViews() + if len(views) != 0 { + t.Errorf("expected 0 drop views when all groups enabled, got %d", len(views)) + } +} + +func TestGroupDropViewsDisabled(t *testing.T) { + t.Setenv("SCION_METRICS_DB_NOTIFY", "false") + + views := groupDropViews() + if len(views) != 1 { + t.Errorf("expected 1 drop view, got %d", len(views)) + } +} + +func TestGroupDropViewsDisabledZero(t *testing.T) { + t.Setenv("SCION_METRICS_DISPATCH", "0") + + views := groupDropViews() + if len(views) != 1 { + t.Errorf("expected 1 drop view, got %d", len(views)) + } +} + +func TestIsGroupDisabled(t *testing.T) { + tests := []struct { + value string + want bool + }{ + {"", false}, + {"true", false}, + {"1", false}, + {"false", true}, + {"0", true}, + } + + for _, tc := range tests { + t.Run(tc.value, func(t *testing.T) { + envVar := "SCION_METRICS_TEST_GROUP" + if tc.value != "" { + t.Setenv(envVar, tc.value) + } else { + if err := os.Unsetenv(envVar); err != nil { + t.Fatalf("Unsetenv(%s): %v", envVar, err) + } + } + if got := isGroupDisabled(envVar); got != tc.want { + t.Errorf("isGroupDisabled(%q=%q) = %v, want %v", envVar, tc.value, got, tc.want) + } + }) + } +} + +func TestRecordersEnabledWithRealProvider(t *testing.T) { + reader := metric.NewManualReader() + mp := metric.NewMeterProvider(metric.WithReader(reader)) + t.Cleanup(func() { _ = mp.Shutdown(context.Background()) }) + + dbRec, err := dbmetrics.New(mp) + if err != nil { + t.Fatalf("dbmetrics.New: %v", err) + } + if !dbRec.Enabled() { + t.Error("dbmetrics.Recorder should be enabled with real MeterProvider") + } + + dispRec, err := dispatchmetrics.New(mp) + if err != nil { + t.Fatalf("dispatchmetrics.New: %v", err) + } + if !dispRec.Enabled() { + t.Error("dispatchmetrics.Recorder should be enabled with real MeterProvider") + } +} + +func TestDropViewPreventsExport(t *testing.T) { + t.Setenv("SCION_METRICS_DB_NOTIFY", "false") + + reader := metric.NewManualReader() + mpOpts := []metric.Option{metric.WithReader(reader)} + mpOpts = append(mpOpts, groupDropViews()...) + mp := metric.NewMeterProvider(mpOpts...) + t.Cleanup(func() { _ = mp.Shutdown(context.Background()) }) + + dbRec, err := dbmetrics.New(mp) + if err != nil { + t.Fatalf("dbmetrics.New: %v", err) + } + + ctx := context.Background() + dbRec.IncPublished(ctx, 1) + dbRec.IncDelivered(ctx, 1) + + var rm metricdata.ResourceMetrics + if err := reader.Collect(ctx, &rm); err != nil { + t.Fatalf("collecting metrics: %v", err) + } + + for _, sm := range rm.ScopeMetrics { + for _, m := range sm.Metrics { + if m.Name == dbmetrics.MetricNotificationsPublished || + m.Name == dbmetrics.MetricNotificationsDelivered { + t.Errorf("metric %q should have been dropped by view, but was exported", m.Name) + } + } + } +} + +func TestPoolMetricsNotDroppedWhenNotifyDisabled(t *testing.T) { + t.Setenv("SCION_METRICS_DB_NOTIFY", "false") + + reader := metric.NewManualReader() + mpOpts := []metric.Option{metric.WithReader(reader)} + mpOpts = append(mpOpts, groupDropViews()...) + mp := metric.NewMeterProvider(mpOpts...) + t.Cleanup(func() { _ = mp.Shutdown(context.Background()) }) + + dbRec, err := dbmetrics.New(mp) + if err != nil { + t.Fatalf("dbmetrics.New: %v", err) + } + + ctx := context.Background() + dbRec.ObservePoolStats(ctx, dbmetrics.PoolStats{Active: 5, Idle: 3, Waiting: 0, Max: 10}) + + var rm metricdata.ResourceMetrics + if err := reader.Collect(ctx, &rm); err != nil { + t.Fatalf("collecting metrics: %v", err) + } + + names := make(map[string]bool) + for _, sm := range rm.ScopeMetrics { + for _, m := range sm.Metrics { + names[m.Name] = true + } + } + + if !names[dbmetrics.MetricPoolConnectionsActive] { + t.Error("pool metric should still be exported when only db-notify is disabled") + } +} diff --git a/pkg/plugin/config.go b/pkg/plugin/config.go index 30b92d4f1..35c455856 100644 --- a/pkg/plugin/config.go +++ b/pkg/plugin/config.go @@ -17,12 +17,6 @@ // processes using hashicorp/go-plugin. package plugin -import ( - "log/slog" - - "github.com/GoogleCloudPlatform/scion/pkg/eventbus" -) - const ( // PluginTypeBroker is the plugin type for message broker implementations. PluginTypeBroker = "broker" @@ -63,27 +57,11 @@ type PluginEntry struct { // The plugin is responsible for its own startup and shutdown. SelfManaged bool `json:"self_managed,omitempty" yaml:"self_managed,omitempty" koanf:"self_managed"` - // Address is the network address for self-managed or gRPC plugins. - // Required when SelfManaged is true or Mode is "grpc". + // Address is the RPC address for self-managed plugins (e.g. "localhost:9090"). + // Required when SelfManaged is true. Address string `json:"address,omitempty" yaml:"address,omitempty" koanf:"address"` - - // Mode selects the plugin communication mode: "" or "plugin" (default go-plugin - // subprocess), "grpc" (standalone gRPC broker), "self-managed" (go-plugin RPC to - // an externally-managed process). - Mode string `json:"mode,omitempty" yaml:"mode,omitempty" koanf:"mode"` } -// ConfigurableEventBus extends eventbus.EventBus with a Configure method. -// Implemented by gRPC broker adapters that need runtime configuration. -type ConfigurableEventBus interface { - eventbus.EventBus - Configure(config map[string]string) error -} - -// GRPCBrokerFactory creates a ConfigurableEventBus for a gRPC broker at the -// given address. The channel parameter is the broker name (e.g. "discord"). -type GRPCBrokerFactory func(address, channel string, log *slog.Logger) (ConfigurableEventBus, error) - // PluginInfo contains metadata reported by a plugin via the GetInfo() RPC call. type PluginInfo struct { // Name is the plugin's self-reported name. diff --git a/pkg/plugin/discovery.go b/pkg/plugin/discovery.go index 67ea7b734..fdf2c41f8 100644 --- a/pkg/plugin/discovery.go +++ b/pkg/plugin/discovery.go @@ -32,7 +32,6 @@ type DiscoveredPlugin struct { FromConfig bool // true if found via settings, false if auto-discovered SelfManaged bool // true if the plugin manages its own process lifecycle Address string // RPC address for self-managed plugins - Mode string // "grpc", "self-managed", or "" (default go-plugin subprocess) } // DiscoverPlugins finds all available plugins from settings configuration and @@ -45,18 +44,7 @@ func DiscoverPlugins(cfg PluginsConfig, pluginsDir string, logger *slog.Logger) // 1. From settings configuration for name, entry := range cfg.Broker { - if entry.Mode == "grpc" { - discovered = append(discovered, DiscoveredPlugin{ - Name: name, - Type: PluginTypeBroker, - Config: entry.Config, - FromConfig: true, - Address: entry.Address, - Mode: "grpc", - }) - continue - } - if entry.SelfManaged || entry.Mode == "self-managed" { + if entry.SelfManaged { discovered = append(discovered, DiscoveredPlugin{ Name: name, Type: PluginTypeBroker, @@ -64,7 +52,6 @@ func DiscoverPlugins(cfg PluginsConfig, pluginsDir string, logger *slog.Logger) FromConfig: true, SelfManaged: true, Address: entry.Address, - Mode: entry.Mode, }) continue } @@ -79,7 +66,6 @@ func DiscoverPlugins(cfg PluginsConfig, pluginsDir string, logger *slog.Logger) Path: path, Config: entry.Config, FromConfig: true, - Mode: entry.Mode, }) } diff --git a/pkg/plugin/manager.go b/pkg/plugin/manager.go index b12adcb34..6762574d7 100644 --- a/pkg/plugin/manager.go +++ b/pkg/plugin/manager.go @@ -30,15 +30,13 @@ import ( // Manager owns the lifecycle of all loaded plugins. // It handles discovery, loading, dispensing, and shutdown of plugin processes. type Manager struct { - clients map[string]*goplugin.Client // "type:name" -> client - dispensed map[string]interface{} // "type:name" -> dispensed interface (cached) - selfManaged map[string]bool // "type:name" -> true if self-managed - grpcBrokers map[string]ConfigurableEventBus // "type:name" -> gRPC broker adapter - configs map[string]DiscoveredPlugin // "type:name" -> original config (for reconnection) + clients map[string]*goplugin.Client // "type:name" -> client + dispensed map[string]interface{} // "type:name" -> dispensed interface (cached) + selfManaged map[string]bool // "type:name" -> true if self-managed + configs map[string]DiscoveredPlugin // "type:name" -> original config (for reconnection) mu sync.RWMutex logger *slog.Logger brokerCallbacks *HostCallbacksForwarder // lazily-wired host callbacks for broker plugins - grpcFactory GRPCBrokerFactory // factory for creating gRPC broker adapters } // NewManager creates a new plugin manager. @@ -50,7 +48,6 @@ func NewManager(logger *slog.Logger) *Manager { clients: make(map[string]*goplugin.Client), dispensed: make(map[string]interface{}), selfManaged: make(map[string]bool), - grpcBrokers: make(map[string]ConfigurableEventBus), configs: make(map[string]DiscoveredPlugin), logger: logger, brokerCallbacks: &HostCallbacksForwarder{}, @@ -64,12 +61,6 @@ func (m *Manager) SetBrokerHostCallbacks(cb HostCallbacks) { m.brokerCallbacks.Set(cb) } -// SetGRPCBrokerFactory sets the factory used to create gRPC broker adapters. -// Must be called before loading any plugins with Mode "grpc". -func (m *Manager) SetGRPCBrokerFactory(f GRPCBrokerFactory) { - m.grpcFactory = f -} - // HostCallbacksForwarder lazily forwards HostCallbacks calls to a target // implementation. It is created immediately with the Manager but the target // is set later (after the MessageBrokerProxy is created). Calls made before @@ -130,17 +121,7 @@ func (m *Manager) LoadAll(cfg PluginsConfig, pluginsDir string) error { // LoadOne loads a single plugin by type and name from the given configuration. func (m *Manager) LoadOne(pluginType, name string, entry PluginEntry, pluginsDir string) error { - if entry.Mode == "grpc" { - return m.loadPlugin(DiscoveredPlugin{ - Name: name, - Type: pluginType, - Config: entry.Config, - FromConfig: true, - Address: entry.Address, - Mode: "grpc", - }) - } - if entry.SelfManaged || entry.Mode == "self-managed" { + if entry.SelfManaged { return m.loadPlugin(DiscoveredPlugin{ Name: name, Type: pluginType, @@ -148,7 +129,6 @@ func (m *Manager) LoadOne(pluginType, name string, entry PluginEntry, pluginsDir FromConfig: true, SelfManaged: true, Address: entry.Address, - Mode: entry.Mode, }) } path := resolvePluginPath(name, pluginType, entry.Path, pluginsDir, m.logger) @@ -161,16 +141,11 @@ func (m *Manager) LoadOne(pluginType, name string, entry PluginEntry, pluginsDir Path: path, Config: entry.Config, FromConfig: true, - Mode: entry.Mode, }) } // loadPlugin starts a plugin process (or connects to a self-managed one) and stores its client. func (m *Manager) loadPlugin(dp DiscoveredPlugin) error { - if dp.Mode == "grpc" { - return m.loadGRPCBroker(dp) - } - var protocolVersion uint var pluginMap map[string]goplugin.Plugin @@ -264,47 +239,6 @@ func (m *Manager) loadPlugin(dp DiscoveredPlugin) error { return nil } -// loadGRPCBroker creates a gRPC broker adapter via the configured factory. -func (m *Manager) loadGRPCBroker(dp DiscoveredPlugin) error { - if m.grpcFactory == nil { - return fmt.Errorf("gRPC broker factory not configured; cannot load plugin %s/%s in grpc mode", dp.Type, dp.Name) - } - if dp.Address == "" { - return fmt.Errorf("address is required for gRPC broker plugin %s/%s", dp.Type, dp.Name) - } - - adapter, err := m.grpcFactory(dp.Address, dp.Name, m.logger) - if err != nil { - return fmt.Errorf("failed to create gRPC broker adapter for %s/%s: %w", dp.Type, dp.Name, err) - } - - if len(dp.Config) > 0 { - if cfgErr := adapter.Configure(dp.Config); cfgErr != nil { - _ = adapter.Close() - return fmt.Errorf("failed to configure gRPC broker plugin %s: %w", dp.Name, cfgErr) - } - } - - key := dp.Type + ":" + dp.Name - m.mu.Lock() - if existing, ok := m.grpcBrokers[key]; ok { - _ = existing.Close() - } - if existing, ok := m.clients[key]; ok { - if !m.selfManaged[key] { - existing.Kill() - } - delete(m.clients, key) - delete(m.dispensed, key) - delete(m.selfManaged, key) - } - m.grpcBrokers[key] = adapter - m.configs[key] = dp - m.mu.Unlock() - - return nil -} - // loadSelfManagedPlugin creates a go-plugin client that connects to an // already-running plugin process at the configured address. The Hub does not // own the process — Kill() will not terminate it. @@ -412,18 +346,9 @@ func (m *Manager) Reconnect(pluginType, name string) error { } // GetBroker returns an eventbus.EventBus backed by the named broker plugin. -// For gRPC brokers, returns the adapter directly. For self-managed plugins, -// returns a reconnecting adapter that automatically re-establishes the -// connection if the plugin process restarts. +// For self-managed plugins, it returns a reconnecting adapter that automatically +// re-establishes the connection if the plugin process restarts. func (m *Manager) GetBroker(name string) (eventbus.EventBus, error) { - key := PluginTypeBroker + ":" + name - m.mu.RLock() - grpcAdapter, isGRPC := m.grpcBrokers[key] - m.mu.RUnlock() - if isGRPC { - return grpcAdapter, nil - } - raw, err := m.Get(PluginTypeBroker, name) if err != nil { return nil, err @@ -448,27 +373,10 @@ func (m *Manager) GetBroker(name string) (eventbus.EventBus, error) { func (m *Manager) ConfigureBroker(name string, extra map[string]string) error { key := PluginTypeBroker + ":" + name m.mu.RLock() - grpcAdapter, isGRPC := m.grpcBrokers[key] - raw, hasRaw := m.dispensed[key] + raw, ok := m.dispensed[key] dp, hasDP := m.configs[key] m.mu.RUnlock() - - // Merge original config with extra values. - merged := make(map[string]string) - if hasDP { - for k, v := range dp.Config { - merged[k] = v - } - } - for k, v := range extra { - merged[k] = v - } - - if isGRPC { - return grpcAdapter.Configure(merged) - } - - if !hasRaw { + if !ok { return fmt.Errorf("broker plugin not loaded: %s", name) } @@ -477,9 +385,19 @@ func (m *Manager) ConfigureBroker(name string, extra map[string]string) error { return fmt.Errorf("plugin %s is not a broker RPC client", name) } + // Start from the original plugin config and layer the extra values on top. + merged := make(map[string]string) + if hasDP { + for k, v := range dp.Config { + merged[k] = v + } + } if rpcClient.hostCallbacksAvailable { merged[hostCallbacksConfigKey] = "true" } + for k, v := range extra { + merged[k] = v + } return rpcClient.Configure(merged) } @@ -489,15 +407,11 @@ func (m *Manager) HasPlugin(pluginType, name string) bool { key := pluginType + ":" + name m.mu.RLock() _, ok := m.clients[key] - if !ok { - _, ok = m.grpcBrokers[key] - } m.mu.RUnlock() return ok } // IsSelfManaged returns true if the named plugin is loaded in self-managed mode. -// Returns false for gRPC mode brokers (they use a different mechanism). func (m *Manager) IsSelfManaged(pluginType, name string) bool { key := pluginType + ":" + name m.mu.RLock() @@ -505,50 +419,30 @@ func (m *Manager) IsSelfManaged(pluginType, name string) bool { return m.selfManaged[key] } -// IsGRPC returns true if the named plugin is loaded in gRPC mode. -func (m *Manager) IsGRPC(pluginType, name string) bool { - key := pluginType + ":" + name - m.mu.RLock() - defer m.mu.RUnlock() - _, ok := m.grpcBrokers[key] - return ok -} - // ListPlugins returns a list of all loaded plugin keys ("type:name"). func (m *Manager) ListPlugins() []string { m.mu.RLock() defer m.mu.RUnlock() - keys := make([]string, 0, len(m.clients)+len(m.grpcBrokers)) + keys := make([]string, 0, len(m.clients)) for k := range m.clients { keys = append(keys, k) } - for k := range m.grpcBrokers { - if _, dup := m.clients[k]; !dup { - keys = append(keys, k) - } - } return keys } // Shutdown kills all plugin processes gracefully. // Self-managed plugins are disconnected but their processes are not terminated. -// gRPC broker adapters are closed. func (m *Manager) Shutdown() { m.mu.Lock() defer m.mu.Unlock() - for key, adapter := range m.grpcBrokers { - m.logger.Info("Closing gRPC broker adapter", "plugin", key) - if err := adapter.Close(); err != nil { - m.logger.Warn("Failed to close gRPC broker adapter", "plugin", key, "error", err) - } - } - m.grpcBrokers = make(map[string]ConfigurableEventBus) - for key, client := range m.clients { if m.selfManaged[key] { m.logger.Info("Disconnecting self-managed plugin", "plugin", key) + // For self-managed plugins, Kill() with Test=true in the + // ReattachConfig will close the RPC connection without + // terminating the external process. } else { m.logger.Info("Shutting down plugin", "plugin", key) } diff --git a/pkg/plugin/settings.go b/pkg/plugin/settings.go index c9495d081..db1203c19 100644 --- a/pkg/plugin/settings.go +++ b/pkg/plugin/settings.go @@ -27,7 +27,6 @@ type V1PluginEntryLike struct { Config map[string]string SelfManaged bool Address string - Mode string } // PluginsConfigFromEntries builds a PluginsConfig from a broker entry map. diff --git a/pkg/runtime/common.go b/pkg/runtime/common.go index 57869a091..d62bde80e 100644 --- a/pkg/runtime/common.go +++ b/pkg/runtime/common.go @@ -428,12 +428,25 @@ func buildCommonRunArgs(config RunConfig) ([]string, error) { // Get command from harness var harnessArgs []string - if config.Harness != nil { + if config.NoAuth && config.NoAuthMessage != "" { + harnessArgs = []string{"sh", "-c", fmt.Sprintf("printf '%%s\\n' %s; exec bash", shellQuote(config.NoAuthMessage))} + } else if config.Harness != nil { harnessArgs = config.Harness.GetCommand(config.Task, config.Resume, config.CommandArgs) } else { return nil, fmt.Errorf("no harness provided") } + // When NoAuth is set, drop to a shell instead of launching the harness CLI. + // TODO: Only drop-to-shell is currently implemented. show-setup-instructions + // and run-setup-wizard are defined in config but not yet handled. + if config.NoAuth { + if config.NoAuthMessage != "" { + harnessArgs = []string{"sh", "-c", fmt.Sprintf("echo %s; exec bash", shellQuote(config.NoAuthMessage))} + } else { + harnessArgs = []string{"bash"} + } + } + // Build tmux-wrapped command — use POSIX single-quote escaping so that // shell metacharacters (backticks, $, etc.) in the task prompt are not // interpreted by sh -c. diff --git a/pkg/runtime/container.go b/pkg/runtime/container.go new file mode 100644 index 000000000..c7ad32e65 --- /dev/null +++ b/pkg/runtime/container.go @@ -0,0 +1,28 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package runtime + +import "os/exec" + +// DetectContainerRuntime finds an available container CLI (docker or podman). +// Returns the binary name, or "" if neither is found. +func DetectContainerRuntime() string { + for _, bin := range []string{"docker", "podman"} { + if p, err := exec.LookPath(bin); err == nil && p != "" { + return bin + } + } + return "" +} diff --git a/pkg/runtime/interface.go b/pkg/runtime/interface.go index 98acfdadd..c8212dabf 100644 --- a/pkg/runtime/interface.go +++ b/pkg/runtime/interface.go @@ -46,6 +46,8 @@ type RunConfig struct { GitClone *api.GitCloneConfig SharedDirs []api.SharedDir BrokerMode bool + NoAuth bool + NoAuthMessage string Debug bool MetadataInterception bool // Add NET_ADMIN cap for iptables-based metadata server interception ExtraHosts []string // Extra /etc/hosts entries (e.g. "host.docker.internal:host-gateway") diff --git a/pkg/runtime/k8s_runtime.go b/pkg/runtime/k8s_runtime.go index 12bf60ea9..db84f5c68 100644 --- a/pkg/runtime/k8s_runtime.go +++ b/pkg/runtime/k8s_runtime.go @@ -881,7 +881,9 @@ func (r *KubernetesRuntime) buildPod(namespace string, config RunConfig) (*corev // Command Resolution var cmd []string var harnessArgs []string - if config.Harness != nil { + if config.NoAuth && config.NoAuthMessage != "" { + harnessArgs = []string{"sh", "-c", fmt.Sprintf("printf '%%s\\n' %s; exec bash", shellQuote(config.NoAuthMessage))} + } else if config.Harness != nil { harnessArgs = config.Harness.GetCommand(config.Task, config.Resume, config.CommandArgs) } else { // Fallback if no harness (though RunConfig implies there should be one or defaults) diff --git a/pkg/runtimebroker/handlers.go b/pkg/runtimebroker/handlers.go index 6588dd191..ef72eee70 100644 --- a/pkg/runtimebroker/handlers.go +++ b/pkg/runtimebroker/handlers.go @@ -592,6 +592,7 @@ func (s *Server) createAgent(w http.ResponseWriter, r *http.Request) { CreatorName: req.CreatorName, ResolvedEnv: req.ResolvedEnv, ResolvedSecrets: req.ResolvedSecrets, + NoAuth: req.NoAuth, Attach: req.Attach, WorkspaceMode: req.WorkspaceMode, HTTPRequest: r, @@ -2325,6 +2326,7 @@ func (s *Server) finalizeEnv(w http.ResponseWriter, r *http.Request, id string) CreatorName: origReq.CreatorName, ResolvedEnv: pending.MergedEnv, ResolvedSecrets: origReq.ResolvedSecrets, + NoAuth: origReq.NoAuth, Attach: origReq.Attach, HTTPRequest: r, }) diff --git a/pkg/runtimebroker/start_context.go b/pkg/runtimebroker/start_context.go index 1a2b0949d..e93f53b0f 100644 --- a/pkg/runtimebroker/start_context.go +++ b/pkg/runtimebroker/start_context.go @@ -76,6 +76,7 @@ type startContextInputs struct { ResolvedSecrets []api.ResolvedSecret // Behavior + NoAuth bool Attach bool // WorkspaceMode is the resolved workspace sharing mode for the project @@ -367,6 +368,7 @@ func (s *Server) buildStartContext(ctx context.Context, in startContextInputs) ( Name: in.Name, BrokerMode: true, ProjectPath: in.ProjectPath, + NoAuth: in.NoAuth, } if in.Attach { @@ -501,7 +503,9 @@ func (s *Server) buildStartContext(ctx context.Context, in startContextInputs) ( opts.TelemetryOverride = &enabled } - if len(in.ResolvedSecrets) > 0 { + if in.NoAuth { + opts.ResolvedSecrets = nil + } else if len(in.ResolvedSecrets) > 0 { opts.ResolvedSecrets = in.ResolvedSecrets if s.config.Debug { s.envSecretLog.Debug("Received resolved secrets", "count", len(in.ResolvedSecrets)) diff --git a/pkg/runtimebroker/start_context_test.go b/pkg/runtimebroker/start_context_test.go index 59a15d819..81dcba97d 100644 --- a/pkg/runtimebroker/start_context_test.go +++ b/pkg/runtimebroker/start_context_test.go @@ -1211,3 +1211,54 @@ func TestWorktreeWorkspace_RepoRootDerivesToBase(t *testing.T) { t.Errorf("rel %q starts with .. — common.go dual-mount would NOT fire", rel) } } + +func TestBuildStartContext_NoAuth(t *testing.T) { + cfg := DefaultServerConfig() + cfg.StateDir = t.TempDir() + srv := newTestServerForStartContext(t, cfg) + + secrets := []api.ResolvedSecret{ + {Name: "CLAUDE_AUTH", Type: "file", Value: "secret-data", Target: "~/.claude/.credentials.json"}, + {Name: "API_KEY", Type: "environment", Value: "key-value", Target: "API_KEY"}, + } + + t.Run("NoAuth=true nils out secrets and sets opts.NoAuth", func(t *testing.T) { + r := httptest.NewRequest("POST", "/api/v1/agents", nil) + sc, err := srv.buildStartContext(context.Background(), startContextInputs{ + Name: "noauth-agent", + ResolvedSecrets: secrets, + NoAuth: true, + HTTPRequest: r, + }) + if err != nil { + t.Fatal(err) + } + + if !sc.Opts.NoAuth { + t.Error("expected opts.NoAuth to be true") + } + if sc.Opts.ResolvedSecrets != nil { + t.Errorf("expected nil ResolvedSecrets with NoAuth, got %d", len(sc.Opts.ResolvedSecrets)) + } + }) + + t.Run("NoAuth=false passes secrets through", func(t *testing.T) { + r := httptest.NewRequest("POST", "/api/v1/agents", nil) + sc, err := srv.buildStartContext(context.Background(), startContextInputs{ + Name: "auth-agent", + ResolvedSecrets: secrets, + NoAuth: false, + HTTPRequest: r, + }) + if err != nil { + t.Fatal(err) + } + + if sc.Opts.NoAuth { + t.Error("expected opts.NoAuth to be false") + } + if len(sc.Opts.ResolvedSecrets) != 2 { + t.Errorf("expected 2 resolved secrets, got %d", len(sc.Opts.ResolvedSecrets)) + } + }) +} diff --git a/pkg/runtimebroker/types.go b/pkg/runtimebroker/types.go index 16f1b2037..7c6f0a2e6 100644 --- a/pkg/runtimebroker/types.go +++ b/pkg/runtimebroker/types.go @@ -286,6 +286,8 @@ type CreateAgentRequest struct { // CreatorName is the human-readable identity of who created this agent. // Injected as the SCION_CREATOR environment variable in the agent container. CreatorName string `json:"creatorName,omitempty"` + // NoAuth indicates the agent should start without any injected credentials. + NoAuth bool `json:"noAuth,omitempty"` // Attach indicates the agent should start in interactive attach mode (not detached). Attach bool `json:"attach,omitempty"` // ProvisionOnly indicates the agent should be provisioned (dirs, worktree, templates) diff --git a/pkg/sciontool/log/log.go b/pkg/sciontool/log/log.go index e2dfaf3df..6311207e7 100644 --- a/pkg/sciontool/log/log.go +++ b/pkg/sciontool/log/log.go @@ -200,8 +200,24 @@ func (h *slogHandler) Enabled(_ context.Context, level slog.Level) bool { func (h *slogHandler) Handle(_ context.Context, r slog.Record) error { level := r.Level.String() msg := r.Message - // In a real implementation we might want to include attributes, - // but for sciontool we keep it simple for now. + if r.NumAttrs() > 0 || len(h.attrs) > 0 { + var buf []byte + buf = append(buf, msg...) + for _, a := range h.attrs { + buf = append(buf, ' ') + buf = append(buf, a.Key...) + buf = append(buf, '=') + buf = append(buf, a.Value.String()...) + } + r.Attrs(func(a slog.Attr) bool { + buf = append(buf, ' ') + buf = append(buf, a.Key...) + buf = append(buf, '=') + buf = append(buf, a.Value.String()...) + return true + }) + msg = string(buf) + } write(level, "slog", "%s", msg) return nil } diff --git a/pkg/sciontool/telemetry/pipeline.go b/pkg/sciontool/telemetry/pipeline.go index 5b09c2f92..bce065d5b 100644 --- a/pkg/sciontool/telemetry/pipeline.go +++ b/pkg/sciontool/telemetry/pipeline.go @@ -6,24 +6,35 @@ package telemetry import ( "context" + "errors" "fmt" + "log/slog" "os" + "strings" "sync" + "time" "github.com/GoogleCloudPlatform/scion/pkg/sciontool/log" + "go.opentelemetry.io/otel/attribute" + otelmetric "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/metric/noop" logspb "go.opentelemetry.io/proto/otlp/logs/v1" metricpb "go.opentelemetry.io/proto/otlp/metrics/v1" tracepb "go.opentelemetry.io/proto/otlp/trace/v1" + "google.golang.org/api/googleapi" ) // Pipeline orchestrates the telemetry collection and forwarding. type Pipeline struct { - config *Config - receiver *Receiver - exporter *CloudExporter - filter *Filter - mu sync.Mutex - running bool + config *Config + receiver *Receiver + exporter *CloudExporter + filter *Filter + mu sync.Mutex + running bool + healthCancel context.CancelFunc + exportErrors otelmetric.Int64Counter + meter otelmetric.Meter } // New creates a new telemetry pipeline. @@ -69,10 +80,21 @@ func (p *Pipeline) Start(ctx context.Context) error { if envVal := os.Getenv(EnvGCPCredentials); envVal == "" { source = "well-known-path" } - log.Info("GCP telemetry credentials: %s (source: %s, project: %s)", - p.config.GCPCredentialsFile, source, p.config.ProjectID) + slog.Info("telemetry pipeline credential resolution", + "credentials_file", p.config.GCPCredentialsFile, + "source", source, + "project_id", p.config.ProjectID, + "provider", p.config.CloudProvider, + "cloud_configured", p.config.IsCloudConfigured(), + ) } else if p.config.IsCloudConfigured() { - log.Info("GCP telemetry credentials: none (using ADC fallback)") + slog.Info("telemetry pipeline credential resolution", + "credentials_file", "", + "source", "adc", + "project_id", p.config.ProjectID, + "provider", p.config.CloudProvider, + "cloud_configured", true, + ) } // Create cloud exporter if configured @@ -93,7 +115,11 @@ func (p *Pipeline) Start(ctx context.Context) error { log.Info("Cloud exporter initialized (%s, project: %s)", mode, p.config.ProjectID) } } else { - log.Debug("Cloud export not configured - telemetry will only be received locally") + slog.Warn("telemetry cloud export not configured", + "reason", "no credentials or endpoint", + "env_checked", EnvGCPCredentials, + "well_known_path", WellKnownGCPCredentialsPath, + ) } // Create receiver with span and metric handlers @@ -108,6 +134,12 @@ func (p *Pipeline) Start(ctx context.Context) error { } p.running = true + + // Register pipeline health gauge and export error counter. + if p.config.IsCloudConfigured() && p.exporter != nil { + p.initSelfMetrics(ctx) + } + log.Info("Telemetry pipeline started (gRPC: %d, HTTP: %d)", p.config.GRPCPort, p.config.HTTPPort) return nil @@ -128,6 +160,12 @@ func (p *Pipeline) Stop(ctx context.Context) error { var errs []error + // Stop health gauge ticker + if p.healthCancel != nil { + p.healthCancel() + p.healthCancel = nil + } + // Stop receiver first if p.receiver != nil { if err := p.receiver.Stop(ctx); err != nil { @@ -188,6 +226,7 @@ func (p *Pipeline) handleSpans(ctx context.Context, resourceSpans []*tracepb.Res // Forward to cloud exporter if available if p.exporter != nil { if err := p.exporter.ExportProtoSpans(ctx, filtered); err != nil { + p.recordExportError(ctx, "spans", err) log.Error("Failed to export spans to cloud: %v", err) return err } @@ -249,6 +288,7 @@ func (p *Pipeline) handleMetrics(ctx context.Context, resourceMetrics []*metricp // directly via a MeterProvider. if p.exporter != nil { if err := p.exporter.ExportProtoMetrics(ctx, resourceMetrics); err != nil { + p.recordExportError(ctx, "metrics", err) log.Error("Failed to export metrics to cloud: %v", err) return err } @@ -274,6 +314,7 @@ func (p *Pipeline) handleLogs(ctx context.Context, resourceLogs []*logspb.Resour // Forward to cloud exporter if available if p.exporter != nil { if err := p.exporter.ExportProtoLogs(ctx, resourceLogs); err != nil { + p.recordExportError(ctx, "logs", err) log.Error("Failed to export logs to cloud: %v", err) return err } @@ -282,3 +323,126 @@ func (p *Pipeline) handleLogs(ctx context.Context, resourceLogs []*logspb.Resour return nil } + +// initSelfMetrics creates a minimal MeterProvider for self-monitoring metrics +// (pipeline health gauge and export error counter) and starts the health ticker. +func (p *Pipeline) initSelfMetrics(ctx context.Context) { + providers, err := NewProviders(ctx, p.config, true) + if err != nil || providers == nil || providers.MeterProvider == nil { + log.Debug("Could not create MeterProvider for pipeline self-metrics: %v", err) + p.meter = noop.Meter{} + } else { + // Shut down TracerProvider and LoggerProvider immediately — we only + // need the MeterProvider for self-monitoring metrics. + if providers.TracerProvider != nil { + _ = providers.TracerProvider.Shutdown(ctx) + } + if providers.LoggerProvider != nil { + _ = providers.LoggerProvider.Shutdown(ctx) + } + p.meter = providers.MeterProvider.Meter("github.com/GoogleCloudPlatform/scion/pkg/sciontool/telemetry") + } + + p.exportErrors, err = p.meter.Int64Counter("scion.telemetry.export.errors", + otelmetric.WithDescription("Count of telemetry export failures by signal type"), + otelmetric.WithUnit("{error}"), + ) + if err != nil { + log.Debug("Failed to create export error counter: %v", err) + } + + p.startHealthGauge(ctx, providers) +} + +// startHealthGauge registers the scion.telemetry.pipeline.status gauge and +// starts a background ticker that reports value 1 every 60 seconds. +func (p *Pipeline) startHealthGauge(ctx context.Context, providers *Providers) { + gauge, err := p.meter.Int64Gauge("scion.telemetry.pipeline.status", + otelmetric.WithDescription("Pipeline health status (1=running)"), + otelmetric.WithUnit("{status}"), + ) + if err != nil { + log.Debug("Failed to create pipeline health gauge: %v", err) + if providers != nil && providers.MeterProvider != nil { + _ = providers.MeterProvider.Shutdown(ctx) + } + return + } + + attrs := otelmetric.WithAttributes( + attribute.String("scion.telemetry.provider", p.config.CloudProvider), + attribute.String("scion.telemetry.project_id", p.config.ProjectID), + ) + + healthCtx, cancel := context.WithCancel(ctx) + p.healthCancel = cancel + + gauge.Record(healthCtx, 1, attrs) + + go func() { + ticker := time.NewTicker(60 * time.Second) + defer ticker.Stop() + for { + select { + case <-healthCtx.Done(): + if providers != nil && providers.MeterProvider != nil { + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) + _ = providers.MeterProvider.Shutdown(shutdownCtx) + shutdownCancel() + } + return + case <-ticker.C: + gauge.Record(healthCtx, 1, attrs) + } + } + }() +} + +// recordExportError increments the export error counter if registered. +func (p *Pipeline) recordExportError(ctx context.Context, signal string, err error) { + if p.exportErrors == nil { + return + } + p.exportErrors.Add(ctx, 1, + otelmetric.WithAttributes( + attribute.String("signal", signal), + attribute.String("error_type", classifyError(err)), + ), + ) +} + +// classifyError buckets an export error into a category for metric attributes. +func classifyError(err error) string { + if err == nil { + return "none" + } + + if errors.Is(err, context.DeadlineExceeded) { + return "timeout" + } + if errors.Is(err, context.Canceled) { + return "timeout" + } + + var gapiErr *googleapi.Error + if errors.As(err, &gapiErr) { + switch gapiErr.Code { + case 401, 403: + return "auth" + case 429: + return "quota" + } + } + + msg := strings.ToLower(err.Error()) + switch { + case strings.Contains(msg, "unauthorized") || strings.Contains(msg, "unauthenticated") || strings.Contains(msg, "permission denied"): + return "auth" + case strings.Contains(msg, "quota") || strings.Contains(msg, "rate limit") || strings.Contains(msg, "resource exhausted"): + return "quota" + case strings.Contains(msg, "deadline exceeded") || strings.Contains(msg, "timeout"): + return "timeout" + } + + return "other" +} diff --git a/pkg/sciontool/telemetry/pipeline_health_test.go b/pkg/sciontool/telemetry/pipeline_health_test.go new file mode 100644 index 000000000..9ea158716 --- /dev/null +++ b/pkg/sciontool/telemetry/pipeline_health_test.go @@ -0,0 +1,188 @@ +/* +Copyright 2025 The Scion Authors. +*/ + +package telemetry + +import ( + "context" + "errors" + "testing" + "time" + + "google.golang.org/api/googleapi" +) + +func TestPipeline_HealthGauge_Registers(t *testing.T) { + clearTelemetryEnv() + t.Setenv(EnvEnabled, "true") + t.Setenv(EnvCloudEnabled, "false") + t.Setenv(EnvGRPCPort, "54401") + t.Setenv(EnvHTTPPort, "54402") + defer clearTelemetryEnv() + + cfg := &Config{ + Enabled: true, + CloudEnabled: false, + GRPCPort: 54401, + HTTPPort: 54402, + CloudProvider: "", + } + pipeline := NewWithConfig(cfg) + if pipeline == nil { + t.Fatal("Expected non-nil pipeline") + } + + ctx := context.Background() + if err := pipeline.Start(ctx); err != nil { + t.Fatalf("Failed to start pipeline: %v", err) + } + defer func() { + stopCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + if err := pipeline.Stop(stopCtx); err != nil { + t.Errorf("pipeline.Stop: %v", err) + } + }() + + // Without cloud configured, health gauge should not be started + if pipeline.healthCancel != nil { + t.Error("Health gauge should not be started without cloud exporter") + } +} + +func TestPipeline_HealthGauge_StopsOnStop(t *testing.T) { + clearTelemetryEnv() + t.Setenv(EnvEnabled, "true") + t.Setenv(EnvCloudEnabled, "false") + t.Setenv(EnvGRPCPort, "54403") + t.Setenv(EnvHTTPPort, "54404") + defer clearTelemetryEnv() + + cfg := &Config{ + Enabled: true, + GRPCPort: 54403, + HTTPPort: 54404, + } + pipeline := NewWithConfig(cfg) + if pipeline == nil { + t.Fatal("Expected non-nil pipeline") + } + + ctx := context.Background() + if err := pipeline.Start(ctx); err != nil { + t.Fatalf("Failed to start pipeline: %v", err) + } + + // Stop the pipeline and verify healthCancel is cleared + stopCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + if err := pipeline.Stop(stopCtx); err != nil { + t.Fatalf("Failed to stop pipeline: %v", err) + } + + if pipeline.healthCancel != nil { + t.Error("healthCancel should be nil after Stop()") + } + if pipeline.IsRunning() { + t.Error("Pipeline should not be running after Stop()") + } +} + +func TestPipeline_ExportErrors_NilCounter(t *testing.T) { + cfg := &Config{ + Enabled: true, + GRPCPort: 54405, + HTTPPort: 54406, + } + pipeline := NewWithConfig(cfg) + if pipeline == nil { + t.Fatal("Expected non-nil pipeline") + } + + // recordExportError should be safe to call with nil counter + pipeline.recordExportError(context.Background(), "metrics", errors.New("test error")) +} + +func TestClassifyError(t *testing.T) { + tests := []struct { + name string + err error + expected string + }{ + { + name: "nil error", + err: nil, + expected: "none", + }, + { + name: "deadline exceeded", + err: context.DeadlineExceeded, + expected: "timeout", + }, + { + name: "context canceled", + err: context.Canceled, + expected: "timeout", + }, + { + name: "wrapped deadline exceeded", + err: errors.Join(errors.New("export failed"), context.DeadlineExceeded), + expected: "timeout", + }, + { + name: "googleapi 401", + err: &googleapi.Error{Code: 401, Message: "unauthorized"}, + expected: "auth", + }, + { + name: "googleapi 403", + err: &googleapi.Error{Code: 403, Message: "forbidden"}, + expected: "auth", + }, + { + name: "googleapi 429", + err: &googleapi.Error{Code: 429, Message: "too many requests"}, + expected: "quota", + }, + { + name: "permission denied string", + err: errors.New("rpc error: code = PermissionDenied desc = permission denied"), + expected: "auth", + }, + { + name: "unauthenticated string", + err: errors.New("rpc error: code = Unauthenticated"), + expected: "auth", + }, + { + name: "quota string", + err: errors.New("resource exhausted: quota exceeded"), + expected: "quota", + }, + { + name: "rate limit string", + err: errors.New("rate limit exceeded"), + expected: "quota", + }, + { + name: "timeout string", + err: errors.New("request timeout"), + expected: "timeout", + }, + { + name: "generic error", + err: errors.New("connection refused"), + expected: "other", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := classifyError(tt.err) + if result != tt.expected { + t.Errorf("classifyError(%v) = %q, want %q", tt.err, result, tt.expected) + } + }) + } +} diff --git a/pkg/store/concurrency.go b/pkg/store/concurrency.go index a54deae51..e0c9f93a2 100644 --- a/pkg/store/concurrency.go +++ b/pkg/store/concurrency.go @@ -57,9 +57,6 @@ const ( LockBrokerAffinityReap AdvisoryLockKey = 0x5C100006 // LockBrokerMessageSweep guards the periodic stuck-pending-message sweep (B5-2). LockBrokerMessageSweep AdvisoryLockKey = 0x5C100007 - // LockDiscordGateway guards the Discord Gateway connection so that only - // one standalone Discord bot instance opens the Gateway at a time. - LockDiscordGateway AdvisoryLockKey = 0x5C100008 // LockWorkspaceProvision is the CLASS ID for per-project workspace // provisioning locks. It is used with the two-int advisory lock form diff --git a/pkg/store/entadapter/composite.go b/pkg/store/entadapter/composite.go index 270b24535..11a7dbf68 100644 --- a/pkg/store/entadapter/composite.go +++ b/pkg/store/entadapter/composite.go @@ -53,7 +53,6 @@ type CompositeStore struct { *PolicyStore *BrokerDispatchStore *LifecycleHookStore - *DiscordPendingLinkStore *SkillStore client *ent.Client @@ -69,25 +68,24 @@ var _ store.Store = (*CompositeStore)(nil) // agent -> project) resolve natively without any shadow synchronization. func NewCompositeStore(client *ent.Client) *CompositeStore { return &CompositeStore{ - AgentStore: NewAgentStore(client), - ProjectStore: NewProjectStore(client), - UserStore: NewUserStore(client), - SecretStore: NewSecretStore(client), - TemplateStore: NewTemplateStore(client), - NotificationStore: NewNotificationStore(client), - ScheduleStore: NewScheduleStore(client), - MaintenanceStore: NewMaintenanceStore(client), - MessageStore: NewMessageStore(client), - ExternalStore: NewExternalStore(client), - BrokerSecretStore: NewBrokerSecretStore(client), - AllowListStore: NewAllowListStore(client), - GroupStore: NewGroupStore(client), - PolicyStore: NewPolicyStore(client), - BrokerDispatchStore: NewBrokerDispatchStore(client), - LifecycleHookStore: NewLifecycleHookStore(client), - DiscordPendingLinkStore: NewDiscordPendingLinkStore(client), - SkillStore: NewSkillStore(client), - client: client, + AgentStore: NewAgentStore(client), + ProjectStore: NewProjectStore(client), + UserStore: NewUserStore(client), + SecretStore: NewSecretStore(client), + TemplateStore: NewTemplateStore(client), + NotificationStore: NewNotificationStore(client), + ScheduleStore: NewScheduleStore(client), + MaintenanceStore: NewMaintenanceStore(client), + MessageStore: NewMessageStore(client), + ExternalStore: NewExternalStore(client), + BrokerSecretStore: NewBrokerSecretStore(client), + AllowListStore: NewAllowListStore(client), + GroupStore: NewGroupStore(client), + PolicyStore: NewPolicyStore(client), + BrokerDispatchStore: NewBrokerDispatchStore(client), + LifecycleHookStore: NewLifecycleHookStore(client), + SkillStore: NewSkillStore(client), + client: client, } } diff --git a/pkg/store/entadapter/discord_pending_link_store.go b/pkg/store/entadapter/discord_pending_link_store.go deleted file mode 100644 index 6b7b17bf3..000000000 --- a/pkg/store/entadapter/discord_pending_link_store.go +++ /dev/null @@ -1,142 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package entadapter - -import ( - "context" - "time" - - "github.com/GoogleCloudPlatform/scion/pkg/ent" - "github.com/GoogleCloudPlatform/scion/pkg/ent/discordpendinglink" - "github.com/GoogleCloudPlatform/scion/pkg/store" - "github.com/google/uuid" -) - -// DiscordPendingLinkStore implements store.DiscordPendingLinkStore using the -// Ent ORM. -type DiscordPendingLinkStore struct { - client *ent.Client -} - -// NewDiscordPendingLinkStore creates a new Ent-backed DiscordPendingLinkStore. -func NewDiscordPendingLinkStore(client *ent.Client) *DiscordPendingLinkStore { - return &DiscordPendingLinkStore{client: client} -} - -func entDiscordPendingLinkToStore(e *ent.DiscordPendingLink) *store.DiscordPendingLink { - return &store.DiscordPendingLink{ - ID: e.ID.String(), - Code: e.Code, - DiscordUserID: e.DiscordUserID, - Status: e.Status, - UserID: e.UserID, - UserEmail: e.UserEmail, - ExpiresAt: e.ExpiresAt, - CreatedAt: e.CreatedAt, - } -} - -func (s *DiscordPendingLinkStore) CreateDiscordPendingLink(ctx context.Context, link *store.DiscordPendingLink) error { - id := uuid.New() - if link.ID != "" { - var err error - id, err = parseUUID(link.ID) - if err != nil { - return err - } - } - if link.CreatedAt.IsZero() { - link.CreatedAt = time.Now() - } - - if err := s.client.DiscordPendingLink.Create(). - SetID(id). - SetCode(link.Code). - SetDiscordUserID(link.DiscordUserID). - SetStatus(link.Status). - SetUserID(link.UserID). - SetUserEmail(link.UserEmail). - SetExpiresAt(link.ExpiresAt). - SetCreatedAt(link.CreatedAt). - Exec(ctx); err != nil { - return mapError(err) - } - link.ID = id.String() - return nil -} - -func (s *DiscordPendingLinkStore) GetDiscordPendingLinkByCode(ctx context.Context, code string) (*store.DiscordPendingLink, error) { - e, err := s.client.DiscordPendingLink.Query(). - Where(discordpendinglink.CodeEQ(code)). - Only(ctx) - if err != nil { - return nil, mapError(err) - } - return entDiscordPendingLinkToStore(e), nil -} - -func (s *DiscordPendingLinkStore) GetDiscordPendingLinkByDiscordUser(ctx context.Context, discordUserID string) (*store.DiscordPendingLink, error) { - e, err := s.client.DiscordPendingLink.Query(). - Where(discordpendinglink.DiscordUserIDEQ(discordUserID)). - Only(ctx) - if err != nil { - return nil, mapError(err) - } - return entDiscordPendingLinkToStore(e), nil -} - -func (s *DiscordPendingLinkStore) UpdateDiscordPendingLink(ctx context.Context, link *store.DiscordPendingLink) error { - uid, err := parseUUID(link.ID) - if err != nil { - return err - } - n, err := s.client.DiscordPendingLink.Update(). - Where( - discordpendinglink.IDEQ(uid), - discordpendinglink.StatusEQ("pending"), - ). - SetStatus(link.Status). - SetUserID(link.UserID). - SetUserEmail(link.UserEmail). - Save(ctx) - if err != nil { - return mapError(err) - } - if n == 0 { - return store.ErrVersionConflict - } - return nil -} - -func (s *DiscordPendingLinkStore) DeleteDiscordPendingLink(ctx context.Context, code string) error { - _, err := s.client.DiscordPendingLink.Delete(). - Where(discordpendinglink.CodeEQ(code)). - Exec(ctx) - return err -} - -func (s *DiscordPendingLinkStore) DeleteDiscordPendingLinksByDiscordUser(ctx context.Context, discordUserID string) error { - _, err := s.client.DiscordPendingLink.Delete(). - Where(discordpendinglink.DiscordUserIDEQ(discordUserID)). - Exec(ctx) - return err -} - -func (s *DiscordPendingLinkStore) DeleteExpiredDiscordPendingLinks(ctx context.Context) (int, error) { - n, err := s.client.DiscordPendingLink.Delete(). - Where(discordpendinglink.ExpiresAtLT(time.Now())). - Exec(ctx) - return n, err -} diff --git a/pkg/store/entadapter/discord_pending_link_store_test.go b/pkg/store/entadapter/discord_pending_link_store_test.go deleted file mode 100644 index eaa5e3f47..000000000 --- a/pkg/store/entadapter/discord_pending_link_store_test.go +++ /dev/null @@ -1,193 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//go:build !no_sqlite - -package entadapter_test - -import ( - "context" - "testing" - "time" - - "github.com/GoogleCloudPlatform/scion/pkg/store" - "github.com/GoogleCloudPlatform/scion/pkg/store/entadapter" - "github.com/GoogleCloudPlatform/scion/pkg/store/enttest" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func newDiscordPendingLinkStore(t *testing.T) *entadapter.DiscordPendingLinkStore { - t.Helper() - return entadapter.NewDiscordPendingLinkStore(enttest.NewClient(t)) -} - -func TestDiscordPendingLink_CreateAndGetByCode(t *testing.T) { - s := newDiscordPendingLinkStore(t) - ctx := context.Background() - - link := &store.DiscordPendingLink{ - Code: "ABC123", - DiscordUserID: "discord-user-1", - Status: "pending", - ExpiresAt: time.Now().Add(15 * time.Minute), - } - require.NoError(t, s.CreateDiscordPendingLink(ctx, link)) - assert.NotEmpty(t, link.ID) - - got, err := s.GetDiscordPendingLinkByCode(ctx, "ABC123") - require.NoError(t, err) - assert.Equal(t, "ABC123", got.Code) - assert.Equal(t, "discord-user-1", got.DiscordUserID) - assert.Equal(t, "pending", got.Status) -} - -func TestDiscordPendingLink_GetByCode_NotFound(t *testing.T) { - s := newDiscordPendingLinkStore(t) - ctx := context.Background() - - _, err := s.GetDiscordPendingLinkByCode(ctx, "NONEXISTENT") - assert.ErrorIs(t, err, store.ErrNotFound) -} - -func TestDiscordPendingLink_GetByDiscordUser(t *testing.T) { - s := newDiscordPendingLinkStore(t) - ctx := context.Background() - - link := &store.DiscordPendingLink{ - Code: "XYZ789", - DiscordUserID: "discord-user-2", - Status: "pending", - ExpiresAt: time.Now().Add(15 * time.Minute), - } - require.NoError(t, s.CreateDiscordPendingLink(ctx, link)) - - got, err := s.GetDiscordPendingLinkByDiscordUser(ctx, "discord-user-2") - require.NoError(t, err) - assert.Equal(t, "XYZ789", got.Code) -} - -func TestDiscordPendingLink_Update(t *testing.T) { - s := newDiscordPendingLinkStore(t) - ctx := context.Background() - - link := &store.DiscordPendingLink{ - Code: "UPD001", - DiscordUserID: "discord-user-3", - Status: "pending", - ExpiresAt: time.Now().Add(15 * time.Minute), - } - require.NoError(t, s.CreateDiscordPendingLink(ctx, link)) - - link.Status = "confirmed" - link.UserID = "user-42" - link.UserEmail = "user@example.com" - require.NoError(t, s.UpdateDiscordPendingLink(ctx, link)) - - got, err := s.GetDiscordPendingLinkByCode(ctx, "UPD001") - require.NoError(t, err) - assert.Equal(t, "confirmed", got.Status) - assert.Equal(t, "user-42", got.UserID) - assert.Equal(t, "user@example.com", got.UserEmail) -} - -func TestDiscordPendingLink_DeleteByCode(t *testing.T) { - s := newDiscordPendingLinkStore(t) - ctx := context.Background() - - link := &store.DiscordPendingLink{ - Code: "DEL001", - DiscordUserID: "discord-user-4", - Status: "pending", - ExpiresAt: time.Now().Add(15 * time.Minute), - } - require.NoError(t, s.CreateDiscordPendingLink(ctx, link)) - require.NoError(t, s.DeleteDiscordPendingLink(ctx, "DEL001")) - - _, err := s.GetDiscordPendingLinkByCode(ctx, "DEL001") - assert.ErrorIs(t, err, store.ErrNotFound) -} - -func TestDiscordPendingLink_DeleteByDiscordUser(t *testing.T) { - s := newDiscordPendingLinkStore(t) - ctx := context.Background() - - link := &store.DiscordPendingLink{ - Code: "DEL002", - DiscordUserID: "discord-user-5", - Status: "pending", - ExpiresAt: time.Now().Add(15 * time.Minute), - } - require.NoError(t, s.CreateDiscordPendingLink(ctx, link)) - require.NoError(t, s.DeleteDiscordPendingLinksByDiscordUser(ctx, "discord-user-5")) - - _, err := s.GetDiscordPendingLinkByCode(ctx, "DEL002") - assert.ErrorIs(t, err, store.ErrNotFound) -} - -func TestDiscordPendingLink_DeleteExpired(t *testing.T) { - s := newDiscordPendingLinkStore(t) - ctx := context.Background() - - // Create one expired and one valid link. - expired := &store.DiscordPendingLink{ - Code: "EXP001", - DiscordUserID: "discord-expired", - Status: "pending", - ExpiresAt: time.Now().Add(-1 * time.Minute), - } - valid := &store.DiscordPendingLink{ - Code: "VAL001", - DiscordUserID: "discord-valid", - Status: "pending", - ExpiresAt: time.Now().Add(15 * time.Minute), - } - require.NoError(t, s.CreateDiscordPendingLink(ctx, expired)) - require.NoError(t, s.CreateDiscordPendingLink(ctx, valid)) - - n, err := s.DeleteExpiredDiscordPendingLinks(ctx) - require.NoError(t, err) - assert.Equal(t, 1, n) - - // Expired link should be gone. - _, err = s.GetDiscordPendingLinkByCode(ctx, "EXP001") - assert.ErrorIs(t, err, store.ErrNotFound) - - // Valid link should still exist. - got, err := s.GetDiscordPendingLinkByCode(ctx, "VAL001") - require.NoError(t, err) - assert.Equal(t, "VAL001", got.Code) -} - -func TestDiscordPendingLink_DuplicateCode(t *testing.T) { - s := newDiscordPendingLinkStore(t) - ctx := context.Background() - - link := &store.DiscordPendingLink{ - Code: "DUP001", - DiscordUserID: "discord-user-dup1", - Status: "pending", - ExpiresAt: time.Now().Add(15 * time.Minute), - } - require.NoError(t, s.CreateDiscordPendingLink(ctx, link)) - - dup := &store.DiscordPendingLink{ - Code: "DUP001", - DiscordUserID: "discord-user-dup2", - Status: "pending", - ExpiresAt: time.Now().Add(15 * time.Minute), - } - err := s.CreateDiscordPendingLink(ctx, dup) - assert.Error(t, err) -} diff --git a/pkg/store/entadapter/skill_store.go b/pkg/store/entadapter/skill_store.go index 4e9bd70bd..6c0187544 100644 --- a/pkg/store/entadapter/skill_store.go +++ b/pkg/store/entadapter/skill_store.go @@ -22,11 +22,11 @@ import ( "time" entsql "entgo.io/ent/dialect/sql" - "github.com/Masterminds/semver/v3" "github.com/GoogleCloudPlatform/scion/pkg/ent" entskill "github.com/GoogleCloudPlatform/scion/pkg/ent/skill" entskillversion "github.com/GoogleCloudPlatform/scion/pkg/ent/skillversion" "github.com/GoogleCloudPlatform/scion/pkg/store" + "github.com/Masterminds/semver/v3" ) // SkillStore implements store.SkillStore using Ent ORM. diff --git a/pkg/store/models.go b/pkg/store/models.go index 39c711efa..909e27683 100644 --- a/pkg/store/models.go +++ b/pkg/store/models.go @@ -165,6 +165,10 @@ type AgentAppliedConfig struct { // broker so it can apply the full configuration during agent provisioning. InlineConfig *api.ScionConfig `json:"inlineConfig,omitempty"` + // NoAuth indicates the agent should start with zero injected credentials. + // Stored on the agent record so restarts preserve the intent. + NoAuth bool `json:"noAuth,omitempty"` + // GCPIdentity holds the GCP identity assignment for this agent. GCPIdentity *GCPIdentityConfig `json:"gcpIdentity,omitempty"` } @@ -1975,18 +1979,6 @@ func (s *ProjectSyncState) UnmarshalJSON(data []byte) error { return nil } -// DiscordPendingLink holds state for a pending Discord account linking. -type DiscordPendingLink struct { - ID string `json:"id"` - Code string `json:"code"` - DiscordUserID string `json:"discordUserId"` - Status string `json:"status"` - UserID string `json:"userId"` - UserEmail string `json:"userEmail"` - ExpiresAt time.Time `json:"expiresAt"` - CreatedAt time.Time `json:"createdAt"` -} - // ============================================================================= // Skills (Skill Bank) // ============================================================================= diff --git a/pkg/store/store.go b/pkg/store/store.go index f7887097c..6753957f0 100644 --- a/pkg/store/store.go +++ b/pkg/store/store.go @@ -115,9 +115,6 @@ type Store interface { // LifecycleHook operations (Configurable Agent Lifecycle Hooks) LifecycleHookStore - // DiscordPendingLink operations (Discord Account Linking) - DiscordPendingLinkStore - // Skill operations (Skill Bank) SkillStore } @@ -1236,18 +1233,6 @@ type LifecycleHookFilter struct { Enabled *bool // Filter by enabled status (nil = no filter) } -// DiscordPendingLinkStore defines persistence operations for Discord account -// link codes, used by DiscordLinkService. -type DiscordPendingLinkStore interface { - CreateDiscordPendingLink(ctx context.Context, link *DiscordPendingLink) error - GetDiscordPendingLinkByCode(ctx context.Context, code string) (*DiscordPendingLink, error) - GetDiscordPendingLinkByDiscordUser(ctx context.Context, discordUserID string) (*DiscordPendingLink, error) - UpdateDiscordPendingLink(ctx context.Context, link *DiscordPendingLink) error - DeleteDiscordPendingLink(ctx context.Context, code string) error - DeleteDiscordPendingLinksByDiscordUser(ctx context.Context, discordUserID string) error - DeleteExpiredDiscordPendingLinks(ctx context.Context) (int, error) -} - // ============================================================================= // Skills (Skill Bank) // ============================================================================= diff --git a/proto/broker/v1/broker.pb.go b/proto/broker/v1/broker.pb.go deleted file mode 100644 index d7dbae050..000000000 --- a/proto/broker/v1/broker.pb.go +++ /dev/null @@ -1,1021 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.36.11 -// protoc v3.21.12 -// source: proto/broker/v1/broker.proto - -package brokerv1 - -import ( - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - reflect "reflect" - sync "sync" - unsafe "unsafe" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -// StructuredMessage mirrors messages.StructuredMessage (pkg/messages/types.go). -type StructuredMessage struct { - state protoimpl.MessageState `protogen:"open.v1"` - Version int32 `protobuf:"varint,1,opt,name=version,proto3" json:"version,omitempty"` - Timestamp string `protobuf:"bytes,2,opt,name=timestamp,proto3" json:"timestamp,omitempty"` - Sender string `protobuf:"bytes,3,opt,name=sender,proto3" json:"sender,omitempty"` - SenderId string `protobuf:"bytes,4,opt,name=sender_id,json=senderId,proto3" json:"sender_id,omitempty"` - Recipient string `protobuf:"bytes,5,opt,name=recipient,proto3" json:"recipient,omitempty"` - RecipientId string `protobuf:"bytes,6,opt,name=recipient_id,json=recipientId,proto3" json:"recipient_id,omitempty"` - Recipients string `protobuf:"bytes,7,opt,name=recipients,proto3" json:"recipients,omitempty"` - Msg string `protobuf:"bytes,8,opt,name=msg,proto3" json:"msg,omitempty"` - Type string `protobuf:"bytes,9,opt,name=type,proto3" json:"type,omitempty"` - Plain bool `protobuf:"varint,10,opt,name=plain,proto3" json:"plain,omitempty"` - Raw bool `protobuf:"varint,11,opt,name=raw,proto3" json:"raw,omitempty"` - Urgent bool `protobuf:"varint,12,opt,name=urgent,proto3" json:"urgent,omitempty"` - Broadcasted bool `protobuf:"varint,13,opt,name=broadcasted,proto3" json:"broadcasted,omitempty"` - ObserverOnly bool `protobuf:"varint,14,opt,name=observer_only,json=observerOnly,proto3" json:"observer_only,omitempty"` - Status string `protobuf:"bytes,15,opt,name=status,proto3" json:"status,omitempty"` - Attachments []string `protobuf:"bytes,16,rep,name=attachments,proto3" json:"attachments,omitempty"` - Metadata map[string]string `protobuf:"bytes,17,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` - Channel string `protobuf:"bytes,18,opt,name=channel,proto3" json:"channel,omitempty"` - ThreadId string `protobuf:"bytes,19,opt,name=thread_id,json=threadId,proto3" json:"thread_id,omitempty"` - Visibility string `protobuf:"bytes,20,opt,name=visibility,proto3" json:"visibility,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *StructuredMessage) Reset() { - *x = StructuredMessage{} - mi := &file_proto_broker_v1_broker_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *StructuredMessage) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*StructuredMessage) ProtoMessage() {} - -func (x *StructuredMessage) ProtoReflect() protoreflect.Message { - mi := &file_proto_broker_v1_broker_proto_msgTypes[0] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use StructuredMessage.ProtoReflect.Descriptor instead. -func (*StructuredMessage) Descriptor() ([]byte, []int) { - return file_proto_broker_v1_broker_proto_rawDescGZIP(), []int{0} -} - -func (x *StructuredMessage) GetVersion() int32 { - if x != nil { - return x.Version - } - return 0 -} - -func (x *StructuredMessage) GetTimestamp() string { - if x != nil { - return x.Timestamp - } - return "" -} - -func (x *StructuredMessage) GetSender() string { - if x != nil { - return x.Sender - } - return "" -} - -func (x *StructuredMessage) GetSenderId() string { - if x != nil { - return x.SenderId - } - return "" -} - -func (x *StructuredMessage) GetRecipient() string { - if x != nil { - return x.Recipient - } - return "" -} - -func (x *StructuredMessage) GetRecipientId() string { - if x != nil { - return x.RecipientId - } - return "" -} - -func (x *StructuredMessage) GetRecipients() string { - if x != nil { - return x.Recipients - } - return "" -} - -func (x *StructuredMessage) GetMsg() string { - if x != nil { - return x.Msg - } - return "" -} - -func (x *StructuredMessage) GetType() string { - if x != nil { - return x.Type - } - return "" -} - -func (x *StructuredMessage) GetPlain() bool { - if x != nil { - return x.Plain - } - return false -} - -func (x *StructuredMessage) GetRaw() bool { - if x != nil { - return x.Raw - } - return false -} - -func (x *StructuredMessage) GetUrgent() bool { - if x != nil { - return x.Urgent - } - return false -} - -func (x *StructuredMessage) GetBroadcasted() bool { - if x != nil { - return x.Broadcasted - } - return false -} - -func (x *StructuredMessage) GetObserverOnly() bool { - if x != nil { - return x.ObserverOnly - } - return false -} - -func (x *StructuredMessage) GetStatus() string { - if x != nil { - return x.Status - } - return "" -} - -func (x *StructuredMessage) GetAttachments() []string { - if x != nil { - return x.Attachments - } - return nil -} - -func (x *StructuredMessage) GetMetadata() map[string]string { - if x != nil { - return x.Metadata - } - return nil -} - -func (x *StructuredMessage) GetChannel() string { - if x != nil { - return x.Channel - } - return "" -} - -func (x *StructuredMessage) GetThreadId() string { - if x != nil { - return x.ThreadId - } - return "" -} - -func (x *StructuredMessage) GetVisibility() string { - if x != nil { - return x.Visibility - } - return "" -} - -// PluginInfo mirrors plugin.PluginInfo (pkg/plugin/config.go). -type PluginInfo struct { - state protoimpl.MessageState `protogen:"open.v1"` - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - Version string `protobuf:"bytes,2,opt,name=version,proto3" json:"version,omitempty"` - MinScionVersion string `protobuf:"bytes,3,opt,name=min_scion_version,json=minScionVersion,proto3" json:"min_scion_version,omitempty"` - ChannelId string `protobuf:"bytes,4,opt,name=channel_id,json=channelId,proto3" json:"channel_id,omitempty"` - Capabilities []string `protobuf:"bytes,5,rep,name=capabilities,proto3" json:"capabilities,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *PluginInfo) Reset() { - *x = PluginInfo{} - mi := &file_proto_broker_v1_broker_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *PluginInfo) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*PluginInfo) ProtoMessage() {} - -func (x *PluginInfo) ProtoReflect() protoreflect.Message { - mi := &file_proto_broker_v1_broker_proto_msgTypes[1] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use PluginInfo.ProtoReflect.Descriptor instead. -func (*PluginInfo) Descriptor() ([]byte, []int) { - return file_proto_broker_v1_broker_proto_rawDescGZIP(), []int{1} -} - -func (x *PluginInfo) GetName() string { - if x != nil { - return x.Name - } - return "" -} - -func (x *PluginInfo) GetVersion() string { - if x != nil { - return x.Version - } - return "" -} - -func (x *PluginInfo) GetMinScionVersion() string { - if x != nil { - return x.MinScionVersion - } - return "" -} - -func (x *PluginInfo) GetChannelId() string { - if x != nil { - return x.ChannelId - } - return "" -} - -func (x *PluginInfo) GetCapabilities() []string { - if x != nil { - return x.Capabilities - } - return nil -} - -// HealthStatus mirrors plugin.HealthStatus (pkg/plugin/broker_plugin.go). -type HealthStatus struct { - state protoimpl.MessageState `protogen:"open.v1"` - Status string `protobuf:"bytes,1,opt,name=status,proto3" json:"status,omitempty"` - Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` - Details map[string]string `protobuf:"bytes,3,rep,name=details,proto3" json:"details,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *HealthStatus) Reset() { - *x = HealthStatus{} - mi := &file_proto_broker_v1_broker_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *HealthStatus) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*HealthStatus) ProtoMessage() {} - -func (x *HealthStatus) ProtoReflect() protoreflect.Message { - mi := &file_proto_broker_v1_broker_proto_msgTypes[2] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use HealthStatus.ProtoReflect.Descriptor instead. -func (*HealthStatus) Descriptor() ([]byte, []int) { - return file_proto_broker_v1_broker_proto_rawDescGZIP(), []int{2} -} - -func (x *HealthStatus) GetStatus() string { - if x != nil { - return x.Status - } - return "" -} - -func (x *HealthStatus) GetMessage() string { - if x != nil { - return x.Message - } - return "" -} - -func (x *HealthStatus) GetDetails() map[string]string { - if x != nil { - return x.Details - } - return nil -} - -type ConfigureRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Config map[string]string `protobuf:"bytes,1,rep,name=config,proto3" json:"config,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ConfigureRequest) Reset() { - *x = ConfigureRequest{} - mi := &file_proto_broker_v1_broker_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ConfigureRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ConfigureRequest) ProtoMessage() {} - -func (x *ConfigureRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_broker_v1_broker_proto_msgTypes[3] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ConfigureRequest.ProtoReflect.Descriptor instead. -func (*ConfigureRequest) Descriptor() ([]byte, []int) { - return file_proto_broker_v1_broker_proto_rawDescGZIP(), []int{3} -} - -func (x *ConfigureRequest) GetConfig() map[string]string { - if x != nil { - return x.Config - } - return nil -} - -type ConfigureResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ConfigureResponse) Reset() { - *x = ConfigureResponse{} - mi := &file_proto_broker_v1_broker_proto_msgTypes[4] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ConfigureResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ConfigureResponse) ProtoMessage() {} - -func (x *ConfigureResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_broker_v1_broker_proto_msgTypes[4] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ConfigureResponse.ProtoReflect.Descriptor instead. -func (*ConfigureResponse) Descriptor() ([]byte, []int) { - return file_proto_broker_v1_broker_proto_rawDescGZIP(), []int{4} -} - -type PublishRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Topic string `protobuf:"bytes,1,opt,name=topic,proto3" json:"topic,omitempty"` - Message *StructuredMessage `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *PublishRequest) Reset() { - *x = PublishRequest{} - mi := &file_proto_broker_v1_broker_proto_msgTypes[5] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *PublishRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*PublishRequest) ProtoMessage() {} - -func (x *PublishRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_broker_v1_broker_proto_msgTypes[5] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use PublishRequest.ProtoReflect.Descriptor instead. -func (*PublishRequest) Descriptor() ([]byte, []int) { - return file_proto_broker_v1_broker_proto_rawDescGZIP(), []int{5} -} - -func (x *PublishRequest) GetTopic() string { - if x != nil { - return x.Topic - } - return "" -} - -func (x *PublishRequest) GetMessage() *StructuredMessage { - if x != nil { - return x.Message - } - return nil -} - -type PublishResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *PublishResponse) Reset() { - *x = PublishResponse{} - mi := &file_proto_broker_v1_broker_proto_msgTypes[6] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *PublishResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*PublishResponse) ProtoMessage() {} - -func (x *PublishResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_broker_v1_broker_proto_msgTypes[6] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use PublishResponse.ProtoReflect.Descriptor instead. -func (*PublishResponse) Descriptor() ([]byte, []int) { - return file_proto_broker_v1_broker_proto_rawDescGZIP(), []int{6} -} - -type SubscribeRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Pattern string `protobuf:"bytes,1,opt,name=pattern,proto3" json:"pattern,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *SubscribeRequest) Reset() { - *x = SubscribeRequest{} - mi := &file_proto_broker_v1_broker_proto_msgTypes[7] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *SubscribeRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*SubscribeRequest) ProtoMessage() {} - -func (x *SubscribeRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_broker_v1_broker_proto_msgTypes[7] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use SubscribeRequest.ProtoReflect.Descriptor instead. -func (*SubscribeRequest) Descriptor() ([]byte, []int) { - return file_proto_broker_v1_broker_proto_rawDescGZIP(), []int{7} -} - -func (x *SubscribeRequest) GetPattern() string { - if x != nil { - return x.Pattern - } - return "" -} - -type SubscribeResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *SubscribeResponse) Reset() { - *x = SubscribeResponse{} - mi := &file_proto_broker_v1_broker_proto_msgTypes[8] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *SubscribeResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*SubscribeResponse) ProtoMessage() {} - -func (x *SubscribeResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_broker_v1_broker_proto_msgTypes[8] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use SubscribeResponse.ProtoReflect.Descriptor instead. -func (*SubscribeResponse) Descriptor() ([]byte, []int) { - return file_proto_broker_v1_broker_proto_rawDescGZIP(), []int{8} -} - -type UnsubscribeRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Pattern string `protobuf:"bytes,1,opt,name=pattern,proto3" json:"pattern,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *UnsubscribeRequest) Reset() { - *x = UnsubscribeRequest{} - mi := &file_proto_broker_v1_broker_proto_msgTypes[9] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *UnsubscribeRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*UnsubscribeRequest) ProtoMessage() {} - -func (x *UnsubscribeRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_broker_v1_broker_proto_msgTypes[9] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use UnsubscribeRequest.ProtoReflect.Descriptor instead. -func (*UnsubscribeRequest) Descriptor() ([]byte, []int) { - return file_proto_broker_v1_broker_proto_rawDescGZIP(), []int{9} -} - -func (x *UnsubscribeRequest) GetPattern() string { - if x != nil { - return x.Pattern - } - return "" -} - -type UnsubscribeResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *UnsubscribeResponse) Reset() { - *x = UnsubscribeResponse{} - mi := &file_proto_broker_v1_broker_proto_msgTypes[10] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *UnsubscribeResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*UnsubscribeResponse) ProtoMessage() {} - -func (x *UnsubscribeResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_broker_v1_broker_proto_msgTypes[10] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use UnsubscribeResponse.ProtoReflect.Descriptor instead. -func (*UnsubscribeResponse) Descriptor() ([]byte, []int) { - return file_proto_broker_v1_broker_proto_rawDescGZIP(), []int{10} -} - -type HealthCheckRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *HealthCheckRequest) Reset() { - *x = HealthCheckRequest{} - mi := &file_proto_broker_v1_broker_proto_msgTypes[11] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *HealthCheckRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*HealthCheckRequest) ProtoMessage() {} - -func (x *HealthCheckRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_broker_v1_broker_proto_msgTypes[11] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use HealthCheckRequest.ProtoReflect.Descriptor instead. -func (*HealthCheckRequest) Descriptor() ([]byte, []int) { - return file_proto_broker_v1_broker_proto_rawDescGZIP(), []int{11} -} - -type HealthCheckResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Status *HealthStatus `protobuf:"bytes,1,opt,name=status,proto3" json:"status,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *HealthCheckResponse) Reset() { - *x = HealthCheckResponse{} - mi := &file_proto_broker_v1_broker_proto_msgTypes[12] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *HealthCheckResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*HealthCheckResponse) ProtoMessage() {} - -func (x *HealthCheckResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_broker_v1_broker_proto_msgTypes[12] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use HealthCheckResponse.ProtoReflect.Descriptor instead. -func (*HealthCheckResponse) Descriptor() ([]byte, []int) { - return file_proto_broker_v1_broker_proto_rawDescGZIP(), []int{12} -} - -func (x *HealthCheckResponse) GetStatus() *HealthStatus { - if x != nil { - return x.Status - } - return nil -} - -type GetInfoRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetInfoRequest) Reset() { - *x = GetInfoRequest{} - mi := &file_proto_broker_v1_broker_proto_msgTypes[13] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetInfoRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetInfoRequest) ProtoMessage() {} - -func (x *GetInfoRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_broker_v1_broker_proto_msgTypes[13] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetInfoRequest.ProtoReflect.Descriptor instead. -func (*GetInfoRequest) Descriptor() ([]byte, []int) { - return file_proto_broker_v1_broker_proto_rawDescGZIP(), []int{13} -} - -type GetInfoResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Info *PluginInfo `protobuf:"bytes,1,opt,name=info,proto3" json:"info,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetInfoResponse) Reset() { - *x = GetInfoResponse{} - mi := &file_proto_broker_v1_broker_proto_msgTypes[14] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetInfoResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetInfoResponse) ProtoMessage() {} - -func (x *GetInfoResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_broker_v1_broker_proto_msgTypes[14] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetInfoResponse.ProtoReflect.Descriptor instead. -func (*GetInfoResponse) Descriptor() ([]byte, []int) { - return file_proto_broker_v1_broker_proto_rawDescGZIP(), []int{14} -} - -func (x *GetInfoResponse) GetInfo() *PluginInfo { - if x != nil { - return x.Info - } - return nil -} - -var File_proto_broker_v1_broker_proto protoreflect.FileDescriptor - -const file_proto_broker_v1_broker_proto_rawDesc = "" + - "\n" + - "\x1cproto/broker/v1/broker.proto\x12\x0fscion.broker.v1\"\xaa\x05\n" + - "\x11StructuredMessage\x12\x18\n" + - "\aversion\x18\x01 \x01(\x05R\aversion\x12\x1c\n" + - "\ttimestamp\x18\x02 \x01(\tR\ttimestamp\x12\x16\n" + - "\x06sender\x18\x03 \x01(\tR\x06sender\x12\x1b\n" + - "\tsender_id\x18\x04 \x01(\tR\bsenderId\x12\x1c\n" + - "\trecipient\x18\x05 \x01(\tR\trecipient\x12!\n" + - "\frecipient_id\x18\x06 \x01(\tR\vrecipientId\x12\x1e\n" + - "\n" + - "recipients\x18\a \x01(\tR\n" + - "recipients\x12\x10\n" + - "\x03msg\x18\b \x01(\tR\x03msg\x12\x12\n" + - "\x04type\x18\t \x01(\tR\x04type\x12\x14\n" + - "\x05plain\x18\n" + - " \x01(\bR\x05plain\x12\x10\n" + - "\x03raw\x18\v \x01(\bR\x03raw\x12\x16\n" + - "\x06urgent\x18\f \x01(\bR\x06urgent\x12 \n" + - "\vbroadcasted\x18\r \x01(\bR\vbroadcasted\x12#\n" + - "\robserver_only\x18\x0e \x01(\bR\fobserverOnly\x12\x16\n" + - "\x06status\x18\x0f \x01(\tR\x06status\x12 \n" + - "\vattachments\x18\x10 \x03(\tR\vattachments\x12L\n" + - "\bmetadata\x18\x11 \x03(\v20.scion.broker.v1.StructuredMessage.MetadataEntryR\bmetadata\x12\x18\n" + - "\achannel\x18\x12 \x01(\tR\achannel\x12\x1b\n" + - "\tthread_id\x18\x13 \x01(\tR\bthreadId\x12\x1e\n" + - "\n" + - "visibility\x18\x14 \x01(\tR\n" + - "visibility\x1a;\n" + - "\rMetadataEntry\x12\x10\n" + - "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xa9\x01\n" + - "\n" + - "PluginInfo\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name\x12\x18\n" + - "\aversion\x18\x02 \x01(\tR\aversion\x12*\n" + - "\x11min_scion_version\x18\x03 \x01(\tR\x0fminScionVersion\x12\x1d\n" + - "\n" + - "channel_id\x18\x04 \x01(\tR\tchannelId\x12\"\n" + - "\fcapabilities\x18\x05 \x03(\tR\fcapabilities\"\xc2\x01\n" + - "\fHealthStatus\x12\x16\n" + - "\x06status\x18\x01 \x01(\tR\x06status\x12\x18\n" + - "\amessage\x18\x02 \x01(\tR\amessage\x12D\n" + - "\adetails\x18\x03 \x03(\v2*.scion.broker.v1.HealthStatus.DetailsEntryR\adetails\x1a:\n" + - "\fDetailsEntry\x12\x10\n" + - "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x94\x01\n" + - "\x10ConfigureRequest\x12E\n" + - "\x06config\x18\x01 \x03(\v2-.scion.broker.v1.ConfigureRequest.ConfigEntryR\x06config\x1a9\n" + - "\vConfigEntry\x12\x10\n" + - "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x13\n" + - "\x11ConfigureResponse\"d\n" + - "\x0ePublishRequest\x12\x14\n" + - "\x05topic\x18\x01 \x01(\tR\x05topic\x12<\n" + - "\amessage\x18\x02 \x01(\v2\".scion.broker.v1.StructuredMessageR\amessage\"\x11\n" + - "\x0fPublishResponse\",\n" + - "\x10SubscribeRequest\x12\x18\n" + - "\apattern\x18\x01 \x01(\tR\apattern\"\x13\n" + - "\x11SubscribeResponse\".\n" + - "\x12UnsubscribeRequest\x12\x18\n" + - "\apattern\x18\x01 \x01(\tR\apattern\"\x15\n" + - "\x13UnsubscribeResponse\"\x14\n" + - "\x12HealthCheckRequest\"L\n" + - "\x13HealthCheckResponse\x125\n" + - "\x06status\x18\x01 \x01(\v2\x1d.scion.broker.v1.HealthStatusR\x06status\"\x10\n" + - "\x0eGetInfoRequest\"B\n" + - "\x0fGetInfoResponse\x12/\n" + - "\x04info\x18\x01 \x01(\v2\x1b.scion.broker.v1.PluginInfoR\x04info2\x87\x04\n" + - "\rBrokerService\x12R\n" + - "\tConfigure\x12!.scion.broker.v1.ConfigureRequest\x1a\".scion.broker.v1.ConfigureResponse\x12L\n" + - "\aPublish\x12\x1f.scion.broker.v1.PublishRequest\x1a .scion.broker.v1.PublishResponse\x12R\n" + - "\tSubscribe\x12!.scion.broker.v1.SubscribeRequest\x1a\".scion.broker.v1.SubscribeResponse\x12X\n" + - "\vUnsubscribe\x12#.scion.broker.v1.UnsubscribeRequest\x1a$.scion.broker.v1.UnsubscribeResponse\x12X\n" + - "\vHealthCheck\x12#.scion.broker.v1.HealthCheckRequest\x1a$.scion.broker.v1.HealthCheckResponse\x12L\n" + - "\aGetInfo\x12\x1f.scion.broker.v1.GetInfoRequest\x1a .scion.broker.v1.GetInfoResponseB?Z=github.com/GoogleCloudPlatform/scion/proto/broker/v1;brokerv1b\x06proto3" - -var ( - file_proto_broker_v1_broker_proto_rawDescOnce sync.Once - file_proto_broker_v1_broker_proto_rawDescData []byte -) - -func file_proto_broker_v1_broker_proto_rawDescGZIP() []byte { - file_proto_broker_v1_broker_proto_rawDescOnce.Do(func() { - file_proto_broker_v1_broker_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_broker_v1_broker_proto_rawDesc), len(file_proto_broker_v1_broker_proto_rawDesc))) - }) - return file_proto_broker_v1_broker_proto_rawDescData -} - -var file_proto_broker_v1_broker_proto_msgTypes = make([]protoimpl.MessageInfo, 18) -var file_proto_broker_v1_broker_proto_goTypes = []any{ - (*StructuredMessage)(nil), // 0: scion.broker.v1.StructuredMessage - (*PluginInfo)(nil), // 1: scion.broker.v1.PluginInfo - (*HealthStatus)(nil), // 2: scion.broker.v1.HealthStatus - (*ConfigureRequest)(nil), // 3: scion.broker.v1.ConfigureRequest - (*ConfigureResponse)(nil), // 4: scion.broker.v1.ConfigureResponse - (*PublishRequest)(nil), // 5: scion.broker.v1.PublishRequest - (*PublishResponse)(nil), // 6: scion.broker.v1.PublishResponse - (*SubscribeRequest)(nil), // 7: scion.broker.v1.SubscribeRequest - (*SubscribeResponse)(nil), // 8: scion.broker.v1.SubscribeResponse - (*UnsubscribeRequest)(nil), // 9: scion.broker.v1.UnsubscribeRequest - (*UnsubscribeResponse)(nil), // 10: scion.broker.v1.UnsubscribeResponse - (*HealthCheckRequest)(nil), // 11: scion.broker.v1.HealthCheckRequest - (*HealthCheckResponse)(nil), // 12: scion.broker.v1.HealthCheckResponse - (*GetInfoRequest)(nil), // 13: scion.broker.v1.GetInfoRequest - (*GetInfoResponse)(nil), // 14: scion.broker.v1.GetInfoResponse - nil, // 15: scion.broker.v1.StructuredMessage.MetadataEntry - nil, // 16: scion.broker.v1.HealthStatus.DetailsEntry - nil, // 17: scion.broker.v1.ConfigureRequest.ConfigEntry -} -var file_proto_broker_v1_broker_proto_depIdxs = []int32{ - 15, // 0: scion.broker.v1.StructuredMessage.metadata:type_name -> scion.broker.v1.StructuredMessage.MetadataEntry - 16, // 1: scion.broker.v1.HealthStatus.details:type_name -> scion.broker.v1.HealthStatus.DetailsEntry - 17, // 2: scion.broker.v1.ConfigureRequest.config:type_name -> scion.broker.v1.ConfigureRequest.ConfigEntry - 0, // 3: scion.broker.v1.PublishRequest.message:type_name -> scion.broker.v1.StructuredMessage - 2, // 4: scion.broker.v1.HealthCheckResponse.status:type_name -> scion.broker.v1.HealthStatus - 1, // 5: scion.broker.v1.GetInfoResponse.info:type_name -> scion.broker.v1.PluginInfo - 3, // 6: scion.broker.v1.BrokerService.Configure:input_type -> scion.broker.v1.ConfigureRequest - 5, // 7: scion.broker.v1.BrokerService.Publish:input_type -> scion.broker.v1.PublishRequest - 7, // 8: scion.broker.v1.BrokerService.Subscribe:input_type -> scion.broker.v1.SubscribeRequest - 9, // 9: scion.broker.v1.BrokerService.Unsubscribe:input_type -> scion.broker.v1.UnsubscribeRequest - 11, // 10: scion.broker.v1.BrokerService.HealthCheck:input_type -> scion.broker.v1.HealthCheckRequest - 13, // 11: scion.broker.v1.BrokerService.GetInfo:input_type -> scion.broker.v1.GetInfoRequest - 4, // 12: scion.broker.v1.BrokerService.Configure:output_type -> scion.broker.v1.ConfigureResponse - 6, // 13: scion.broker.v1.BrokerService.Publish:output_type -> scion.broker.v1.PublishResponse - 8, // 14: scion.broker.v1.BrokerService.Subscribe:output_type -> scion.broker.v1.SubscribeResponse - 10, // 15: scion.broker.v1.BrokerService.Unsubscribe:output_type -> scion.broker.v1.UnsubscribeResponse - 12, // 16: scion.broker.v1.BrokerService.HealthCheck:output_type -> scion.broker.v1.HealthCheckResponse - 14, // 17: scion.broker.v1.BrokerService.GetInfo:output_type -> scion.broker.v1.GetInfoResponse - 12, // [12:18] is the sub-list for method output_type - 6, // [6:12] is the sub-list for method input_type - 6, // [6:6] is the sub-list for extension type_name - 6, // [6:6] is the sub-list for extension extendee - 0, // [0:6] is the sub-list for field type_name -} - -func init() { file_proto_broker_v1_broker_proto_init() } -func file_proto_broker_v1_broker_proto_init() { - if File_proto_broker_v1_broker_proto != nil { - return - } - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_broker_v1_broker_proto_rawDesc), len(file_proto_broker_v1_broker_proto_rawDesc)), - NumEnums: 0, - NumMessages: 18, - NumExtensions: 0, - NumServices: 1, - }, - GoTypes: file_proto_broker_v1_broker_proto_goTypes, - DependencyIndexes: file_proto_broker_v1_broker_proto_depIdxs, - MessageInfos: file_proto_broker_v1_broker_proto_msgTypes, - }.Build() - File_proto_broker_v1_broker_proto = out.File - file_proto_broker_v1_broker_proto_goTypes = nil - file_proto_broker_v1_broker_proto_depIdxs = nil -} diff --git a/proto/broker/v1/broker.proto b/proto/broker/v1/broker.proto deleted file mode 100644 index 586a3f3ae..000000000 --- a/proto/broker/v1/broker.proto +++ /dev/null @@ -1,108 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -syntax = "proto3"; - -package scion.broker.v1; - -option go_package = "github.com/GoogleCloudPlatform/scion/proto/broker/v1;brokerv1"; - -// BrokerService mirrors MessageBrokerPluginInterface (pkg/plugin/broker_plugin.go). -// It provides the gRPC transport for standalone broker plugins that run as -// independent services rather than go-plugin subprocesses. -service BrokerService { - rpc Configure(ConfigureRequest) returns (ConfigureResponse); - rpc Publish(PublishRequest) returns (PublishResponse); - rpc Subscribe(SubscribeRequest) returns (SubscribeResponse); - rpc Unsubscribe(UnsubscribeRequest) returns (UnsubscribeResponse); - rpc HealthCheck(HealthCheckRequest) returns (HealthCheckResponse); - rpc GetInfo(GetInfoRequest) returns (GetInfoResponse); -} - -// StructuredMessage mirrors messages.StructuredMessage (pkg/messages/types.go). -message StructuredMessage { - int32 version = 1; - string timestamp = 2; - string sender = 3; - string sender_id = 4; - string recipient = 5; - string recipient_id = 6; - string recipients = 7; - string msg = 8; - string type = 9; - bool plain = 10; - bool raw = 11; - bool urgent = 12; - bool broadcasted = 13; - bool observer_only = 14; - string status = 15; - repeated string attachments = 16; - map metadata = 17; - string channel = 18; - string thread_id = 19; - string visibility = 20; -} - -// PluginInfo mirrors plugin.PluginInfo (pkg/plugin/config.go). -message PluginInfo { - string name = 1; - string version = 2; - string min_scion_version = 3; - string channel_id = 4; - repeated string capabilities = 5; -} - -// HealthStatus mirrors plugin.HealthStatus (pkg/plugin/broker_plugin.go). -message HealthStatus { - string status = 1; - string message = 2; - map details = 3; -} - -message ConfigureRequest { - map config = 1; -} - -message ConfigureResponse {} - -message PublishRequest { - string topic = 1; - StructuredMessage message = 2; -} - -message PublishResponse {} - -message SubscribeRequest { - string pattern = 1; -} - -message SubscribeResponse {} - -message UnsubscribeRequest { - string pattern = 1; -} - -message UnsubscribeResponse {} - -message HealthCheckRequest {} - -message HealthCheckResponse { - HealthStatus status = 1; -} - -message GetInfoRequest {} - -message GetInfoResponse { - PluginInfo info = 1; -} diff --git a/proto/broker/v1/broker_grpc.pb.go b/proto/broker/v1/broker_grpc.pb.go deleted file mode 100644 index d6c348835..000000000 --- a/proto/broker/v1/broker_grpc.pb.go +++ /dev/null @@ -1,333 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -// Code generated by protoc-gen-go-grpc. DO NOT EDIT. -// versions: -// - protoc-gen-go-grpc v1.6.2 -// - protoc v3.21.12 -// source: proto/broker/v1/broker.proto - -package brokerv1 - -import ( - context "context" - grpc "google.golang.org/grpc" - codes "google.golang.org/grpc/codes" - status "google.golang.org/grpc/status" -) - -// This is a compile-time assertion to ensure that this generated file -// is compatible with the grpc package it is being compiled against. -// Requires gRPC-Go v1.64.0 or later. -const _ = grpc.SupportPackageIsVersion9 - -const ( - BrokerService_Configure_FullMethodName = "/scion.broker.v1.BrokerService/Configure" - BrokerService_Publish_FullMethodName = "/scion.broker.v1.BrokerService/Publish" - BrokerService_Subscribe_FullMethodName = "/scion.broker.v1.BrokerService/Subscribe" - BrokerService_Unsubscribe_FullMethodName = "/scion.broker.v1.BrokerService/Unsubscribe" - BrokerService_HealthCheck_FullMethodName = "/scion.broker.v1.BrokerService/HealthCheck" - BrokerService_GetInfo_FullMethodName = "/scion.broker.v1.BrokerService/GetInfo" -) - -// BrokerServiceClient is the client API for BrokerService service. -// -// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. -// -// BrokerService mirrors MessageBrokerPluginInterface (pkg/plugin/broker_plugin.go). -// It provides the gRPC transport for standalone broker plugins that run as -// independent services rather than go-plugin subprocesses. -type BrokerServiceClient interface { - Configure(ctx context.Context, in *ConfigureRequest, opts ...grpc.CallOption) (*ConfigureResponse, error) - Publish(ctx context.Context, in *PublishRequest, opts ...grpc.CallOption) (*PublishResponse, error) - Subscribe(ctx context.Context, in *SubscribeRequest, opts ...grpc.CallOption) (*SubscribeResponse, error) - Unsubscribe(ctx context.Context, in *UnsubscribeRequest, opts ...grpc.CallOption) (*UnsubscribeResponse, error) - HealthCheck(ctx context.Context, in *HealthCheckRequest, opts ...grpc.CallOption) (*HealthCheckResponse, error) - GetInfo(ctx context.Context, in *GetInfoRequest, opts ...grpc.CallOption) (*GetInfoResponse, error) -} - -type brokerServiceClient struct { - cc grpc.ClientConnInterface -} - -func NewBrokerServiceClient(cc grpc.ClientConnInterface) BrokerServiceClient { - return &brokerServiceClient{cc} -} - -func (c *brokerServiceClient) Configure(ctx context.Context, in *ConfigureRequest, opts ...grpc.CallOption) (*ConfigureResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(ConfigureResponse) - err := c.cc.Invoke(ctx, BrokerService_Configure_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *brokerServiceClient) Publish(ctx context.Context, in *PublishRequest, opts ...grpc.CallOption) (*PublishResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(PublishResponse) - err := c.cc.Invoke(ctx, BrokerService_Publish_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *brokerServiceClient) Subscribe(ctx context.Context, in *SubscribeRequest, opts ...grpc.CallOption) (*SubscribeResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(SubscribeResponse) - err := c.cc.Invoke(ctx, BrokerService_Subscribe_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *brokerServiceClient) Unsubscribe(ctx context.Context, in *UnsubscribeRequest, opts ...grpc.CallOption) (*UnsubscribeResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(UnsubscribeResponse) - err := c.cc.Invoke(ctx, BrokerService_Unsubscribe_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *brokerServiceClient) HealthCheck(ctx context.Context, in *HealthCheckRequest, opts ...grpc.CallOption) (*HealthCheckResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(HealthCheckResponse) - err := c.cc.Invoke(ctx, BrokerService_HealthCheck_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *brokerServiceClient) GetInfo(ctx context.Context, in *GetInfoRequest, opts ...grpc.CallOption) (*GetInfoResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(GetInfoResponse) - err := c.cc.Invoke(ctx, BrokerService_GetInfo_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -// BrokerServiceServer is the server API for BrokerService service. -// All implementations must embed UnimplementedBrokerServiceServer -// for forward compatibility. -// -// BrokerService mirrors MessageBrokerPluginInterface (pkg/plugin/broker_plugin.go). -// It provides the gRPC transport for standalone broker plugins that run as -// independent services rather than go-plugin subprocesses. -type BrokerServiceServer interface { - Configure(context.Context, *ConfigureRequest) (*ConfigureResponse, error) - Publish(context.Context, *PublishRequest) (*PublishResponse, error) - Subscribe(context.Context, *SubscribeRequest) (*SubscribeResponse, error) - Unsubscribe(context.Context, *UnsubscribeRequest) (*UnsubscribeResponse, error) - HealthCheck(context.Context, *HealthCheckRequest) (*HealthCheckResponse, error) - GetInfo(context.Context, *GetInfoRequest) (*GetInfoResponse, error) - mustEmbedUnimplementedBrokerServiceServer() -} - -// UnimplementedBrokerServiceServer must be embedded to have -// forward compatible implementations. -// -// NOTE: this should be embedded by value instead of pointer to avoid a nil -// pointer dereference when methods are called. -type UnimplementedBrokerServiceServer struct{} - -func (UnimplementedBrokerServiceServer) Configure(context.Context, *ConfigureRequest) (*ConfigureResponse, error) { - return nil, status.Error(codes.Unimplemented, "method Configure not implemented") -} -func (UnimplementedBrokerServiceServer) Publish(context.Context, *PublishRequest) (*PublishResponse, error) { - return nil, status.Error(codes.Unimplemented, "method Publish not implemented") -} -func (UnimplementedBrokerServiceServer) Subscribe(context.Context, *SubscribeRequest) (*SubscribeResponse, error) { - return nil, status.Error(codes.Unimplemented, "method Subscribe not implemented") -} -func (UnimplementedBrokerServiceServer) Unsubscribe(context.Context, *UnsubscribeRequest) (*UnsubscribeResponse, error) { - return nil, status.Error(codes.Unimplemented, "method Unsubscribe not implemented") -} -func (UnimplementedBrokerServiceServer) HealthCheck(context.Context, *HealthCheckRequest) (*HealthCheckResponse, error) { - return nil, status.Error(codes.Unimplemented, "method HealthCheck not implemented") -} -func (UnimplementedBrokerServiceServer) GetInfo(context.Context, *GetInfoRequest) (*GetInfoResponse, error) { - return nil, status.Error(codes.Unimplemented, "method GetInfo not implemented") -} -func (UnimplementedBrokerServiceServer) mustEmbedUnimplementedBrokerServiceServer() {} -func (UnimplementedBrokerServiceServer) testEmbeddedByValue() {} - -// UnsafeBrokerServiceServer may be embedded to opt out of forward compatibility for this service. -// Use of this interface is not recommended, as added methods to BrokerServiceServer will -// result in compilation errors. -type UnsafeBrokerServiceServer interface { - mustEmbedUnimplementedBrokerServiceServer() -} - -func RegisterBrokerServiceServer(s grpc.ServiceRegistrar, srv BrokerServiceServer) { - // If the following call panics, it indicates UnimplementedBrokerServiceServer was - // embedded by pointer and is nil. This will cause panics if an - // unimplemented method is ever invoked, so we test this at initialization - // time to prevent it from happening at runtime later due to I/O. - if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { - t.testEmbeddedByValue() - } - s.RegisterService(&BrokerService_ServiceDesc, srv) -} - -func _BrokerService_Configure_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(ConfigureRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(BrokerServiceServer).Configure(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: BrokerService_Configure_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(BrokerServiceServer).Configure(ctx, req.(*ConfigureRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _BrokerService_Publish_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(PublishRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(BrokerServiceServer).Publish(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: BrokerService_Publish_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(BrokerServiceServer).Publish(ctx, req.(*PublishRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _BrokerService_Subscribe_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(SubscribeRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(BrokerServiceServer).Subscribe(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: BrokerService_Subscribe_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(BrokerServiceServer).Subscribe(ctx, req.(*SubscribeRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _BrokerService_Unsubscribe_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(UnsubscribeRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(BrokerServiceServer).Unsubscribe(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: BrokerService_Unsubscribe_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(BrokerServiceServer).Unsubscribe(ctx, req.(*UnsubscribeRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _BrokerService_HealthCheck_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(HealthCheckRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(BrokerServiceServer).HealthCheck(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: BrokerService_HealthCheck_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(BrokerServiceServer).HealthCheck(ctx, req.(*HealthCheckRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _BrokerService_GetInfo_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(GetInfoRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(BrokerServiceServer).GetInfo(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: BrokerService_GetInfo_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(BrokerServiceServer).GetInfo(ctx, req.(*GetInfoRequest)) - } - return interceptor(ctx, in, info, handler) -} - -// BrokerService_ServiceDesc is the grpc.ServiceDesc for BrokerService service. -// It's only intended for direct use with grpc.RegisterService, -// and not to be introspected or modified (even as a copy) -var BrokerService_ServiceDesc = grpc.ServiceDesc{ - ServiceName: "scion.broker.v1.BrokerService", - HandlerType: (*BrokerServiceServer)(nil), - Methods: []grpc.MethodDesc{ - { - MethodName: "Configure", - Handler: _BrokerService_Configure_Handler, - }, - { - MethodName: "Publish", - Handler: _BrokerService_Publish_Handler, - }, - { - MethodName: "Subscribe", - Handler: _BrokerService_Subscribe_Handler, - }, - { - MethodName: "Unsubscribe", - Handler: _BrokerService_Unsubscribe_Handler, - }, - { - MethodName: "HealthCheck", - Handler: _BrokerService_HealthCheck_Handler, - }, - { - MethodName: "GetInfo", - Handler: _BrokerService_GetInfo_Handler, - }, - }, - Streams: []grpc.StreamDesc{}, - Metadata: "proto/broker/v1/broker.proto", -} diff --git a/scripts/starter-hub/gce-demo-provision.sh b/scripts/starter-hub/gce-demo-provision.sh index b22f5a4db..150a5f227 100755 --- a/scripts/starter-hub/gce-demo-provision.sh +++ b/scripts/starter-hub/gce-demo-provision.sh @@ -139,6 +139,7 @@ fi if ! gcloud iam service-accounts describe "${SERVICE_ACCOUNT_EMAIL}" &>/dev/null; then echo "Creating service account ${SERVICE_ACCOUNT_NAME}..." gcloud iam service-accounts create "${SERVICE_ACCOUNT_NAME}" \ + --project="${PROJECT_ID}" \ --display-name "Scion Demo Service Account" echo "Waiting for service account to propagate..." diff --git a/scripts/starter-hub/gce-demo-telemetry-sa.sh b/scripts/starter-hub/gce-demo-telemetry-sa.sh index cc504121e..a347fd874 100755 --- a/scripts/starter-hub/gce-demo-telemetry-sa.sh +++ b/scripts/starter-hub/gce-demo-telemetry-sa.sh @@ -107,6 +107,7 @@ gcloud services enable \ if ! gcloud iam service-accounts describe "${SA_EMAIL}" &>/dev/null; then echo "Creating service account ${SA_NAME}..." gcloud iam service-accounts create "${SA_NAME}" \ + --project="${PROJECT_ID}" \ --display-name "Scion Telemetry Writer" \ --description "Least-privilege SA for agent telemetry export (traces, logs, metrics)" diff --git a/web/src/components/pages/agent-configure.ts b/web/src/components/pages/agent-configure.ts index 7ea2bd46e..1e47871d7 100644 --- a/web/src/components/pages/agent-configure.ts +++ b/web/src/components/pages/agent-configure.ts @@ -836,6 +836,7 @@ export class ScionPageAgentConfigure extends LitElement { OAuth Token (env var) Vertex Model Garden Harness credential file + No Authentication ${this.authMethod && this.isUnsupported(selectedAuthCap || undefined) ? html`
${this.supportReason(selectedAuthCap || undefined)}
` diff --git a/web/src/components/pages/agent-create.ts b/web/src/components/pages/agent-create.ts index ce592a36f..f545662c3 100644 --- a/web/src/components/pages/agent-create.ts +++ b/web/src/components/pages/agent-create.ts @@ -1180,6 +1180,7 @@ private selectBrokerForProject(): void { OAuth Token (env var) Vertex Model Garden Harness credential file + No Authentication
Override the authentication method for the harness.