diff --git a/.gitignore b/.gitignore index 34ec766f7..70f2b01ca 100644 --- a/.gitignore +++ b/.gitignore @@ -138,6 +138,7 @@ scripts/feedback-loop/.last-run **/minio-credentials-secret.yaml **/postgresql-credentials-secret.yaml **/unleash-credentials-secret.yaml +.secrets/ # One-off analysis and experiments repomix-analysis/ diff --git a/components/ambient-cli/cmd/acpctl/apply/cmd.go b/components/ambient-cli/cmd/acpctl/apply/cmd.go index ff9696c29..6fa7fa1b0 100644 --- a/components/ambient-cli/cmd/acpctl/apply/cmd.go +++ b/components/ambient-cli/cmd/acpctl/apply/cmd.go @@ -243,7 +243,7 @@ func applyProject(ctx context.Context, client *sdkclient.Client, doc resource) ( } func applyCredential(ctx context.Context, client *sdkclient.Client, doc resource) (applyResult, error) { - existing, err := client.Credentials().Get(ctx, doc.Name) + existing, err := client.Credentials().FindByName(ctx, doc.Name) if err != nil { token := os.ExpandEnv(doc.Token) builder := sdktypes.NewCredentialBuilder(). @@ -271,7 +271,7 @@ func applyCredential(ctx context.Context, client *sdkclient.Client, doc resource if buildErr != nil { return applyResult{}, buildErr } - if _, createErr := client.Credentials().Create(ctx, cred); createErr != nil { + if _, createErr := client.Credentials().CreateCompat(ctx, cred); createErr != nil { return applyResult{}, createErr } return applyResult{Kind: "Credential", Name: doc.Name, Status: "created"}, nil diff --git a/components/ambient-cli/cmd/acpctl/credential/cmd.go b/components/ambient-cli/cmd/acpctl/credential/cmd.go index ff320fbe0..848a1cd1f 100644 --- a/components/ambient-cli/cmd/acpctl/credential/cmd.go +++ b/components/ambient-cli/cmd/acpctl/credential/cmd.go @@ -9,6 +9,7 @@ import ( "github.com/ambient-code/platform/components/ambient-cli/pkg/config" "github.com/ambient-code/platform/components/ambient-cli/pkg/connection" "github.com/ambient-code/platform/components/ambient-cli/pkg/output" + sdkclient "github.com/ambient-code/platform/components/ambient-sdk/go-sdk/client" sdktypes "github.com/ambient-code/platform/components/ambient-sdk/go-sdk/types" "github.com/spf13/cobra" ) @@ -103,7 +104,11 @@ var getCmd = &cobra.Command{ ctx, cancel := context.WithTimeout(context.Background(), cfg.GetRequestTimeout()) defer cancel() - credential, err := client.Credentials().Get(ctx, args[0]) + credID, err := resolveCredentialID(ctx, client, args[0]) + if err != nil { + return err + } + credential, err := client.Credentials().Get(ctx, credID) if err != nil { return fmt.Errorf("get credential %q: %w", args[0], err) } @@ -187,7 +192,7 @@ var createCmd = &cobra.Command{ return fmt.Errorf("build credential: %w", err) } - created, err := client.Credentials().Create(ctx, cred) + created, err := client.Credentials().CreateCompat(ctx, cred) if err != nil { return fmt.Errorf("create credential: %w", err) } @@ -259,7 +264,11 @@ var updateCmd = &cobra.Command{ patch = patch.Annotations(updateArgs.annotations) } - updated, err := client.Credentials().Update(ctx, args[0], patch.Build()) + credID, err := resolveCredentialID(ctx, client, args[0]) + if err != nil { + return err + } + updated, err := client.Credentials().Update(ctx, credID, patch.Build()) if err != nil { return fmt.Errorf("update credential: %w", err) } @@ -296,7 +305,11 @@ var deleteCmd = &cobra.Command{ ctx, cancel := context.WithTimeout(context.Background(), cfg.GetRequestTimeout()) defer cancel() - if err := client.Credentials().Delete(ctx, args[0]); err != nil { + credID, err := resolveCredentialID(ctx, client, args[0]) + if err != nil { + return err + } + if err := client.Credentials().Delete(ctx, credID); err != nil { return fmt.Errorf("delete credential: %w", err) } @@ -329,7 +342,11 @@ var tokenCmd = &cobra.Command{ ctx, cancel := context.WithTimeout(context.Background(), cfg.GetRequestTimeout()) defer cancel() - resp, err := client.Credentials().GetToken(ctx, args[0]) + credID, err := resolveCredentialID(ctx, client, args[0]) + if err != nil { + return err + } + resp, err := client.Credentials().GetToken(ctx, credID) if err != nil { return fmt.Errorf("get token for credential %q: %w", args[0], err) } @@ -451,6 +468,18 @@ func init() { bindCmd.Flags().StringVar(&bindArgs.project, "project", "", "Project to bind the credential to (required)") } +func resolveCredentialID(ctx context.Context, client *sdkclient.Client, nameOrID string) (string, error) { + cred, err := client.Credentials().Get(ctx, nameOrID) + if err == nil { + return cred.ID, nil + } + cred, err = client.Credentials().FindByName(ctx, nameOrID) + if err != nil { + return "", fmt.Errorf("credential %q not found by ID or name", nameOrID) + } + return cred.ID, nil +} + func printCredentialTable(printer *output.Printer, credentials []sdktypes.Credential) error { columns := []output.Column{ {Name: "ID", Width: 27}, diff --git a/components/ambient-cli/cmd/acpctl/delete/cmd.go b/components/ambient-cli/cmd/acpctl/delete/cmd.go index 9a0443f1e..537697299 100644 --- a/components/ambient-cli/cmd/acpctl/delete/cmd.go +++ b/components/ambient-cli/cmd/acpctl/delete/cmd.go @@ -132,7 +132,11 @@ func run(cmd *cobra.Command, cmdArgs []string) error { return nil case "credential", "credentials", "cred", "creds": - if err := client.Credentials().Delete(ctx, name); err != nil { + deleteID := name + if cred, findErr := client.Credentials().FindByName(ctx, name); findErr == nil { + deleteID = cred.ID + } + if err := client.Credentials().Delete(ctx, deleteID); err != nil { return fmt.Errorf("delete credential %q: %w", name, err) } fmt.Fprintf(cmd.OutOrStdout(), "credential/%s deleted\n", name) diff --git a/components/ambient-cli/cmd/acpctl/describe/cmd.go b/components/ambient-cli/cmd/acpctl/describe/cmd.go index e213b8ba8..7a307f80f 100644 --- a/components/ambient-cli/cmd/acpctl/describe/cmd.go +++ b/components/ambient-cli/cmd/acpctl/describe/cmd.go @@ -102,7 +102,10 @@ func run(cmd *cobra.Command, cmdArgs []string) error { case "credential", "credentials", "cred", "creds": cred, err := client.Credentials().Get(ctx, name) if err != nil { - return fmt.Errorf("describe credential %q: %w", name, err) + cred, err = client.Credentials().FindByName(ctx, name) + if err != nil { + return fmt.Errorf("describe credential %q: %w", name, err) + } } return printer.PrintJSON(cred) diff --git a/components/ambient-cli/cmd/acpctl/get/cmd.go b/components/ambient-cli/cmd/acpctl/get/cmd.go index 95b004fba..779940478 100644 --- a/components/ambient-cli/cmd/acpctl/get/cmd.go +++ b/components/ambient-cli/cmd/acpctl/get/cmd.go @@ -472,7 +472,10 @@ func getCredentials(ctx context.Context, client *sdkclient.Client, printer *outp if name != "" { cred, err := client.Credentials().Get(ctx, name) if err != nil { - return fmt.Errorf("get credential %q: %w", name, err) + cred, err = client.Credentials().FindByName(ctx, name) + if err != nil { + return fmt.Errorf("get credential %q: %w", name, err) + } } if printer.Format() == output.FormatJSON { return printer.PrintJSON(cred) diff --git a/components/ambient-cli/demo-credentials.sh b/components/ambient-cli/demo-credentials.sh new file mode 100755 index 000000000..e864a698a --- /dev/null +++ b/components/ambient-cli/demo-credentials.sh @@ -0,0 +1,385 @@ +#!/usr/bin/env bash +# demo-credentials.sh — acpctl credential lifecycle demo +# +# Demonstrates the full credential workflow: +# 1. Verify login +# 2. Create three credentials (GitHub, GitLab, Jira) +# 3. Create a project +# 4. Bind all credentials to the project +# 5. Create an agent and start a session +# 6. Ask the agent to verify it can access the credentials +# 7. Clean up +# +# Prerequisites: +# You must create three secret files before running this demo: +# +# .secrets/GITHUB_TOKEN — a GitHub Personal Access Token (classic or fine-grained) +# Create at: https://github.com/settings/tokens +# Required scopes: repo (or fine-grained with Contents read) +# +# .secrets/GITLAB_TOKEN — a GitLab Personal Access Token +# Create at: https://gitlab.com/-/user_settings/personal_access_tokens +# Required scopes: read_api +# +# .secrets/JIRA_TOKEN — a Jira API Token +# Create at: https://id.atlassian.com/manage-profile/security/api-tokens +# Used with your Atlassian email for Basic auth +# +# Each file should contain the raw token string with no trailing newline. +# Example: +# echo -n "ghp_abc123..." > .secrets/GITHUB_TOKEN +# echo -n "glpat-xyz..." > .secrets/GITLAB_TOKEN +# echo -n "ATATT3x..." > .secrets/JIRA_TOKEN +# chmod 600 .secrets/* +# +# Usage: +# ./demo-credentials.sh +# PAUSE=2 ./demo-credentials.sh # pause between steps +# SECRETS_DIR=~/my-secrets ./demo-credentials.sh +# NO_CLEANUP=1 ./demo-credentials.sh # skip cleanup +# +# Optional env: +# SECRETS_DIR — directory containing token files (default: .secrets) +# JIRA_URL — Jira instance URL (default: prompted) +# JIRA_EMAIL — Jira account email (default: prompted) +# GITLAB_URL — GitLab instance URL (default: https://gitlab.com) +# ACPCTL — path to acpctl binary (default: acpctl from PATH) +# PAUSE — seconds between demo steps (default: 0) +# SESSION_READY_TIMEOUT — seconds to wait for Running (default: 180) +# MESSAGE_WAIT_TIMEOUT — seconds to wait for RUN_FINISHED (default: 300) +# NO_CLEANUP — set to 1 to skip cleanup + +set -euo pipefail + +ACPCTL="${ACPCTL:-acpctl}" +PAUSE="${PAUSE:-0}" +SESSION_READY_TIMEOUT="${SESSION_READY_TIMEOUT:-180}" +MESSAGE_WAIT_TIMEOUT="${MESSAGE_WAIT_TIMEOUT:-300}" +SECRETS_DIR="${SECRETS_DIR:-.secrets}" +GITLAB_URL="${GITLAB_URL:-https://gitlab.com}" + +# ── helpers ──────────────────────────────────────────────────────────────────── + +bold() { printf '\033[1m%s\033[0m\n' "$*"; } +dim() { printf '\033[2m%s\033[0m\n' "$*"; } +cyan() { printf '\033[36m%s\033[0m\n' "$*"; } +green() { printf '\033[32m%s\033[0m\n' "$*"; } +yellow(){ printf '\033[33m%s\033[0m\n' "$*"; } +red() { printf '\033[31m%s\033[0m\n' "$*"; } +sep() { printf '\033[2m%s\033[0m\n' "──────────────────────────────────────────────────"; } + +step() { + local description="$1" + shift + echo + sep + bold "▶ $description" + printf '\033[38;5;214m $ %s\033[0m\n' "$*" + sleep "$PAUSE" + "$@" + echo +} + +announce() { + echo + sep + cyan "━━ $*" + sep + sleep "$PAUSE" +} + +die() { red "error: $*" >&2; exit 1; } + +json_field() { + local json="$1" field="$2" + echo "$json" | python3 -c "import sys,json; print(json.load(sys.stdin)['${field}'])" 2>/dev/null +} + +wait_for_running() { + local session_id="$1" + local deadline=$(( $(date +%s) + SESSION_READY_TIMEOUT )) + local last_phase="" + printf ' waiting for Running (timeout %ds)...\n' "${SESSION_READY_TIMEOUT}" + while true; do + local phase + phase=$( + "$ACPCTL" get session "$session_id" -o json 2>/dev/null \ + | python3 -c "import sys,json; print(json.load(sys.stdin).get('phase',''))" 2>/dev/null || true + ) + if [[ "$phase" != "$last_phase" ]]; then + printf ' phase: %s\n' "$phase" + last_phase="$phase" + fi + [[ "$phase" == "Running" ]] && { green " session is Running"; return 0; } + [[ $(date +%s) -ge $deadline ]] && { yellow " timed out (phase=${phase:-unknown})"; return 1; } + sleep 3 + done +} + +# ── preflight ────────────────────────────────────────────────────────────────── + +command -v "$ACPCTL" &>/dev/null || die "${ACPCTL} not found. Set ACPCTL=/path/to/acpctl or add to PATH." +command -v python3 &>/dev/null || die "python3 not found." + +# ── intro ──────────────────────────────────────────────────────────────────── + +echo +bold "Ambient CLI Demo — Credential Lifecycle" +sep +echo +printf ' %s\n' "This demo creates three credentials (GitHub, GitLab, Jira)," +printf ' %s\n' "binds them to a project, starts an agent session, and verifies" +printf ' %s\n' "the agent can access all three credentials at runtime." +echo +printf ' %s\n' "Steps:" +printf ' %s\n' " 1. Verify login" +printf ' %s\n' " 2. Create credentials: github-pat, gitlab-pat, jira-token" +printf ' %s\n' " 3. Create project" +printf ' %s\n' " 4. Bind all credentials to the project" +printf ' %s\n' " 5. Create agent + start session" +printf ' %s\n' " 6. Verify credentials in session" +printf ' %s\n' " 7. Clean up" +echo +printf ' \033[38;5;214m%-38s\033[0m %s\n' "Orange text like this" "= a terminal command being run" +echo +sep +echo +bold " Prerequisites — secret files" +echo +printf ' %s\n' "The demo reads tokens from files in ${SECRETS_DIR}/:" +echo +printf ' \033[36m%-28s\033[0m %s\n' "${SECRETS_DIR}/GITHUB_TOKEN" "GitHub PAT (https://github.com/settings/tokens)" +printf ' \033[36m%-28s\033[0m %s\n' "${SECRETS_DIR}/GITLAB_TOKEN" "GitLab PAT (https://gitlab.com/-/user_settings/personal_access_tokens)" +printf ' \033[36m%-28s\033[0m %s\n' "${SECRETS_DIR}/JIRA_TOKEN" "Jira token (https://id.atlassian.com/manage-profile/security/api-tokens)" +echo +printf ' %s\n' "Create them like this:" +dim " echo -n \"ghp_abc123...\" > ${SECRETS_DIR}/GITHUB_TOKEN" +dim " echo -n \"glpat-xyz...\" > ${SECRETS_DIR}/GITLAB_TOKEN" +dim " echo -n \"ATATT3x...\" > ${SECRETS_DIR}/JIRA_TOKEN" +dim " chmod 600 ${SECRETS_DIR}/*" +echo +sep + +# ── validate secrets ───────────────────────────────────────────────────────── + +GITHUB_TOKEN_FILE="${SECRETS_DIR}/GITHUB_TOKEN" +GITLAB_TOKEN_FILE="${SECRETS_DIR}/GITLAB_TOKEN" +JIRA_TOKEN_FILE="${SECRETS_DIR}/JIRA_TOKEN" + +[[ -f "${GITHUB_TOKEN_FILE}" ]] || die "Missing ${GITHUB_TOKEN_FILE} — see prerequisites above." +[[ -f "${GITLAB_TOKEN_FILE}" ]] || die "Missing ${GITLAB_TOKEN_FILE} — see prerequisites above." +[[ -f "${JIRA_TOKEN_FILE}" ]] || die "Missing ${JIRA_TOKEN_FILE} — see prerequisites above." + +GITHUB_TOKEN_VALUE="$(cat "${GITHUB_TOKEN_FILE}")" +GITLAB_TOKEN_VALUE="$(cat "${GITLAB_TOKEN_FILE}")" +JIRA_TOKEN_VALUE="$(cat "${JIRA_TOKEN_FILE}")" + +[[ -n "${GITHUB_TOKEN_VALUE}" ]] || die "${GITHUB_TOKEN_FILE} is empty." +[[ -n "${GITLAB_TOKEN_VALUE}" ]] || die "${GITLAB_TOKEN_FILE} is empty." +[[ -n "${JIRA_TOKEN_VALUE}" ]] || die "${JIRA_TOKEN_FILE} is empty." + +green " All three secret files found." + +# ── gather Jira config ─────────────────────────────────────────────────────── + +if [[ -z "${JIRA_URL:-}" ]]; then + printf '\n\033[1m Jira instance URL\033[0m (e.g. https://myco.atlassian.net): ' + read -r JIRA_URL + [[ -n "${JIRA_URL}" ]] || die "JIRA_URL is required for the jira credential." +fi + +if [[ -z "${JIRA_EMAIL:-}" ]]; then + printf '\033[1m Jira account email\033[0m: ' + read -r JIRA_EMAIL + [[ -n "${JIRA_EMAIL}" ]] || die "JIRA_EMAIL is required for the jira credential." +fi + +# ── generate names ─────────────────────────────────────────────────────────── + +RUN_ID=$(date +%s | tail -c6) +PROJECT_NAME="demo-creds-${RUN_ID}" +AGENT_NAME="credential-verifier" + +CRED_GITHUB="github-pat-${RUN_ID}" +CRED_GITLAB="gitlab-pat-${RUN_ID}" +CRED_JIRA="jira-token-${RUN_ID}" + +echo +dim " Run ID: ${RUN_ID}" +dim " Project: ${PROJECT_NAME}" +dim " Agent: ${AGENT_NAME}" +dim " GitHub: ${CRED_GITHUB}" +dim " GitLab: ${CRED_GITLAB}" +dim " Jira: ${CRED_JIRA}" + +echo +bold " Press Enter to begin..." +read -r + +# ── cleanup trap ───────────────────────────────────────────────────────────── + +CREATED_PROJECT="" +CREATED_SESSION_ID="" + +cleanup() { + if [[ -n "${NO_CLEANUP:-}" ]]; then + echo + yellow " NO_CLEANUP set — skipping cleanup" + dim " project: ${CREATED_PROJECT}" + dim " session: ${CREATED_SESSION_ID}" + dim " credentials: ${CRED_GITHUB}, ${CRED_GITLAB}, ${CRED_JIRA}" + return + fi + echo + announce "Cleanup" + if [[ -n "${CREATED_SESSION_ID}" ]]; then + dim " stopping session ${CREATED_SESSION_ID}..." + "$ACPCTL" stop "${CREATED_SESSION_ID}" 2>/dev/null || true + "$ACPCTL" delete session "${CREATED_SESSION_ID}" -y 2>/dev/null || true + fi + for cred in "${CRED_GITHUB}" "${CRED_GITLAB}" "${CRED_JIRA}"; do + dim " deleting credential ${cred}..." + "$ACPCTL" credential delete "${cred}" --confirm 2>/dev/null || true + done + if [[ -n "${CREATED_PROJECT}" ]]; then + dim " deleting project ${CREATED_PROJECT}..." + "$ACPCTL" delete project "${CREATED_PROJECT}" -y 2>/dev/null || true + fi + green " cleanup done" +} +trap cleanup EXIT + +# ── 1: verify login ───────────────────────────────────────────────────────── + +announce "1 · Verify login" + +step "Show authenticated user" \ + "$ACPCTL" whoami + +# ── 2: create credentials ─────────────────────────────────────────────────── + +announce "2 · Create credentials" + +step "Create GitHub credential: ${CRED_GITHUB}" \ + "$ACPCTL" credential create \ + --name "${CRED_GITHUB}" \ + --provider github \ + --token "${GITHUB_TOKEN_VALUE}" \ + --description "GitHub PAT for credential demo" + +step "Create GitLab credential: ${CRED_GITLAB}" \ + "$ACPCTL" credential create \ + --name "${CRED_GITLAB}" \ + --provider gitlab \ + --token "${GITLAB_TOKEN_VALUE}" \ + --url "${GITLAB_URL}" \ + --description "GitLab PAT for credential demo" + +step "Create Jira credential: ${CRED_JIRA}" \ + "$ACPCTL" credential create \ + --name "${CRED_JIRA}" \ + --provider jira \ + --token "${JIRA_TOKEN_VALUE}" \ + --url "${JIRA_URL}" \ + --email "${JIRA_EMAIL}" \ + --description "Jira API token for credential demo" + +step "List all credentials" \ + "$ACPCTL" credential list + +# ── 3: create project ─────────────────────────────────────────────────────── + +announce "3 · Create project" + +step "Create project: ${PROJECT_NAME}" \ + "$ACPCTL" create project \ + --name "${PROJECT_NAME}" \ + --description "Credential lifecycle demo" + +CREATED_PROJECT="${PROJECT_NAME}" + +step "Set project context" \ + "$ACPCTL" project "${PROJECT_NAME}" + +# ── 4: bind credentials to project ────────────────────────────────────────── + +announce "4 · Bind credentials to project" + +step "Bind GitHub credential" \ + "$ACPCTL" credential bind "${CRED_GITHUB}" --project "${PROJECT_NAME}" + +step "Bind GitLab credential" \ + "$ACPCTL" credential bind "${CRED_GITLAB}" --project "${PROJECT_NAME}" + +step "Bind Jira credential" \ + "$ACPCTL" credential bind "${CRED_JIRA}" --project "${PROJECT_NAME}" + +# ── 5: create agent + start session ───────────────────────────────────────── + +announce "5 · Create agent and start session" + +sep; bold "▶ Create agent: ${AGENT_NAME}"; sleep "$PAUSE" +AGENT_JSON=$( + "$ACPCTL" agent create \ + --project-id "${PROJECT_NAME}" \ + --name "${AGENT_NAME}" \ + --prompt "You are a credential verification agent. When asked, you confirm which credentials are available to you by listing their provider, name, and whether the token is present." \ + -o json 2>/dev/null +) +AGENT_ID=$(json_field "$AGENT_JSON" "id") +[[ -n "${AGENT_ID}" ]] || die "Failed to parse agent ID" +green " agent created: ${AGENT_ID}" +echo + +sep; bold "▶ Start session via agent"; sleep "$PAUSE" +printf '\033[38;5;214m $ %s\033[0m\n' "acpctl start ${AGENT_ID} --project-id ${PROJECT_NAME}" +START_OUTPUT=$( + "$ACPCTL" start "${AGENT_ID}" \ + --project-id "${PROJECT_NAME}" \ + --prompt "List all credentials available to you. For each one, report: provider, name, and whether the token is non-empty. Do NOT print the actual token value." \ + 2>&1 +) +echo " ${START_OUTPUT}" + +SESSION_ID=$( + echo "${START_OUTPUT}" | sed -n 's|^session/\([^ ]*\) started.*|\1|p' +) +if [[ -z "${SESSION_ID}" ]]; then + red " Failed to parse session ID from start output" + die "Expected output like: session/ started (phase: ...)" +fi +CREATED_SESSION_ID="${SESSION_ID}" +green " session: ${SESSION_ID}" +echo + +# ── wait for Running ───────────────────────────────────────────────────────── + +announce "5b · Wait for session Running" + +wait_for_running "${SESSION_ID}" || die "Session did not reach Running phase" + +# ── 6: verify credentials in session ──────────────────────────────────────── + +announce "6 · Verify credentials in session" + +sep +bold "▶ Send verification message and stream response" +printf '\033[38;5;214m $ %s\033[0m\n' "acpctl session send ${SESSION_ID} \"...\" -f" +sleep "$PAUSE" + +"$ACPCTL" session send "${SESSION_ID}" \ + "List every credential available to this session. For each credential, report: 1) provider name, 2) credential name, 3) whether a token value is present (yes/no). Do NOT reveal the actual token. Format as a simple table." \ + -f || yellow " stream ended (may have timed out)" + +echo +step "Session messages" \ + "$ACPCTL" session messages "${SESSION_ID}" + +# ── done ───────────────────────────────────────────────────────────────────── + +echo +sep +green " Demo complete" +dim " Project ${PROJECT_NAME} and credentials will be deleted by cleanup." +sep +echo diff --git a/components/ambient-control-plane/internal/reconciler/kube_reconciler.go b/components/ambient-control-plane/internal/reconciler/kube_reconciler.go index cf2d739f1..79be5ace1 100644 --- a/components/ambient-control-plane/internal/reconciler/kube_reconciler.go +++ b/components/ambient-control-plane/internal/reconciler/kube_reconciler.go @@ -659,10 +659,22 @@ func (r *SimpleKubeReconciler) buildEnv(ctx context.Context, session types.Sessi func (r *SimpleKubeReconciler) resolveCredentialIDs(ctx context.Context, sdk *sdkclient.Client, projectID string) (map[string]string, error) { result := map[string]string{} - it := sdk.Credentials().ListAll(ctx, &types.ListOptions{Size: 100}) + bindingOpts := &types.ListOptions{ + Size: 100, + Search: fmt.Sprintf("scope = 'credential' and project_id = '%s'", projectID), + } + it := sdk.RoleBindings().ListAll(ctx, bindingOpts) for it.Next() { - cred := it.Item() - if cred.Provider == "" || cred.ID == "" { + rb := it.Item() + if rb.CredentialID == nil || *rb.CredentialID == "" { + continue + } + cred, err := sdk.Credentials().Get(ctx, *rb.CredentialID) + if err != nil { + r.logger.Warn().Err(err).Str("credential_id", *rb.CredentialID).Msg("skipping unresolvable bound credential") + continue + } + if cred.Provider == "" { continue } if _, already := result[cred.Provider]; !already { @@ -670,10 +682,10 @@ func (r *SimpleKubeReconciler) resolveCredentialIDs(ctx context.Context, sdk *sd } } if err := it.Err(); err != nil { - return nil, fmt.Errorf("listing credentials: %w", err) + return nil, fmt.Errorf("listing credential bindings for project %s: %w", projectID, err) } - r.logger.Info().Int("count", len(result)).Msg("resolved credential IDs for session") + r.logger.Info().Int("count", len(result)).Str("project", projectID).Msg("resolved project-bound credential IDs") return result, nil } diff --git a/components/ambient-sdk/go-sdk/client/credential_extensions.go b/components/ambient-sdk/go-sdk/client/credential_extensions.go index 43044a6a3..9616fa993 100644 --- a/components/ambient-sdk/go-sdk/client/credential_extensions.go +++ b/components/ambient-sdk/go-sdk/client/credential_extensions.go @@ -2,6 +2,8 @@ package client import ( "context" + "encoding/json" + "fmt" "net/http" "net/url" @@ -15,3 +17,40 @@ func (a *CredentialAPI) GetToken(ctx context.Context, id string) (*types.Credent } return &result, nil } + +func (a *CredentialAPI) FindByName(ctx context.Context, name string) (*types.Credential, error) { + opts := types.NewListOptions().Size(100).Build() + opts.Search = fmt.Sprintf("name = '%s'", name) + list, err := a.List(ctx, opts) + if err != nil { + return nil, err + } + for _, c := range list.Items { + if c.Name == name { + return &c, nil + } + } + return nil, fmt.Errorf("credential with name %q not found", name) +} + +// CreateCompat creates a credential with project_id for backward compatibility +// with server images that predate migration 202505120001 (drop project_id column). +// Remove once all environments run the updated server. +func (a *CredentialAPI) CreateCompat(ctx context.Context, resource *types.Credential) (*types.Credential, error) { + payload := struct { + *types.Credential + ProjectID string `json:"project_id,omitempty"` + }{ + Credential: resource, + ProjectID: a.client.project, + } + body, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("marshal credential: %w", err) + } + var result types.Credential + if err := a.client.do(ctx, http.MethodPost, "/credentials", body, http.StatusCreated, &result); err != nil { + return nil, err + } + return &result, nil +} diff --git a/components/manifests/base/runner-networkpolicy.yaml b/components/manifests/base/runner-networkpolicy.yaml index a873bd697..2eb515dc3 100644 --- a/components/manifests/base/runner-networkpolicy.yaml +++ b/components/manifests/base/runner-networkpolicy.yaml @@ -1,15 +1,25 @@ apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: - name: allow-from-runner-namespaces + name: allow-platform-ingress spec: podSelector: {} policyTypes: - - Ingress + - Ingress ingress: - - {} - - from: - - namespaceSelector: {} - podSelector: - matchLabels: - app: ambient-code-runner + - from: + - namespaceSelector: + matchLabels: + policy-group.network.openshift.io/ingress: "" + - from: + - podSelector: {} + - from: + - namespaceSelector: {} + podSelector: + matchLabels: + app: ambient-code-runner + - from: + - namespaceSelector: {} + podSelector: + matchLabels: + ambient-code.io/managed: "true" diff --git a/components/manifests/overlays/kind/ambient-api-server-dev-patch.yaml b/components/manifests/overlays/kind/ambient-api-server-dev-patch.yaml index 50fec876c..a266d6388 100644 --- a/components/manifests/overlays/kind/ambient-api-server-dev-patch.yaml +++ b/components/manifests/overlays/kind/ambient-api-server-dev-patch.yaml @@ -6,6 +6,47 @@ spec: template: spec: initContainers: + - name: pre-migration + image: postgres:16 + imagePullPolicy: IfNotPresent + command: + - sh + - -c + - | + until pg_isready -h "$PGHOST" -p "$PGPORT" -U "$PGUSER"; do + echo "waiting for database..."; sleep 2 + done + psql -v ON_ERROR_STOP=0 <<'SQL' + CREATE TABLE IF NOT EXISTS role_bindings ( + id TEXT PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + deleted_at TIMESTAMPTZ, + user_id TEXT NOT NULL, + role_id TEXT NOT NULL, + scope TEXT NOT NULL, + scope_id TEXT + ); + CREATE INDEX IF NOT EXISTS idx_role_bindings_deleted_at ON role_bindings(deleted_at); + SQL + echo "pre-migration complete" + env: + - name: PGHOST + value: ambient-api-server-db + - name: PGPORT + value: "5432" + - name: PGUSER + value: ambient + - name: PGPASSWORD + value: TheBlurstOfTimes + - name: PGDATABASE + value: ambient_api_server + securityContext: + allowPrivilegeEscalation: false + runAsNonRoot: true + runAsUser: 999 + capabilities: + drop: ["ALL"] - name: migration imagePullPolicy: IfNotPresent command: diff --git a/components/manifests/overlays/kind/api-server-security-patch.yaml b/components/manifests/overlays/kind/api-server-security-patch.yaml new file mode 100644 index 000000000..5d5b37e2a --- /dev/null +++ b/components/manifests/overlays/kind/api-server-security-patch.yaml @@ -0,0 +1,9 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ambient-api-server +spec: + template: + spec: + securityContext: + runAsNonRoot: false diff --git a/components/manifests/overlays/kind/control-plane-env-patch.yaml b/components/manifests/overlays/kind/control-plane-env-patch.yaml new file mode 100644 index 000000000..da76b6560 --- /dev/null +++ b/components/manifests/overlays/kind/control-plane-env-patch.yaml @@ -0,0 +1,47 @@ +- op: replace + path: /spec/template/spec/containers/0/imagePullPolicy + value: IfNotPresent +- op: replace + path: /spec/template/spec/containers/0/env + value: + - name: AMBIENT_API_TOKEN + valueFrom: + secretKeyRef: + name: ambient-control-plane-token + key: token + - name: AMBIENT_API_SERVER_URL + value: "http://ambient-api-server.ambient-code.svc:8000" + - name: AMBIENT_GRPC_SERVER_ADDR + value: "ambient-api-server.ambient-code.svc:9000" + - name: AMBIENT_GRPC_USE_TLS + value: "false" + - name: MODE + value: "kube" + - name: LOG_LEVEL + value: "debug" + - name: RUNNER_IMAGE + valueFrom: + configMapKeyRef: + name: operator-config + key: AMBIENT_CODE_RUNNER_IMAGE + optional: true + - name: CP_TOKEN_URL + value: "http://ambient-control-plane.ambient-code.svc:8080/token" + - name: CP_RUNTIME_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: USE_VERTEX + valueFrom: + configMapKeyRef: + name: operator-config + key: USE_VERTEX + optional: true +- op: replace + path: /spec/template/spec/containers/0/securityContext + value: + allowPrivilegeEscalation: false + runAsNonRoot: true + readOnlyRootFilesystem: false + capabilities: + drop: ["ALL"] diff --git a/components/manifests/overlays/kind/kustomization.yaml b/components/manifests/overlays/kind/kustomization.yaml index 142d0dcaf..06ddf993a 100644 --- a/components/manifests/overlays/kind/kustomization.yaml +++ b/components/manifests/overlays/kind/kustomization.yaml @@ -114,6 +114,28 @@ patches: version: v1 kind: Deployment name: postgresql +- path: networkpolicy-permissive-patch.yaml + target: + kind: NetworkPolicy + name: allow-platform-ingress +- path: api-server-security-patch.yaml + target: + group: apps + version: v1 + kind: Deployment + name: ambient-api-server +- path: control-plane-env-patch.yaml + target: + group: apps + version: v1 + kind: Deployment + name: ambient-control-plane +- path: observability-dashboard-scale-patch.yaml + target: + group: apps + version: v1 + kind: Deployment + name: observability-dashboard # Kind overlay: Use Quay.io production images by default # For local development with local images, use overlays/kind-local/ instead diff --git a/components/manifests/overlays/kind/networkpolicy-permissive-patch.yaml b/components/manifests/overlays/kind/networkpolicy-permissive-patch.yaml new file mode 100644 index 000000000..d0e028cba --- /dev/null +++ b/components/manifests/overlays/kind/networkpolicy-permissive-patch.yaml @@ -0,0 +1,10 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-platform-ingress +spec: + podSelector: {} + policyTypes: + - Ingress + ingress: + - {} diff --git a/components/manifests/overlays/kind/observability-dashboard-scale-patch.yaml b/components/manifests/overlays/kind/observability-dashboard-scale-patch.yaml new file mode 100644 index 000000000..3d0cb3a91 --- /dev/null +++ b/components/manifests/overlays/kind/observability-dashboard-scale-patch.yaml @@ -0,0 +1,6 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: observability-dashboard +spec: + replicas: 0 diff --git a/components/runners/ambient-runner/Dockerfile b/components/runners/ambient-runner/Dockerfile index 0d15b77cf..08b1279ee 100755 --- a/components/runners/ambient-runner/Dockerfile +++ b/components/runners/ambient-runner/Dockerfile @@ -43,8 +43,15 @@ RUN pip3 install --break-system-packages --no-cache-dir '/app/ambient-runner[all RUN npm install -g @google/gemini-cli@${GEMINI_CLI_VERSION} && \ npm cache clean --force -# Install CodeRabbit CLI (official install script, binary for current arch) -RUN curl -fsSL https://cli.coderabbit.ai/install.sh | CODERABBIT_INSTALL_DIR=/usr/local/bin sh +# Install CodeRabbit CLI (direct binary download, pinned version) +ARG CODERABBIT_VERSION=0.5.0 +RUN ARCH=$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/') && \ + curl -fsSL "https://cli.coderabbit.ai/releases/${CODERABBIT_VERSION}/coderabbit-linux-${ARCH}.zip" \ + -o /tmp/coderabbit.zip && \ + unzip -q /tmp/coderabbit.zip -d /usr/local/bin && \ + chmod +x /usr/local/bin/coderabbit && \ + ln -sf /usr/local/bin/coderabbit /usr/local/bin/cr && \ + rm -f /tmp/coderabbit.zip # Set environment variables ENV PYTHONUNBUFFERED=1 diff --git a/components/runners/ambient-runner/ambient_runner/platform/auth.py b/components/runners/ambient-runner/ambient_runner/platform/auth.py index 68a32ea5b..7e23d8c8f 100755 --- a/components/runners/ambient-runner/ambient_runner/platform/auth.py +++ b/components/runners/ambient-runner/ambient_runner/platform/auth.py @@ -118,11 +118,8 @@ async def _fetch_credential(context: RunnerContext, credential_type: str) -> dic project = os.getenv("PROJECT_NAME") or os.getenv("AGENTIC_SESSION_NAMESPACE", "") project = project.strip() - if credential_id and project: - url = ( - f"{base}/api/ambient/v1/projects/{project}" - f"/credentials/{credential_id}/token" - ) + if credential_id: + url = f"{base}/api/ambient/v1/credentials/{credential_id}/token" elif project and context.session_id: url = ( f"{base}/projects/{project}" diff --git a/components/runners/ambient-runner/tests/test_shared_session_credentials.py b/components/runners/ambient-runner/tests/test_shared_session_credentials.py index 8a5b10e81..3d2e69408 100755 --- a/components/runners/ambient-runner/tests/test_shared_session_credentials.py +++ b/components/runners/ambient-runner/tests/test_shared_session_credentials.py @@ -511,7 +511,7 @@ def log_message(self, fmt, *args): assert result.get("token") == "gh-tok-cp" assert captured["path"] == ( - "/api/ambient/v1/projects/my-project/credentials/cred-abc/token" + "/api/ambient/v1/credentials/cred-abc/token" ) finally: server.server_close()