Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ gateway Service exists, creates a sandbox through the OpenShell
gateway API, and tracks status to completion. Parameters are
domain-agnostic — the controller passes them through without
interpretation. For Konveyor-managed agents, Hub injects connectivity
info (`HUB_BASE_URL`, `HUB_APP_ID`, scoped API token) into the
info (`HUB_BASE_URL`, `APP_ID`, scoped API token) into the
AgentRun's env at create time; the harness resolves application
metadata from Hub at runtime.

Expand Down Expand Up @@ -150,14 +150,15 @@ pattern; and (2) a runtime data service that the harness calls (via a
scoped API token) to fetch application metadata, decrypted git
credentials, and analysis results — the same role Hub plays for
addons today. At AgentRun create time, Hub mints a scoped token and
injects `HUB_BASE_URL`, `HUB_APP_ID`, and the token into the AgentRun's
env/envFrom, then creates the CR. Hub does not resolve application
injects `HUB_BASE_URL`, `APP_ID`, the token (`HUB_TOKEN`), and the
token's database ID (`HUB_TOKEN_ID`) into the AgentRun's env/envFrom,
then creates the CR. Hub does not resolve application
data at create time — the harness resolves at runtime. Hub is
fire-and-forget; it does not launch or manage agent workloads.

**Harness** — The Go binary entrypoint in the agent base image,
analogous to the addon adapter (`shared/addon/adapter`) in Hub. In
Konveyor-managed mode (`HUB_BASE_URL` + `HUB_APP_ID` set), the harness
Konveyor-managed mode (`HUB_BASE_URL` + `APP_ID` set), the harness
acts as a Hub client: resolves the application's git URL, branch,
and decrypted credentials from Hub, clones the repo, and configures
the workspace so the agent cannot push (credentials stay in the
Expand Down
6 changes: 6 additions & 0 deletions changes/unreleased/74-stage-aware-token-revocation.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
kind: enhancement
description: >
Harness revokes its Hub API token on exit for standalone AgentRuns and
the last stage of an AgentWorkflowRun. Intermediate workflow stages
skip revocation so subsequent stages can reuse the shared token. Requires
Hub to inject HUB_TOKEN_ID alongside HUB_TOKEN in the run Secret.
5 changes: 5 additions & 0 deletions changes/unreleased/91-sandbox-tmp-emptydir.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
kind: bugfix
description: >
Mount an EmptyDir volume at /tmp in sandbox pods so tools can write
temp files at runtime. Removed stale chmod from Containerfile since
containerd overlay FS does not preserve image-layer permissions.
5 changes: 5 additions & 0 deletions changes/unreleased/92-skill-commit-hygiene.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
kind: bugfix
description: >
Updated plan, execute, and verify skills to use targeted git add
instead of git add -A, preventing gitignored artifacts like
graphify-out/ from being committed to migration PRs.
9 changes: 5 additions & 4 deletions docs/adr/0006-hub-addon-pattern-for-agent-resources.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Hub's integration with the agent platform follows the established addon
pattern rather than introducing smart resolution endpoints. Hub creates
AgentRun/AgentWorkflowRun CRs with `HUB_BASE_URL`, `HUB_APP_ID`, and a
AgentRun/AgentWorkflowRun CRs with `HUB_BASE_URL`, `APP_ID`, and a
scoped API token injected as env/envFrom — then walks away
(fire-and-forget). The harness resolves application metadata from Hub at
runtime, the same way the addon adapter does for addon tasks today.
Expand Down Expand Up @@ -34,8 +34,9 @@ env var.
When Hub receives a create request for an AgentRun or AgentWorkflowRun:

1. Mints a scoped API token with `AddonScopes`
2. Stores the token in a Kubernetes Secret
3. Adds `HUB_BASE_URL`, `HUB_APP_ID`, and the token Secret to the CR's
2. Stores the token and its database ID in a Kubernetes Secret
(`HUB_TOKEN`, `HUB_TOKEN_ID`)
3. Adds `HUB_BASE_URL`, `APP_ID`, and the token Secret to the CR's
`spec.env` and `spec.envFrom`
4. Creates the CR via `client.Create()`

Expand All @@ -55,7 +56,7 @@ Other resource types are listed unfiltered.
### Harness resolves at runtime

The harness acts as a Hub client (analogous to the addon adapter). In
managed mode (`HUB_BASE_URL` + `HUB_APP_ID` set), it calls Hub's existing
managed mode (`HUB_BASE_URL` + `APP_ID` set), it calls Hub's existing
REST API to resolve the application's git URL, branch, and decrypted
credentials. In standalone mode, it reads from `KONVEYOR_PARAM_*` env
vars and mounted Secrets.
Expand Down
21 changes: 10 additions & 11 deletions hack/harness-test/setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,12 @@ kubectl create secret generic vertex-credentials \
--dry-run=client -o yaml | kubectl apply -f -
echo " vertex-credentials created"

# Hub token (JWT signed with default key "tackle")
HUB_KEY="${HUB_KEY:-tackle}"
EXP=$(( $(date +%s) + 86400 ))
HEADER_B64=$(printf '{"typ":"JWT","alg":"HS512"}' | base64 | tr -d '=' | tr '+/' '-_' | tr -d '\n')
PAYLOAD_B64=$(printf '{"sub":"admin","scope":"*:*","exp":%d}' "$EXP" | base64 | tr -d '=' | tr '+/' '-_' | tr -d '\n')
SIGNATURE=$(printf '%s.%s' "$HEADER_B64" "$PAYLOAD_B64" | openssl dgst -sha512 -hmac "$HUB_KEY" -binary | base64 | tr -d '=' | tr '+/' '-_' | tr -d '\n')
HUB_TOKEN="${HEADER_B64}.${PAYLOAD_B64}.${SIGNATURE}"
echo " hub token generated (expires in 24h)"
# Hub token — must be set in environment.
if [ -z "${HUB_TOKEN:-}" ]; then
echo "ERROR: HUB_TOKEN must be set. Export HUB_TOKEN from your Hub instance."
exit 1
fi
echo " hub token set (HUB_TOKEN_ID=${HUB_TOKEN_ID:-<unset>})"

echo ""
echo "=== Building agent images ==="
Expand Down Expand Up @@ -105,11 +103,12 @@ TIMESTAMP=$(date +%s)
sed -e "s/__GCP_PROJECT_ID__/$GCP_PROJECT_ID/g" \
-e "s/__TIMESTAMP__/$TIMESTAMP/g" \
-e "s|__HUB_TOKEN__|$HUB_TOKEN|g" \
"$SCRIPT_DIR/playbook-resources.yaml" | kubectl apply -f -
echo " AgentPlaybookRun: coolstore-migration-$TIMESTAMP"
-e "s|__HUB_TOKEN_ID__|${HUB_TOKEN_ID:-}|g" \
"$SCRIPT_DIR/workflow-resources.yaml" | kubectl apply -f -
echo " AgentWorkflowRun: coolstore-migration-$TIMESTAMP"

echo ""
echo "=== Done ==="
echo "Watch the run: kubectl get agentplaybookrun coolstore-migration-$TIMESTAMP -w"
echo "Watch the run: kubectl get agentworkflowrun coolstore-migration-$TIMESTAMP -w"
echo "Check pods: kubectl get pods"
echo "View logs: kubectl logs -f coolstore-migration-${TIMESTAMP}-plan -c agent"
2 changes: 2 additions & 0 deletions hack/harness-test/workflow-resources.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,8 @@ spec:
value: "http://host.containers.internal:8080"
- name: HUB_TOKEN
value: "__HUB_TOKEN__"
- name: HUB_TOKEN_ID
value: "__HUB_TOKEN_ID__"
- name: APP_ID
value: "1"
- name: TARGET_BRANCH
Expand Down
2 changes: 2 additions & 0 deletions hack/setup-e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ if [ "${CONTAINER_TOOL}" = "podman" ]; then
export KIND_EXPERIMENTAL_PROVIDER=podman
fi

echo "=== Source: $(git rev-parse --short HEAD) ($(git rev-parse --abbrev-ref HEAD)) ==="
echo ""
echo "=== Building images ==="

echo "Building controller image: ${IMG}"
Expand Down
73 changes: 61 additions & 12 deletions harness/cmd/migration-harness/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"os"
"os/signal"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
Expand Down Expand Up @@ -60,7 +61,30 @@ func runStage(cmd *cobra.Command, args []string) error {
cloneDir = "/workspace/repo"
}

creds, hubClient, err := resolveFromHub(cfg)
// Stage-aware token revocation: register cleanup before Hub resolution
// so the token is revoked even if resolveFromHub fails partway.
hubClient := hub.NewClient(cfg.HubBaseURL, cfg.HubToken)
if tokenID, revoke := shouldRevokeToken(cfg); revoke {
defer func() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: HubTokenID is parsed in shouldRevokeToken for validation, then parsed again here with the error discarded. Since shouldRevokeToken gates entry, this is safe, but you could avoid the double-parse by having shouldRevokeToken return the parsed tokenID (e.g., shouldRevokeToken(cfg) (uint64, bool)).

if err := hubClient.RevokeToken(tokenID); err != nil {
logging.Warn("hub token revocation (id=%d): %v", tokenID, err)
} else {
logging.Ok("hub token revoked (id=%d)", tokenID)
}
}()
} else if cfg.HubTokenID == "" && cfg.HubToken != "" {
logging.Warn("HUB_TOKEN_ID not set — skipping token revocation (token will expire via TTL)")
} else if cfg.HubTokenID != "" {
stage, sErr := strconv.ParseUint(cfg.WorkflowStage, 10, 64)
count, cErr := strconv.ParseUint(cfg.WorkflowStageCount, 10, 64)
if sErr == nil && cErr == nil && stage > 0 && count > 0 {
logging.Info("workflow stage %d/%d — skipping token revocation", stage, count)
} else {
logging.Warn("invalid workflow metadata (stage=%q, count=%q) — skipping token revocation", cfg.WorkflowStage, cfg.WorkflowStageCount)
}
}

