From 0d138f39d56294f5ad9e5a0a5ec7eeaca5973139 Mon Sep 17 00:00:00 2001 From: vmsharoshkin Date: Thu, 13 Aug 2026 13:02:51 +0300 Subject: [PATCH] feat: api key auth --- CLAUDE.md | 116 +++++------------- README.md | 73 ++++------- cmd/config.go | 14 +-- cmd/helpers.go | 105 ++-------------- cmd/job.go | 2 +- cmd/login.go | 54 ++------ cmd/root.go | 15 +-- internal/config/config.go | 51 +------- internal/iam/client.go | 89 -------------- internal/repoapi/client.go | 35 +----- .../workflow_client/.openapi-generator/FILES | 1 - internal/workflow_client/README.md | 4 +- internal/workflow_client/api_pipelines.go | 24 ++-- internal/workflow_client/api_workflows.go | 24 ++-- internal/workflowapi/client.go | 41 +------ internal/workflowapi/job.go | 11 -- skill/SKILL.md | 85 ++++--------- 17 files changed, 149 insertions(+), 595 deletions(-) delete mode 100644 internal/iam/client.go diff --git a/CLAUDE.md b/CLAUDE.md index e28ff4f..3642279 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,17 +12,9 @@ that currently drives two independent products: - **Workflow Studio** (wires a repository + branch to a deploy pipeline and publishes it) — `eds wf app|run|job *`. -These are separate products with **independent credentials**, namespaced -accordingly. Repo takes a static `--repo-api-key`/`EDS_REPO_API_KEY` sent -as `X-API-KEY`. Workflow Studio takes a `--wf-key-id`/`--wf-secret` pair -(`EDS_WF_KEY_ID`/`EDS_WF_SECRET`) that the CLI exchanges for a short-lived -Bearer access token via the cloud.ru IAM service (`internal/iam`) the -first time it's needed, caching the token + its expiry in the config file -and re-exchanging automatically once it expires — callers never handle a -raw Workflow Studio token themselves. `--project`/`EDS_PROJECT_ID` and -`--iam-url`/`EDS_IAM_URL` are the only platform-level (unprefixed) -settings, shared across products. Having one product's credential -configured does not imply the other is. +Both products are authenticated with the same API key (`--api-key`/`EDS_API_KEY`), +sent as `X-API-KEY`. `--project`/`EDS_PROJECT_ID` is the only other platform-level +(shared, unprefixed) setting. The CLI is explicitly designed to be **agent-friendly**: every command has stable `--json` output, config comes from env vars, and `skill/SKILL.md` @@ -69,42 +61,25 @@ genuinely internal and not tied to where the repo lives. Cobra-based CLI, one command per file under `cmd/`, thin API clients under `internal/repoapi/` (Repo product) and `internal/workflowapi/` (Workflow -Studio), plus a small `internal/iam/` client used only to mint Workflow -Studio's Bearer token. +Studio). - `main.go` — entry point, wires `main.version` (ldflags) into `cmd.SetVersion`. - `cmd/root.go` — builds the root `eds` command, registers global persistent - flags. Product-scoped: `--repo-api-url`, `--repo-api-key`, `--repo-git-host` - (Repo product); `--wf-api-url`, `--wf-key-id`, `--wf-secret` (Workflow - Studio). Platform-level (shared, unprefixed): `--project`, `--iam-url`. - Plus `--json`, `--quiet`, and the TEMPORARY `--repo-use-wf-auth` (see below). + flags. Product-scoped: `--repo-api-url`, `--repo-git-host` + (Repo product); `--wf-api-url` (Workflow Studio). Platform-level (shared, + unprefixed): `--project`, `--api-key`. Plus `--json`, `--quiet`. - `cmd/helpers.go` — `resolveContext(cmd)` is the entry point every subcommand calls first. It loads config, layers flag overrides on top (flags > env > file > defaults), and builds a `runtimeContext{Cfg, API, WorkflowAPI, Printer, Quiet}`. `requireAPIKey()` guards Repo-product commands. - `ensureWorkflowAuth(ctx)` guards Workflow Studio commands: it reuses the - cached access token in `Cfg` if still valid *and* was minted for the - currently-active `WorkflowKeyID` (see `WorkflowAccessTokenKeyID` below), - otherwise calls `internal/iam.FetchToken` to exchange `WorkflowKeyID`/ - `WorkflowSecret` for a fresh one, pushes it into `WorkflowAPI` via - `SetToken`, and persists the new token + expiry + key id back to the - config file (reloaded fresh from disk first, so it doesn't also persist - unrelated one-off flag overrides for this run). `resolveContext` also - implements the temporary `--repo-use-wf-auth`/`EDS_REPO_USE_WF_AUTH` - escape hatch: when set, it calls `ensureWorkflowAuth` unconditionally and - hands the resulting Bearer token to `ctx.API.SetBearerToken`, so Repo - commands authenticate the same way Workflow Studio does. This exists - because some cloud.ru environments don't (yet) accept `X-API-KEY` for the - Repo product; remove `--repo-use-wf-auth`, `SetBearerToken`, and - `UsesBearerAuth` once that's fixed server-side everywhere. + `ensureWorkflowAuth(ctx)` guards Workflow Studio commands: it validates + that `APIKey` is set (the same key is used for both products). Both helpers + return the same friendly error when the key is missing. - `cmd/login.go`, `cmd/config.go`, `cmd/version.go`, `cmd/repo.go` — one subcommand tree each. `repo.go` holds `eds repo list|create|show|delete|clone` plus `resolveRepoID`, which lets users pass either a UUID or a repo name (falls back to listing + case-insensitive name match when the arg doesn't - look like a UUID). `login.go` accepts the Repo-product and Workflow Studio - credential pairs independently (either or both); setting a new - `--wf-key-id`/`--wf-secret` clears any cached access token so it isn't - reused across a credential change. + look like a UUID). `login.go` accepts `--api-key` plus optional product URLs. - `cmd/wf.go` — the `eds wf` parent command; just groups `newAppCmd()`, `newRunCmd()`, `newJobCmd()` as children. Doesn't hold any logic itself. - `cmd/app.go`, `cmd/run.go`, `cmd/job.go` — Workflow Studio subcommand @@ -134,20 +109,8 @@ Studio's Bearer token. `$XDG_CONFIG_HOME/eds/config.json`) -> `EDS_*` env vars -> CLI flags (flags applied later, in `cmd/helpers.go`). Config file is written with `0600` permissions and is a single platform-level file shared by both - products (not split per-product), since project id/IAM are shared. Also - holds the Workflow Studio credential pair (`WorkflowKeyID`/`WorkflowSecret`) - and the CLI-managed token cache (`WorkflowAccessToken`/ - `WorkflowTokenExpiresAt`/`WorkflowAccessTokenKeyID`) — these three have no - flag/env equivalent since they're never meant to be set directly. - `WorkflowAccessTokenKeyID` records which `WorkflowKeyID` the cached token - was minted for; `ensureWorkflowAuth` compares it against the currently - active `WorkflowKeyID` before trusting the cache. Without this check, - switching Workflow Studio credentials via flag/env (rather than through - `login`, which explicitly clears the cache) would silently keep reusing a - token minted for the *old* credentials until it expired -- this bit us - switching from a dev to a prod `wf-key-id` while the disk cache still held - a live dev-minted token. Note: the internal `Config` - struct field names and JSON keys (`api_url`, `workflow_key_id`, etc.) keep + products (not split per-product), since project id is shared. Note: the + internal `Config` struct field names and JSON keys (`api_url`, etc.) keep their pre-rename spelling — only the user-facing CLI flags/env vars/command names follow the `eds`/`repo`/`wf` product split; this is a deliberate minimal-diff choice, not an oversight. @@ -157,14 +120,13 @@ Studio's Bearer token. output modes stay in sync from one call site. Also has `HumanTime` / `HumanSize` formatters. - `internal/repoapi/client.go` — generic authenticated HTTP client (`Do`), - sends `X-API-KEY` by default, decodes JSON, maps non-2xx responses to + sends `X-API-KEY`, decodes JSON, maps non-2xx responses to `*APIError` (tries `error`, `message`, and per-field `errors` response shapes; also captures a server-assigned request id from common headers like `X-Request-Id` via `requestIDFromHeader` and includes it in `APIError.Error()` when present -- the single most useful thing to grab when reporting a bug to the API team, since bodies are sometimes empty - even on a 500). Also has `SetBearerToken`/`UsesBearerAuth` -- TEMPORARY, - backs `--repo-use-wf-auth` (see `cmd/helpers.go` above); remove together. + even on a 500). - `internal/repoapi/repositories.go` — repository-specific request/response types and the four API calls (`ListRepositories`, `GetRepository`, `CreateRepository`, `DeleteRepository`), all scoped under @@ -172,21 +134,14 @@ Studio's Bearer token. `search` query param entirely when empty — the API treats an empty string as a literal (matches nothing) rather than a wildcard. - `internal/workflowapi/client.go` — the Workflow Studio counterpart to - `repoapi/client.go`: same `Do`/`APIError`/request-id shape, but sends - `Authorization: Bearer ` instead of `X-API-KEY`, has no built-in - request timeout (bounded by the caller's context instead, since `logs` - can stay open for a running job), and adds `Stream` for reading an SSE - response body line-by-line plus `SetToken` to swap the token after an - IAM refresh. + `repoapi/client.go`: same `Do`/`APIError`/request-id shape, sends + `X-API-KEY` (the same key as Repo), has no built-in request timeout + (bounded by the caller's context instead, since `logs` can stay open for + a running job), and adds `Stream` for reading an SSE response body + line-by-line. - `internal/workflowapi/application.go`, `run.go`, `job.go` — application/deployment, run/stage, and job request/response types plus the API calls, all scoped under `/project/{projectID}/...`. -- `internal/iam/client.go` — `FetchToken(ctx, iamURL, keyID, secret)` - exchanges Workflow Studio's key id/secret for a Bearer `access_token` - (Keycloak-shaped response: `access_token`, `expires_in`, etc.) against - `https://iam.api.cloud.ru/api/v1/auth/token`. This is the **only** - consumer of the IAM endpoint — everything else in the CLI talks to - `repoapi`/`workflowapi`, never IAM directly. - `openapi-user.yaml` — OpenAPI (Swagger 2.0) spec for the Workflow Studio user API; the source of truth for request/response shapes when extending `internal/workflowapi`. @@ -208,23 +163,10 @@ Studio's Bearer token. ## Conventions specific to this codebase -- Every new Repo-product subcommand should start with - `ctx, err := resolveContext(cmd)` and, if it hits the API, - `ctx.requireAPIKey()`. Every new Workflow Studio subcommand - (under `eds wf`: `app`/`run`/`job`) should call - `ctx.ensureWorkflowAuth(cmd.Context())` instead — it both validates - credentials and refreshes the cached token. -- Never add a way to set `WorkflowAccessToken`/`WorkflowTokenExpiresAt`/ - `WorkflowAccessTokenKeyID` directly (no flag, no env var); they are - populated only by `ensureWorkflowAuth`. If Workflow Studio's auth model - changes, change `internal/iam` and `ensureWorkflowAuth`, not the cache - fields themselves. -- `--repo-use-wf-auth`/`EDS_REPO_USE_WF_AUTH` is a deliberately TEMPORARY - escape hatch (Repo product auth via the Workflow Studio Bearer token - instead of `X-API-KEY`) for environments where Repo's own API key auth - isn't accepted yet. If you add another workaround flag of this shape, - label it TEMPORARY in the flag help text and in code comments the same - way, so it's easy to grep for and remove later. +- Every new subcommand should start with `ctx, err := resolveContext(cmd)`. + If it hits a Repo-product API, call `ctx.requireAPIKey()`. If it hits a + Workflow Studio API, call `ctx.ensureWorkflowAuth(cmd.Context())` — both + validate that `EDS_API_KEY` is set. - Every subcommand that returns structured data should support both table (default on TTY) and `--json` output via `ctx.Printer`, not ad-hoc `fmt.Println`. @@ -232,11 +174,11 @@ Studio's Bearer token. repository UUID or a name (`resolveRepoID` / `looksLikeUUID`); don't require callers to look up IDs first. - New flags/env vars must follow the existing product-scoping convention: - Repo-product settings get a `repo-`/`REPO_` infix (`--repo-api-key`, - `EDS_REPO_API_KEY`), Workflow Studio settings get a `wf-`/`WF_` infix - (`--wf-key-id`, `EDS_WF_KEY_ID`), and only genuinely cross-product - settings (project id, IAM) stay unprefixed. Don't add a third product's - settings without picking an analogous short infix. + Repo-product settings get a `repo-`/`REPO_` infix (`--repo-api-url`, + `EDS_REPO_API_URL`), Workflow Studio settings get a `wf-`/`WF_` infix + (`--wf-api-url`, `EDS_WF_API_URL`), and only genuinely cross-product + settings (project id and the API key) stay unprefixed. Don't add a third + product's settings without picking an analogous short infix. - Errors returned from `RunE` are printed as a single `Error: ` line to stderr by `cmd.Execute()` (root sets `SilenceUsage`/`SilenceErrors`) — keep error messages one line and actionable (see `requireAPIKey`'s message as diff --git a/README.md b/README.md index a042fd6..e21b5c4 100644 --- a/README.md +++ b/README.md @@ -7,19 +7,17 @@ developer tools products. It currently covers two products: - **Workflow Studio** (`eds wf`) — wires a repository + branch to a deploy pipeline and publishes it. -Repo and Workflow Studio are independent products with independent -credentials; `eds` is just the platform CLI they're both driven through. -Every command is designed to be safely driven by automation and AI -agents: stable `--json` output, environment variables for secrets. +Repo and Workflow Studio are independent products, but both are authenticated +with the same API key (`EDS_API_KEY`) via `X-API-KEY`. `eds` is just the +platform CLI they're both driven through. Every command is designed to be safely +driven by automation and AI agents: stable `--json` output, environment variables +for secrets. - Single static binary (Go, no runtime dependencies). -- Reads Repo's API key from `EDS_REPO_API_KEY`, Workflow Studio's key - id/secret from `EDS_WF_KEY_ID`/`EDS_WF_SECRET`, or from - `~/.config/eds/config.json`. +- Reads the API key from `EDS_API_KEY` or from `~/.config/eds/config.json`. - Outputs JSON when piped, pretty tables on a TTY (`--json` to force). -- Repo is authenticated with `X-API-KEY` and uses the local `git` CLI for - clone. Workflow Studio is authenticated with a Bearer token that `eds` - mints on your behalf (see "Workflow Studio auth" below). +- Repo uses the local `git` CLI for clone. Both products authenticate with + `X-API-KEY`. ## Installation @@ -65,17 +63,16 @@ The CLI looks for configuration in this order (later wins): 1. Built-in defaults: Repo API URL `https://devtools.api.cloud.ru/repo/api/v1`, git host `https://repo.cloud.ru/`, Workflow Studio API URL - `https://pipeline.cloud.ru/public-api/v1`, IAM URL - `https://iam.api.cloud.ru/api/v1/auth/token`. + `https://pipeline.cloud.ru/public-api/v1`. 2. File `~/.config/eds/config.json` (overridable via `EDS_CONFIG` or `XDG_CONFIG_HOME`) — a single platform-level file shared by both products. 3. Environment variables (see table below). 4. Per-command flags. -Env vars and flags are namespaced by product — `EDS_REPO_*`/`--repo-*` for -Repo, `EDS_WF_*`/`--wf-*` for Workflow Studio — except for the truly -platform-level settings (`EDS_PROJECT_ID`/`--project`, `EDS_IAM_URL`/`--iam-url`), -which are shared. +Env vars and flags are namespaced by product for URLs — `EDS_REPO_*`/`--repo-*` +for Repo, `EDS_WF_*`/`--wf-*` for Workflow Studio — except for `--api-key`/ +`EDS_API_KEY` and `--project`/`EDS_PROJECT_ID`, which are shared platform-level +settings. Example config file (`~/.config/eds/config.json`): @@ -85,12 +82,7 @@ Example config file (`~/.config/eds/config.json`): "project_id": "3232b2d0-1063-41e6-b2fa-13df767f4a0a", "api_key": "...", "git_host": "https://repo.cloud.ru/", - "workflow_api_url": "https://pipeline.cloud.ru/public-api/v1", - "workflow_key_id": "...", - "workflow_secret": "...", - "iam_url": "https://iam.api.cloud.ru/api/v1/auth/token", - "workflow_access_token": "... (cached, managed by the CLI)", - "workflow_token_expires_at": "... (cached, managed by the CLI)" + "workflow_api_url": "https://pipeline.cloud.ru/public-api/v1" } ``` @@ -104,16 +96,9 @@ Override with `--repo-api-url` or `EDS_REPO_API_URL`. ### Workflow Studio auth -Workflow Studio has its own base URL (`--wf-api-url` / `EDS_WF_API_URL`) and -its own credential pair (`--wf-key-id`/`--wf-secret` or -`EDS_WF_KEY_ID`/`EDS_WF_SECRET`) — it is **not** authenticated with -`--repo-api-key`/`EDS_REPO_API_KEY`. The key id/secret are exchanged for a -short-lived Bearer access token via the cloud.ru IAM service -(`POST /api/v1/auth/token` at `--iam-url` / `EDS_IAM_URL`, default -`https://iam.api.cloud.ru/api/v1/auth/token`). The CLI does this -automatically before the first `eds wf ...` call and caches the resulting -token (and its expiry) in the config file, re-exchanging it once it -expires — you never need to call the IAM endpoint yourself. +Workflow Studio uses the same API key as Repo (`--api-key` / `EDS_API_KEY`), +sent as `X-API-KEY` on every request. There is no separate credential pair or +token exchange — the same key works for both `eds repo *` and `eds wf *`. ## Commands @@ -134,8 +119,8 @@ eds wf app show show application details eds wf app update --branch B [--name N] update name/branch eds wf app delete [--force] delete an application eds wf app deploy run the pipeline and publish -eds wf app deployments list publish history -eds wf app status run/stage/job status + live URL +eds wf app deployments list publish history +eds wf app status run status + live URL eds wf run show show a run's status, stages and jobs eds wf run list [--pipeline-id ID] list runs @@ -151,14 +136,9 @@ eds wf job stop stop a running job ### Login ```bash -eds login --repo-api-key "$EDS_REPO_API_KEY" --project +eds login --api-key "$EDS_API_KEY" --project eds login --repo-api-url https://devtools.dev.api.internal.cloud.ru/repo/api/v1 \ - --repo-api-key "$EDS_REPO_API_KEY" --project - -# Workflow Studio uses a separate credential pair, exchanged for a Bearer -# token via cloud.ru IAM on first use (cached automatically afterwards) -eds login --wf-key-id "$WF_KEY_ID" --wf-secret "$WF_SECRET" \ - --project + --api-key "$EDS_API_KEY" --project ``` ### For an AI agent @@ -166,7 +146,7 @@ eds login --wf-key-id "$WF_KEY_ID" --wf-secret "$WF_SECRET" \ The CLI is designed to be safely scripted. Recommended pattern: ```bash -export EDS_REPO_API_KEY="..." +export EDS_API_KEY="..." export EDS_PROJECT_ID="..." # List repos as JSON, pipe into jq @@ -201,8 +181,7 @@ produces a live URL. This is the CLI's main "vibe-coded a site, now ship it" path — `eds repo` gets your code hosted, `eds wf app` publishes it. ```bash -export EDS_WF_KEY_ID="..." # separate credential pair from EDS_REPO_API_KEY -export EDS_WF_SECRET="..." # exchanged for a Bearer token via cloud.ru IAM automatically +export EDS_API_KEY="..." export EDS_PROJECT_ID="..." # 1. Create the repo and push code (see "File upload / push" below) @@ -307,7 +286,6 @@ internal/ output/ # JSON / table formatting repoapi/ # thin HTTP client for the Repo product API workflowapi/ # thin HTTP client for the Workflow Studio product API - iam/ # exchanges wf-key-id/secret for a Bearer token scripts/ install.sh # one-liner installer (GitHub Releases by default) skill/ @@ -320,13 +298,10 @@ Makefile # build, build-all, release, upload, … | Variable | Description | | ----------------- | ------------------------------------------------------------ | | `EDS_PROJECT_ID` | Default project ID (shared across products) | -| `EDS_IAM_URL` | cloud.ru IAM token endpoint (shared, overrides config) | | `EDS_REPO_API_URL` | Repo product API base URL (overrides config) | -| `EDS_REPO_API_KEY` | Repo product X-API-KEY value | +| `EDS_API_KEY` | API key used for both products (X-API-KEY) | | `EDS_REPO_GIT_HOST` | Repo product git smart-HTTP host | | `EDS_WF_API_URL` | Workflow Studio product API base URL (overrides config) | -| `EDS_WF_KEY_ID` | Workflow Studio key id (separate from Repo's API key) | -| `EDS_WF_SECRET` | Workflow Studio secret (paired with key id) | | `EDS_CONFIG` | Path to config file (overrides default) | | `XDG_CONFIG_HOME` | Respected when locating the config file | | `AWS_ENDPOINT_URL` | S3-compatible endpoint for `make upload` (optional mirror) | diff --git a/cmd/config.go b/cmd/config.go index 751dc4d..874bb4d 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -17,25 +17,13 @@ variables. Useful to debug "why is my CLI pointing at the wrong host?".`, if err != nil { return err } - workflowTokenStatus := "not cached" - if ctx.Cfg.WorkflowAccessToken != "" { - if workflowTokenExpired(ctx.Cfg.WorkflowTokenExpiresAt) { - workflowTokenStatus = "expired" - } else { - workflowTokenStatus = "valid until " + ctx.Cfg.WorkflowTokenExpiresAt - } - } ctx.Printer.KeyValue([][2]string{ {"project_id", ctx.Cfg.ProjectID}, - {"iam_url", ctx.Cfg.IAMURL}, {"repo_api_url", ctx.Cfg.APIURL}, - {"repo_api_key", maskKey(ctx.Cfg.APIKey)}, + {"api_key", maskKey(ctx.Cfg.APIKey)}, {"repo_git_host", ctx.Cfg.GitHost}, {"wf_api_url", ctx.Cfg.WorkflowAPIURL}, - {"wf_key_id", ctx.Cfg.WorkflowKeyID}, - {"wf_secret", maskKey(ctx.Cfg.WorkflowSecret)}, - {"wf_access_token", workflowTokenStatus}, }) return nil }, diff --git a/cmd/helpers.go b/cmd/helpers.go index 4f1e481..8887627 100644 --- a/cmd/helpers.go +++ b/cmd/helpers.go @@ -3,24 +3,16 @@ package cmd import ( "context" "fmt" - "os" - "time" workflowclient "github.com/cloud-ru/evolution-devservices-cli/internal/workflow_client" "github.com/spf13/cobra" "github.com/cloud-ru/evolution-devservices-cli/internal/config" - "github.com/cloud-ru/evolution-devservices-cli/internal/iam" "github.com/cloud-ru/evolution-devservices-cli/internal/output" "github.com/cloud-ru/evolution-devservices-cli/internal/repoapi" "github.com/cloud-ru/evolution-devservices-cli/internal/workflowapi" ) -// workflowTokenExpiryBuffer is subtracted from a fetched token's lifetime -// so a cached token is treated as expired slightly before the IAM service -// actually rejects it. -const workflowTokenExpiryBuffer = 30 * time.Second - // runtimeContext holds the resolved configuration, API clients and printer // for a single command invocation. type runtimeContext struct { @@ -45,13 +37,10 @@ func resolveContext(cmd *cobra.Command) (*runtimeContext, error) { if v, _ := cmd.Flags().GetString("project"); v != "" { cfg.ProjectID = v } - if v, _ := cmd.Flags().GetString("iam-url"); v != "" { - cfg.IAMURL = v - } if v, _ := cmd.Flags().GetString("repo-api-url"); v != "" { cfg.APIURL = v } - if v, _ := cmd.Flags().GetString("repo-api-key"); v != "" { + if v, _ := cmd.Flags().GetString("api-key"); v != "" { cfg.APIKey = v } if v, _ := cmd.Flags().GetString("repo-git-host"); v != "" { @@ -60,12 +49,6 @@ func resolveContext(cmd *cobra.Command) (*runtimeContext, error) { if v, _ := cmd.Flags().GetString("wf-api-url"); v != "" { cfg.WorkflowAPIURL = v } - if v, _ := cmd.Flags().GetString("wf-key-id"); v != "" { - cfg.WorkflowKeyID = v - } - if v, _ := cmd.Flags().GetString("wf-secret"); v != "" { - cfg.WorkflowSecret = v - } wantJSON, _ := cmd.Flags().GetBool("json") quiet, _ := cmd.Flags().GetBool("quiet") @@ -76,101 +59,35 @@ func resolveContext(cmd *cobra.Command) (*runtimeContext, error) { } workflowClientCfg := workflowclient.NewConfiguration() - workflowClientCfg.AddDefaultHeader("Authorization", "Bearer "+cfg.WorkflowAccessToken) + workflowClientCfg.AddDefaultHeader("X-API-KEY", cfg.APIKey) + workflowClientCfg.Servers[0].URL = cfg.WorkflowAPIURL rt := &runtimeContext{ Cfg: cfg, API: repoapi.New(cfg.APIURL, cfg.APIKey, cfg.ProjectID), - WorkflowAPI: workflowapi.New(cfg.WorkflowAPIURL, cfg.WorkflowAccessToken, cfg.ProjectID), + WorkflowAPI: workflowapi.New(cfg.WorkflowAPIURL, cfg.APIKey, cfg.ProjectID), Printer: output.New(format), Quiet: quiet, ProjectID: cfg.ProjectID, WorkflowClient: workflowclient.NewAPIClient(workflowClientCfg), } - // TEMPORARY: some prod environments don't yet accept X-API-KEY for the - // Repo product and require a Bearer token exchanged via the same IAM - // flow as Workflow Studio. Remove this flag once Repo's own X-API-KEY - // auth works everywhere. - repoUseWFAuth, _ := cmd.Flags().GetBool("repo-use-wf-auth") - if !repoUseWFAuth { - v := os.Getenv("EDS_REPO_USE_WF_AUTH") - repoUseWFAuth = v == "1" || v == "true" - } - if repoUseWFAuth { - if err := rt.ensureWorkflowAuth(cmd.Context()); err != nil { - return nil, fmt.Errorf("--repo-use-wf-auth: %w", err) - } - rt.API.SetBearerToken(rt.Cfg.WorkflowAccessToken) - } - return rt, nil } -// requireAPIKey returns a friendly error if the Repo product API key is -// missing. Skipped when --repo-use-wf-auth switched the client to Bearer -// auth instead (see resolveContext). +// requireAPIKey returns a friendly error if the API key is missing. func (r *runtimeContext) requireAPIKey() error { - if r.API.UsesBearerAuth() { - return nil - } if r.Cfg.APIKey == "" { - return fmt.Errorf("repo API key is not set. Run `eds login --repo-api-key ` or set EDS_REPO_API_KEY") + return fmt.Errorf("API key is not set. Run `eds login --api-key ` or set EDS_API_KEY") } return nil } -// ensureWorkflowAuth makes sure r.WorkflowAPI holds a valid Bearer access -// token before a Workflow Studio call. It reuses the cached token (from -// ~/.config/eds/config.json) while still valid, otherwise exchanges -// WorkflowKeyID/WorkflowSecret for a fresh one via the cloud.ru IAM service -// and caches the result to disk so subsequent commands can reuse it. -func (r *runtimeContext) ensureWorkflowAuth(ctx context.Context) error { - cacheValid := r.Cfg.WorkflowAccessToken != "" && - r.Cfg.WorkflowAccessTokenKeyID == r.Cfg.WorkflowKeyID && - !workflowTokenExpired(r.Cfg.WorkflowTokenExpiresAt) - if cacheValid { - return nil - } - - if r.Cfg.WorkflowKeyID == "" || r.Cfg.WorkflowSecret == "" { - return fmt.Errorf("wf credentials are not set. Run `eds login --wf-key-id --wf-secret ` or set EDS_WF_KEY_ID/EDS_WF_SECRET") - } - - tok, err := iam.FetchToken(ctx, r.Cfg.IAMURL, r.Cfg.WorkflowKeyID, r.Cfg.WorkflowSecret) - if err != nil { - return fmt.Errorf("exchange workflow credentials for an access token: %w", err) - } - - lifetime := time.Duration(tok.ExpiresIn) * time.Second - if lifetime > workflowTokenExpiryBuffer { - lifetime -= workflowTokenExpiryBuffer - } - expiresAt := time.Now().Add(lifetime).Format(time.RFC3339) - - r.Cfg.WorkflowAccessToken = tok.AccessToken - r.Cfg.WorkflowTokenExpiresAt = expiresAt - r.Cfg.WorkflowAccessTokenKeyID = r.Cfg.WorkflowKeyID - r.WorkflowAPI.SetToken(tok.AccessToken) - r.WorkflowClient.GetConfig().DefaultHeader["Authorization"] = "Bearer " + tok.AccessToken - - // Cache the token on disk (best-effort: a caching failure shouldn't - // fail the command, since we already have a valid token in memory). - // Reload from disk first so we don't persist any other in-memory - // overrides that only apply to this invocation (e.g. one-off flags). - if diskCfg, err := config.Load(); err == nil { - diskCfg.WorkflowAccessToken = tok.AccessToken - diskCfg.WorkflowTokenExpiresAt = expiresAt - diskCfg.WorkflowAccessTokenKeyID = r.Cfg.WorkflowKeyID - _ = diskCfg.Save() +// ensureWorkflowAuth validates that the API key is set before a Workflow +// Studio call. The same API key is used for both products. +func (r *runtimeContext) ensureWorkflowAuth(_ context.Context) error { + if r.Cfg.APIKey == "" { + return fmt.Errorf("API key is not set. Run `eds login --api-key ` or set EDS_API_KEY") } return nil } - -func workflowTokenExpired(expiresAt string) bool { - t, err := time.Parse(time.RFC3339, expiresAt) - if err != nil { - return true - } - return !time.Now().Before(t) -} diff --git a/cmd/job.go b/cmd/job.go index f7d7c3e..dc13137 100644 --- a/cmd/job.go +++ b/cmd/job.go @@ -134,7 +134,7 @@ func newJobLogsCmd() *cobra.Command { } resp, _, err := ctx.WorkflowClient.WorkflowsAPI. - ProjectProjectIdJobJobIdLogListPost(cmd.Context(), ctx.ProjectID, args[0]). + ProjectProjectIdJobJobIdLogListGet(cmd.Context(), ctx.ProjectID, args[0]). Request(workflowclient.GitSbercloudTechDsworksServicesPipelineSrcInternalApplicationRequestJobLogList{ BeforeTime: &beforeTime, Limit: &limit, diff --git a/cmd/login.go b/cmd/login.go index 1d05392..5fd2522 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -11,14 +11,11 @@ import ( // newLoginCmd creates the `eds login` command. func newLoginCmd() *cobra.Command { var ( - repoAPIKey string + apiKey string repoAPIURL string project string repoGitHost string - wfKeyID string - wfSecret string wfAPIURL string - iamURL string fromStdin bool ) @@ -29,28 +26,17 @@ func newLoginCmd() *cobra.Command { and git host in the user's local config (~/.config/eds/config.json by default, configurable via EDS_CONFIG or XDG_CONFIG_HOME). -The Repo product credential (--repo-api-key) and the Workflow Studio -credentials (--wf-key-id/--wf-secret) are independent; provide either or -both. Workflow Studio key id + secret are exchanged for a short-lived -Bearer access token via the cloud.ru IAM service on first use; the CLI -caches that token in the config file and refreshes it automatically once -it expires. - It is safe to call this from a script: the file is written with 0600 permissions.`, Example: ` # interactive (you'll be prompted for the key) eds login --project 3232b2d0-1063-41e6-b2fa-13df767f4a0a # non-interactive (preferred for automation / agents) - eds login --repo-api-key $EDS_REPO_API_KEY --project + eds login --api-key $EDS_API_KEY --project # dev environment eds login --repo-api-url https://devtools.dev.api.internal.cloud.ru/repo/api/v1 \ - --repo-api-key --project - - # add Workflow Studio access (separate credential pair) - eds login --wf-key-id "$WF_KEY_ID" --wf-secret "$WF_SECRET" \ - --project `, + --api-key --project `, RunE: func(cmd *cobra.Command, _ []string) error { cfg, err := config.Load() if err != nil { @@ -59,28 +45,16 @@ permissions.`, // Read from stdin if requested. if fromStdin { - if _, err := fmt.Scanln(&repoAPIKey); err != nil { + if _, err := fmt.Scanln(&apiKey); err != nil { return fmt.Errorf("read api key from stdin: %w", err) } } - if repoAPIKey == "" && wfKeyID == "" && wfSecret == "" { - return fmt.Errorf("at least one credential is required: --repo-api-key/--stdin or --wf-key-id/--wf-secret") - } - if repoAPIKey != "" { - cfg.APIKey = repoAPIKey + if apiKey == "" && !fromStdin { + return fmt.Errorf("--api-key or --stdin is required") } - if wfKeyID != "" { - cfg.WorkflowKeyID = wfKeyID - } - if wfSecret != "" { - cfg.WorkflowSecret = wfSecret - } - if wfKeyID != "" || wfSecret != "" { - // Credentials changed: drop any cached token from the old pair. - cfg.WorkflowAccessToken = "" - cfg.WorkflowTokenExpiresAt = "" - cfg.WorkflowAccessTokenKeyID = "" + if apiKey != "" { + cfg.APIKey = apiKey } if repoAPIURL != "" { @@ -95,9 +69,6 @@ permissions.`, if wfAPIURL != "" { cfg.WorkflowAPIURL = wfAPIURL } - if iamURL != "" { - cfg.IAMURL = iamURL - } if err := cfg.Save(); err != nil { return err @@ -107,15 +78,12 @@ permissions.`, }, } - cmd.Flags().StringVar(&repoAPIKey, "repo-api-key", "", "Repo product API key (X-API-KEY)") + cmd.Flags().StringVar(&apiKey, "api-key", "", "API key (shared across products)") cmd.Flags().StringVar(&repoAPIURL, "repo-api-url", "", "Repo product API base URL") cmd.Flags().StringVar(&project, "project", "", "default project id (shared across products)") cmd.Flags().StringVar(&repoGitHost, "repo-git-host", "", "Repo product git smart-HTTP host") - cmd.Flags().StringVar(&wfKeyID, "wf-key-id", "", "Workflow Studio key id (separate credential from --repo-api-key)") - cmd.Flags().StringVar(&wfSecret, "wf-secret", "", "Workflow Studio secret") - cmd.Flags().StringVar(&wfAPIURL, "wf-api-url", "", "Workflow Studio API base URL") - cmd.Flags().StringVar(&iamURL, "iam-url", "", "cloud.ru IAM token endpoint (shared)") - cmd.Flags().BoolVar(&fromStdin, "stdin", false, "read the Repo API key from stdin") + cmd.Flags().StringVar(&wfAPIURL, "wf-api-url", "", "Workflow Studio product API base URL") + cmd.Flags().BoolVar(&fromStdin, "stdin", false, "read the API key from stdin") return cmd } diff --git a/cmd/root.go b/cmd/root.go index 708a351..f62fec2 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -25,27 +25,18 @@ It is designed to be safely driven by automation and AI agents via the // Global flags. Repo and Workflow Studio are separate products with // separate credentials, so their flags/env vars are namespaced - // per-product (repo-*, wf-*); --project and --iam-url are shared. + // per-product (repo-*, wf-*); --project is shared. root.PersistentFlags().String("project", "", "Project ID, shared across products (env EDS_PROJECT_ID)") - root.PersistentFlags().String("iam-url", "", - "cloud.ru IAM token endpoint used to exchange wf-key-id/secret for a Bearer token (env EDS_IAM_URL)") root.PersistentFlags().String("repo-api-url", "", "Repo product API base URL (default https://devtools.api.cloud.ru/repo/api/v1; env EDS_REPO_API_URL)") - root.PersistentFlags().String("repo-api-key", "", - "Repo product API key (env EDS_REPO_API_KEY; overrides saved config)") + root.PersistentFlags().String("api-key", "", + "Repo product API key (env EDS_API_KEY; overrides saved config)") root.PersistentFlags().String("repo-git-host", "", "Repo product git smart-HTTP host (default repo.cloud.ru; env EDS_REPO_GIT_HOST)") - root.PersistentFlags().Bool("repo-use-wf-auth", false, - "TEMPORARY: authenticate Repo product calls with the Workflow Studio Bearer token (wf-key-id/wf-secret via IAM) instead of X-API-KEY (env EDS_REPO_USE_WF_AUTH); remove once Repo's own API key auth works everywhere") - root.PersistentFlags().String("wf-api-url", "", "Workflow Studio product API base URL (env EDS_WF_API_URL)") - root.PersistentFlags().String("wf-key-id", "", - "Workflow Studio key id (env EDS_WF_KEY_ID; separate credential from --repo-api-key)") - root.PersistentFlags().String("wf-secret", "", - "Workflow Studio secret (env EDS_WF_SECRET)") root.PersistentFlags().Bool("json", false, "force JSON output") diff --git a/internal/config/config.go b/internal/config/config.go index cf9d68d..0208f24 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,35 +14,13 @@ type Config struct { APIURL string `json:"api_url"` // ProjectID is the default project ID used for API calls. ProjectID string `json:"project_id"` - // APIKey is the X-API-KEY value used for authentication. + // APIKey is the X-API-KEY value used for authentication (shared across products). APIKey string `json:"api_key"` // GitHost is the host name used for git smart HTTP operations // (e.g. repo.cloud.ru). It is separate from APIURL. GitHost string `json:"git_host"` // WorkflowAPIURL is the base URL of the Workflow Studio user API. WorkflowAPIURL string `json:"workflow_api_url"` - // WorkflowKeyID and WorkflowSecret are the Workflow Studio credentials, - // a separate credential pair from APIKey. They are exchanged for a - // short-lived Bearer access token via the cloud.ru IAM service (see - // internal/iam) -- Workflow Studio itself never sees them directly. - WorkflowKeyID string `json:"workflow_key_id"` - WorkflowSecret string `json:"workflow_secret"` - // IAMURL is the cloud.ru IAM token-exchange endpoint. - IAMURL string `json:"iam_url"` - // WorkflowAccessToken and WorkflowTokenExpiresAt cache the token - // exchanged from WorkflowKeyID/WorkflowSecret, so repeated commands - // don't re-authenticate with IAM until it actually expires. Managed by - // the CLI itself -- there is no flag/env to set these directly. - WorkflowAccessToken string `json:"workflow_access_token"` - WorkflowTokenExpiresAt string `json:"workflow_token_expires_at"` - // WorkflowAccessTokenKeyID records which WorkflowKeyID the cached - // WorkflowAccessToken was minted for. WorkflowKeyID/Secret can change via - // flag/env without ever going through `login` (which would otherwise - // clear the cache), so ensureWorkflowAuth compares this against the - // currently active WorkflowKeyID before trusting the cached token -- - // otherwise switching credentials (e.g. dev -> prod) silently keeps - // reusing a token minted for the old ones until it expires. - WorkflowAccessTokenKeyID string `json:"workflow_access_token_key_id"` } // DefaultAPIURL is the production Repo API URL. @@ -54,24 +32,18 @@ const DefaultGitHost = "https://repo.cloud.ru/" // DefaultWorkflowAPIURL is the Workflow Studio user API base URL. const DefaultWorkflowAPIURL = "https://pipeline.cloud.ru/public-api/v1" -// DefaultIAMURL is the cloud.ru IAM token-exchange endpoint. -const DefaultIAMURL = "https://iam.api.cloud.ru/api/v1/auth/token" - // Load reads the config from disk, applies defaults and environment overrides. // Environment variables take precedence over file values. // // Evolution DevServices (eds) is the platform; Repo and Workflow Studio are products on it, so // their credentials/hosts are namespaced per-product. Only truly -// platform-level settings (project id, IAM, config path) are unprefixed. +// platform-level settings (project id, config path) are unprefixed. // // EDS_PROJECT_ID - overrides project_id (shared across products) -// EDS_IAM_URL - overrides iam_url (shared: mints the wf Bearer token) // EDS_REPO_API_URL - overrides api_url (Repo product) -// EDS_REPO_API_KEY - overrides api_key (Repo product) +// EDS_API_KEY - overrides api_key (shared across products) // EDS_REPO_GIT_HOST - overrides git_host (Repo product) // EDS_WF_API_URL - overrides workflow_api_url (Workflow Studio product) -// EDS_WF_KEY_ID - overrides workflow_key_id (Workflow Studio product) -// EDS_WF_SECRET - overrides workflow_secret (Workflow Studio product) func Load() (*Config, error) { cfgPath, err := configPath() if err != nil { @@ -82,7 +54,6 @@ func Load() (*Config, error) { APIURL: DefaultAPIURL, GitHost: DefaultGitHost, WorkflowAPIURL: DefaultWorkflowAPIURL, - IAMURL: DefaultIAMURL, ProjectID: os.Getenv("EDS_PROJECT_ID"), } @@ -104,20 +75,14 @@ func Load() (*Config, error) { if cfg.WorkflowAPIURL == "" { cfg.WorkflowAPIURL = DefaultWorkflowAPIURL } - if cfg.IAMURL == "" { - cfg.IAMURL = DefaultIAMURL - } if v := os.Getenv("EDS_PROJECT_ID"); v != "" { cfg.ProjectID = v } - if v := os.Getenv("EDS_IAM_URL"); v != "" { - cfg.IAMURL = v - } if v := os.Getenv("EDS_REPO_API_URL"); v != "" { cfg.APIURL = v } - if v := os.Getenv("EDS_REPO_API_KEY"); v != "" { + if v := os.Getenv("EDS_API_KEY"); v != "" { cfg.APIKey = v } if v := os.Getenv("EDS_REPO_GIT_HOST"); v != "" { @@ -126,12 +91,6 @@ func Load() (*Config, error) { if v := os.Getenv("EDS_WF_API_URL"); v != "" { cfg.WorkflowAPIURL = v } - if v := os.Getenv("EDS_WF_KEY_ID"); v != "" { - cfg.WorkflowKeyID = v - } - if v := os.Getenv("EDS_WF_SECRET"); v != "" { - cfg.WorkflowSecret = v - } return cfg, nil } @@ -159,7 +118,7 @@ func (c *Config) Save() error { } // configPath returns the absolute path to the config file. This is a -// platform-level (not per-product) file, since a single project id/IAM +// platform-level (not per-product) file, since a single project id // setup is shared across products. Honours EDS_CONFIG and XDG_CONFIG_HOME // for non-Linux convenience. func configPath() (string, error) { diff --git a/internal/iam/client.go b/internal/iam/client.go deleted file mode 100644 index 0397051..0000000 --- a/internal/iam/client.go +++ /dev/null @@ -1,89 +0,0 @@ -// Package iam exchanges a Workflow Studio key id + secret for a short-lived -// Bearer access token via the cloud.ru IAM service. Workflow Studio does not -// accept the key id/secret directly -- every request needs the exchanged -// access_token in its Authorization header. -package iam - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "time" -) - -// DefaultURL is the cloud.ru IAM token endpoint. -const DefaultURL = "https://iam.api.cloud.ru/api/v1/auth/token" - -var httpClient = &http.Client{Timeout: 15 * time.Second} - -// TokenResponse is the IAM service's token exchange response. -type TokenResponse struct { - AccessToken string `json:"access_token"` - IDToken string `json:"id_token"` - RefreshToken string `json:"refresh_token"` - ExpiresIn int `json:"expires_in"` - RefreshExpiresIn int `json:"refresh_expires_in"` - Scopes []string `json:"scopes"` - TokenType string `json:"token_type"` - NotBefore int64 `json:"not_before"` -} - -// FetchToken exchanges keyID/secret for a Bearer access token. iamURL -// defaults to DefaultURL when empty. -func FetchToken(ctx context.Context, iamURL, keyID, secret string) (*TokenResponse, error) { - if iamURL == "" { - iamURL = DefaultURL - } - if keyID == "" || secret == "" { - return nil, fmt.Errorf("workflow key id and secret are both required") - } - - reqBody, err := json.Marshal(struct { - KeyID string `json:"keyId"` - Secret string `json:"secret"` - }{KeyID: keyID, Secret: secret}) - if err != nil { - return nil, fmt.Errorf("marshal iam request: %w", err) - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, iamURL, bytes.NewReader(reqBody)) - if err != nil { - return nil, fmt.Errorf("build iam request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - - resp, err := httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("http POST %s: %w", iamURL, err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("read iam response: %w", err) - } - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, fmt.Errorf("iam auth failed (%d): %s", resp.StatusCode, truncate(string(body), 256)) - } - - var out TokenResponse - if err := json.Unmarshal(body, &out); err != nil { - return nil, fmt.Errorf("decode iam response: %w (body: %s)", err, truncate(string(body), 256)) - } - if out.AccessToken == "" { - return nil, fmt.Errorf("iam response did not include an access_token") - } - return &out, nil -} - -func truncate(s string, max int) string { - if len(s) <= max { - return s - } - return s[:max] + "…" -} diff --git a/internal/repoapi/client.go b/internal/repoapi/client.go index 26c99cb..2eb44cd 100644 --- a/internal/repoapi/client.go +++ b/internal/repoapi/client.go @@ -13,15 +13,12 @@ import ( "time" ) -// Client talks to the Repo API and authenticates with X-API-KEY (or, -// temporarily, a Bearer token -- see SetBearerToken). +// Client talks to the Repo API and authenticates with X-API-KEY. type Client struct { - baseURL string - apiKey string - bearerToken string - useBearer bool - projectID string - http *http.Client + baseURL string + apiKey string + projectID string + http *http.Client } // New returns a configured API client. @@ -39,22 +36,6 @@ func New(baseURL, apiKey, projectID string) *Client { // ProjectID returns the configured project ID. func (c *Client) ProjectID() string { return c.projectID } -// SetBearerToken switches the client to authenticate with -// "Authorization: Bearer " instead of X-API-KEY. -// -// TEMPORARY: some prod environments don't yet accept X-API-KEY for the Repo -// product and require a Bearer token exchanged via the same IAM flow as -// Workflow Studio (see --repo-use-wf-auth in cmd/root.go). Remove this once -// the Repo product's own X-API-KEY auth works everywhere. -func (c *Client) SetBearerToken(token string) { - c.bearerToken = token - c.useBearer = true -} - -// UsesBearerAuth reports whether SetBearerToken has switched this client -// away from X-API-KEY auth. -func (c *Client) UsesBearerAuth() bool { return c.useBearer } - // APIError is a non-2xx response from the API. type APIError struct { StatusCode int @@ -101,11 +82,7 @@ func (c *Client) Do(ctx context.Context, method, path string, query url.Values, if err != nil { return fmt.Errorf("build request: %w", err) } - if c.useBearer { - req.Header.Set("Authorization", "Bearer "+c.bearerToken) - } else { - req.Header.Set("X-API-KEY", c.apiKey) - } + req.Header.Set("X-API-KEY", c.apiKey) if body != nil { req.Header.Set("Content-Type", "application/json") } diff --git a/internal/workflow_client/.openapi-generator/FILES b/internal/workflow_client/.openapi-generator/FILES index aa7fda6..a50282c 100644 --- a/internal/workflow_client/.openapi-generator/FILES +++ b/internal/workflow_client/.openapi-generator/FILES @@ -1,5 +1,4 @@ .gitignore -.openapi-generator-ignore .travis.yml README.md api/openapi.yaml diff --git a/internal/workflow_client/README.md b/internal/workflow_client/README.md index 5fc6e6f..0366626 100644 --- a/internal/workflow_client/README.md +++ b/internal/workflow_client/README.md @@ -71,7 +71,7 @@ All URIs are relative to *https://pipeline.cloud.ru/public-api/v1* Class | Method | HTTP request | Description ------------ | ------------- | ------------- | ------------- -*PipelinesAPI* | [**ProjectProjectIdJobJobIdLogListPost**](docs/PipelinesAPI.md#projectprojectidjobjobidloglistpost) | **Post** /project/{project_id}/job/{job_id}/log/list | Get job log entries +*PipelinesAPI* | [**ProjectProjectIdJobJobIdLogListGet**](docs/PipelinesAPI.md#projectprojectidjobjobidloglistget) | **Get** /project/{project_id}/job/{job_id}/log/list | Get job logs *PipelinesAPI* | [**ProjectProjectIdPipelinePipelineIdRunPost**](docs/PipelinesAPI.md#projectprojectidpipelinepipelineidrunpost) | **Post** /project/{project_id}/pipeline/{pipeline_id}/run | Run pipeline *PipelinesAPI* | [**ProjectProjectIdRunRunIdGet**](docs/PipelinesAPI.md#projectprojectidrunrunidget) | **Get** /project/{project_id}/run/{run_id} | Get run *PipelinesAPI* | [**ProjectProjectIdRunRunIdStopPost**](docs/PipelinesAPI.md#projectprojectidrunrunidstoppost) | **Post** /project/{project_id}/run/{run_id}/stop | Stop run @@ -82,7 +82,7 @@ Class | Method | HTTP request | Description *ServicesAPI* | [**ProjectProjectIdApplicationApplicationIdPatch**](docs/ServicesAPI.md#projectprojectidapplicationapplicationidpatch) | **Patch** /project/{project_id}/application/{application_id} | Update application *ServicesAPI* | [**ProjectProjectIdApplicationListGet**](docs/ServicesAPI.md#projectprojectidapplicationlistget) | **Get** /project/{project_id}/application/list | List applications *ServicesAPI* | [**ProjectProjectIdApplicationPost**](docs/ServicesAPI.md#projectprojectidapplicationpost) | **Post** /project/{project_id}/application | Create application -*WorkflowsAPI* | [**ProjectProjectIdJobJobIdLogListPost**](docs/WorkflowsAPI.md#projectprojectidjobjobidloglistpost) | **Post** /project/{project_id}/job/{job_id}/log/list | Get job log entries +*WorkflowsAPI* | [**ProjectProjectIdJobJobIdLogListGet**](docs/WorkflowsAPI.md#projectprojectidjobjobidloglistget) | **Get** /project/{project_id}/job/{job_id}/log/list | Get job logs *WorkflowsAPI* | [**ProjectProjectIdPipelinePipelineIdRunPost**](docs/WorkflowsAPI.md#projectprojectidpipelinepipelineidrunpost) | **Post** /project/{project_id}/pipeline/{pipeline_id}/run | Run pipeline *WorkflowsAPI* | [**ProjectProjectIdRunRunIdGet**](docs/WorkflowsAPI.md#projectprojectidrunrunidget) | **Get** /project/{project_id}/run/{run_id} | Get run *WorkflowsAPI* | [**ProjectProjectIdRunRunIdStopPost**](docs/WorkflowsAPI.md#projectprojectidrunrunidstoppost) | **Post** /project/{project_id}/run/{run_id}/stop | Stop run diff --git a/internal/workflow_client/api_pipelines.go b/internal/workflow_client/api_pipelines.go index 7b8a952..d86574b 100644 --- a/internal/workflow_client/api_pipelines.go +++ b/internal/workflow_client/api_pipelines.go @@ -22,7 +22,7 @@ import ( // PipelinesAPIService PipelinesAPI service type PipelinesAPIService service -type PipelinesAPIProjectProjectIdJobJobIdLogListPostRequest struct { +type PipelinesAPIProjectProjectIdJobJobIdLogListGetRequest struct { ctx context.Context ApiService *PipelinesAPIService projectId string @@ -31,27 +31,27 @@ type PipelinesAPIProjectProjectIdJobJobIdLogListPostRequest struct { } // Request body -func (r PipelinesAPIProjectProjectIdJobJobIdLogListPostRequest) Request(request GitSbercloudTechDsworksServicesPipelineSrcInternalApplicationRequestJobLogList) PipelinesAPIProjectProjectIdJobJobIdLogListPostRequest { +func (r PipelinesAPIProjectProjectIdJobJobIdLogListGetRequest) Request(request GitSbercloudTechDsworksServicesPipelineSrcInternalApplicationRequestJobLogList) PipelinesAPIProjectProjectIdJobJobIdLogListGetRequest { r.request = &request return r } -func (r PipelinesAPIProjectProjectIdJobJobIdLogListPostRequest) Execute() (*GitSbercloudTechDsworksServicesPipelineSrcInternalApplicationResponseJobLogList, *http.Response, error) { - return r.ApiService.ProjectProjectIdJobJobIdLogListPostExecute(r) +func (r PipelinesAPIProjectProjectIdJobJobIdLogListGetRequest) Execute() (*GitSbercloudTechDsworksServicesPipelineSrcInternalApplicationResponseJobLogList, *http.Response, error) { + return r.ApiService.ProjectProjectIdJobJobIdLogListGetExecute(r) } /* -ProjectProjectIdJobJobIdLogListPost Get job log entries +ProjectProjectIdJobJobIdLogListGet Get job logs -Get paginated job log entries +Get job logs @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). @param projectId Project ID @param jobId Job ID - @return PipelinesAPIProjectProjectIdJobJobIdLogListPostRequest + @return PipelinesAPIProjectProjectIdJobJobIdLogListGetRequest */ -func (a *PipelinesAPIService) ProjectProjectIdJobJobIdLogListPost(ctx context.Context, projectId string, jobId string) PipelinesAPIProjectProjectIdJobJobIdLogListPostRequest { - return PipelinesAPIProjectProjectIdJobJobIdLogListPostRequest{ +func (a *PipelinesAPIService) ProjectProjectIdJobJobIdLogListGet(ctx context.Context, projectId string, jobId string) PipelinesAPIProjectProjectIdJobJobIdLogListGetRequest { + return PipelinesAPIProjectProjectIdJobJobIdLogListGetRequest{ ApiService: a, ctx: ctx, projectId: projectId, @@ -62,15 +62,15 @@ func (a *PipelinesAPIService) ProjectProjectIdJobJobIdLogListPost(ctx context.Co // Execute executes the request // // @return GitSbercloudTechDsworksServicesPipelineSrcInternalApplicationResponseJobLogList -func (a *PipelinesAPIService) ProjectProjectIdJobJobIdLogListPostExecute(r PipelinesAPIProjectProjectIdJobJobIdLogListPostRequest) (*GitSbercloudTechDsworksServicesPipelineSrcInternalApplicationResponseJobLogList, *http.Response, error) { +func (a *PipelinesAPIService) ProjectProjectIdJobJobIdLogListGetExecute(r PipelinesAPIProjectProjectIdJobJobIdLogListGetRequest) (*GitSbercloudTechDsworksServicesPipelineSrcInternalApplicationResponseJobLogList, *http.Response, error) { var ( - localVarHTTPMethod = http.MethodPost + localVarHTTPMethod = http.MethodGet localVarPostBody interface{} formFiles []formFile localVarReturnValue *GitSbercloudTechDsworksServicesPipelineSrcInternalApplicationResponseJobLogList ) - localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "PipelinesAPIService.ProjectProjectIdJobJobIdLogListPost") + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "PipelinesAPIService.ProjectProjectIdJobJobIdLogListGet") if err != nil { return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} } diff --git a/internal/workflow_client/api_workflows.go b/internal/workflow_client/api_workflows.go index 2476831..a7634f4 100644 --- a/internal/workflow_client/api_workflows.go +++ b/internal/workflow_client/api_workflows.go @@ -22,7 +22,7 @@ import ( // WorkflowsAPIService WorkflowsAPI service type WorkflowsAPIService service -type WorkflowsAPIProjectProjectIdJobJobIdLogListPostRequest struct { +type WorkflowsAPIProjectProjectIdJobJobIdLogListGetRequest struct { ctx context.Context ApiService *WorkflowsAPIService projectId string @@ -31,27 +31,27 @@ type WorkflowsAPIProjectProjectIdJobJobIdLogListPostRequest struct { } // Request body -func (r WorkflowsAPIProjectProjectIdJobJobIdLogListPostRequest) Request(request GitSbercloudTechDsworksServicesPipelineSrcInternalApplicationRequestJobLogList) WorkflowsAPIProjectProjectIdJobJobIdLogListPostRequest { +func (r WorkflowsAPIProjectProjectIdJobJobIdLogListGetRequest) Request(request GitSbercloudTechDsworksServicesPipelineSrcInternalApplicationRequestJobLogList) WorkflowsAPIProjectProjectIdJobJobIdLogListGetRequest { r.request = &request return r } -func (r WorkflowsAPIProjectProjectIdJobJobIdLogListPostRequest) Execute() (*GitSbercloudTechDsworksServicesPipelineSrcInternalApplicationResponseJobLogList, *http.Response, error) { - return r.ApiService.ProjectProjectIdJobJobIdLogListPostExecute(r) +func (r WorkflowsAPIProjectProjectIdJobJobIdLogListGetRequest) Execute() (*GitSbercloudTechDsworksServicesPipelineSrcInternalApplicationResponseJobLogList, *http.Response, error) { + return r.ApiService.ProjectProjectIdJobJobIdLogListGetExecute(r) } /* -ProjectProjectIdJobJobIdLogListPost Get job log entries +ProjectProjectIdJobJobIdLogListGet Get job logs -Get paginated job log entries +Get job logs @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). @param projectId Project ID @param jobId Job ID - @return WorkflowsAPIProjectProjectIdJobJobIdLogListPostRequest + @return WorkflowsAPIProjectProjectIdJobJobIdLogListGetRequest */ -func (a *WorkflowsAPIService) ProjectProjectIdJobJobIdLogListPost(ctx context.Context, projectId string, jobId string) WorkflowsAPIProjectProjectIdJobJobIdLogListPostRequest { - return WorkflowsAPIProjectProjectIdJobJobIdLogListPostRequest{ +func (a *WorkflowsAPIService) ProjectProjectIdJobJobIdLogListGet(ctx context.Context, projectId string, jobId string) WorkflowsAPIProjectProjectIdJobJobIdLogListGetRequest { + return WorkflowsAPIProjectProjectIdJobJobIdLogListGetRequest{ ApiService: a, ctx: ctx, projectId: projectId, @@ -62,15 +62,15 @@ func (a *WorkflowsAPIService) ProjectProjectIdJobJobIdLogListPost(ctx context.Co // Execute executes the request // // @return GitSbercloudTechDsworksServicesPipelineSrcInternalApplicationResponseJobLogList -func (a *WorkflowsAPIService) ProjectProjectIdJobJobIdLogListPostExecute(r WorkflowsAPIProjectProjectIdJobJobIdLogListPostRequest) (*GitSbercloudTechDsworksServicesPipelineSrcInternalApplicationResponseJobLogList, *http.Response, error) { +func (a *WorkflowsAPIService) ProjectProjectIdJobJobIdLogListGetExecute(r WorkflowsAPIProjectProjectIdJobJobIdLogListGetRequest) (*GitSbercloudTechDsworksServicesPipelineSrcInternalApplicationResponseJobLogList, *http.Response, error) { var ( - localVarHTTPMethod = http.MethodPost + localVarHTTPMethod = http.MethodGet localVarPostBody interface{} formFiles []formFile localVarReturnValue *GitSbercloudTechDsworksServicesPipelineSrcInternalApplicationResponseJobLogList ) - localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "WorkflowsAPIService.ProjectProjectIdJobJobIdLogListPost") + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "WorkflowsAPIService.ProjectProjectIdJobJobIdLogListGet") if err != nil { return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} } diff --git a/internal/workflowapi/client.go b/internal/workflowapi/client.go index 27569b5..05b8e57 100644 --- a/internal/workflowapi/client.go +++ b/internal/workflowapi/client.go @@ -1,7 +1,6 @@ // Package workflowapi provides a thin client for the Workflow Studio user API -// (cloud.ru "developer tools" -> Workflow Studio). Unlike internal/repoapi, -// authentication is a Bearer token (Authorization header), not X-API-KEY -- -// Workflow Studio uses a separate credential from the Repo API key. +// (cloud.ru "developer tools" -> Workflow Studio). Both products use the same +// API key sent as X-API-KEY. package workflowapi import ( @@ -15,7 +14,7 @@ import ( "strings" ) -// Client talks to the Workflow Studio API and authenticates with a Bearer token. +// Client talks to the Workflow Studio API and authenticates with X-API-KEY. type Client struct { baseURL string token string @@ -38,8 +37,7 @@ func New(baseURL, token, projectID string) *Client { // ProjectID returns the configured project ID. func (c *Client) ProjectID() string { return c.projectID } -// SetToken updates the Bearer access token used for subsequent requests. -// Used after exchanging a key id/secret for a fresh token (see internal/iam). +// SetToken updates the API key used for subsequent requests. func (c *Client) SetToken(token string) { c.token = token } // APIError is a non-2xx response from the API. @@ -88,7 +86,7 @@ func (c *Client) Do(ctx context.Context, method, path string, query url.Values, if err != nil { return fmt.Errorf("build request: %w", err) } - req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("X-API-KEY", c.token) if body != nil { req.Header.Set("Content-Type", "application/json") } @@ -134,35 +132,6 @@ func requestIDFromHeader(h http.Header) string { return "" } -// Stream performs an authenticated GET and returns the raw response body -// unread, for line-by-line consumption (e.g. the SSE job logs endpoint). -// The caller must close the returned body. -func (c *Client) Stream(ctx context.Context, path string, query url.Values) (io.ReadCloser, error) { - u, err := c.buildURL(path, query) - if err != nil { - return nil, err - } - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) - if err != nil { - return nil, fmt.Errorf("build request: %w", err) - } - req.Header.Set("Authorization", "Bearer "+c.token) - req.Header.Set("Accept", "text/event-stream") - - resp, err := c.http.Do(req) - if err != nil { - return nil, fmt.Errorf("http GET %s: %w", u, err) - } - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - defer resp.Body.Close() - respBody, _ := io.ReadAll(resp.Body) - return nil, parseError(resp.StatusCode, respBody, requestIDFromHeader(resp.Header)) - } - return resp.Body, nil -} - func (c *Client) buildURL(path string, query url.Values) (string, error) { u, err := url.Parse(c.baseURL) if err != nil { diff --git a/internal/workflowapi/job.go b/internal/workflowapi/job.go index 85e7899..a5f3244 100644 --- a/internal/workflowapi/job.go +++ b/internal/workflowapi/job.go @@ -3,7 +3,6 @@ package workflowapi import ( "context" "fmt" - "io" "net/url" "strconv" ) @@ -93,13 +92,3 @@ func (c *Client) StopJob(ctx context.Context, id string) error { } return c.Do(ctx, "POST", fmt.Sprintf("/project/%s/job/%s/stop", c.projectID, id), nil, nil, nil) } - -// StreamJobLogs opens the job's log stream (server-sent events). The caller -// must close the returned reader. Use bufio.Scanner to read it line by line; -// "data: ..." lines carry log content, ": ..." lines are heartbeats. -func (c *Client) StreamJobLogs(ctx context.Context, id string) (io.ReadCloser, error) { - if c.projectID == "" { - return nil, fmt.Errorf("project id is not configured; use --project or EDS_PROJECT_ID") - } - return c.Stream(ctx, fmt.Sprintf("/project/%s/job/%s/logs", c.projectID, id), nil) -} diff --git a/skill/SKILL.md b/skill/SKILL.md index 083a7b2..800fc05 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -6,12 +6,13 @@ description: Manage cloud.ru developer tools products via the `eds` CLI — git # Evolution DevServices CLI (eds) — Agent Skill The `eds` CLI is a thin, agent-friendly wrapper around two independent -cloud.ru "developer tools" products, each with its own HTTP API and its -own credentials: +cloud.ru "developer tools" products: - **Repo** (`eds repo`) — git repositories. - **Workflow Studio** (`eds wf`) — deploy pipelines / publishing. +Both products are authenticated with the same API key (`EDS_API_KEY`). + It produces stable JSON output, accepts configuration through environment variables, and is safe to invoke from automation. @@ -68,8 +69,7 @@ Errors go to stderr and the process exits non-zero. | `eds wf app status [--json]` | Convenience: run status + stage/job breakdown + live URL | | `eds wf run show [--json]` / `eds wf run list [--pipeline-id ID] [--json]` / `eds wf run stop ` | Inspect/control a pipeline run | | `eds wf job show [--json]` / `eds wf job list --run-id ID [--json]` / `eds wf job logs ` / `eds wf job retry\|stop ` | Inspect/control a job | -| `eds login --repo-api-key --project [--repo-api-url URL]` / `eds login --wf-key-id --wf-secret --project ` | Persist credentials (one-time setup; two independent credential pairs) | -| `eds repo * --repo-use-wf-auth` (or `EDS_REPO_USE_WF_AUTH=1`) | TEMPORARY: authenticate Repo calls with the Workflow Studio Bearer token instead of `X-API-KEY`, for environments where the Repo API key isn't accepted yet | +| `eds login --api-key --project [--repo-api-url URL]` | Persist credentials (one-time setup) | The `` argument on `eds repo *` and `--repository` on `eds wf app create` are resolved automatically: UUIDs are used as-is, names @@ -79,52 +79,32 @@ and must be passed as-is. ## Configuration -`eds repo *` and `eds wf *` use **independent credentials** — having one -configured does not imply the other is. Flags/env vars are namespaced by -product: `--repo-*`/`EDS_REPO_*` for Repo, `--wf-*`/`EDS_WF_*` for Workflow -Studio; `--project`/`EDS_PROJECT_ID` and `--iam-url`/`EDS_IAM_URL` are -shared platform-level settings. - -Workflow Studio authenticates differently from Repo: the agent provides a -**key id + secret** pair (not a usable token directly). The CLI exchanges -that pair for a short-lived Bearer access token via the cloud.ru IAM -service (`POST https://iam.api.cloud.ru/api/v1/auth/token`) the first time -it's needed, and caches the token (plus its expiry) in -`~/.config/eds/config.json`, re-exchanging automatically once it expires. -**The agent never calls the IAM endpoint itself** — just provide the key -id/secret and the CLI handles the exchange transparently on every -`eds wf` command. +`eds repo *` and `eds wf *` share the same API key (`EDS_API_KEY`). +Flags/env vars are namespaced by product for URLs: `--repo-*`/`EDS_REPO_*` +for Repo, `--wf-*`/`EDS_WF_*` for Workflow Studio; `--api-key`/`EDS_API_KEY` +and `--project`/`EDS_PROJECT_ID` are shared platform-level settings. Precedence (lowest → highest): 1. Built-in defaults: `api_url=https://devtools.api.cloud.ru/repo/api/v1`, `git_host=https://repo.cloud.ru/`, - `workflow_api_url=https://pipeline.cloud.ru/public-api/v1`, - `iam_url=https://iam.api.cloud.ru/api/v1/auth/token`. + `workflow_api_url=https://pipeline.cloud.ru/public-api/v1`. 2. File at `~/.config/eds/config.json` (override with `EDS_CONFIG`). -3. Environment: `EDS_PROJECT_ID`, `EDS_IAM_URL`, `EDS_REPO_API_URL`, - `EDS_REPO_API_KEY`, `EDS_REPO_GIT_HOST`, `EDS_WF_API_URL`, `EDS_WF_KEY_ID`, - `EDS_WF_SECRET`. -4. Flags: `--project`, `--iam-url`, `--repo-api-url`, `--repo-api-key`, - `--repo-git-host`, `--wf-api-url`, `--wf-key-id`, `--wf-secret`. +3. Environment: `EDS_PROJECT_ID`, `EDS_REPO_API_URL`, + `EDS_API_KEY`, `EDS_REPO_GIT_HOST`, `EDS_WF_API_URL`. +4. Flags: `--project`, `--repo-api-url`, `--api-key`, + `--repo-git-host`, `--wf-api-url`. Dev environment: `EDS_REPO_API_URL=https://devtools.dev.api.internal.cloud.ru/repo/api/v1`. ## Required environment for an agent -Before invoking any `eds repo *` command, the agent must ensure: +Before invoking any `eds repo *` or `eds wf *` command, the agent must +ensure: -- `EDS_REPO_API_KEY` is set (or the key was saved via `eds login --repo-api-key`). +- `EDS_API_KEY` is set (or the key was saved via `eds login --api-key`). - `EDS_PROJECT_ID` is set. -Before invoking any `eds wf app *` / `eds wf run *` / `eds wf job *` -command, the agent must additionally ensure: - -- `EDS_WF_KEY_ID` and `EDS_WF_SECRET` are set (or saved via - `eds login --wf-key-id --wf-secret`) — this is **not** the same - credential as `EDS_REPO_API_KEY`. Do not attempt to set a raw Bearer - token directly; the CLI derives it from this pair automatically. - The skill's runtime should arrange for these before the first call. ## Installation inside the agent's sandbox @@ -201,10 +181,9 @@ echo "my-old-repo" | eds repo delete my-old-repo --force --json ```bash eds config --json -# { "project_id": "...", "iam_url": "...", -# "repo_api_url": "...", "repo_api_key": "abcd…wxyz", "repo_git_host": "...", -# "wf_api_url": "...", "wf_key_id": "...", "wf_secret": "abcd…wxyz", -# "wf_access_token": "valid until 2026-07-28T12:00:00Z" } +# { "project_id": "...", +# "repo_api_url": "...", "api_key": "abcd…wxyz", "repo_git_host": "...", +# "wf_api_url": "..." } ``` ### Publish a repository as a live service (main Workflow Studio scenario) @@ -213,8 +192,8 @@ This is the end-to-end path from "code was pushed to a repository" to "it's live at a URL" — the primary reason to use `eds wf app *`: ```bash -export EDS_WF_KEY_ID=... # separate credential pair from EDS_REPO_API_KEY -export EDS_WF_SECRET=... # the CLI exchanges this for a Bearer token automatically +export EDS_API_KEY=... +export EDS_PROJECT_ID=... # 1. The repository already exists (created + pushed via `eds repo`), and its # Dockerfile is Container Apps-compatible -- see "Dockerfile requirements" @@ -290,7 +269,7 @@ Sample failure: ``` $ eds repo list -Error: repo API key is not set. Run `eds login --repo-api-key ` or set EDS_REPO_API_KEY +Error: API key is not set. Run `eds login --api-key ` or set EDS_API_KEY exit=1 ``` @@ -314,18 +293,12 @@ exit=1 between `error`/`deleted` across repeated calls). Treat it as best-effort: call it, then check `eds wf app list`/`show` to see the actual outcome rather than trusting the delete call's own exit code. -- Some environments don't accept `X-API-KEY` for `eds repo *` yet; if you - see `Jwt issuer is not configured` from a Repo call, retry with - `--repo-use-wf-auth` (requires `EDS_WF_KEY_ID`/`EDS_WF_SECRET` to already - be set) before concluding the repo API key itself is wrong. ## Quick reference ```text # 1. bootstrap -export EDS_REPO_API_KEY=... -export EDS_WF_KEY_ID=... # only needed for wf app/run/job commands -export EDS_WF_SECRET=... # exchanged for a Bearer token automatically +export EDS_API_KEY=... export EDS_PROJECT_ID=... # (optional) install via curl | bash — see "Installation" @@ -333,13 +306,9 @@ export EDS_PROJECT_ID=... eds repo list --json | jq '.repositories[].name' # 3. write -eds repo create demo --description "agent created" --json | jq -r '.id' +NEW_REPO=$(eds repo create my-app --json | jq -r '.id') -# 4. act -eds repo clone demo ./work/demo - -# 5. publish (Workflow Studio) -APP_ID=$(eds wf app create demo --repository demo --branch main --json | jq -r '.id') -eds wf app deploy "$APP_ID" --json | jq -r '.run_id' -eds wf app status "$APP_ID" --json | jq '.latest_deployment.url' +# 4. deploy +APP_ID=$(eds wf app create my-app --repository "$NEW_REPO" --branch main --json | jq -r '.id') +eds wf app status "$APP_ID" --json | jq '{status: .application.status, url: .latest_deployment.url}' ```