Skip to content

Commit 074f5c2

Browse files
committed
fix: pass regional Flight gRPC URL; correct app-url to spice.ai/<org>/<app>
Two fixes from the latest failed deploy run. 1. SQL probe `14 UNAVAILABLE: No connection established` The @spiceai/spice SDK was initialized without `flightUrl`, so its gRPC client targeted the default localhost:50051. Per the Spice docs the regional flight endpoint is `<region>-prod-aws-flight.spiceai.io:443` (mirrors the data hostname with `-data` swapped for `-flight`). Derive that from the resolved app's cname / region and pass it through to `RuntimeClient` → `SpiceClient`. Add a `flight-url` input override that accepts `host:port` or `grpc+tls://host:port` form (scheme is stripped). 2. `app-url` was wrong Apps live at `https://spice.ai/<org>/<app-name>`, not `https://<app-name>.spice.ai`. New helper `buildAppUrl` constructs the right URL using the new `org` input, falling back to the owner part of `GITHUB_REPOSITORY` (which matches the Spice org slug for personal orgs and connected GitHub orgs), and finally to `https://spice.ai/apps` if neither is available. Tests: 101 passing (up from 93). New cases cover flight URL derivation from cname / region / explicit input / scheme stripping, and the buildAppUrl org-precedence logic.
1 parent ca6f77b commit 074f5c2

10 files changed

Lines changed: 212 additions & 56 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

77
## [Unreleased]
88

9+
### Added
10+
- New `org` input. The action now constructs the `app-url` output as `https://spice.ai/<org>/<app-name>` (the canonical Spice Cloud portal URL pattern). When `org` is unset, it falls back to the owner part of `GITHUB_REPOSITORY`, which matches the Spice org slug for personal orgs and orgs created from a connected GitHub organization.
11+
- New `flight-url` input. The action now passes a regional Apache Arrow Flight gRPC endpoint to the `@spiceai/spice` SDK so the SQL probe uses gRPC instead of falling through to localhost. When `flight-url` is unset, it's derived from the resolved app's region as `<region>-prod-aws-flight.spiceai.io:443` (mirrors the data hostname with `-data` swapped for `-flight`). A `grpc+tls://` / `grpc://` scheme prefix on the input is stripped automatically.
12+
13+
### Fixed
14+
- The `app-url` output was previously `https://<app-name>.spice.ai`, which doesn't resolve. It is now `https://spice.ai/<org>/<app-name>` (e.g. `https://spice.ai/lukekim/home`).
15+
- SQL smoke test failed with `14 UNAVAILABLE: No connection established` because the SDK initialized a gRPC client with no `flightUrl` configured for Spice Cloud. The action now derives a regional flight URL by default; the SDK uses gRPC for SQL queries with HTTP fallback as it was designed to.
16+
917
### Added
1018
- Auto-capture a `repository` tag from `GITHUB_REPOSITORY` when set, sanitized to fit the API's tag-value rule (`/``_`). Users can override by setting `repository:` explicitly in the `tags` input.
1119
- New post-deploy dataset readiness check: poll `GET /v1/datasets?status=true` until every dataset reaches a terminal-ok state (`ready`, `disabled`, or `refreshing`); fail the job immediately on `error` or on timeout-while-pending — regardless of `fail-on-test-error`, which still only governs runtime-probe results. Statuses like `shuttingdown` and any unrecognized values are treated as still-pending so the loop never returns a false-positive "loaded". Configured via `dataset-ready-timeout-seconds` (default `300`, set `0` to skip). Dataset states are surfaced as a `datasets` action output and as a table in the GitHub job step summary.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ Grant exactly the scopes for the features you use. The "All-in" row at the botto
118118
| --- | --- |
119119
| `app-id` | Resolved numeric app ID. |
120120
| `app-name` | Resolved app name. |
121-
| `app-url` | `https://<app-name>.spice.ai`. |
121+
| `app-url` | Portal URL of the deployed app: `https://spice.ai/<org>/<app-name>`. The `<org>` slug comes from the `org` input when set, otherwise the owner part of `GITHUB_REPOSITORY`. |
122122
| `deployment-id` | Created deployment ID. |
123123
| `deployment-status` | Final status (`queued`, `in_progress`, `succeeded`, `failed`). |
124124
| `deployment-created-at` | ISO 8601 timestamp the deployment was created. |

