From 69f80b402838f3050317a8413733c8a68a8f00bf Mon Sep 17 00:00:00 2001 From: Preston Holmes Date: Thu, 11 Jun 2026 12:00:32 -0700 Subject: [PATCH 01/16] fix(starter-hub): add project flag to gcloud iam service-accounts create --- scripts/starter-hub/gce-demo-provision.sh | 1 + scripts/starter-hub/gce-demo-telemetry-sa.sh | 1 + 2 files changed, 2 insertions(+) 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)" From 76ffa9c3aea8d44e7a679d3de7f303fbe6d10417 Mon Sep 17 00:00:00 2001 From: Preston Holmes Date: Thu, 11 Jun 2026 12:10:33 -0700 Subject: [PATCH 02/16] docs(discord): add guide for agent-led installation and setup --- extras/scion-discord/README.md | 35 ++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/extras/scion-discord/README.md b/extras/scion-discord/README.md index 434a6c278..0508233e8 100644 --- a/extras/scion-discord/README.md +++ b/extras/scion-discord/README.md @@ -114,6 +114,41 @@ 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. Preparation & Credentials +Provide the agent with your Discord Bot credentials. Since these are sensitive secrets, you should either: +- Export them as environment variables on your deployment host: + ```bash + export DISCORD_BOT_TOKEN="your-bot-token" + export DISCORD_APP_ID="your-application-id" + export DISCORD_PUBLIC_KEY="your-public-key" + ``` +- Or point the agent to a temporary local environment file or secure path where they are stored. + +### 2. Instructing the Agent +You can copy and paste the following prompt to have the agent execute the installation: + +> **Agent Prompt:** +> Please build and configure the Discord plugin for the Scion hub on our active GCE instance. +> +> 1. **Build & Install:** Compile the plugin located in `extras/scion-discord` and install the binary `scion-plugin-discord` to `/usr/local/bin/`. +> 2. **Configure Hub Settings:** Read the existing `/home/scion/.scion/settings.yaml` (or the instance's active settings path) and update it to: +> - Add `- discord` to `server.message_broker.types` (enable message_broker if not already true). +> - Create the `plugins.broker.discord` block with `bot_token`, `application_id`, and `public_key` set from the environment variables (`DISCORD_BOT_TOKEN`, `DISCORD_APP_ID`, `DISCORD_PUBLIC_KEY`). +> - Set `db_path` to `/home/scion/.scion/discord.db` (or ensure the target directory exists and has write permissions for the `scion` user). +> 3. **Restart Service:** Restart the `scion-hub` systemd service (`sudo systemctl restart scion-hub`). +> 4. **Verify Deployment:** Stream or grep the `scion-hub` systemd logs (`journalctl -u scion-hub -n 50`) to verify that the message `Discord broker configured` or similar startup log is present and that there are no connection errors. + +### 3. 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. +- [ ] `systemctl is-active scion-hub` returns `active`. + ## User Guide ### Slash Commands From 904c5dca2de20ed4ec379be286baee6c93ae279b Mon Sep 17 00:00:00 2001 From: Preston Holmes Date: Thu, 11 Jun 2026 12:16:00 -0700 Subject: [PATCH 03/16] docs(discord): improve agent-led installation with interactive details and permissions integer --- extras/scion-discord/README.md | 52 ++++++++++++++++++++-------------- 1 file changed, 30 insertions(+), 22 deletions(-) diff --git a/extras/scion-discord/README.md b/extras/scion-discord/README.md index 0508233e8..10a0f4b06 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 @@ -118,31 +118,39 @@ The hub will discover and launch `scion-plugin-discord` as a managed subprocess. 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. Preparation & Credentials -Provide the agent with your Discord Bot credentials. Since these are sensitive secrets, you should either: -- Export them as environment variables on your deployment host: - ```bash - export DISCORD_BOT_TOKEN="your-bot-token" - export DISCORD_APP_ID="your-application-id" - export DISCORD_PUBLIC_KEY="your-public-key" - ``` -- Or point the agent to a temporary local environment file or secure path where they are stored. +### 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):** -### 2. Instructing the Agent -You can copy and paste the following prompt to have the agent execute the installation: +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 build and configure the Discord plugin for the Scion hub on our active GCE instance. +> Please configure the Discord plugin on our active Scion Hub instance. > -> 1. **Build & Install:** Compile the plugin located in `extras/scion-discord` and install the binary `scion-plugin-discord` to `/usr/local/bin/`. -> 2. **Configure Hub Settings:** Read the existing `/home/scion/.scion/settings.yaml` (or the instance's active settings path) and update it to: -> - Add `- discord` to `server.message_broker.types` (enable message_broker if not already true). -> - Create the `plugins.broker.discord` block with `bot_token`, `application_id`, and `public_key` set from the environment variables (`DISCORD_BOT_TOKEN`, `DISCORD_APP_ID`, `DISCORD_PUBLIC_KEY`). -> - Set `db_path` to `/home/scion/.scion/discord.db` (or ensure the target directory exists and has write permissions for the `scion` user). -> 3. **Restart Service:** Restart the `scion-hub` systemd service (`sudo systemctl restart scion-hub`). -> 4. **Verify Deployment:** Stream or grep the `scion-hub` systemd logs (`journalctl -u scion-hub -n 50`) to verify that the message `Discord broker configured` or similar startup log is present and that there are no connection errors. - -### 3. Verification Checklist (for the Agent) +> 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 `plugins.broker.discord` block with the provided token and app-id. +> - 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 broker configured` 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. From 790f49c2268d0fdb359488e4decc4e9ff13e1e5f Mon Sep 17 00:00:00 2001 From: Preston Holmes Date: Thu, 11 Jun 2026 12:20:17 -0700 Subject: [PATCH 04/16] docs(discord): fix configuration examples and add troubleshooting section --- extras/scion-discord/README.md | 84 +++++++++++++++++++++------------- 1 file changed, 51 insertions(+), 33 deletions(-) diff --git a/extras/scion-discord/README.md b/extras/scion-discord/README.md index 10a0f4b06..a57e2c037 100644 --- a/extras/scion-discord/README.md +++ b/extras/scion-discord/README.md @@ -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 @@ -243,18 +243,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 @@ -293,3 +293,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`). + From bffaef05421fe2cc08c8a98567f5bd7dfcce34c1 Mon Sep 17 00:00:00 2001 From: Preston Holmes Date: Thu, 11 Jun 2026 12:44:13 -0700 Subject: [PATCH 05/16] docs(discord): refine agent prompt and add configuration checklist to README --- extras/scion-discord/README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/extras/scion-discord/README.md b/extras/scion-discord/README.md index a57e2c037..b5651711e 100644 --- a/extras/scion-discord/README.md +++ b/extras/scion-discord/README.md @@ -146,17 +146,19 @@ You can copy and paste the following prompt to have an agent execute this instal > 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 `plugins.broker.discord` block with the provided token and app-id. +> - 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 broker configured` is present. +> 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 From 04b14a78bb5d5456f010107fb15a2c71cf586707 Mon Sep 17 00:00:00 2001 From: Preston Holmes Date: Thu, 11 Jun 2026 15:55:16 -0700 Subject: [PATCH 06/16] feat: add scion build CLI command for local harness container builds (#403) * Add `scion build` command for local harness-config image builds Introduces a top-level `scion build ` CLI command that builds a container image from a Dockerfile bundled in a harness-config directory. Supports --tag, --base-image, --push, --platform, and --dry-run flags. After a successful build, updates the harness-config's config.yaml image field to reference the built image. Also fixes Dockerfile content hashing: Dockerfiles were previously excluded from ComputeHarnessConfigRevision, so changes to them did not trigger re-sync to the Hub. Removes Dockerfile from the skipBasenames exclusion list. Extracts detectContainerRuntime() from pkg/hub/maintenance_executors.go into a shared pkg/runtime/container.go so both the new build command and the Hub executor can use it. * Address PR review: yaml.Node config update, error handling, dedup settings H1: Replace destructive yaml.Unmarshal/Marshal round-trip with targeted yaml.Node edit for config.yaml image update. Preserves comments, field order, and unknown fields. M1: Handle all os.Stat errors on Dockerfile, not just IsNotExist. M2: Load settings once instead of twice when both --base-image is unset and --push is set. L3: Remove extra blank line in maintenance_executors.go. * Guard against empty harness-config path and non-mapping YAML nodes Add empty-path check after FindHarnessConfigDir to prevent synthetic harness-configs (e.g. 'generic') from resolving Dockerfile against CWD. Verify yaml.MappingNode kind before manipulating doc.Content[0] to handle malformed config.yaml gracefully. --------- Co-authored-by: Scion Agent (harness-local-build-p1) --- cmd/build.go | 181 ++++++++++++++++++++++++++++++ pkg/config/harness_config.go | 1 - pkg/config/harness_config_test.go | 10 +- pkg/hub/maintenance_executors.go | 13 +-- pkg/runtime/container.go | 28 +++++ 5 files changed, 220 insertions(+), 13 deletions(-) create mode 100644 cmd/build.go create mode 100644 pkg/runtime/container.go 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/pkg/config/harness_config.go b/pkg/config/harness_config.go index 5745df0b5..9b1f21588 100644 --- a/pkg/config/harness_config.go +++ b/pkg/config/harness_config.go @@ -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/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/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 "" +} From 722858dc4d0da231c40a20ded5f06a3e98828c68 Mon Sep 17 00:00:00 2001 From: Preston Holmes Date: Thu, 11 Jun 2026 15:56:10 -0700 Subject: [PATCH 07/16] Integration login endpoint followup pr (#400) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: test-login hardening, agent CLI access, and UI nits - M1: Distinguish store.ErrNotFound from transient DB errors in GetUserByEmail — return 500 for unexpected failures instead of silently creating duplicate users. - L1: Add http.MaxBytesReader(4096) body size limit. - L2: Validate email contains "@" before processing. - L3: Track whether displayName was explicitly provided so the default (email) doesn't overwrite an existing user's display name. - Add harness-config subcommands to the agentAllowed map so agents can manage harness configs via the CLI. Fixes 3 (file-browser badge) and 4 (page title) were already resolved in the current codebase. * style: fix gofmt alignment in cli_mode.go * ci: retry after flaky TestPipeline_LogHandlerRegistered port conflict * test: update cli_mode tests to expect harness-config in agentAllowed --------- Co-authored-by: Scion Agent (dev-followup-pr) --- cmd/cli_mode.go | 10 ++++++++ cmd/cli_mode_test.go | 7 +++-- pkg/hub/handlers_test_login.go | 16 +++++++++++- pkg/hub/handlers_test_login_test.go | 40 +++++++++++++++++++++++++++-- 4 files changed, 68 insertions(+), 5 deletions(-) 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/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) From 5d47e7ea8848ae65ca6a41cc95fd193b56668051 Mon Sep 17 00:00:00 2001 From: Preston Holmes Date: Thu, 11 Jun 2026 16:04:02 -0700 Subject: [PATCH 08/16] feat: wire hub OTel metrics pipeline with GCP Cloud Monitoring export (#401) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: wire hub OTel metric recorders to Cloud Monitoring The hub's dbmetrics and dispatchmetrics recorders were created with NewDisabled() — all OTel instruments recorded to no-op sinks. This wires them to a real GCP Cloud Monitoring MeterProvider during server startup, lighting up ~20 existing instruments (pg LISTEN/NOTIFY latency, notifications published/delivered/dropped, subscriber lag, dispatch lifecycle, pool stats). New package pkg/observability/hubmetrics provides NewMeterProvider() which creates a PeriodicReader (60s) with the GCP metric exporter. Metric groups (db-notify, db-pool, dispatch, hub-auth, hub-gcp) can be independently disabled via env vars using OTel View Drop(). Graceful degradation: when GCPProjectID is empty or the exporter fails, the hub logs a warning and continues with disabled recorders — identical to the previous behavior. * test: clean up TestIsGroupDisabled table-driven test Use the table's 'want' field directly instead of re-deriving the expected value in the assertion body. * fix: add 10s timeout to hub MeterProvider shutdown Prevents indefinite hang if the GCP metric exporter is unresponsive during server shutdown. * feat: replace hub in-memory counters with OTel instruments (Phase 2) Add OTelMetricsRecorder and OTelGCPTokenMetrics that implement the existing MetricsRecorder and new GCPTokenMetricsRecorder interfaces using OTel instruments for Cloud Monitoring export. Both use a dual-write pattern — OTel instruments for cloud export plus embedded atomic structs for the /api/metrics JSON snapshot endpoint. New metrics: scion.hub.auth.*, scion.hub.registration.count, scion.hub.join.*, scion.hub.rotation.count, scion.hub.dispatch.*, scion.hub.brokers.connected, scion.hub.gcp.token.*, scion.hub.gcp.iam.duration. Closes #240 * fix: address Phase 2 review findings (M1, M2, M3) M1: Add operation attribute to RecordDispatch OTel instruments so dispatch failures can be broken down by operation type. M2: Expand hub-auth metric group to cover all broker lifecycle instruments (registration, join, rotation, brokers, dispatch) — not just scion.hub.auth.*. M3: Gate SetMetrics(otelMetrics) on broker auth being enabled, preventing /api/metrics from showing an all-zeros broker block when auth is disabled. * feat: add pipeline health gauge, export error counter, and structured logging Phase 3 of metrics-delivery: add observability to the agent-side telemetry pipeline so we can confirm it's working end-to-end in production. - Add scion.telemetry.pipeline.status gauge (Int64, value=1) that self-reports via the cloud exporter on a 60s ticker, confirming the pipeline is alive - Add scion.telemetry.export.errors counter with signal and error_type attributes, incrementing on metric/span/log export failures - Add classifyError() to bucket errors into timeout/auth/quota/other - Upgrade credential logging at pipeline startup from log.Info format strings to structured slog.Info with credentials_file, source, project_id, provider, and cloud_configured fields - Add structured slog.Warn when cloud export is not configured, including the env var and well-known path that were checked - Fix slog handler in pkg/sciontool/log to render key=value attributes instead of silently dropping them - Add pipeline_health_test.go with tests for health gauge lifecycle, nil-safe export error recording, and classifyError bucketing * fix: shut down unused TracerProvider and LoggerProvider in initSelfMetrics initSelfMetrics() creates providers via NewProviders() but only needs the MeterProvider. The TracerProvider and LoggerProvider were never shut down, leaking goroutines. Shut them down immediately after creation. * fix(metrics-delivery): address errcheck and staticcheck CI failures - Replace os.Setenv+cleanup with t.Setenv in hubmetrics tests - Wrap standalone os.Unsetenv calls with error checks - Handle Shutdown errors on TracerProvider, LoggerProvider, MeterProvider - Check pipeline.Stop error in test cleanup - Convert switch to tagged form (staticcheck QF1002) - Fix MeterProvider leak on early return in startHealthGauge --------- Co-authored-by: Scion Agent (metrics-phase1-dev) --- cmd/server_foreground.go | 69 +++++- pkg/hub/gcp_metrics.go | 8 + pkg/hub/otel_gcp_metrics.go | 125 ++++++++++ pkg/hub/otel_gcp_metrics_test.go | 185 +++++++++++++++ pkg/hub/otel_metrics.go | 174 ++++++++++++++ pkg/hub/otel_metrics_test.go | 224 ++++++++++++++++++ pkg/hub/server.go | 9 +- pkg/observability/hubmetrics/hubmetrics.go | 146 ++++++++++++ .../hubmetrics/hubmetrics_test.go | 172 ++++++++++++++ pkg/sciontool/log/log.go | 20 +- pkg/sciontool/telemetry/pipeline.go | 184 +++++++++++++- .../telemetry/pipeline_health_test.go | 189 +++++++++++++++ 12 files changed, 1484 insertions(+), 21 deletions(-) create mode 100644 pkg/hub/otel_gcp_metrics.go create mode 100644 pkg/hub/otel_gcp_metrics_test.go create mode 100644 pkg/hub/otel_metrics.go create mode 100644 pkg/hub/otel_metrics_test.go create mode 100644 pkg/observability/hubmetrics/hubmetrics.go create mode 100644 pkg/observability/hubmetrics/hubmetrics_test.go create mode 100644 pkg/sciontool/telemetry/pipeline_health_test.go diff --git a/cmd/server_foreground.go b/cmd/server_foreground.go index 0dd5b4f61..7d9bbac6b 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 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/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..d66a7d512 100644 --- a/pkg/hub/server.go +++ b/pkg/hub/server.go @@ -602,7 +602,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 @@ -1472,6 +1472,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/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/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..b37755650 --- /dev/null +++ b/pkg/sciontool/telemetry/pipeline_health_test.go @@ -0,0 +1,189 @@ +/* +Copyright 2025 The Scion Authors. +*/ + +package telemetry + +import ( + "context" + "errors" + "os" + "testing" + "time" + + "google.golang.org/api/googleapi" +) + +func TestPipeline_HealthGauge_Registers(t *testing.T) { + clearTelemetryEnv() + os.Setenv(EnvEnabled, "true") + os.Setenv(EnvCloudEnabled, "false") + os.Setenv(EnvGRPCPort, "54401") + os.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() + os.Setenv(EnvEnabled, "true") + os.Setenv(EnvCloudEnabled, "false") + os.Setenv(EnvGRPCPort, "54403") + os.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) + } + }) + } +} From 1efa5bd183ec2c774b072b705eacdbf3bde11b66 Mon Sep 17 00:00:00 2001 From: Preston Holmes Date: Thu, 11 Jun 2026 16:19:59 -0700 Subject: [PATCH 09/16] fix: suppress commentary (TypeAssistantReply) messages in Discord (#405) * fix: suppress commentary (TypeAssistantReply) messages in Discord Add unconditional early return in broker.Publish() to filter TypeAssistantReply before per-channel logic, covering all channels including those with ShowAssistantReply=true from the buggy setup default. Fix the setup default to false for new channel links. Clean up now-dead per-channel isAssistantReply check. * style: run gofmt on skill-related files inherited from main Fix formatting issues in files that were merged from main without gofmt, causing CI Build & Test check to fail. * fix: move commentary filter before webhook routing, remove dead branch Move the TypeAssistantReply early return before webhook routing and message formatting to avoid computing text that gets immediately discarded. Remove the now-unreachable TypeAssistantReply branch from the useWebhook condition. --------- Co-authored-by: Scion Agent (discord-commentary-filter-dev) --- extras/scion-discord/internal/discord/broker.go | 17 +++++++++-------- .../scion-discord/internal/discord/callbacks.go | 2 +- pkg/agent/caching_skill_resolver_test.go | 2 +- pkg/agent/skill_resolver.go | 4 +++- pkg/api/skill_uri.go | 8 ++++---- pkg/hub/skill_handlers.go | 4 ++-- pkg/hubclient/skills.go | 8 ++++---- pkg/store/entadapter/skill_store.go | 2 +- 8 files changed, 25 insertions(+), 22 deletions(-) diff --git a/extras/scion-discord/internal/discord/broker.go b/extras/scion-discord/internal/discord/broker.go index 52298b9fb..d00ec0891 100644 --- a/extras/scion-discord/internal/discord/broker.go +++ b/extras/scion-discord/internal/discord/broker.go @@ -455,14 +455,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 +494,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 +510,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/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/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/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/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. From 8b93b43e11aaa6f4ef42674d12c55b92fc12eff4 Mon Sep 17 00:00:00 2001 From: Preston Holmes Date: Thu, 11 Jun 2026 18:03:02 -0700 Subject: [PATCH 10/16] harness-auth-flow Phase 2 (#404) --- Makefile | 14 +- cmd/sciontool/commands/secret.go | 13 +- cmd/server_foreground.go | 6 - extras/scion-discord/Dockerfile | 9 - .../cmd/scion-plugin-discord/main.go | 193 +--- extras/scion-discord/go.mod | 2 +- .../scion-discord/internal/discord/broker.go | 5 - .../internal/discord/grpc_server.go | 174 --- .../internal/discord/grpc_server_test.go | 175 --- .../scion-discord/internal/discord/store.go | 21 +- .../internal/discord/store_postgres.go | 56 +- harnesses/codex/capture_auth.py | 168 +++ harnesses/codex/config.yaml | 7 + harnesses/opencode/capture_auth.py | 168 +++ harnesses/opencode/config.yaml | 7 + pkg/agent/provision.go | 10 + pkg/agent/run.go | 14 +- pkg/api/skill_uri_test.go | 2 +- pkg/config/harness_config.go | 2 +- pkg/config/schemas/settings-v1.schema.json | 20 + pkg/config/settings_v1.go | 29 +- pkg/ent/client.go | 179 +-- pkg/ent/discordpendinglink.go | 173 --- .../discordpendinglink/discordpendinglink.go | 115 -- pkg/ent/discordpendinglink/where.go | 511 --------- pkg/ent/discordpendinglink_create.go | 851 -------------- pkg/ent/discordpendinglink_delete.go | 88 -- pkg/ent/discordpendinglink_query.go | 565 --------- pkg/ent/discordpendinglink_update.go | 416 ------- pkg/ent/ent.go | 2 - pkg/ent/hook/hook.go | 12 - pkg/ent/migrate/schema.go | 33 - pkg/ent/mutation.go | 658 ----------- pkg/ent/predicate/predicate.go | 3 - pkg/ent/runtime.go | 31 - pkg/ent/schema/discordpendinglink.go | 72 -- pkg/ent/tx.go | 3 - pkg/harness/auth.go | 74 ++ pkg/harness/auth_test.go | 200 ++++ pkg/harness/claude/embeds/capture_auth.py | 168 +++ pkg/harness/claude/embeds/config.yaml | 8 + pkg/harness/container_script_harness.go | 97 ++ pkg/harness/gemini/embeds/capture_auth.py | 168 +++ pkg/harness/gemini/embeds/config.yaml | 8 + pkg/hub/discord_link.go | 134 +-- pkg/hub/discord_link_test.go | 240 ---- pkg/hub/grpc_broker_adapter.go | 329 ------ pkg/hub/grpc_broker_adapter_test.go | 308 ----- pkg/hub/handlers.go | 73 +- pkg/hub/httpdispatcher.go | 60 +- pkg/hub/httpdispatcher_test.go | 102 ++ pkg/hub/server.go | 8 +- pkg/plugin/config.go | 26 +- pkg/plugin/discovery.go | 16 +- pkg/plugin/manager.go | 152 +-- pkg/plugin/settings.go | 1 - pkg/runtime/common.go | 11 + pkg/runtime/interface.go | 2 + pkg/runtimebroker/handlers.go | 1 + pkg/runtimebroker/start_context.go | 6 +- pkg/runtimebroker/start_context_test.go | 51 + pkg/runtimebroker/types.go | 4 + pkg/store/concurrency.go | 3 - pkg/store/entadapter/composite.go | 38 +- .../entadapter/discord_pending_link_store.go | 142 --- .../discord_pending_link_store_test.go | 193 ---- pkg/store/models.go | 16 +- pkg/store/store.go | 15 - proto/broker/v1/broker.pb.go | 1021 ----------------- proto/broker/v1/broker.proto | 108 -- proto/broker/v1/broker_grpc.pb.go | 333 ------ 71 files changed, 1565 insertions(+), 7358 deletions(-) delete mode 100644 extras/scion-discord/Dockerfile delete mode 100644 extras/scion-discord/internal/discord/grpc_server.go delete mode 100644 extras/scion-discord/internal/discord/grpc_server_test.go create mode 100644 harnesses/codex/capture_auth.py create mode 100644 harnesses/opencode/capture_auth.py delete mode 100644 pkg/ent/discordpendinglink.go delete mode 100644 pkg/ent/discordpendinglink/discordpendinglink.go delete mode 100644 pkg/ent/discordpendinglink/where.go delete mode 100644 pkg/ent/discordpendinglink_create.go delete mode 100644 pkg/ent/discordpendinglink_delete.go delete mode 100644 pkg/ent/discordpendinglink_query.go delete mode 100644 pkg/ent/discordpendinglink_update.go delete mode 100644 pkg/ent/schema/discordpendinglink.go create mode 100644 pkg/harness/claude/embeds/capture_auth.py create mode 100644 pkg/harness/gemini/embeds/capture_auth.py delete mode 100644 pkg/hub/discord_link_test.go delete mode 100644 pkg/hub/grpc_broker_adapter.go delete mode 100644 pkg/hub/grpc_broker_adapter_test.go delete mode 100644 pkg/store/entadapter/discord_pending_link_store.go delete mode 100644 pkg/store/entadapter/discord_pending_link_store_test.go delete mode 100644 proto/broker/v1/broker.pb.go delete mode 100644 proto/broker/v1/broker.proto delete mode 100644 proto/broker/v1/broker_grpc.pb.go 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/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 7d9bbac6b..25d952229 100644 --- a/cmd/server_foreground.go +++ b/cmd/server_foreground.go @@ -1622,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), @@ -1637,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/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 d00ec0891..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) 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..8892097e6 100644 --- a/harnesses/codex/config.yaml +++ b/harnesses/codex/config.yaml @@ -82,9 +82,16 @@ auth: - name: CODEX_AUTH type: file target_suffix: "/.codex/auth.json" + field: CodexAuthFile autodetect: env: CODEX_API_KEY: api-key OPENAI_API_KEY: api-key files: CODEX_AUTH: auth-file +no_auth: + behavior: drop-to-shell + message: | + This agent started without credentials. + Set CODEX_API_KEY or OPENAI_API_KEY, or authenticate interactively. + Then capture credentials: python3 ~/.scion/harness/capture_auth.py 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..388caf64d 100644 --- a/harnesses/opencode/config.yaml +++ b/harnesses/opencode/config.yaml @@ -78,9 +78,16 @@ auth: - name: OPENCODE_AUTH type: file target_suffix: "/.local/share/opencode/auth.json" + field: OpenCodeAuthFile autodetect: env: ANTHROPIC_API_KEY: api-key OPENAI_API_KEY: api-key files: OPENCODE_AUTH: auth-file +no_auth: + behavior: drop-to-shell + message: | + This agent started without credentials. + Set ANTHROPIC_API_KEY or OPENAI_API_KEY, or authenticate interactively. + Then capture credentials: python3 ~/.scion/harness/capture_auth.py 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..a83087934 100644 --- a/pkg/agent/run.go +++ b/pkg/agent/run.go @@ -341,6 +341,8 @@ func (m *AgentManager) Start(ctx context.Context, opts api.StartOptions) (*api.A var h api.Harness var harnessConfigRevision string var resolvedImpl string + var noAuthMessage string + var resolvedAuthMeta *config.HarnessAuthMetadata if harnessConfigName != "" { var resolveTemplatePaths []string if opts.Template != "" { @@ -371,6 +373,10 @@ func (m *AgentManager) Start(ctx context.Context, opts api.StartOptions) (*api.A harnessConfigRevision = config.ComputeHarnessConfigRevision(resolved.ConfigDir.Path) } util.Debugf("harness resolution: implementation=%s harness=%q", resolved.Implementation, resolved.Config.Harness) + if opts.NoAuth && resolved.Config.NoAuth != nil { + noAuthMessage = resolved.Config.NoAuth.Message + } + resolvedAuthMeta = resolved.Config.Auth } } else { h = harness.New(harnessName) @@ -425,7 +431,11 @@ func (m *AgentManager) Start(ctx context.Context, opts api.StartOptions) (*api.A if !opts.NoAuth { auth = harness.GatherAuthWithEnv(authEnvOverlay, !opts.BrokerMode) if opts.BrokerMode { - harness.OverlayFileSecrets(&auth, opts.ResolvedSecrets) + if resolvedAuthMeta != nil { + harness.OverlayFileSecretsFromConfig(&auth, opts.ResolvedSecrets, resolvedAuthMeta) + } else { + harness.OverlayFileSecrets(&auth, opts.ResolvedSecrets) + } } util.Debugf("auth: gathered credentials — selectedType=%q, hasGeminiKey=%t, hasGoogleKey=%t, hasOAuth=%t, hasADC=%t, hasAnthropicKey=%t, hasClaudeOAuthToken=%t, hasClaudeAuthFile=%t, cloudProject=%q, gcpMetadataMode=%q, brokerMode=%t", auth.SelectedType, @@ -886,6 +896,8 @@ func (m *AgentManager) Start(ctx context.Context, opts api.StartOptions) (*api.A GitClone: opts.GitClone, SharedDirs: effectiveSharedDirs, BrokerMode: opts.BrokerMode, + NoAuth: opts.NoAuth, + NoAuthMessage: noAuthMessage, Debug: util.DebugEnabled(), Resume: opts.Resume, MetadataInterception: hasMetadataInterception(agentEnv), 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 9b1f21588..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/"} { diff --git a/pkg/config/schemas/settings-v1.schema.json b/pkg/config/schemas/settings-v1.schema.json index ceb9f5d22..10c11a643 100644 --- a/pkg/config/schemas/settings-v1.schema.json +++ b/pkg/config/schemas/settings-v1.schema.json @@ -328,6 +328,10 @@ "$ref": "#/$defs/harnessAuthMetadata", "description": "Declarative auth preflight metadata for broker and hosted dispatch." }, + "no_auth": { + "$ref": "#/$defs/harnessNoAuthConfig", + "description": "Defines behavior when an agent starts with --no-auth (no credentials)." + }, "mcp": { "$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." @@ -340,6 +344,21 @@ }, "additionalProperties": false }, + "harnessNoAuthConfig": { + "type": "object", + "properties": { + "behavior": { + "type": "string", + "enum": ["drop-to-shell", "show-setup-instructions", "run-setup-wizard"], + "description": "How the container starts when no credentials are present." + }, + "message": { + "type": "string", + "description": "Message displayed to the user when the agent starts without credentials." + } + }, + "additionalProperties": false + }, "harnessMCPMapping": { "type": "object", "properties": { @@ -528,6 +547,7 @@ "type": { "type": "string" }, "description": { "type": "string" }, "target_suffix": { "type": "string" }, + "field": { "type": "string" }, "alternative_env_keys": { "type": "array", "items": { "type": "string" } diff --git a/pkg/config/settings_v1.go b/pkg/config/settings_v1.go index 401e9ca39..15d4dcdc8 100644 --- a/pkg/config/settings_v1.go +++ b/pkg/config/settings_v1.go @@ -331,14 +331,9 @@ type V1PluginEntry struct { // SelfManaged indicates the plugin manages its own process lifecycle. // The Hub connects to the plugin's RPC server rather than starting it. 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). When empty, falls back to SelfManaged for - // backward compatibility. - Mode string `json:"mode,omitempty" yaml:"mode,omitempty" koanf:"mode"` } // V1ServerHubConfig holds the Hub API server settings (when running scion-server). @@ -720,6 +715,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"` + NoAuth *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 +764,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 +793,20 @@ type HarnessAuthAutodetect struct { Files map[string]string `json:"files,omitempty" yaml:"files,omitempty" koanf:"files"` } +// HarnessNoAuthConfig defines what happens when an agent starts with +// --no-auth (no credentials injected). The behavior field determines +// how the container starts. +type HarnessNoAuthConfig struct { + // Behavior controls the container startup when no credentials are present. + // Supported values: + // "drop-to-shell" — start the container but don't launch the harness CLI + // "show-setup-instructions" — launch normally but display setup instructions + // "run-setup-wizard" — execute a setup script before the main process + Behavior string `json:"behavior,omitempty" yaml:"behavior,omitempty" koanf:"behavior"` + // Message is displayed to the user when the agent starts without credentials. + 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..37a358bb5 100644 --- a/pkg/harness/claude/embeds/config.yaml +++ b/pkg/harness/claude/embeds/config.yaml @@ -66,6 +66,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 +75,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 @@ -86,3 +88,9 @@ auth: files: CLAUDE_AUTH: auth-file gcloud-adc: vertex-ai +no_auth: + behavior: drop-to-shell + message: | + This agent started without credentials. + To authenticate, run: claude login + Then capture credentials: python3 ~/.scion/harness/capture_auth.py 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..f94e99a6d 100644 --- a/pkg/harness/gemini/embeds/config.yaml +++ b/pkg/harness/gemini/embeds/config.yaml @@ -63,6 +63,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 +72,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 @@ -83,3 +85,9 @@ auth: files: GEMINI_OAUTH_CREDS: auth-file gcloud-adc: vertex-ai +no_auth: + behavior: drop-to-shell + message: | + This agent started without credentials. + To authenticate, run: gcloud auth application-default login + Then capture credentials: python3 ~/.scion/harness/capture_auth.py 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/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..e977c036e 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 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/server.go b/pkg/hub/server.go index d66a7d512..f02a8d1f8 100644 --- a/pkg/hub/server.go +++ b/pkg/hub/server.go @@ -433,6 +433,10 @@ type RemoteCreateAgentRequest struct { // (e.g. "shared", "per-agent", "worktree-per-agent"). Threaded from the // Hub so the broker can branch dispatch without re-deriving from labels. WorkspaceMode string `json:"workspaceMode,omitempty"` + + // NoAuth indicates the agent should start with zero injected credentials. + // When true, the broker skips credential injection. + NoAuth bool `json:"noAuth,omitempty"` } // ResolvedSecret represents a secret resolved by the Hub for projection into an agent container. @@ -746,8 +750,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() { 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..e07f7424b 100644 --- a/pkg/runtime/common.go +++ b/pkg/runtime/common.go @@ -434,6 +434,17 @@ func buildCommonRunArgs(config RunConfig) ([]string, error) { 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/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/runtimebroker/handlers.go b/pkg/runtimebroker/handlers.go index 6588dd191..55c1921c2 100644 --- a/pkg/runtimebroker/handlers.go +++ b/pkg/runtimebroker/handlers.go @@ -593,6 +593,7 @@ func (s *Server) createAgent(w http.ResponseWriter, r *http.Request) { ResolvedEnv: req.ResolvedEnv, ResolvedSecrets: req.ResolvedSecrets, Attach: req.Attach, + NoAuth: req.NoAuth, WorkspaceMode: req.WorkspaceMode, HTTPRequest: r, }) diff --git a/pkg/runtimebroker/start_context.go b/pkg/runtimebroker/start_context.go index 1a2b0949d..cd951acfa 100644 --- a/pkg/runtimebroker/start_context.go +++ b/pkg/runtimebroker/start_context.go @@ -77,6 +77,7 @@ type startContextInputs struct { // Behavior Attach bool + NoAuth bool // WorkspaceMode is the resolved workspace sharing mode for the project // (e.g. "worktree-per-agent"). Threaded from CreateAgentRequest so the @@ -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..46e477d02 100644 --- a/pkg/runtimebroker/types.go +++ b/pkg/runtimebroker/types.go @@ -326,6 +326,10 @@ type CreateAgentRequest struct { // (e.g. "shared", "per-agent", "worktree-per-agent"). Threaded from the // Hub so the broker can branch dispatch without re-deriving from labels. WorkspaceMode string `json:"workspaceMode,omitempty"` + + // NoAuth indicates the agent should start with zero injected credentials. + // When true, the broker skips credential injection. + NoAuth bool `json:"noAuth,omitempty"` } // UnmarshalJSON implements custom unmarshaling to support legacy grove fields. 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/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", -} From d8b7fc693bc001d3aebe617ad8f75d3775a95507 Mon Sep 17 00:00:00 2001 From: Preston Holmes Date: Thu, 11 Jun 2026 18:45:36 -0700 Subject: [PATCH 11/16] Scion/harness local build p2 (#406) * Add hub build executor and web UI for harness-config images Phase 2: adds BuildHarnessConfigImageExecutor to build container images from a harness-config's bundled Dockerfile, with web UI for triggering builds, monitoring progress, and viewing logs. Includes all review fixes: - Path traversal containment check in file materialization - Uses shared scionruntime.DetectContainerRuntime() from Phase 1 - Uses hc.Slug (identifier-safe) for image names with Name fallback - Consecutive-error limit (5) in build status polling - Reactive checkbox binding (?checked) in build dialog - Property binding (.value) for tag input - Auto-scroll build log to bottom on new output * fix: address PR #406 review comments --------- Co-authored-by: Scion Agent (harness-local-build-p2-fix) --- pkg/hub/admin_maintenance.go | 10 + pkg/hub/maintenance_executors.go | 157 +++++++++++ pkg/store/entadapter/maintenance_store.go | 6 + .../components/pages/harness-config-detail.ts | 261 +++++++++++++++++- 4 files changed, 433 insertions(+), 1 deletion(-) diff --git a/pkg/hub/admin_maintenance.go b/pkg/hub/admin_maintenance.go index c484b8583..dafc17b5e 100644 --- a/pkg/hub/admin_maintenance.go +++ b/pkg/hub/admin_maintenance.go @@ -293,6 +293,16 @@ func (s *Server) resolveMaintenanceExecutor(key string) (MaintenanceExecutor, er return &RebuildContainerBinariesExecutor{ repoPath: mc.RepoPath, }, nil + case "build-harness-config-image": + log.Debug("Resolved build-harness-config-image executor", + "runtime_bin", mc.RuntimeBin, "registry", mc.ImageRegistry, "tag", mc.ImageTag) + return &BuildHarnessConfigImageExecutor{ + store: s.store, + storage: s.GetStorage(), + runtimeBin: mc.RuntimeBin, + registry: mc.ImageRegistry, + tag: mc.ImageTag, + }, nil default: return nil, fmt.Errorf("no executor registered for operation %q", key) } diff --git a/pkg/hub/maintenance_executors.go b/pkg/hub/maintenance_executors.go index cb303e3f4..e7fd7fc05 100644 --- a/pkg/hub/maintenance_executors.go +++ b/pkg/hub/maintenance_executors.go @@ -27,6 +27,7 @@ import ( scionruntime "github.com/GoogleCloudPlatform/scion/pkg/runtime" "github.com/GoogleCloudPlatform/scion/pkg/secret" + "github.com/GoogleCloudPlatform/scion/pkg/storage" "github.com/GoogleCloudPlatform/scion/pkg/store" "github.com/GoogleCloudPlatform/scion/pkg/util/logging" ) @@ -428,6 +429,162 @@ func (e *RebuildContainerBinariesExecutor) Run(ctx context.Context, logger io.Wr return nil } +// BuildHarnessConfigImageExecutor builds a container image from a harness-config's Dockerfile. +type BuildHarnessConfigImageExecutor struct { + store store.Store + storage storage.Storage + runtimeBin string + registry string + tag string +} + +func (e *BuildHarnessConfigImageExecutor) Run(ctx context.Context, logger io.Writer, params map[string]string) error { + log := logging.Subsystem("hub.maintenance.build-harness-config-image") + + harnessConfigID := params["harness_config_id"] + if harnessConfigID == "" { + return fmt.Errorf("missing required parameter: harness_config_id") + } + + tag := e.tag + if tag == "" { + tag = "latest" + } + if v := params["tag"]; v != "" { + tag = v + } + + registry := e.registry + if v := params["registry"]; v != "" { + registry = v + } + registry = strings.TrimSuffix(registry, "/") + + hc, err := e.store.GetHarnessConfig(ctx, harnessConfigID) + if err != nil { + return fmt.Errorf("failed to load harness-config %q: %w", harnessConfigID, err) + } + + hasDockerfile := false + for _, f := range hc.Files { + if f.Path == "Dockerfile" { + hasDockerfile = true + break + } + } + if !hasDockerfile { + return fmt.Errorf("harness-config %q does not contain a Dockerfile", hc.Name) + } + + if e.storage == nil { + return fmt.Errorf("storage not configured") + } + + tmpDir, err := os.MkdirTemp("", "scion-build-*") + if err != nil { + return fmt.Errorf("failed to create temp directory: %w", err) + } + defer os.RemoveAll(tmpDir) + + fmt.Fprintf(logger, "Materializing %d file(s) from harness-config %q...\n", len(hc.Files), hc.Name) + for _, f := range hc.Files { + objectPath := hc.StoragePath + "/" + f.Path + reader, _, err := e.storage.Download(ctx, objectPath) + if err != nil { + return fmt.Errorf("failed to download %q from storage: %w", f.Path, err) + } + + destPath := filepath.Join(tmpDir, f.Path) + if !strings.HasPrefix(destPath, tmpDir+string(os.PathSeparator)) { + _ = reader.Close() + return fmt.Errorf("invalid file path %q: escapes build directory", f.Path) + } + if dir := filepath.Dir(destPath); dir != tmpDir { + if err := os.MkdirAll(dir, 0o755); err != nil { + _ = reader.Close() + return fmt.Errorf("failed to create directory for %q: %w", f.Path, err) + } + } + + outFile, err := os.Create(destPath) + if err != nil { + _ = reader.Close() + return fmt.Errorf("failed to create file %q: %w", f.Path, err) + } + _, err = io.Copy(outFile, reader) + _ = reader.Close() + _ = outFile.Close() + if err != nil { + return fmt.Errorf("failed to write file %q: %w", f.Path, err) + } + + if f.Mode != "" { + mode := os.FileMode(0o644) + if _, err := fmt.Sscanf(f.Mode, "%o", &mode); err == nil { + _ = os.Chmod(destPath, mode) + } + } + } + + baseImage := "scion-base:" + tag + if registry != "" { + baseImage = registry + "/scion-base:" + tag + } + fmt.Fprintf(logger, "Base image: %s\n", baseImage) + + runtimeBin := e.runtimeBin + if runtimeBin == "" { + runtimeBin = scionruntime.DetectContainerRuntime() + } + if runtimeBin == "" { + return fmt.Errorf("no container runtime found (tried docker, podman)") + } + + imageName := hc.Slug + if imageName == "" { + imageName = hc.Name + } + outputImage := imageName + ":" + tag + fmt.Fprintf(logger, "Building %s from harness-config %q...\n", outputImage, hc.Name) + log.Debug("Starting container build", + "image", outputImage, "base_image", baseImage, + "runtime", runtimeBin, "harness_config", hc.Name) + + cmd := exec.CommandContext(ctx, runtimeBin, "build", + "--build-arg", "BASE_IMAGE="+baseImage, + "-t", outputImage, + tmpDir) + cmd.Stdout = logger + cmd.Stderr = logger + if err := cmd.Run(); err != nil { + return fmt.Errorf("build failed: %w", err) + } + + if params["push"] == "true" && registry != "" { + pushImage := registry + "/" + outputImage + fmt.Fprintf(logger, "Tagging %s as %s...\n", outputImage, pushImage) + tagCmd := exec.CommandContext(ctx, runtimeBin, "tag", outputImage, pushImage) + tagCmd.Stdout = logger + tagCmd.Stderr = logger + if err := tagCmd.Run(); err != nil { + return fmt.Errorf("tag failed: %w", err) + } + + fmt.Fprintf(logger, "Pushing %s...\n", pushImage) + pushCmd := exec.CommandContext(ctx, runtimeBin, "push", pushImage) + pushCmd.Stdout = logger + pushCmd.Stderr = logger + if err := pushCmd.Run(); err != nil { + return fmt.Errorf("push failed: %w", err) + } + outputImage = pushImage + } + + fmt.Fprintf(logger, "\nBuild complete: %s\n", outputImage) + log.Info("Build complete", "image", outputImage, "harness_config", hc.Name) + return nil +} + // UpdateCheckResult contains the result of a check-for-updates operation. type UpdateCheckResult struct { UpdateAvailable bool `json:"update_available"` diff --git a/pkg/store/entadapter/maintenance_store.go b/pkg/store/entadapter/maintenance_store.go index 7172e90d3..65268e81b 100644 --- a/pkg/store/entadapter/maintenance_store.go +++ b/pkg/store/entadapter/maintenance_store.go @@ -74,6 +74,12 @@ var defaultSeedOperations = []store.MaintenanceOperation{ Description: "Rebuilds scion and sciontool binaries for Linux containers (make container-binaries). Only available when SCION_DEV_BINARIES is set. Binaries are written to .build/container/ in the source checkout.", Category: store.MaintenanceCategoryOperation, }, + { + Key: "build-harness-config-image", + Title: "Build Harness Config Image", + Description: "Builds a container image from a harness-config's bundled Dockerfile. The base image is resolved from the configured image registry.", + Category: store.MaintenanceCategoryOperation, + }, } // ============================================================================ diff --git a/web/src/components/pages/harness-config-detail.ts b/web/src/components/pages/harness-config-detail.ts index 7f8f80b5a..db4f37d9a 100644 --- a/web/src/components/pages/harness-config-detail.ts +++ b/web/src/components/pages/harness-config-detail.ts @@ -70,8 +70,37 @@ export class ScionPageHarnessConfigDetail extends LitElement { @state() private editorInitialPreview = false; + @state() + private hasDockerfile = false; + + @state() + private buildDialogOpen = false; + + @state() + private buildRunning = false; + + @state() + private buildTag = 'latest'; + + @state() + private buildPush = false; + + @state() + private buildLog = ''; + + @state() + private buildStatus = ''; + + @state() + private buildRunId = ''; + + @state() + private buildError = ''; + private fileBrowserDataSource: FileBrowserDataSource | null = null; private fileEditorDataSource: FileEditorDataSource | null = null; + private buildPollTimer: ReturnType | null = null; + private buildPollErrors = 0; static override styles = css` :host { @@ -155,6 +184,52 @@ export class ScionPageHarnessConfigDetail extends LitElement { margin-bottom: 0.5rem; } + .header-actions { + margin-left: auto; + } + + .build-log-section { + margin-top: 1.5rem; + } + .build-log-section h3 { + font-size: 0.95rem; + font-weight: 600; + margin: 0 0 0.5rem; + display: flex; + align-items: center; + gap: 0.5rem; + } + .build-log { + background: var(--sl-color-neutral-50); + border: 1px solid var(--sl-color-neutral-200); + border-radius: var(--sl-border-radius-medium); + padding: 1rem; + font-family: var(--sl-font-mono); + font-size: 0.8rem; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-all; + max-height: 400px; + overflow-y: auto; + } + + .build-status-badge { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-size: 0.75rem; + font-weight: 500; + } + .build-status-badge.running { color: var(--sl-color-primary-600); } + .build-status-badge.completed { color: var(--sl-color-success-600); } + .build-status-badge.failed { color: var(--sl-color-danger-600); } + + .build-error { + color: var(--sl-color-danger-600); + font-size: 0.85rem; + margin-top: 0.5rem; + } + .error-state, .loading-state { text-align: center; @@ -214,6 +289,7 @@ export class ScionPageHarnessConfigDetail extends LitElement { throw new Error(await extractApiError(response, `HTTP ${response.status}`)); } this.harnessConfig = (await response.json()) as HarnessConfig; + this.hasDockerfile = this.harnessConfig.files?.some(f => f.path === 'Dockerfile') ?? false; dispatchPageTitle( this, this.harnessConfig.displayName || this.harnessConfig.name || this.harnessConfigId, @@ -293,7 +369,7 @@ export class ScionPageHarnessConfigDetail extends LitElement { )} - ${this.renderHeader()} ${this.renderFilesSection()} + ${this.renderHeader()} ${this.renderFilesSection()} ${this.renderBuildDialog()} ${this.renderBuildLog()} `; } @@ -308,6 +384,19 @@ export class ScionPageHarnessConfigDetail extends LitElement { >

${hc.displayName || hc.name}

${hc.harness ? html`${hc.harness}` : ''} + ${this.hasDockerfile ? html` +
+ + + ${this.buildRunning ? 'Building...' : 'Build Image'} + +
+ ` : nothing} ${hc.description ? html`

${hc.description}

` : ''}
@@ -361,6 +450,176 @@ export class ScionPageHarnessConfigDetail extends LitElement {
`; } + // ── Build Image ── + + private openBuildDialog(): void { + this.buildTag = 'latest'; + this.buildPush = false; + this.buildError = ''; + this.buildDialogOpen = true; + } + + private renderBuildDialog() { + return html` + (this.buildDialogOpen = false)} + > + (this.buildTag = (e.target as HTMLInputElement).value)} + > +
+ (this.buildPush = (e.target as HTMLInputElement).checked)}> + Push to registry after building + + ${this.buildError ? html`

${this.buildError}

` : nothing} + + Build + + (this.buildDialogOpen = false)}> + Cancel + +
+ `; + } + + private async startBuild(): Promise { + this.buildDialogOpen = false; + this.buildRunning = true; + this.buildLog = ''; + this.buildStatus = 'running'; + this.buildError = ''; + this.buildPollErrors = 0; + + try { + const response = await apiFetch( + '/api/v1/admin/maintenance/operations/build-harness-config-image/run', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + params: { + harness_config_id: this.harnessConfigId, + tag: this.buildTag || 'latest', + push: this.buildPush ? 'true' : 'false', + }, + }), + }, + ); + + if (!response.ok) { + const errMsg = await extractApiError(response, `HTTP ${response.status}`); + this.buildError = errMsg; + this.buildRunning = false; + this.buildStatus = 'failed'; + return; + } + + const result = await response.json(); + this.buildRunId = result.run_id ?? ''; + this.startBuildPolling(); + } catch (err) { + this.buildError = err instanceof Error ? err.message : 'Failed to start build'; + this.buildRunning = false; + this.buildStatus = 'failed'; + } + } + + private startBuildPolling(): void { + if (this.buildPollTimer) return; + this.buildPollErrors = 0; + void this.pollBuildStatus(); + } + + private stopBuildPolling(): void { + if (this.buildPollTimer) { + clearTimeout(this.buildPollTimer); + this.buildPollTimer = null; + } + } + + private async pollBuildStatus(): Promise { + if (!this.buildRunId) return; + + try { + const resp = await apiFetch( + `/api/v1/admin/maintenance/operations/build-harness-config-image/runs/${this.buildRunId}`, + ); + if (!resp.ok) { + this.buildPollErrors++; + if (this.buildPollErrors >= 5) { + this.buildRunning = false; + this.buildStatus = 'failed'; + this.buildError = 'Lost connection to build'; + this.stopBuildPolling(); + } else if (this.buildRunning) { + this.buildPollTimer = setTimeout(() => void this.pollBuildStatus(), 3000); + } + return; + } + + this.buildPollErrors = 0; + const run = await resp.json(); + this.buildLog = run.log ?? ''; + this.buildStatus = run.status ?? ''; + void this.updateComplete.then(() => this.scrollBuildLog()); + + if (run.status !== 'running') { + this.buildRunning = false; + this.stopBuildPolling(); + if (run.status === 'completed') { + await this.loadHarnessConfig(); + } + } else if (this.buildRunning) { + this.buildPollTimer = setTimeout(() => void this.pollBuildStatus(), 3000); + } + } catch { + this.buildPollErrors++; + if (this.buildPollErrors >= 5) { + this.buildRunning = false; + this.buildStatus = 'failed'; + this.buildError = 'Lost connection to build'; + this.stopBuildPolling(); + } else if (this.buildRunning) { + this.buildPollTimer = setTimeout(() => void this.pollBuildStatus(), 3000); + } + } + } + + private scrollBuildLog(): void { + const el = this.renderRoot?.querySelector('.build-log'); + if (el) { + el.scrollTop = el.scrollHeight; + } + } + + private renderBuildLog() { + if (!this.buildLog && !this.buildRunning) return nothing; + + const statusClass = this.buildStatus === 'completed' ? 'completed' : this.buildStatus === 'running' ? 'running' : 'failed'; + + return html` +
+

+ Build Output + + ${this.buildStatus === 'running' + ? html` Running` + : this.buildStatus} + +

+
${this.buildLog}
+
+ `; + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this.stopBuildPolling(); + } } declare global { From aa16b361aec66cee4c1d50cc8f7f0c7c66d7ed56 Mon Sep 17 00:00:00 2001 From: Preston Holmes Date: Fri, 12 Jun 2026 04:41:22 -0700 Subject: [PATCH 12/16] docs: add Observability section to glossary (infrastructure vs agent metrics) (#407) Clarify the two distinct metric families in Scion: - Infrastructure metrics (scion.hub.*, scion.db.*, scion.dispatch.*) for platform health, produced by the Hub process - Agent metrics (gen_ai.*, agent.*) for harness/model telemetry, produced inside agent containers via the telemetry pipeline Also defines the Telemetry pipeline term. Co-authored-by: Scion Agent (metrics-architect) --- GLOSSARY.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/GLOSSARY.md b/GLOSSARY.md index d221851d6..16132c213 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -230,6 +230,25 @@ A time-based trigger that fires an action — sending a message or dispatching ( _Avoid_: cron job (recurring only), scheduled message (too narrow), reminder, timer _See also_: Dispatch +## Observability + +Scion produces two distinct families of metrics. They serve different audiences, use different prefixes, and flow through different pipelines — but both export to the same Cloud Monitoring backend. + +**Infrastructure metrics**: +Operational health metrics for Scion as a system — the Hub process, its database connections, dispatch pipeline, broker authentication, and GCP token minting. These answer "is Scion itself healthy?" and are consumed by platform operators. Prefixes: `scion.hub.*`, `scion.db.*`, `scion.dispatch.*`. Produced by the Hub process; exported directly to Cloud Monitoring via an OTel MeterProvider with a GCP exporter. +_Avoid_: system metrics, platform metrics, server metrics +_See also_: Agent metrics (the other family) + +**Agent metrics**: +Telemetry about what agents and their harnesses are doing — token usage, tool calls, model API latency, session counts, and cost signals. These answer "what are the agents doing and what do they cost?" and are consumed by users and project owners. Prefixes: `gen_ai.*`, `agent.*` (following OpenTelemetry Generative AI semantic conventions). Produced inside agent containers by the harness and sciontool; exported to Cloud Monitoring via the telemetry pipeline (`pkg/sciontool/telemetry`). +_Avoid_: harness metrics, user metrics, LLM metrics +_See also_: Infrastructure metrics (the other family), Telemetry pipeline + +**Telemetry pipeline**: +The in-container OTLP receiver and forwarding pipeline (`pkg/sciontool/telemetry`) that collects traces, metrics, and logs from the harness and exports them to a cloud backend (GCP Cloud Monitoring, Cloud Trace, Cloud Logging). Requires the `scion-telemetry-gcp-credentials` secret for cloud export; runs in local-only mode without it. +_Avoid_: metrics pipeline, collector, OTel collector +_See also_: Agent metrics + ## Potential Future Additions Terms that recur in the codebase and may warrant canonical entries, but are **not yet defined** here. Listed so they aren't lost; promote to full entries (verified against the code) as the glossary matures. From 5da221d9f2b06a279622ca35cbc1cd6a1d039ba7 Mon Sep 17 00:00:00 2001 From: Preston Holmes Date: Fri, 12 Jun 2026 09:37:05 -0700 Subject: [PATCH 13/16] fix: correct runId JSON key mismatch in build polling (#410) Co-authored-by: Scion Agent (harness-build-blocker-fix) --- web/src/components/pages/harness-config-detail.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/web/src/components/pages/harness-config-detail.ts b/web/src/components/pages/harness-config-detail.ts index db4f37d9a..825bbd6eb 100644 --- a/web/src/components/pages/harness-config-detail.ts +++ b/web/src/components/pages/harness-config-detail.ts @@ -519,7 +519,13 @@ export class ScionPageHarnessConfigDetail extends LitElement { } const result = await response.json(); - this.buildRunId = result.run_id ?? ''; + if (!result?.runId) { + this.buildError = 'Build started but no run ID was returned'; + this.buildRunning = false; + this.buildStatus = 'failed'; + return; + } + this.buildRunId = result.runId; this.startBuildPolling(); } catch (err) { this.buildError = err instanceof Error ? err.message : 'Failed to start build'; From 22d64a9b8874f275bdb0f6bf6c28db476e7a072b Mon Sep 17 00:00:00 2001 From: "Scion Agent (harness-auth-flow-fix)" Date: Fri, 12 Jun 2026 03:36:34 +0000 Subject: [PATCH 14/16] Fix duplicate no_auth keys and missing field schema attribute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove duplicate no_auth sections from all four harness config.yaml files (claude, gemini, codex, opencode) — the first occurrence between capabilities and auth is kept, the second at end-of-file is removed. Add the "field" property to the harnessAuthFileRequirement JSON schema definition so config validation accepts the new field attribute on required_files entries. --- GLOSSARY.md | 19 -- harnesses/codex/config.yaml | 12 +- harnesses/opencode/config.yaml | 12 +- pkg/agent/run.go | 29 +- pkg/config/schemas/settings-v1.schema.json | 20 +- pkg/config/settings_v1.go | 23 +- pkg/harness/claude/embeds/config.yaml | 12 +- pkg/harness/gemini/embeds/config.yaml | 12 +- pkg/hub/admin_maintenance.go | 10 - pkg/hub/handlers.go | 4 + pkg/hub/maintenance_executors.go | 157 ---------- pkg/hub/server.go | 6 +- pkg/runtime/common.go | 4 +- pkg/runtime/k8s_runtime.go | 4 +- pkg/runtimebroker/handlers.go | 3 +- pkg/runtimebroker/start_context.go | 2 +- pkg/runtimebroker/types.go | 6 +- .../telemetry/pipeline_health_test.go | 17 +- pkg/store/entadapter/maintenance_store.go | 6 - web/src/components/pages/agent-configure.ts | 1 + web/src/components/pages/agent-create.ts | 1 + .../components/pages/harness-config-detail.ts | 267 +----------------- 22 files changed, 85 insertions(+), 542 deletions(-) diff --git a/GLOSSARY.md b/GLOSSARY.md index 16132c213..d221851d6 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -230,25 +230,6 @@ A time-based trigger that fires an action — sending a message or dispatching ( _Avoid_: cron job (recurring only), scheduled message (too narrow), reminder, timer _See also_: Dispatch -## Observability - -Scion produces two distinct families of metrics. They serve different audiences, use different prefixes, and flow through different pipelines — but both export to the same Cloud Monitoring backend. - -**Infrastructure metrics**: -Operational health metrics for Scion as a system — the Hub process, its database connections, dispatch pipeline, broker authentication, and GCP token minting. These answer "is Scion itself healthy?" and are consumed by platform operators. Prefixes: `scion.hub.*`, `scion.db.*`, `scion.dispatch.*`. Produced by the Hub process; exported directly to Cloud Monitoring via an OTel MeterProvider with a GCP exporter. -_Avoid_: system metrics, platform metrics, server metrics -_See also_: Agent metrics (the other family) - -**Agent metrics**: -Telemetry about what agents and their harnesses are doing — token usage, tool calls, model API latency, session counts, and cost signals. These answer "what are the agents doing and what do they cost?" and are consumed by users and project owners. Prefixes: `gen_ai.*`, `agent.*` (following OpenTelemetry Generative AI semantic conventions). Produced inside agent containers by the harness and sciontool; exported to Cloud Monitoring via the telemetry pipeline (`pkg/sciontool/telemetry`). -_Avoid_: harness metrics, user metrics, LLM metrics -_See also_: Infrastructure metrics (the other family), Telemetry pipeline - -**Telemetry pipeline**: -The in-container OTLP receiver and forwarding pipeline (`pkg/sciontool/telemetry`) that collects traces, metrics, and logs from the harness and exports them to a cloud backend (GCP Cloud Monitoring, Cloud Trace, Cloud Logging). Requires the `scion-telemetry-gcp-credentials` secret for cloud export; runs in local-only mode without it. -_Avoid_: metrics pipeline, collector, OTel collector -_See also_: Agent metrics - ## Potential Future Additions Terms that recur in the codebase and may warrant canonical entries, but are **not yet defined** here. Listed so they aren't lost; promote to full entries (verified against the code) as the glossary matures. diff --git a/harnesses/codex/config.yaml b/harnesses/codex/config.yaml index 8892097e6..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: @@ -89,9 +95,3 @@ auth: OPENAI_API_KEY: api-key files: CODEX_AUTH: auth-file -no_auth: - behavior: drop-to-shell - message: | - This agent started without credentials. - Set CODEX_API_KEY or OPENAI_API_KEY, or authenticate interactively. - Then capture credentials: python3 ~/.scion/harness/capture_auth.py diff --git a/harnesses/opencode/config.yaml b/harnesses/opencode/config.yaml index 388caf64d..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: @@ -85,9 +91,3 @@ auth: OPENAI_API_KEY: api-key files: OPENCODE_AUTH: auth-file -no_auth: - behavior: drop-to-shell - message: | - This agent started without credentials. - Set ANTHROPIC_API_KEY or OPENAI_API_KEY, or authenticate interactively. - Then capture credentials: python3 ~/.scion/harness/capture_auth.py diff --git a/pkg/agent/run.go b/pkg/agent/run.go index a83087934..ced5bdbc8 100644 --- a/pkg/agent/run.go +++ b/pkg/agent/run.go @@ -341,8 +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 noAuthMessage string - var resolvedAuthMeta *config.HarnessAuthMetadata + var noAuthConfig *config.HarnessNoAuthConfig if harnessConfigName != "" { var resolveTemplatePaths []string if opts.Template != "" { @@ -369,14 +368,11 @@ 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) } util.Debugf("harness resolution: implementation=%s harness=%q", resolved.Implementation, resolved.Config.Harness) - if opts.NoAuth && resolved.Config.NoAuth != nil { - noAuthMessage = resolved.Config.NoAuth.Message - } - resolvedAuthMeta = resolved.Config.Auth } } else { h = harness.New(harnessName) @@ -431,11 +427,7 @@ func (m *AgentManager) Start(ctx context.Context, opts api.StartOptions) (*api.A if !opts.NoAuth { auth = harness.GatherAuthWithEnv(authEnvOverlay, !opts.BrokerMode) if opts.BrokerMode { - if resolvedAuthMeta != nil { - harness.OverlayFileSecretsFromConfig(&auth, opts.ResolvedSecrets, resolvedAuthMeta) - } else { - harness.OverlayFileSecrets(&auth, opts.ResolvedSecrets) - } + harness.OverlayFileSecrets(&auth, opts.ResolvedSecrets) } util.Debugf("auth: gathered credentials — selectedType=%q, hasGeminiKey=%t, hasGoogleKey=%t, hasOAuth=%t, hasADC=%t, hasAnthropicKey=%t, hasClaudeOAuthToken=%t, hasClaudeAuthFile=%t, cloudProject=%q, gcpMetadataMode=%q, brokerMode=%t", auth.SelectedType, @@ -893,11 +885,16 @@ func (m *AgentManager) Start(ctx context.Context, opts api.StartOptions) (*api.A } return nil }(), - GitClone: opts.GitClone, - SharedDirs: effectiveSharedDirs, - BrokerMode: opts.BrokerMode, - NoAuth: opts.NoAuth, - NoAuthMessage: noAuthMessage, + 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/config/schemas/settings-v1.schema.json b/pkg/config/schemas/settings-v1.schema.json index 10c11a643..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", @@ -328,14 +328,14 @@ "$ref": "#/$defs/harnessAuthMetadata", "description": "Declarative auth preflight metadata for broker and hosted dispatch." }, - "no_auth": { - "$ref": "#/$defs/harnessNoAuthConfig", - "description": "Defines behavior when an agent starts with --no-auth (no credentials)." - }, "mcp": { "$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.", @@ -350,11 +350,11 @@ "behavior": { "type": "string", "enum": ["drop-to-shell", "show-setup-instructions", "run-setup-wizard"], - "description": "How the container starts when no credentials are present." + "description": "What the harness should do when no credentials are provided." }, "message": { "type": "string", - "description": "Message displayed to the user when the agent starts without credentials." + "description": "Message to display to the user in no-auth mode." } }, "additionalProperties": false @@ -547,13 +547,13 @@ "type": { "type": "string" }, "description": { "type": "string" }, "target_suffix": { "type": "string" }, - "field": { "type": "string" }, "alternative_env_keys": { "type": "array", "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 15d4dcdc8..7d1f7aed6 100644 --- a/pkg/config/settings_v1.go +++ b/pkg/config/settings_v1.go @@ -331,9 +331,14 @@ type V1PluginEntry struct { // SelfManaged indicates the plugin manages its own process lifecycle. // The Hub connects to the plugin's RPC server rather than starting it. SelfManaged bool `json:"self_managed,omitempty" yaml:"self_managed,omitempty" koanf:"self_managed"` - // Address is the RPC address for self-managed plugins (e.g. "localhost:9090"). - // Required when SelfManaged is true. + // Address is the network address for self-managed or gRPC plugins. + // Required when SelfManaged is true or Mode is "grpc". 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). When empty, falls back to SelfManaged for + // backward compatibility. + Mode string `json:"mode,omitempty" yaml:"mode,omitempty" koanf:"mode"` } // V1ServerHubConfig holds the Hub API server settings (when running scion-server). @@ -715,7 +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"` - NoAuth *HarnessNoAuthConfig `json:"no_auth,omitempty" yaml:"no_auth,omitempty" koanf:"no_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"` } @@ -793,18 +798,10 @@ type HarnessAuthAutodetect struct { Files map[string]string `json:"files,omitempty" yaml:"files,omitempty" koanf:"files"` } -// HarnessNoAuthConfig defines what happens when an agent starts with -// --no-auth (no credentials injected). The behavior field determines -// how the container starts. +// HarnessNoAuthConfig defines harness behavior when an agent starts without credentials. type HarnessNoAuthConfig struct { - // Behavior controls the container startup when no credentials are present. - // Supported values: - // "drop-to-shell" — start the container but don't launch the harness CLI - // "show-setup-instructions" — launch normally but display setup instructions - // "run-setup-wizard" — execute a setup script before the main process Behavior string `json:"behavior,omitempty" yaml:"behavior,omitempty" koanf:"behavior"` - // Message is displayed to the user when the agent starts without credentials. - Message string `json:"message,omitempty" yaml:"message,omitempty" koanf:"message"` + Message string `json:"message,omitempty" yaml:"message,omitempty" koanf:"message"` } // HarnessMCPConfig is the declarative mapping that lets a harness's diff --git a/pkg/harness/claude/embeds/config.yaml b/pkg/harness/claude/embeds/config.yaml index 37a358bb5..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: @@ -88,9 +94,3 @@ auth: files: CLAUDE_AUTH: auth-file gcloud-adc: vertex-ai -no_auth: - behavior: drop-to-shell - message: | - This agent started without credentials. - To authenticate, run: claude login - Then capture credentials: python3 ~/.scion/harness/capture_auth.py diff --git a/pkg/harness/gemini/embeds/config.yaml b/pkg/harness/gemini/embeds/config.yaml index f94e99a6d..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: @@ -85,9 +91,3 @@ auth: files: GEMINI_OAUTH_CREDS: auth-file gcloud-adc: vertex-ai -no_auth: - behavior: drop-to-shell - message: | - This agent started without credentials. - To authenticate, run: gcloud auth application-default login - Then capture credentials: python3 ~/.scion/harness/capture_auth.py diff --git a/pkg/hub/admin_maintenance.go b/pkg/hub/admin_maintenance.go index dafc17b5e..c484b8583 100644 --- a/pkg/hub/admin_maintenance.go +++ b/pkg/hub/admin_maintenance.go @@ -293,16 +293,6 @@ func (s *Server) resolveMaintenanceExecutor(key string) (MaintenanceExecutor, er return &RebuildContainerBinariesExecutor{ repoPath: mc.RepoPath, }, nil - case "build-harness-config-image": - log.Debug("Resolved build-harness-config-image executor", - "runtime_bin", mc.RuntimeBin, "registry", mc.ImageRegistry, "tag", mc.ImageTag) - return &BuildHarnessConfigImageExecutor{ - store: s.store, - storage: s.GetStorage(), - runtimeBin: mc.RuntimeBin, - registry: mc.ImageRegistry, - tag: mc.ImageTag, - }, nil default: return nil, fmt.Errorf("no executor registered for operation %q", key) } diff --git a/pkg/hub/handlers.go b/pkg/hub/handlers.go index e977c036e..200c499db 100644 --- a/pkg/hub/handlers.go +++ b/pkg/hub/handlers.go @@ -9251,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/maintenance_executors.go b/pkg/hub/maintenance_executors.go index e7fd7fc05..cb303e3f4 100644 --- a/pkg/hub/maintenance_executors.go +++ b/pkg/hub/maintenance_executors.go @@ -27,7 +27,6 @@ import ( scionruntime "github.com/GoogleCloudPlatform/scion/pkg/runtime" "github.com/GoogleCloudPlatform/scion/pkg/secret" - "github.com/GoogleCloudPlatform/scion/pkg/storage" "github.com/GoogleCloudPlatform/scion/pkg/store" "github.com/GoogleCloudPlatform/scion/pkg/util/logging" ) @@ -429,162 +428,6 @@ func (e *RebuildContainerBinariesExecutor) Run(ctx context.Context, logger io.Wr return nil } -// BuildHarnessConfigImageExecutor builds a container image from a harness-config's Dockerfile. -type BuildHarnessConfigImageExecutor struct { - store store.Store - storage storage.Storage - runtimeBin string - registry string - tag string -} - -func (e *BuildHarnessConfigImageExecutor) Run(ctx context.Context, logger io.Writer, params map[string]string) error { - log := logging.Subsystem("hub.maintenance.build-harness-config-image") - - harnessConfigID := params["harness_config_id"] - if harnessConfigID == "" { - return fmt.Errorf("missing required parameter: harness_config_id") - } - - tag := e.tag - if tag == "" { - tag = "latest" - } - if v := params["tag"]; v != "" { - tag = v - } - - registry := e.registry - if v := params["registry"]; v != "" { - registry = v - } - registry = strings.TrimSuffix(registry, "/") - - hc, err := e.store.GetHarnessConfig(ctx, harnessConfigID) - if err != nil { - return fmt.Errorf("failed to load harness-config %q: %w", harnessConfigID, err) - } - - hasDockerfile := false - for _, f := range hc.Files { - if f.Path == "Dockerfile" { - hasDockerfile = true - break - } - } - if !hasDockerfile { - return fmt.Errorf("harness-config %q does not contain a Dockerfile", hc.Name) - } - - if e.storage == nil { - return fmt.Errorf("storage not configured") - } - - tmpDir, err := os.MkdirTemp("", "scion-build-*") - if err != nil { - return fmt.Errorf("failed to create temp directory: %w", err) - } - defer os.RemoveAll(tmpDir) - - fmt.Fprintf(logger, "Materializing %d file(s) from harness-config %q...\n", len(hc.Files), hc.Name) - for _, f := range hc.Files { - objectPath := hc.StoragePath + "/" + f.Path - reader, _, err := e.storage.Download(ctx, objectPath) - if err != nil { - return fmt.Errorf("failed to download %q from storage: %w", f.Path, err) - } - - destPath := filepath.Join(tmpDir, f.Path) - if !strings.HasPrefix(destPath, tmpDir+string(os.PathSeparator)) { - _ = reader.Close() - return fmt.Errorf("invalid file path %q: escapes build directory", f.Path) - } - if dir := filepath.Dir(destPath); dir != tmpDir { - if err := os.MkdirAll(dir, 0o755); err != nil { - _ = reader.Close() - return fmt.Errorf("failed to create directory for %q: %w", f.Path, err) - } - } - - outFile, err := os.Create(destPath) - if err != nil { - _ = reader.Close() - return fmt.Errorf("failed to create file %q: %w", f.Path, err) - } - _, err = io.Copy(outFile, reader) - _ = reader.Close() - _ = outFile.Close() - if err != nil { - return fmt.Errorf("failed to write file %q: %w", f.Path, err) - } - - if f.Mode != "" { - mode := os.FileMode(0o644) - if _, err := fmt.Sscanf(f.Mode, "%o", &mode); err == nil { - _ = os.Chmod(destPath, mode) - } - } - } - - baseImage := "scion-base:" + tag - if registry != "" { - baseImage = registry + "/scion-base:" + tag - } - fmt.Fprintf(logger, "Base image: %s\n", baseImage) - - runtimeBin := e.runtimeBin - if runtimeBin == "" { - runtimeBin = scionruntime.DetectContainerRuntime() - } - if runtimeBin == "" { - return fmt.Errorf("no container runtime found (tried docker, podman)") - } - - imageName := hc.Slug - if imageName == "" { - imageName = hc.Name - } - outputImage := imageName + ":" + tag - fmt.Fprintf(logger, "Building %s from harness-config %q...\n", outputImage, hc.Name) - log.Debug("Starting container build", - "image", outputImage, "base_image", baseImage, - "runtime", runtimeBin, "harness_config", hc.Name) - - cmd := exec.CommandContext(ctx, runtimeBin, "build", - "--build-arg", "BASE_IMAGE="+baseImage, - "-t", outputImage, - tmpDir) - cmd.Stdout = logger - cmd.Stderr = logger - if err := cmd.Run(); err != nil { - return fmt.Errorf("build failed: %w", err) - } - - if params["push"] == "true" && registry != "" { - pushImage := registry + "/" + outputImage - fmt.Fprintf(logger, "Tagging %s as %s...\n", outputImage, pushImage) - tagCmd := exec.CommandContext(ctx, runtimeBin, "tag", outputImage, pushImage) - tagCmd.Stdout = logger - tagCmd.Stderr = logger - if err := tagCmd.Run(); err != nil { - return fmt.Errorf("tag failed: %w", err) - } - - fmt.Fprintf(logger, "Pushing %s...\n", pushImage) - pushCmd := exec.CommandContext(ctx, runtimeBin, "push", pushImage) - pushCmd.Stdout = logger - pushCmd.Stderr = logger - if err := pushCmd.Run(); err != nil { - return fmt.Errorf("push failed: %w", err) - } - outputImage = pushImage - } - - fmt.Fprintf(logger, "\nBuild complete: %s\n", outputImage) - log.Info("Build complete", "image", outputImage, "harness_config", hc.Name) - return nil -} - // UpdateCheckResult contains the result of a check-for-updates operation. type UpdateCheckResult struct { UpdateAvailable bool `json:"update_available"` diff --git a/pkg/hub/server.go b/pkg/hub/server.go index f02a8d1f8..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) @@ -433,10 +435,6 @@ type RemoteCreateAgentRequest struct { // (e.g. "shared", "per-agent", "worktree-per-agent"). Threaded from the // Hub so the broker can branch dispatch without re-deriving from labels. WorkspaceMode string `json:"workspaceMode,omitempty"` - - // NoAuth indicates the agent should start with zero injected credentials. - // When true, the broker skips credential injection. - NoAuth bool `json:"noAuth,omitempty"` } // ResolvedSecret represents a secret resolved by the Hub for projection into an agent container. diff --git a/pkg/runtime/common.go b/pkg/runtime/common.go index e07f7424b..d62bde80e 100644 --- a/pkg/runtime/common.go +++ b/pkg/runtime/common.go @@ -428,7 +428,9 @@ 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") 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 55c1921c2..ef72eee70 100644 --- a/pkg/runtimebroker/handlers.go +++ b/pkg/runtimebroker/handlers.go @@ -592,8 +592,8 @@ func (s *Server) createAgent(w http.ResponseWriter, r *http.Request) { CreatorName: req.CreatorName, ResolvedEnv: req.ResolvedEnv, ResolvedSecrets: req.ResolvedSecrets, - Attach: req.Attach, NoAuth: req.NoAuth, + Attach: req.Attach, WorkspaceMode: req.WorkspaceMode, HTTPRequest: r, }) @@ -2326,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 cd951acfa..e93f53b0f 100644 --- a/pkg/runtimebroker/start_context.go +++ b/pkg/runtimebroker/start_context.go @@ -76,8 +76,8 @@ type startContextInputs struct { ResolvedSecrets []api.ResolvedSecret // Behavior - Attach bool NoAuth bool + Attach bool // WorkspaceMode is the resolved workspace sharing mode for the project // (e.g. "worktree-per-agent"). Threaded from CreateAgentRequest so the diff --git a/pkg/runtimebroker/types.go b/pkg/runtimebroker/types.go index 46e477d02..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) @@ -326,10 +328,6 @@ type CreateAgentRequest struct { // (e.g. "shared", "per-agent", "worktree-per-agent"). Threaded from the // Hub so the broker can branch dispatch without re-deriving from labels. WorkspaceMode string `json:"workspaceMode,omitempty"` - - // NoAuth indicates the agent should start with zero injected credentials. - // When true, the broker skips credential injection. - NoAuth bool `json:"noAuth,omitempty"` } // UnmarshalJSON implements custom unmarshaling to support legacy grove fields. diff --git a/pkg/sciontool/telemetry/pipeline_health_test.go b/pkg/sciontool/telemetry/pipeline_health_test.go index b37755650..9ea158716 100644 --- a/pkg/sciontool/telemetry/pipeline_health_test.go +++ b/pkg/sciontool/telemetry/pipeline_health_test.go @@ -7,7 +7,6 @@ package telemetry import ( "context" "errors" - "os" "testing" "time" @@ -16,10 +15,10 @@ import ( func TestPipeline_HealthGauge_Registers(t *testing.T) { clearTelemetryEnv() - os.Setenv(EnvEnabled, "true") - os.Setenv(EnvCloudEnabled, "false") - os.Setenv(EnvGRPCPort, "54401") - os.Setenv(EnvHTTPPort, "54402") + t.Setenv(EnvEnabled, "true") + t.Setenv(EnvCloudEnabled, "false") + t.Setenv(EnvGRPCPort, "54401") + t.Setenv(EnvHTTPPort, "54402") defer clearTelemetryEnv() cfg := &Config{ @@ -54,10 +53,10 @@ func TestPipeline_HealthGauge_Registers(t *testing.T) { func TestPipeline_HealthGauge_StopsOnStop(t *testing.T) { clearTelemetryEnv() - os.Setenv(EnvEnabled, "true") - os.Setenv(EnvCloudEnabled, "false") - os.Setenv(EnvGRPCPort, "54403") - os.Setenv(EnvHTTPPort, "54404") + t.Setenv(EnvEnabled, "true") + t.Setenv(EnvCloudEnabled, "false") + t.Setenv(EnvGRPCPort, "54403") + t.Setenv(EnvHTTPPort, "54404") defer clearTelemetryEnv() cfg := &Config{ diff --git a/pkg/store/entadapter/maintenance_store.go b/pkg/store/entadapter/maintenance_store.go index 65268e81b..7172e90d3 100644 --- a/pkg/store/entadapter/maintenance_store.go +++ b/pkg/store/entadapter/maintenance_store.go @@ -74,12 +74,6 @@ var defaultSeedOperations = []store.MaintenanceOperation{ Description: "Rebuilds scion and sciontool binaries for Linux containers (make container-binaries). Only available when SCION_DEV_BINARIES is set. Binaries are written to .build/container/ in the source checkout.", Category: store.MaintenanceCategoryOperation, }, - { - Key: "build-harness-config-image", - Title: "Build Harness Config Image", - Description: "Builds a container image from a harness-config's bundled Dockerfile. The base image is resolved from the configured image registry.", - Category: store.MaintenanceCategoryOperation, - }, } // ============================================================================ 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.
diff --git a/web/src/components/pages/harness-config-detail.ts b/web/src/components/pages/harness-config-detail.ts index 825bbd6eb..7f8f80b5a 100644 --- a/web/src/components/pages/harness-config-detail.ts +++ b/web/src/components/pages/harness-config-detail.ts @@ -70,37 +70,8 @@ export class ScionPageHarnessConfigDetail extends LitElement { @state() private editorInitialPreview = false; - @state() - private hasDockerfile = false; - - @state() - private buildDialogOpen = false; - - @state() - private buildRunning = false; - - @state() - private buildTag = 'latest'; - - @state() - private buildPush = false; - - @state() - private buildLog = ''; - - @state() - private buildStatus = ''; - - @state() - private buildRunId = ''; - - @state() - private buildError = ''; - private fileBrowserDataSource: FileBrowserDataSource | null = null; private fileEditorDataSource: FileEditorDataSource | null = null; - private buildPollTimer: ReturnType | null = null; - private buildPollErrors = 0; static override styles = css` :host { @@ -184,52 +155,6 @@ export class ScionPageHarnessConfigDetail extends LitElement { margin-bottom: 0.5rem; } - .header-actions { - margin-left: auto; - } - - .build-log-section { - margin-top: 1.5rem; - } - .build-log-section h3 { - font-size: 0.95rem; - font-weight: 600; - margin: 0 0 0.5rem; - display: flex; - align-items: center; - gap: 0.5rem; - } - .build-log { - background: var(--sl-color-neutral-50); - border: 1px solid var(--sl-color-neutral-200); - border-radius: var(--sl-border-radius-medium); - padding: 1rem; - font-family: var(--sl-font-mono); - font-size: 0.8rem; - line-height: 1.5; - white-space: pre-wrap; - word-break: break-all; - max-height: 400px; - overflow-y: auto; - } - - .build-status-badge { - display: inline-flex; - align-items: center; - gap: 0.25rem; - font-size: 0.75rem; - font-weight: 500; - } - .build-status-badge.running { color: var(--sl-color-primary-600); } - .build-status-badge.completed { color: var(--sl-color-success-600); } - .build-status-badge.failed { color: var(--sl-color-danger-600); } - - .build-error { - color: var(--sl-color-danger-600); - font-size: 0.85rem; - margin-top: 0.5rem; - } - .error-state, .loading-state { text-align: center; @@ -289,7 +214,6 @@ export class ScionPageHarnessConfigDetail extends LitElement { throw new Error(await extractApiError(response, `HTTP ${response.status}`)); } this.harnessConfig = (await response.json()) as HarnessConfig; - this.hasDockerfile = this.harnessConfig.files?.some(f => f.path === 'Dockerfile') ?? false; dispatchPageTitle( this, this.harnessConfig.displayName || this.harnessConfig.name || this.harnessConfigId, @@ -369,7 +293,7 @@ export class ScionPageHarnessConfigDetail extends LitElement { )} - ${this.renderHeader()} ${this.renderFilesSection()} ${this.renderBuildDialog()} ${this.renderBuildLog()} + ${this.renderHeader()} ${this.renderFilesSection()} `; } @@ -384,19 +308,6 @@ export class ScionPageHarnessConfigDetail extends LitElement { >

${hc.displayName || hc.name}

${hc.harness ? html`${hc.harness}` : ''} - ${this.hasDockerfile ? html` -
- - - ${this.buildRunning ? 'Building...' : 'Build Image'} - -
- ` : nothing} ${hc.description ? html`

${hc.description}

` : ''}
@@ -450,182 +361,6 @@ export class ScionPageHarnessConfigDetail extends LitElement {
`; } - // ── Build Image ── - - private openBuildDialog(): void { - this.buildTag = 'latest'; - this.buildPush = false; - this.buildError = ''; - this.buildDialogOpen = true; - } - - private renderBuildDialog() { - return html` - (this.buildDialogOpen = false)} - > - (this.buildTag = (e.target as HTMLInputElement).value)} - > -
- (this.buildPush = (e.target as HTMLInputElement).checked)}> - Push to registry after building - - ${this.buildError ? html`

${this.buildError}

` : nothing} - - Build - - (this.buildDialogOpen = false)}> - Cancel - -
- `; - } - - private async startBuild(): Promise { - this.buildDialogOpen = false; - this.buildRunning = true; - this.buildLog = ''; - this.buildStatus = 'running'; - this.buildError = ''; - this.buildPollErrors = 0; - - try { - const response = await apiFetch( - '/api/v1/admin/maintenance/operations/build-harness-config-image/run', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - params: { - harness_config_id: this.harnessConfigId, - tag: this.buildTag || 'latest', - push: this.buildPush ? 'true' : 'false', - }, - }), - }, - ); - - if (!response.ok) { - const errMsg = await extractApiError(response, `HTTP ${response.status}`); - this.buildError = errMsg; - this.buildRunning = false; - this.buildStatus = 'failed'; - return; - } - - const result = await response.json(); - if (!result?.runId) { - this.buildError = 'Build started but no run ID was returned'; - this.buildRunning = false; - this.buildStatus = 'failed'; - return; - } - this.buildRunId = result.runId; - this.startBuildPolling(); - } catch (err) { - this.buildError = err instanceof Error ? err.message : 'Failed to start build'; - this.buildRunning = false; - this.buildStatus = 'failed'; - } - } - - private startBuildPolling(): void { - if (this.buildPollTimer) return; - this.buildPollErrors = 0; - void this.pollBuildStatus(); - } - - private stopBuildPolling(): void { - if (this.buildPollTimer) { - clearTimeout(this.buildPollTimer); - this.buildPollTimer = null; - } - } - - private async pollBuildStatus(): Promise { - if (!this.buildRunId) return; - - try { - const resp = await apiFetch( - `/api/v1/admin/maintenance/operations/build-harness-config-image/runs/${this.buildRunId}`, - ); - if (!resp.ok) { - this.buildPollErrors++; - if (this.buildPollErrors >= 5) { - this.buildRunning = false; - this.buildStatus = 'failed'; - this.buildError = 'Lost connection to build'; - this.stopBuildPolling(); - } else if (this.buildRunning) { - this.buildPollTimer = setTimeout(() => void this.pollBuildStatus(), 3000); - } - return; - } - - this.buildPollErrors = 0; - const run = await resp.json(); - this.buildLog = run.log ?? ''; - this.buildStatus = run.status ?? ''; - void this.updateComplete.then(() => this.scrollBuildLog()); - - if (run.status !== 'running') { - this.buildRunning = false; - this.stopBuildPolling(); - if (run.status === 'completed') { - await this.loadHarnessConfig(); - } - } else if (this.buildRunning) { - this.buildPollTimer = setTimeout(() => void this.pollBuildStatus(), 3000); - } - } catch { - this.buildPollErrors++; - if (this.buildPollErrors >= 5) { - this.buildRunning = false; - this.buildStatus = 'failed'; - this.buildError = 'Lost connection to build'; - this.stopBuildPolling(); - } else if (this.buildRunning) { - this.buildPollTimer = setTimeout(() => void this.pollBuildStatus(), 3000); - } - } - } - - private scrollBuildLog(): void { - const el = this.renderRoot?.querySelector('.build-log'); - if (el) { - el.scrollTop = el.scrollHeight; - } - } - - private renderBuildLog() { - if (!this.buildLog && !this.buildRunning) return nothing; - - const statusClass = this.buildStatus === 'completed' ? 'completed' : this.buildStatus === 'running' ? 'running' : 'failed'; - - return html` -
-