creds, err := resolveFromHub(cfg, hubClient)
if err != nil {
return fmt.Errorf("hub resolution: %w", err)
}
Expand Down Expand Up @@ -117,10 +141,8 @@ func runStage(cmd *cobra.Command, args []string) error {

if hasSkills {
// 4b. Write analysis to workspace (if resolved from Hub)
if hubClient != nil {
if err := fetchAndWriteAnalysis(hubClient, cfg.AppID, cloneDir); err != nil {
logging.Warn("analysis fetch: %v", err)
}
if err := fetchAndWriteAnalysis(hubClient, cfg.AppID, cloneDir); err != nil {
logging.Warn("analysis fetch: %v", err)
}

// 4c. Commit harness-managed files so they survive on the branch
Expand Down Expand Up @@ -256,25 +278,23 @@ func discoverSkills() (string, []string, error) {
return combined.String(), matches, nil
}

func resolveFromHub(cfg *config.Config) (*git.Credentials, *hub.Client, error) {
func resolveFromHub(cfg *config.Config, hubClient *hub.Client) (*git.Credentials, error) {
logging.Header("Hub Resolution")

appID, err := hub.ParseAppID(cfg.AppID)
if err != nil {
return nil, nil, fmt.Errorf("invalid APP_ID %q: %w", cfg.AppID, err)
return nil, fmt.Errorf("invalid APP_ID %q: %w", cfg.AppID, err)
}

hubClient := hub.NewClient(cfg.HubBaseURL, cfg.HubToken)

app, err := hubClient.FetchApp(appID)
if err != nil {
return nil, nil, fmt.Errorf("fetch app: %w", err)
return nil, fmt.Errorf("fetch app: %w", err)
}
logging.Ok("app: %s (id=%d), repo: %s", app.Name, app.ID, app.Repository.URL)

identity, err := hubClient.FetchGitCreds(appID)
if err != nil {
return nil, nil, fmt.Errorf("fetch git creds: %w", err)
return nil, fmt.Errorf("fetch git creds: %w", err)
}

creds := &git.Credentials{
Expand All @@ -290,7 +310,36 @@ func resolveFromHub(cfg *config.Config) (*git.Credentials, *hub.Client, error) {
logging.Ok("git identity: %s", identity.Name)
}

return creds, hubClient, nil
return creds, nil
}

// shouldRevokeToken decides whether the harness should revoke the Hub API
// token on exit and returns the parsed token ID. Standalone AgentRuns
// always revoke. Workflow stages revoke only on the last stage so
// subsequent stages can reuse the token.
func shouldRevokeToken(cfg *config.Config) (uint, bool) {
if cfg.HubTokenID == "" {
return 0, false
}
tokenID, err := strconv.ParseUint(cfg.HubTokenID, 10, 64)
if err != nil {
return 0, false
}
if cfg.WorkflowStage == "" && cfg.WorkflowStageCount == "" {
return uint(tokenID), true
}
stage, err := strconv.ParseUint(cfg.WorkflowStage, 10, 64)
if err != nil || stage == 0 {
return 0, false
}
count, err := strconv.ParseUint(cfg.WorkflowStageCount, 10, 64)
if err != nil || count == 0 {
return 0, false
}
if stage == count {
return uint(tokenID), true
}
return 0, false
}

func fetchAndWriteAnalysis(hubClient *hub.Client, appIDStr string, workDir string) error {
Expand Down
126 changes: 126 additions & 0 deletions harness/cmd/migration-harness/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"os"
"path/filepath"
"testing"

"github.com/konveyor/migration-harness/internal/config"
)

func TestDiscoverSkills_NoSkills(t *testing.T) {
Expand Down Expand Up @@ -69,3 +71,127 @@ func TestDiscoverSkills_EmptySkillFile(t *testing.T) {
t.Errorf("expected 1 path (skill is mounted), got: %v", paths)
}
}

func TestShouldRevokeToken(t *testing.T) {
tests := []struct {
name string
hubTokenID string
workflowStage string
workflowStageCount string
want bool
}{
{
name: "no token ID — skip revocation",
want: false,
},
{
name: "standalone run — revoke",
hubTokenID: "1",
want: true,
},
{
name: "last workflow stage — revoke",
hubTokenID: "1",
workflowStage: "3",
workflowStageCount: "3",
want: true,
},
{
name: "intermediate workflow stage — skip",
hubTokenID: "1",
workflowStage: "1",
workflowStageCount: "3",
want: false,
},
{
name: "first of two stages — skip",
hubTokenID: "1",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: This test case is named "second of two stages" but sets workflowStage: "1" with workflowStageCount: "2". Since the controller uses 1-based indexing, this is actually the first of two stages. Consider renaming to "first of two stages — skip".

workflowStage: "1",
workflowStageCount: "2",
want: false,
},
{
name: "single-stage workflow — revoke",
hubTokenID: "1",
workflowStage: "1",
workflowStageCount: "1",
want: true,
},
{
name: "stage set but count missing — skip",
hubTokenID: "1",
workflowStage: "1",
workflowStageCount: "",
want: false,
},
{
name: "count set but stage missing — skip",
hubTokenID: "1",
workflowStage: "",
workflowStageCount: "3",
want: false,
},
{
name: "stage exceeds count — skip",
hubTokenID: "1",
workflowStage: "5",
workflowStageCount: "3",
want: false,
},
{
name: "non-numeric stage — skip",
hubTokenID: "1",
workflowStage: "abc",
workflowStageCount: "3",
want: false,
},
{
name: "non-numeric count — skip",
hubTokenID: "1",
workflowStage: "1",
workflowStageCount: "xyz",
want: false,
},
{
name: "stage zero — skip",
hubTokenID: "1",
workflowStage: "0",
workflowStageCount: "3",
want: false,
},
{
name: "equal non-numeric values — skip",
hubTokenID: "1",
workflowStage: "abc",
workflowStageCount: "abc",
want: false,
},
{
name: "equal zero values — skip",
hubTokenID: "1",
workflowStage: "0",
workflowStageCount: "0",
want: false,
},
{
name: "non-numeric token ID — skip",
hubTokenID: "abc",
workflowStage: "",
workflowStageCount: "",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &config.Config{
HubTokenID: tt.hubTokenID,
WorkflowStage: tt.workflowStage,
WorkflowStageCount: tt.workflowStageCount,
}
_, got := shouldRevokeToken(cfg)
if got != tt.want {
t.Errorf("shouldRevokeToken() = %v, want %v", got, tt.want)
}
})
}
}
Loading
Loading