__tests__/deploy.test.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ afterEach(() => {
2020
});
2121

2222
import type { SpiceApiClient } from "../src/api.js";
23-
import { resolveRuntimeUrl, runDeploy } from "../src/deploy.js";
23+
import { resolveFlightUrl, resolveRuntimeUrl, runDeploy } from "../src/deploy.js";
2424
import {
2525
DeploymentFailedError,
2626
DeploymentTimeoutError,
@@ -307,6 +307,33 @@ describe("runDeploy", () => {
307307
).toThrow(/Cannot determine runtime URL/);
308308
});
309309

310+
it("resolves flight URL by swapping `-data` for `-flight` in the cname", () => {
311+
expect(resolveFlightUrl(sampleApp, baseInputs)).toBe(
312+
"us-west-2-prod-aws-flight.spiceai.io:443",
313+
);
314+
});
315+
316+
it("resolves flight URL from app region when cname is missing", () => {
317+
expect(resolveFlightUrl({ id: 1, name: "x", region: "us-east-1" }, baseInputs)).toBe(
318+
"us-east-1-prod-aws-flight.spiceai.io:443",
319+
);
320+
});
321+
322+
it("respects explicit flight-url override (with scheme stripping)", () => {
323+
expect(
324+
resolveFlightUrl(sampleApp, { ...baseInputs, flightUrl: "custom-flight.example:443" }),
325+
).toBe("custom-flight.example:443");
326+
});
327+
328+
it("returns undefined when no flight URL can be derived", () => {
329+
expect(
330+
resolveFlightUrl(
331+
{ id: 1, name: "x" },
332+
{ ...baseInputs, region: undefined, flightUrl: undefined },
333+
),
334+
).toBeUndefined();
335+
});
336+
310337
it("runs all configured probes in order", async () => {
311338
const listApps = vi.fn().mockResolvedValue([sampleApp]);
312339
const createDeployment = vi.fn().mockResolvedValue(succeededDeployment);

__tests__/main.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
vi.mock("@actions/core", () => ({
4+
setFailed: vi.fn(),
5+
setOutput: vi.fn(),
6+
setSecret: vi.fn(),
7+
info: vi.fn(),
8+
summary: { addHeading: vi.fn(), addTable: vi.fn(), write: vi.fn() },
9+
}));
10+
11+
import { buildAppUrl } from "../src/main.js";
12+
13+
describe("buildAppUrl", () => {
14+
const ORIGINAL = process.env.GITHUB_REPOSITORY;
15+
16+
beforeEach(() => {
17+
delete process.env.GITHUB_REPOSITORY;
18+
});
19+
afterEach(() => {
20+
if (ORIGINAL === undefined) delete process.env.GITHUB_REPOSITORY;
21+
else process.env.GITHUB_REPOSITORY = ORIGINAL;
22+
});
23+
24+
it("uses the explicit org input when provided", () => {
25+
expect(buildAppUrl("home", "lukekim")).toBe("https://spice.ai/lukekim/home");
26+
});
27+
28+
it("falls back to the owner part of GITHUB_REPOSITORY", () => {
29+
process.env.GITHUB_REPOSITORY = "lukekim/home";
30+
expect(buildAppUrl("home", undefined)).toBe("https://spice.ai/lukekim/home");
31+
});
32+
33+
it("prefers the explicit org over GITHUB_REPOSITORY", () => {
34+
process.env.GITHUB_REPOSITORY = "github-org/repo";
35+
expect(buildAppUrl("home", "spice-org")).toBe("https://spice.ai/spice-org/home");
36+
});
37+
38+
it("falls back to the apps listing when no org is available", () => {
39+
expect(buildAppUrl("home", undefined)).toBe("https://spice.ai/apps");
40+
});
41+
});

action.yml

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,15 @@ inputs:
2929
description: Create the app if it does not exist. Requires `app-name`.
3030
required: false
3131
default: "false"
32+
org:
33+
description: |
34+
Spice Cloud organization slug. Used to construct the `app-url` output
35+
(`https://spice.ai/<org>/<app-name>`). When unset, the action falls back
36+
to the owner part of `GITHUB_REPOSITORY` — which matches the Spice org
37+
slug for personal orgs and for orgs created from a connected GitHub
38+
organization. Set this explicitly only when your Spice org slug differs
39+
from the GitHub repo owner.
40+
required: false
3241
region:
3342
description: |
3443
Spice Cloud region (e.g. `us-east-1`, `us-west-2`).
@@ -161,11 +170,20 @@ inputs:
161170
default: "300"
162171
runtime-url:
163172
description: |
164-
Override the runtime base URL probes connect to. When unset, the URL is
165-
derived from the resolved app's region as
173+
Override the runtime HTTP base URL probes connect to. When unset, the
174+
URL is derived from the resolved app's region as
166175
`https://<region>-prod-aws-data.spiceai.io` (e.g.
167176
`https://us-east-1-prod-aws-data.spiceai.io`).
168177
required: false
178+
flight-url:
179+
description: |
180+
Override the Apache Arrow Flight gRPC endpoint the SQL probe connects
181+
to (`host:port`, optionally prefixed with `grpc+tls://`). When unset,
182+
it's derived from the app's region as
183+
`<region>-prod-aws-flight.spiceai.io:443` (e.g.
184+
`us-west-2-prod-aws-flight.spiceai.io:443`). Set this if your app
185+
uses a custom Flight host.
186+
required: false
169187
fail-on-test-error:
170188
description: Fail the action when any probe fails. Set to `false` to record failures without failing the job.
171189
required: false

dist/index.js

Lines changed: 46 additions & 46 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/index.js.map

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/deploy.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@ import * as core from "@actions/core";
22
import type { SpiceApiClient } from "./api.js";
33
import { DeploymentFailedError, DeploymentTimeoutError, InputValidationError } from "./errors.js";
44
import type { ActionInputs } from "./inputs.js";
5-
import { deriveRuntimeUrl, deriveRuntimeUrlFromCname } from "./inputs.js";
5+
import {
6+
deriveFlightUrl,
7+
deriveFlightUrlFromCname,
8+
deriveRuntimeUrl,
9+
deriveRuntimeUrlFromCname,
10+
} from "./inputs.js";
611
import { buildProbePlans } from "./probes.js";
712
import type { DatasetState, ProbeResult } from "./runtime.js";
813
import { DatasetReadinessError, RuntimeClient } from "./runtime.js";
@@ -131,6 +136,21 @@ export function resolveRuntimeUrl(app: App, inputs: ActionInputs): string {
131136
);
132137
}
133138

139+
/**
140+
* Resolve the regional Flight gRPC endpoint (`host:port`) for the SDK. Prefer
141+
* an explicit `flight-url` input, then derive from the app's cname (replacing
142+
* `-data` with `-flight`), then from the app's region, then from the input
143+
* region. Returns `undefined` if no source is available — callers fall back to
144+
* letting the SDK use HTTP.
145+
*/
146+
export function resolveFlightUrl(app: App, inputs: ActionInputs): string | undefined {
147+
if (inputs.flightUrl) return inputs.flightUrl;
148+
if (app.cname) return deriveFlightUrlFromCname(app.cname);
149+
if (app.region) return deriveFlightUrl(app.region);
150+
if (inputs.region) return deriveFlightUrl(inputs.region);
151+
return undefined;
152+
}
153+
134154
async function maybeUpdateAppMetadata(
135155
api: SpiceApiClient,
136156
app: App,
@@ -217,13 +237,16 @@ async function runPostDeployChecks(
217237
core.setSecret(apiKey);
218238

219239
const runtimeUrl = resolveRuntimeUrl(app, inputs);
240+
const flightUrl = resolveFlightUrl(app, inputs);
220241
core.info(`Runtime URL: ${runtimeUrl}`);
242+
if (flightUrl) core.info(`Flight URL: grpc+tls://${flightUrl}`);
221243

222244
const runtime = deps.runtimeFactory
223-
? deps.runtimeFactory(apiKey, { ...inputs, runtimeUrl })
245+
? deps.runtimeFactory(apiKey, { ...inputs, runtimeUrl, flightUrl })
224246
: new RuntimeClient({
225247
apiKey,
226248
baseUrl: runtimeUrl,
249+
flightUrl,
227250
warmupSeconds: inputs.testWarmupSeconds,
228251
timeoutSeconds: inputs.testTimeoutSeconds,
229252
clock: deps.clock,

src/inputs.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export interface ActionInputs {
77
clientSecret: string;
88
appId?: number;
99
appName?: string;
10+
org?: string;
1011
createAppIfMissing: boolean;
1112
region?: string;
1213
visibility: "public" | "private";
@@ -38,6 +39,7 @@ export interface ActionInputs {
3839
testTimeoutSeconds: number;
3940
datasetReadyTimeoutSeconds: number;
4041
runtimeUrl?: string;
42+
flightUrl?: string;
4143
failOnTestError: boolean;
4244
}
4345

@@ -51,6 +53,20 @@ export function deriveRuntimeUrlFromCname(cname: string): string {
5153
return `https://${cname}.spiceai.io`;
5254
}
5355

56+
/**
57+
* Build the regional Apache Arrow Flight gRPC endpoint as `host:port`.
58+
* Spice Cloud's flight endpoint mirrors the data hostname but with `-data`
59+
* swapped for `-flight`, e.g. `us-west-2-prod-aws-flight.spiceai.io:443`.
60+
*/
61+
export function deriveFlightUrl(region: string): string {
62+
return `${region}-prod-aws-flight.spiceai.io:443`;
63+
}
64+
65+
export function deriveFlightUrlFromCname(cname: string): string {
66+
const flightCname = cname.endsWith("-data") ? `${cname.slice(0, -"-data".length)}-flight` : cname;
67+
return `${flightCname}.spiceai.io:443`;
68+
}
69+
5470
function getOptional(name: string): string | undefined {
5571
const value = core.getInput(name);
5672
return value === "" ? undefined : value;
@@ -181,11 +197,17 @@ export function readInputs(): ActionInputs {
181197
const runtimeUrlRaw = getOptional("runtime-url");
182198
const runtimeUrl = runtimeUrlRaw ? parseUrl("runtime-url", runtimeUrlRaw) : undefined;
183199

200+
// Flight URL is `host:port` (gRPC), not an HTTP URL — don't validate as URL.
201+
// Strip an optional `grpc+tls://` / `grpc://` scheme so docs-style values work.
202+
const flightUrlRaw = getOptional("flight-url");
203+
const flightUrl = flightUrlRaw?.replace(/^grpc(\+tls)?:\/\//, "");
204+
184205
return {
185206
clientId,
186207
clientSecret,
187208
appId,
188209
appName,
210+
org: getOptional("org"),
189211
createAppIfMissing,
190212
region,
191213
visibility,
@@ -217,6 +239,7 @@ export function readInputs(): ActionInputs {
217239
testTimeoutSeconds: getRequiredInt("test-timeout-seconds", 30, { min: 1 }),
218240
datasetReadyTimeoutSeconds: getRequiredInt("dataset-ready-timeout-seconds", 300, { min: 0 }),
219241
runtimeUrl,
242+
flightUrl,
220243
failOnTestError: getBool("fail-on-test-error", true),
221244
};
222245
}

src/main.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ async function run(): Promise<void> {
3737
try {
3838
const { app, deployment, probeResults, datasets } = await runDeploy(api, inputs);
3939

40-
const appUrl = `https://${app.name}.spice.ai`;
40+
const appUrl = buildAppUrl(app.name, inputs.org);
4141
core.setOutput("app-id", String(app.id));
4242
core.setOutput("app-name", app.name);
4343
core.setOutput("app-url", appUrl);
@@ -91,6 +91,22 @@ function handleError(err: unknown): void {
9191
core.setFailed(`Unexpected error: ${String(err)}`);
9292
}
9393

94+
/**
95+
* Build the Spice Cloud portal URL for the deployed app.
96+
*
97+
* Apps live at `https://spice.ai/<org>/<app>` — there is no
98+
* `https://<app>.spice.ai` host. The org slug is the user's Spice
99+
* organization, which for personal orgs and connected GitHub orgs matches the
100+
* GitHub owner of the repository. We use the `org` input when set, fall back
101+
* to the owner part of `GITHUB_REPOSITORY`, and finally fall back to the
102+
* org-less `https://spice.ai/apps` listing if neither is available.
103+
*/
104+
export function buildAppUrl(appName: string, orgInput: string | undefined): string {
105+
const org = orgInput ?? process.env.GITHUB_REPOSITORY?.split("/", 1)[0];
106+
if (org) return `https://spice.ai/${org}/${appName}`;
107+
return `https://spice.ai/apps`;
108+
}
109+
94110
async function writeSummary(args: {
95111
app: { id: number; name: string };
96112
deployment: { id: number | string; status: string; commit_sha?: string; branch?: string };

0 commit comments

Comments
 (0)