- Build Output - - ${this.buildStatus === 'running' - ? html` Running` - : this.buildStatus} - -

-
${this.buildLog}
-
- `; - } - - override disconnectedCallback(): void { - super.disconnectedCallback(); - this.stopBuildPolling(); - } } declare global { From e67f645d25a36a3388ffa03c68fdfecc85c96ad3 Mon Sep 17 00:00:00 2001 From: "Scion Agent (harness-auth-glossary-fix)" Date: Sat, 13 Jun 2026 03:58:03 +0000 Subject: [PATCH 15/16] Restore accidentally deleted Observability section in GLOSSARY.md The Observability section (Infrastructure metrics, Agent metrics, Telemetry pipeline) was dropped during rebase conflict resolution in commit 22d64a9b. Restore the content originally added in aa16b361. --- GLOSSARY.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/GLOSSARY.md b/GLOSSARY.md index d221851d6..16132c213 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -230,6 +230,25 @@ A time-based trigger that fires an action — sending a message or dispatching ( _Avoid_: cron job (recurring only), scheduled message (too narrow), reminder, timer _See also_: Dispatch +## Observability + +Scion produces two distinct families of metrics. They serve different audiences, use different prefixes, and flow through different pipelines — but both export to the same Cloud Monitoring backend. + +**Infrastructure metrics**: +Operational health metrics for Scion as a system — the Hub process, its database connections, dispatch pipeline, broker authentication, and GCP token minting. These answer "is Scion itself healthy?" and are consumed by platform operators. Prefixes: `scion.hub.*`, `scion.db.*`, `scion.dispatch.*`. Produced by the Hub process; exported directly to Cloud Monitoring via an OTel MeterProvider with a GCP exporter. +_Avoid_: system metrics, platform metrics, server metrics +_See also_: Agent metrics (the other family) + +**Agent metrics**: +Telemetry about what agents and their harnesses are doing — token usage, tool calls, model API latency, session counts, and cost signals. These answer "what are the agents doing and what do they cost?" and are consumed by users and project owners. Prefixes: `gen_ai.*`, `agent.*` (following OpenTelemetry Generative AI semantic conventions). Produced inside agent containers by the harness and sciontool; exported to Cloud Monitoring via the telemetry pipeline (`pkg/sciontool/telemetry`). +_Avoid_: harness metrics, user metrics, LLM metrics +_See also_: Infrastructure metrics (the other family), Telemetry pipeline + +**Telemetry pipeline**: +The in-container OTLP receiver and forwarding pipeline (`pkg/sciontool/telemetry`) that collects traces, metrics, and logs from the harness and exports them to a cloud backend (GCP Cloud Monitoring, Cloud Trace, Cloud Logging). Requires the `scion-telemetry-gcp-credentials` secret for cloud export; runs in local-only mode without it. +_Avoid_: metrics pipeline, collector, OTel collector +_See also_: Agent metrics + ## Potential Future Additions Terms that recur in the codebase and may warrant canonical entries, but are **not yet defined** here. Listed so they aren't lost; promote to full entries (verified against the code) as the glossary matures. From a0e23cc523fc8c2a8c71c8bc7d1ae1d7a85c4ee6 Mon Sep 17 00:00:00 2001 From: "Scion Agent (harness-auth-noauth-fix)" Date: Sat, 13 Jun 2026 04:05:57 +0000 Subject: [PATCH 16/16] Fix NoAuth fallback when NoAuthMessage is empty When config.NoAuth is true but config.NoAuthMessage is empty, the combined condition `config.NoAuth && config.NoAuthMessage != ""` evaluated to false, incorrectly falling through to the harness command instead of dropping to shell. Split the condition so NoAuth is checked first, then branch on whether a message is set. --- pkg/runtime/common.go | 8 ++++++-- pkg/runtime/k8s_runtime.go | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/pkg/runtime/common.go b/pkg/runtime/common.go index d62bde80e..1d2ffab97 100644 --- a/pkg/runtime/common.go +++ b/pkg/runtime/common.go @@ -428,8 +428,12 @@ func buildCommonRunArgs(config RunConfig) ([]string, error) { // Get command from harness var harnessArgs []string - if config.NoAuth && config.NoAuthMessage != "" { - harnessArgs = []string{"sh", "-c", fmt.Sprintf("printf '%%s\\n' %s; exec bash", shellQuote(config.NoAuthMessage))} + if config.NoAuth { + if config.NoAuthMessage != "" { + harnessArgs = []string{"sh", "-c", fmt.Sprintf("printf '%%s\\n' %s; exec bash", shellQuote(config.NoAuthMessage))} + } else { + harnessArgs = []string{"bash"} + } } else if config.Harness != nil { harnessArgs = config.Harness.GetCommand(config.Task, config.Resume, config.CommandArgs) } else { diff --git a/pkg/runtime/k8s_runtime.go b/pkg/runtime/k8s_runtime.go index db84f5c68..b856d5efe 100644 --- a/pkg/runtime/k8s_runtime.go +++ b/pkg/runtime/k8s_runtime.go @@ -881,8 +881,12 @@ func (r *KubernetesRuntime) buildPod(namespace string, config RunConfig) (*corev // Command Resolution var cmd []string var harnessArgs []string - if config.NoAuth && config.NoAuthMessage != "" { - harnessArgs = []string{"sh", "-c", fmt.Sprintf("printf '%%s\\n' %s; exec bash", shellQuote(config.NoAuthMessage))} + if config.NoAuth { + if config.NoAuthMessage != "" { + harnessArgs = []string{"sh", "-c", fmt.Sprintf("printf '%%s\\n' %s; exec bash", shellQuote(config.NoAuthMessage))} + } else { + harnessArgs = []string{"bash"} + } } else if config.Harness != nil { harnessArgs = config.Harness.GetCommand(config.Task, config.Resume, config.CommandArgs) } else {