diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index 777878131a..f094ac4a65 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -194,8 +194,10 @@ jobs: # subtrees plus the shared Bazel scaffold). Java and stargate no longer # ride the root row: discovered Java components and stargate have # their own scoped rows, so src/libraries/ is narrowed to - # src/libraries/go/. - ROOT_GLOBS=(MODULE.bazel .bazelignore .bazelrc .bazelversion BUILD.bazel WORKSPACE WORKSPACE.bazel go.work go.work.bazel rules/ platforms/ tools/ ci/ src/clis/ src/libraries/go/ .github/bazel-root-test-quarantine.txt) + # src/libraries/go/. The two stacks are the data of //:nvcf-cli-stacks, + # which the nvcf-cli tests that pin the CLI to the stacks read, so a + # change to a stack runs those tests. + ROOT_GLOBS=(MODULE.bazel .bazelignore .bazelrc .bazelversion BUILD.bazel WORKSPACE WORKSPACE.bazel go.work go.work.bazel rules/ platforms/ tools/ ci/ src/clis/ src/libraries/go/ deploy/stacks/self-managed/ deploy/stacks/nvcf-compute-plane/ .github/bazel-root-test-quarantine.txt) run_all=false changed="" @@ -255,7 +257,7 @@ jobs: # subtrees a change actually touches (path prefix), plus the # reverse-dependency edges below (shared Java config -> every Java row; # a Java framework -> every Java service) and ROOT_GLOBS -> root row. A - # docs-only or helm-only change touches no subtree and runs nothing. + # docs-only or chart-only change touches no subtree and runs nothing. # The full-matrix fallbacks stay in the case above (workflow_dispatch, # an unreadable or rewritten diff, a new branch) so an untrustworthy # changeset is never under-tested. @@ -416,7 +418,7 @@ jobs: hit=true elif [ "$path" = "." ]; then # Root is glob-gated on PRs and merges alike: it runs only when a - # ROOT_GLOB file changed (a docs- or helm-only change never triggers + # ROOT_GLOB file changed (a docs- or chart-only change never triggers # it). When it does run on a main push it uploads (CACHE_UPLOAD), # warming the cache; the rings do not evict, so a later root PR reads # that warm closure. diff --git a/BUILD.bazel b/BUILD.bazel index 41bcd08e0d..1e89f04447 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -51,6 +51,26 @@ filegroup( visibility = ["//visibility:public"], ) +# The self-managed and compute-plane stacks, also outside gazelle, for the +# nvcf-cli tests that pin the CLI's view of the stacks to their source. +# ROOT_GLOBS in .github/workflows/bazel.yml lists the same directories, so a +# change to them runs those tests. +filegroup( + name = "nvcf-cli-stacks", + srcs = glob( + [ + "deploy/stacks/nvcf-compute-plane/**", + "deploy/stacks/self-managed/**", + ], + exclude = [ + "deploy/stacks/*/out/**", + "deploy/stacks/*/testdata/**", + "deploy/stacks/*/tests/**", + ], + ), + visibility = ["//src/clis/nvcf-cli:__subpackages__"], +) + # Gazelle manages only the native Go subtrees listed in go.work.bazel. # nv-boot-parent and Cloud Tasks are also owned by the root Bazel module, but # their checked-in Java BUILD files are maintained manually and remain outside diff --git a/ai-tooling/user/skills/nvcf-self-managed-cli/SKILL.md b/ai-tooling/user/skills/nvcf-self-managed-cli/SKILL.md index ee92486c20..ffad3a41dc 100644 --- a/ai-tooling/user/skills/nvcf-self-managed-cli/SKILL.md +++ b/ai-tooling/user/skills/nvcf-self-managed-cli/SKILL.md @@ -59,13 +59,21 @@ KUBECONFIG=cp.yaml:gpu1.yaml nvcf-cli self-hosted up \ --compute-plane-context=admin@gpu1 \ --icms-url=https://icms.nvcf.example.com -# Add a new compute plane to an existing control plane (no kubectl access to CP needed; +# Add a new compute plane to an existing control plane, from the profile +# `self-hosted control-plane profile export` wrote to the path it printed, +# copied here as control-plane-profile.yaml (no kubectl access to CP needed; # reaches the control plane via the public ICMS HTTPRoute): -nvcf-cli self-hosted add-compute-plane \ +nvcf-cli self-hosted compute-plane register \ + --control-plane-profile=control-plane-profile.yaml \ --cluster-name=ncp-local-2 \ - --compute-plane-context=admin@gpu2 \ + --kube-context=admin@gpu2 \ --icms-url=https://icms.nvcf.example.com \ - --token=$ADMIN_JWT + --token=$ADMIN_JWT \ + --output=ncp-local-2-register-values.yaml +nvcf-cli self-hosted compute-plane install \ + --values=ncp-local-2-register-values.yaml \ + --kube-context=admin@gpu2 \ + --cluster-name=ncp-local-2 # Tear down (always plan-only first): nvcf-cli self-hosted down --plan-only --cluster-name=ncp-local --json | jq @@ -75,7 +83,7 @@ nvcf-cli self-hosted down --cluster-name=ncp-local nvcf-cli self-hosted uninstall --no-apply --compute-plane --cluster-name=ncp-local | kubectl delete -f - ``` -> **`up` vs `add-compute-plane`.** `up` always installs both planes — use it for the *first* install. `add-compute-plane` is the right subcommand any time the control plane is already running and you want to attach an Nth compute cluster. +> `up` vs `compute-plane register` and `install`. `up` always installs both planes; use it for the first install. To attach an Nth compute cluster to a running control plane, register it with `compute-plane register` and install it with `compute-plane install`. > > **`down` always with `--plan-only` first.** Show the user the `willUninstall.commands[]` array before running for real. @@ -135,12 +143,12 @@ After `init`, the credentials live in `~/.nvcf-cli.state`, so later commands wor | Subcommand | What it does | When to use | |---|---|---| -| `nvcf-cli self-hosted check --pre [--local-only \| --control-plane-context=X \| --compute-plane-context=Y]` | Pre-flight: local-host tools + cluster-side prerequisites | Always run first on a new environment | +| `nvcf-cli self-hosted check --pre [--local-only \| --control-plane-context=X --compute-plane-context=Y]` | Pre-flight: local-host tools, registry credentials, and cluster-side prerequisites for both planes. Pass both context flags or neither | Always run first on a new environment | | `nvcf-cli self-hosted install --control-plane \| kubectl apply -f -` | Render + apply the control plane | When you want manual control over apply (GitOps-friendly) | | `nvcf-cli self-hosted install --compute-plane --cluster-name=X \| kubectl apply -f -` | Register cluster + render compute plane | Same — manual apply path | | `nvcf-cli self-hosted up --cluster-name=X` | One-shot first install: pre-flight → control plane → register → compute plane | Standard install path (both planes from scratch) | | `nvcf-cli self-hosted up --plan-only --cluster-name=X` | Dry-run: emit phase-by-phase plan + ETA without changing state | Agent / CI preview before commit | -| `nvcf-cli self-hosted add-compute-plane --cluster-name=X --compute-plane-context=Y --icms-url=… --token=$JWT` | Add a new compute plane to an *existing* control plane (no CP install) | Adding the 2nd, 3rd, … GPU cluster after the initial `up` | +| `nvcf-cli self-hosted compute-plane register --control-plane-profile=P --cluster-name=X --kube-context=Y --output=V`, then `nvcf-cli self-hosted compute-plane install --values=V --kube-context=Y` | Add a new compute plane to an existing control plane (no CP install) | Adding the 2nd, 3rd, ... GPU cluster after the initial `up` | | `nvcf-cli self-hosted uninstall --control-plane` | Per-plane primitive: `helmfile destroy` on the control plane (refuses if compute planes still registered) | Final teardown after all compute planes are gone, or scripted pipelines | | `nvcf-cli self-hosted uninstall --compute-plane --cluster-name=X` | Per-plane primitive: `helmfile destroy` on a compute plane (no ICMS unregister, no drain) | Just remove the helm releases without the ICMS-side cleanup | | `nvcf-cli self-hosted uninstall --no-apply ` | Render delete YAML via `helm get manifest` | GitOps; `\| kubectl delete -f -` or commit + Argo applies | @@ -195,7 +203,7 @@ Session stickiness uses `x-multi-turn-session-id` for chat completions and Respo For step-by-step playbooks, load the prompt that matches the user's intent: - **Install from scratch.** [prompts/install-from-scratch.md](prompts/install-from-scratch.md) — k3d cluster → preflight → up → deploy a smoke function. -- **Add a new compute plane.** [prompts/add-compute-plane.md](prompts/add-compute-plane.md) — split-cluster `up` against an existing control plane. +- **Add a new compute plane.** [prompts/add-compute-plane.md](prompts/add-compute-plane.md): register and install a compute plane against an existing control plane. - **Deploy and invoke a function.** [prompts/deploy-and-invoke.md](prompts/deploy-and-invoke.md) — create → deploy → API key → invoke, including the LLM create/invoke variant. - **Diagnose a failed install.** [prompts/diagnose-failed-install.md](prompts/diagnose-failed-install.md) — `status --json` → identify failed component → kubectl describe → remediation. - **Rotate JWKS.** [prompts/rotate-cluster-jwks.md](prompts/rotate-cluster-jwks.md) — when PSAT auth starts failing. @@ -250,16 +258,21 @@ For step-by-step playbooks, load the prompt that matches the user's intent: `nvcf-cli` subcommands that long-run (`up`, `status`, `check`) accept four output modes: -- **`--json`** — JSONL events on stderr; one event per line; stable schema (`schemaVersion: 2`). **Use this when running under an agent.** Parse line-by-line with `jq -c .` or `json.loads()`. -- **`--plain`** — Plain timestamped lines, RFC3339 UTC, `[NN/8]` phase prefix; grep-friendly. Default in non-TTY. -- **`--accessible`** — Plain output without spinners, with verbose state markers (`[completed]`, `[running]`, `[pending]`, `[failed]`). For screen readers and constrained terminals. -- **(no flag)** — Bubbletea TTY dashboard. Default in TTY ≥100×30. Don't use under an agent (cursor-up sequences are noisy). +- `--json`: JSONL events on stderr, one event per line, stable schema (`schemaVersion: 1`). Use this when running under an agent. Redirect stderr to the parser (`2>&1 >/dev/null`), keep only lines that start with `{`, and parse line-by-line with `jq -c .` or `json.loads()`. +- `--plain`: plain timestamped lines, RFC3339 UTC, `[NN/8]` phase prefix; grep-friendly. Default in non-TTY. +- `--accessible`: plain output without spinners, with verbose state markers (`[completed]`, `[running]`, `[pending]`, `[failed]`). For screen readers and constrained terminals. +- (no flag): Bubbletea TTY dashboard. Default in a TTY of at least 100x30. Don't use under an agent (cursor-up sequences are noisy). Auto-detect picks the right mode for whatever stdout/stderr is. The CLI also honors `NO_COLOR` (any value → forces plain), `TERM=dumb` (forces plain), and `CI=truthy` (forces plain even on a fake TTY). When the terminal is smaller than 100×30, the bubbletea renderer falls back to a compact layout. Explicit `--json` is the right call for agent piping. ## On failure -`nvcf-cli` failures emit structured `phase_failed` events in JSON: +`check` never emits `phase_failed`. It ends with one `final` event: gate on +`success`, and treat a missing `final` event as a failure. See +[examples/ci-pipelines.md](examples/ci-pipelines.md) and +[reference/exit-codes.md](reference/exit-codes.md). + +Other `nvcf-cli` failures emit structured `phase_failed` events in JSON: ```json {"event":"phase_failed","phaseNum":4,"phase":"apply-cp", diff --git a/ai-tooling/user/skills/nvcf-self-managed-cli/examples/ci-pipelines.md b/ai-tooling/user/skills/nvcf-self-managed-cli/examples/ci-pipelines.md index e854a34373..e8d082aa8f 100644 --- a/ai-tooling/user/skills/nvcf-self-managed-cli/examples/ci-pipelines.md +++ b/ai-tooling/user/skills/nvcf-self-managed-cli/examples/ci-pipelines.md @@ -12,20 +12,25 @@ deploy_nvcf: variables: KUBECONFIG: $KUBECONFIG_FILE script: - - nvcf-cli self-hosted check --pre --json | jq -e '.event != "phase_failed"' || exit 2 + - nvcf-cli self-hosted check --pre --json 2>&1 >/dev/null | grep '^{' | jq -se 'any(.[]; .event == "final" and .success)' || exit 2 - nvcf-cli self-hosted up --cluster-name=$CLUSTER_NAME --token=$NVCF_ADMIN_JWT --non-interactive --json - - nvcf-cli self-hosted status --json | jq -e '.verdict == "healthy"' + - nvcf-cli self-hosted status --json 2>&1 >/dev/null | grep '^{' | jq -se 'any(.[]; .verdict == "healthy")' ``` Notes: - `--non-interactive --token=$JWT` is required in CI; never use interactive `init`. - Always `--json` for machine-parsing. +- `--json` writes JSONL to stderr, not stdout, so redirect with `2>&1 >/dev/null` before a parser. +- `check` reports its verdict in one `final` event, with `success: false` when any check failed at error severity or the run timed out (exit `5`, `verdict: "timeout"`). Gate on that event: `check` never emits `phase_failed`, so a condition on it always passes. Requiring the `final` event also fails the step when the command dies before emitting it. +- Slurp with `jq -s` before testing a condition. Without it `jq -e` takes its exit status from the last event alone. +- stderr also carries plain-text notices, so filter to JSON lines. Do not add `--show-logs` here: it appends a non-JSON transcript to the same stream. +- The plain-text notice that says how to remove objects a validator run left in the cluster is filtered out with the rest. Read the same commands from the `final` event instead, for example `jq -r '.[] | select(.event == "final") | .cleanup[]?'` on the slurped stream. - Final status check gates downstream stages on `verdict == "healthy"`. ## GitOps (Argo / Flux) pattern -CI doesn't `kubectl apply` — instead, render manifests, commit them, let the controller apply. +CI doesn't `kubectl apply`. Instead, render manifests, commit them, let the controller apply. ```yaml render: @@ -62,16 +67,20 @@ deploy_compute_planes: - CLUSTER: ncp-eu-1 CONTEXT: admin@gpu-eu-1 script: - - nvcf-cli self-hosted up + - nvcf-cli self-hosted compute-plane register + --control-plane-profile=control-plane-profile.yaml --cluster-name=$CLUSTER - --compute-plane-context=$CONTEXT + --kube-context=$CONTEXT --icms-url=$ICMS_PUBLIC_URL --token=$NVCF_ADMIN_JWT - --non-interactive - --json + --output=$CLUSTER-register-values.yaml + - nvcf-cli self-hosted compute-plane install + --values=$CLUSTER-register-values.yaml + --kube-context=$CONTEXT + --cluster-name=$CLUSTER ``` -Each parallel job registers + installs one compute plane against the shared control plane. Failures are isolated. +Each parallel job registers + installs one compute plane against the shared control plane. Failures are isolated. The control-plane profile comes from `nvcf-cli self-hosted control-plane profile export`, run once against the control plane. It writes the profile to the stack's `out/` directory and prints that path; hand the file to each job as `control-plane-profile.yaml`, for example as an artifact. ## Plan-only preview in PRs diff --git a/ai-tooling/user/skills/nvcf-self-managed-cli/examples/multi-cluster.md b/ai-tooling/user/skills/nvcf-self-managed-cli/examples/multi-cluster.md index a3a246f19b..2e86f39a2e 100644 --- a/ai-tooling/user/skills/nvcf-self-managed-cli/examples/multi-cluster.md +++ b/ai-tooling/user/skills/nvcf-self-managed-cli/examples/multi-cluster.md @@ -11,19 +11,30 @@ The canonical multi-cluster topology. Each compute plane is registered separatel KUBECONFIG=cp.yaml nvcf-cli self-hosted install --control-plane | kubectl apply -f - nvcf-cli self-hosted check --control-plane --wait 5m -# 2. Register + install each compute plane: +# 2. Export the control-plane profile every compute plane registers from. +# It is written to the stack's out/ directory, and the command prints +# that path. Copy the file here: +KUBECONFIG=cp.yaml nvcf-cli self-hosted control-plane profile export --cluster-name=ncp-cp +cp control-plane-profile.yaml + +# 3. Register + install each compute plane. Both commands name the GPU +# cluster's context with --kube-context: for CTX in admin@gpu-east-1 admin@gpu-west-1 admin@gpu-eu-1; do NAME=$(echo "$CTX" | cut -d@ -f2) - nvcf-cli self-hosted up \ + nvcf-cli self-hosted compute-plane register \ + --control-plane-profile=control-plane-profile.yaml \ --cluster-name=$NAME \ - --compute-plane-context=$CTX \ + --kube-context=$CTX \ --icms-url=https://icms.nvcf.example.com \ --token=$NVCF_ADMIN_JWT \ - --non-interactive \ - --json + --output=$NAME-register-values.yaml + nvcf-cli self-hosted compute-plane install \ + --values=$NAME-register-values.yaml \ + --kube-context=$CTX \ + --cluster-name=$NAME done -# 3. Verify all compute planes registered: +# 4. Verify all compute planes registered: nvcf-cli cluster list-registered --nca-id=$NCA_ID --icms-url=$ICMS --json \ | jq '.clusters[] | {name: .clusterName, status, nvcaVersion}' ``` @@ -37,24 +48,45 @@ The operator has kubectl access to ONE compute plane only — the control plane # service is reachable via api.nvcf.example.com — no kubectl access needed): nvcf-cli init --api-url=https://api.nvcf.example.com -# Run pre-flight scoped to the compute plane only: -nvcf-cli self-hosted check --pre \ - --compute-plane-context=admin@gpu1 \ +# Run pre-flight scoped to the compute plane only. check takes both context +# flags or neither and has no --kubeconfig flag, so give it, through +# KUBECONFIG for this command only, a kubeconfig holding just the compute +# cluster's context. The current context of your kubeconfig stays as it was. +# --compute-plane checks the compute plane as installed, so a docker login +# with no access to the org of the compute-plane charts passes where +# NGC_API_KEY reaches it: treat a registry-credentials row that says the +# docker login has no access as blocking, since compute-plane install pulls +# its charts with that login. A docker login nvcr.io rejects fails either way. +# With a control-plane context, check --pre --control-plane checks the new +# compute plane as not yet installed instead; see prompts/add-compute-plane.md. +KC=$(mktemp) +kubectl config view --minify --flatten --context=admin@gpu1 > "$KC" +KUBECONFIG="$KC" nvcf-cli self-hosted check --compute-plane \ --icms-url=https://icms.nvcf.example.com \ --json -# Bring up just the compute plane: -nvcf-cli self-hosted up \ +# Register and install just the compute plane, from the profile the +# control-plane operator exported with `self-hosted control-plane profile export`, +# saved here as control-plane-profile.yaml: +nvcf-cli self-hosted compute-plane register \ + --control-plane-profile=control-plane-profile.yaml \ --cluster-name=my-compute-1 \ - --compute-plane-context=admin@gpu1 \ + --kube-context=admin@gpu1 \ --icms-url=https://icms.nvcf.example.com \ --token=$ADMIN_JWT \ - --non-interactive - -# Status (compute-only — operator can't see control-plane component health): -nvcf-cli self-hosted status \ - --compute-plane-context=admin@gpu1 \ + --output=my-compute-1-register-values.yaml +nvcf-cli self-hosted compute-plane install \ + --values=my-compute-1-register-values.yaml \ + --kube-context=admin@gpu1 \ + --cluster-name=my-compute-1 + +# Status of admin@gpu1, from the same kubeconfig (compute-only: the operator +# can't see control-plane component health). Then remove the file, which +# holds the context's credentials: +KUBECONFIG="$KC" nvcf-cli self-hosted status \ + --cluster-name=my-compute-1 \ --icms-url=https://icms.nvcf.example.com +rm -f "$KC" ``` ## Functions targeting specific compute planes @@ -84,13 +116,17 @@ The `clusters` array (using cluster names, not IDs) limits scheduling to those c nvcf-cli cluster list-registered --nca-id=$NCA_ID --icms-url=$ICMS --json \ | jq -r '.clusters[].clusterName' > clusters.txt -# Status snapshot per compute plane (assuming each context name matches): +# Status snapshot per compute plane (assuming each context name matches). +# Each run reads a temporary kubeconfig holding only that cluster's context, +# so the loop leaves the current context of your kubeconfig alone: while read NAME; do echo "=== $NAME ===" - nvcf-cli self-hosted status \ - --cluster-name=$NAME \ - --compute-plane-context=admin@$NAME \ - --json | jq -c '{cluster:.cluster, verdict:.verdict, reconcile:.reconcileAgeSec}' + KC=$(mktemp) + kubectl config view --minify --flatten --context="admin@$NAME" > "$KC" && + KUBECONFIG="$KC" nvcf-cli self-hosted status \ + --cluster-name=$NAME \ + --json | jq -c '{cluster:.cluster, verdict:.verdict, reconcile:.reconcileAgeSec}' + rm -f "$KC" done < clusters.txt ``` diff --git a/ai-tooling/user/skills/nvcf-self-managed-cli/prompts/add-compute-plane.md b/ai-tooling/user/skills/nvcf-self-managed-cli/prompts/add-compute-plane.md index 54b21985bf..20cbf015cd 100644 --- a/ai-tooling/user/skills/nvcf-self-managed-cli/prompts/add-compute-plane.md +++ b/ai-tooling/user/skills/nvcf-self-managed-cli/prompts/add-compute-plane.md @@ -4,51 +4,110 @@ User has a working NVCF control plane (running somewhere) and wants to register ## Prerequisites the user must have -- **kubectl context for the new compute plane** in their `KUBECONFIG`. +- **kubectl context for the new compute plane** in their `KUBECONFIG`, and + ideally one for the control-plane cluster too, for the pre-flight in + step 2. - **Public ICMS URL** of the existing control plane (e.g. `https://icms.nvcf.example.com`). - **Admin JWT** for the control plane's account, OR ability to mint one via `nvcf-cli init` against the control plane's public api endpoint. (Admin tokens come from the API Keys service via the public api gateway — kubectl access to the control plane is NOT required to obtain one.) +- The control plane's profile file, which `nvcf-cli self-hosted control-plane profile export` writes on a machine that can reach the control-plane cluster. It carries the control plane's endpoints and trust for registration. The command writes it to the stack's `out/` directory and prints that path; the steps below expect it copied to `control-plane-profile.yaml` in the working directory. - A unique `--cluster-name` that doesn't collide with already-registered clusters. Use `nvcf-cli cluster list-registered --nca-id=$NCA_ID --icms-url=$ICMS` to check. ## Steps -1. **Ask for inputs:** +1. Ask for inputs: - `--cluster-name=` — must be unique within the control plane's NCA scope - - `--compute-plane-context=` — the new GPU cluster's kubectl context + - `--kube-context=`: the new GPU cluster's kubectl context - `--icms-url=` — control plane's public ICMS URL - GPU type? (default `H100`, but ask) + - Optional, for the pre-flight in step 2: the control-plane cluster's + kubectl context (`$CP_CTX`) and the `--env` the control plane was + installed with (`$CP_ENV`). Without either, use the `--compute-plane` + fallback in step 2. -2. **Pre-flight just the compute plane:** +2. Pre-flight the new compute plane as not yet installed. With a kubectl + context for the control-plane cluster as well, `check --pre + --control-plane` checks the control plane as installed and the new + compute plane as not yet installed: ```sh - nvcf-cli self-hosted check --pre \ - --compute-plane-context=$CTX \ + nvcf-cli self-hosted check --pre --control-plane \ + --control-plane-context="$CP_CTX" --compute-plane-context="$CTX" \ + --env="$CP_ENV" \ + --json 2>&1 >/dev/null | grep '^{' > check.jsonl + jq -se 'any(.[]; .event == "final" and .success)' check.jsonl + ``` + + `$CP_ENV` is the `--env` the control plane was installed with. This checks + the control-plane cluster, and the new cluster: leftover NVCF namespaces, + node inotify limits, and the compute-plane cluster-validator (GPU + resources, GPU Operator, SMB CSI driver) when `cluster_validator_image` is + set. It grades this machine's registry credentials as `compute-plane + install` uses them: `helm` on this machine pulls the compute-plane charts + with the docker login, so a docker login `nvcr.io` rejects, or one with no + access to the org the charts come from, fails the gate even when + `NGC_API_KEY` works. A missing `helm`, `helmfile` or `kubectl` fails it + too. SIS reachability is not probed before install. + + Without a context for the control-plane cluster, or without the `--env` + it was installed with, check the new cluster alone with + `--compute-plane`. `check` takes both context flags or neither + and has no `--kubeconfig` flag, so give it, through `KUBECONFIG` for this + command only, a temporary kubeconfig holding just the new GPU cluster's + context. The current context of the user's kubeconfig stays as it was: + + ```sh + KC=$(mktemp) + kubectl config view --minify --flatten --context="$CTX" > "$KC" + KUBECONFIG="$KC" nvcf-cli self-hosted check --compute-plane \ --icms-url=$ICMS \ - --json | jq -c . + --json 2>&1 >/dev/null | grep '^{' > check.jsonl + rm -f "$KC" + jq -se 'any(.[]; .event == "final" and .success)' check.jsonl ``` - This validates the GPU operator is installed, GPU node labels are present, and ICMS is reachable via HTTP. Address any failures before proceeding. + That runs the same checks on the new cluster, and SIS reachability at + `--icms-url`, but checks the compute plane as installed: a missing tool + only warns. A docker login `nvcr.io` rejects still fails the gate, but one + with no access to the org the compute-plane charts come from passes where + `NGC_API_KEY` reaches it. Treat a `registry-credentials` row that says the + docker login has no access as blocking, and log in with a credential that + has access before the install. + + If the gate fails, show the user the `check_completed` events with + `passed: false` and `severity: "error"`, and address them before + proceeding. -3. **Run `add-compute-plane`:** +3. Register the cluster, then install its compute plane from the values + file registration writes. Both commands name the new cluster's context + with `--kube-context`: ```sh - nvcf-cli self-hosted add-compute-plane \ + nvcf-cli self-hosted compute-plane register \ + --control-plane-profile=control-plane-profile.yaml \ --cluster-name=$NAME \ - --compute-plane-context=$CTX \ + --kube-context=$CTX \ --icms-url=$ICMS \ --token=$JWT \ - --non-interactive \ - --json + --output=$NAME-register-values.yaml + nvcf-cli self-hosted compute-plane install \ + --values=$NAME-register-values.yaml \ + --kube-context=$CTX \ + --cluster-name=$NAME ``` - This is a separate subcommand from `up`. It runs only the compute-plane-relevant phases: 1 (compute-plane preflight), 5 (register), 6 (compute-plane apply), 8 (final health on the new compute plane). It does NOT touch the control plane and does NOT accept `--control-plane-context` — the control plane is reached over HTTPS at `--icms-url`. `--token` is required because there's no kubectl path to mint one from. + `compute-plane register` records the cluster's OIDC issuer and JWKS with + ICMS at `--icms-url` and writes the NVCA operator values. `compute-plane + install` installs the compute-plane stack with them. Neither touches the + control-plane cluster: ICMS is reached over HTTPS. `--token` passes the + admin JWT; without it the token `nvcf-cli init` stored is used. -4. **Verify.** `nvcf-cli self-hosted status --cluster-name=$NAME --json | jq` — expect `verdict: "healthy"`. The Registered Compute Planes panel from ICMS should now list the new cluster. +4. Verify. `nvcf-cli self-hosted status --cluster-name=$NAME --json | jq`: expect `verdict: "healthy"`. The Registered Compute Planes panel from ICMS should now list the new cluster. -5. **Smoke.** [deploy-and-invoke.md](deploy-and-invoke.md) — deploy a small function to the new compute plane to confirm scheduling works. +5. Smoke. [deploy-and-invoke.md](deploy-and-invoke.md): deploy a small function to the new compute plane to confirm scheduling works. ## What if a cluster with the same name already exists? -`add-compute-plane`'s register phase uses `--ignore-existing` semantics — it'll match the existing ICMS row and reuse its clusterId. Two scenarios: +`compute-plane register` reuses an existing registration: it matches the ICMS row with the same name, reuses its clusterId and replaces its JWKS. Two scenarios: - **Re-registering the same cluster** (re-running on the same compute plane that was previously registered): expected, no-op semantics. - **Different physical cluster but same name**: the second attempt will reuse the ICMS row, but the new compute plane's JWKS will be silently *replaced* — the old compute plane's NVCA agent will start failing PSAT auth. **Confirm with the user** that they meant to overwrite. diff --git a/ai-tooling/user/skills/nvcf-self-managed-cli/prompts/install-from-scratch.md b/ai-tooling/user/skills/nvcf-self-managed-cli/prompts/install-from-scratch.md index d8b3151efd..3d3bfd730a 100644 --- a/ai-tooling/user/skills/nvcf-self-managed-cli/prompts/install-from-scratch.md +++ b/ai-tooling/user/skills/nvcf-self-managed-cli/prompts/install-from-scratch.md @@ -196,26 +196,29 @@ User wants to bring up self-hosted NVCF on a fresh Kubernetes cluster (or k3d fo EOF ``` -3. Run pre-flight. Choose the flavor matching the topology: +3. Run pre-flight. Choose the flavor matching the topology. `--json` writes + JSONL to stderr, so redirect it to the parser and keep only JSON lines: ```sh # Single-cluster - nvcf-cli self-hosted check --pre --json | jq -c . + nvcf-cli self-hosted check --pre --json 2>&1 >/dev/null | grep '^{' > check.jsonl - # Split + # Split: pass both context flags nvcf-cli self-hosted check --pre \ --control-plane-context=admin@cp \ --compute-plane-context=admin@gpu1 \ - --json | jq -c . + --json 2>&1 >/dev/null | grep '^{' > check.jsonl - # Compute-only (operator with kubectl on compute plane only) - nvcf-cli self-hosted check --pre \ - --compute-plane-context=admin@gpu1 \ - --icms-url=https://icms.nvcf.example.com \ - --json | jq -c . + # Gate on the final event. A missing final event also fails the gate. + jq -se 'any(.[]; .event == "final" and .success)' check.jsonl ``` - Parse the JSONL stream. If any check fails (`"passed":false`) with `severity: error`, surface `message` + `hintURL` to the user and stop. Don't proceed to install with broken prereqs. + If the gate fails, show the user each `check_completed` event with + `passed: false` and `severity: "error"` (its `message` and `hintURL`), and + stop. Don't proceed to install with broken prereqs. Rows at + `severity: "warning"` do not fail the gate; mention them. For an operator + with kubectl access to the compute plane only, use the compute-only form in + [add-compute-plane.md](add-compute-plane.md) instead. 4. Mint an admin token if needed. `nvcf-cli init` is idempotent. Call it if the user does not have a session yet. Init talks to API Keys through the public diff --git a/ai-tooling/user/skills/nvcf-self-managed-cli/prompts/rotate-cluster-jwks.md b/ai-tooling/user/skills/nvcf-self-managed-cli/prompts/rotate-cluster-jwks.md index 3eda25f2f6..b2fbd9a684 100644 --- a/ai-tooling/user/skills/nvcf-self-managed-cli/prompts/rotate-cluster-jwks.md +++ b/ai-tooling/user/skills/nvcf-self-managed-cli/prompts/rotate-cluster-jwks.md @@ -12,13 +12,16 @@ User says NVCA stopped authenticating, or PSAT auth is 401-ing against ICMS. The 1. Confirm the cluster ID. Run `nvcf-cli cluster list-registered --nca-id=$NCA_ID --icms-url=$ICMS`. Identify the row matching the user's compute plane. -2. **Rotate.** From a context that can reach the compute plane's K8s API (because rotation re-fetches the K8s API's `/openid/v1/jwks`): +2. **Rotate.** Use a context that can reach the compute plane's K8s API (because rotation re-fetches the K8s API's `/openid/v1/jwks`). Write that context alone to a temporary kubeconfig and pass it with `--kubeconfig`, so the current context of your kubeconfig stays as it was: ```sh - nvcf-cli cluster rotate --cluster-id= --compute-plane-context= + KC=$(mktemp) + kubectl config view --minify --flatten --context= > "$KC" && + nvcf-cli cluster rotate --cluster-id= --kubeconfig="$KC" + rm -f "$KC" ``` - This re-fetches JWKS from the compute plane's K8s API and PUTs it to ICMS. The cluster ID stays the same. + `cluster rotate` re-fetches JWKS from the compute plane's K8s API and PUTs it to ICMS. The cluster ID stays the same. The temporary file holds the context's credentials: `mktemp` makes it readable only by you, and `rm` removes it. 3. **Verify.** `nvcf-cli cluster get --cluster-id=` — confirm `updatedAt` advanced. Then `kubectl logs -n nvca-system -l app.kubernetes.io/name=nvca --tail=20` — register attempts should succeed (200, not 401). diff --git a/ai-tooling/user/skills/nvcf-self-managed-cli/reference/commands.md b/ai-tooling/user/skills/nvcf-self-managed-cli/reference/commands.md index 3a17bba020..7d14765d51 100644 --- a/ai-tooling/user/skills/nvcf-self-managed-cli/reference/commands.md +++ b/ai-tooling/user/skills/nvcf-self-managed-cli/reference/commands.md @@ -6,15 +6,16 @@ Full subcommand list. Always pair with [flags.md](flags.md) for global flags and | Command | Purpose | Output | Notes | |---|---|---|---| -| `self-hosted check --pre [flags]` | Pre-flight validation (no admin creds) | streaming events | Per-role with `--control-plane-context` / `--compute-plane-context`; `--local-only` skips kubectl | -| `self-hosted check --control-plane [--wait DUR]` | Every CP release healthy | streaming events | Polls until pass or `--wait` elapses | -| `self-hosted check --compute-plane --cluster-name=X [--wait DUR]` | Per-cluster worker state | streaming events | NVCA + operator + JWKS-fingerprint match | -| `self-hosted check --all [--wait DUR]` | Fan-out over all of the above | streaming events | | +| `self-hosted check --pre [flags]` | Pre-flight before install (no admin creds): local tools, registry credentials, and both planes' cluster checks | streaming events on stderr | Split clusters need both `--control-plane-context` and `--compute-plane-context`; `--local-only` makes no cluster or registry contact | +| `self-hosted check --control-plane [--wait DUR]` | Installed control plane: leftover namespaces and the control-plane cluster-validator | streaming events on stderr | `--wait` polls until pass or DUR elapses | +| `self-hosted check --compute-plane [--wait DUR]` | Installed compute plane: leftover namespaces, node inotify limits, SIS reachability, and the compute-plane cluster-validator | streaming events on stderr | SIS is probed only at a configured `--icms-url`. Without context flags it checks the current context | +| `self-hosted check --all [--wait DUR]` | Both of the above | streaming events on stderr | Gate on the `final` event; see [exit-codes.md](exit-codes.md) | | `self-hosted install --control-plane` | Render control-plane manifests | YAML on stdout | Pipe to `kubectl apply -f -` | | `self-hosted install --compute-plane --cluster-name=X` | Register cluster + render compute manifests | YAML on stdout | One invocation per GPU cluster | -| `self-hosted up --cluster-name=X` | One-shot first install (both planes) | (empty) stdout, progress on stderr | Always installs both planes; for compute-only use `add-compute-plane` | +| `self-hosted up --cluster-name=X` | One-shot first install (both planes) | (empty) stdout, progress on stderr | Always installs both planes; for compute-only use `compute-plane register` and `compute-plane install` | | `self-hosted up --plan-only --cluster-name=X` | Dry-run preview | JSONL on stderr | Agent-friendly, no cluster changes | -| `self-hosted add-compute-plane --cluster-name=X --compute-plane-context=Y --icms-url=URL --token=$JWT` | Add a compute plane to an existing control plane | progress on stderr | For 2nd, 3rd, … GPU cluster after the first install. CP not touched. | +| `self-hosted compute-plane register --control-plane-profile=P --cluster-name=X --kube-context=Y [--output=V]` | Register a compute cluster with an existing control plane and write its NVCA operator values | summary on stdout | For the 2nd, 3rd, ... GPU cluster after the first install. CP not touched | +| `self-hosted compute-plane install --values=V --kube-context=Y` | Install a compute plane from the values `compute-plane register` wrote | progress on stderr | Run after `compute-plane register`. Installs V whatever its name, never another values file beside it | | `self-hosted status [--cluster-name=X] [--watch]` | Snapshot cluster health | TTY/plain/JSONL | `--watch` for live re-render | | `self-hosted uninstall --compute-plane --cluster-name=X` | Per-plane primitive: `helmfile destroy` on one compute plane (no ICMS unregister, no drain) | progress | Just the helm releases on that compute plane | | `self-hosted uninstall --control-plane [--force-with-registered-clusters]` | Per-plane primitive: `helmfile destroy` on the control plane | progress | Refuses if compute planes still registered unless `--force-with-registered-clusters` | diff --git a/ai-tooling/user/skills/nvcf-self-managed-cli/reference/exit-codes.md b/ai-tooling/user/skills/nvcf-self-managed-cli/reference/exit-codes.md index fe27d62671..c68691fb6f 100644 --- a/ai-tooling/user/skills/nvcf-self-managed-cli/reference/exit-codes.md +++ b/ai-tooling/user/skills/nvcf-self-managed-cli/reference/exit-codes.md @@ -6,10 +6,10 @@ Stable across subcommands. Use these to drive agent retry / surfacing logic. |---|---|---| | `0` | Success | All checks passed; install completed; deployment ACTIVE | | `1` | Generic error | Helm render failed; network unreachable; file not found; YAML parse error | -| `2` | Pre-flight check failed | Gateway API CRDs missing; kubectl not on PATH; default StorageClass absent | +| `2` | Pre-flight check failed | A `check` result at error severity: Gateway API CRDs missing; kubectl not on PATH with `--pre`; default StorageClass absent; a cluster the CLI cannot reach (missing kubeconfig or context, or a first API call that fails for any reason: refused or dropped connection, rejected credentials, a failing credential plugin, a server error); a cluster-validator that could not run (RBAC denied, image pull failure, its own timeout, no tag found for an untagged image); a `stale-namespaces` row naming a namespace `stuck Terminating`. Such a result is exit `2` even when the time budget cut another check short. Warning-severity results exit `0`, including a namespace still deleting, a Helm release left mid-operation, a namespace with no Helm release, and a `cluster-validator` row saying no `cluster_validator_image` is set | | `3` | Admin auth failed | No token + `--non-interactive` set; ICMS rejected JWT; init endpoint unreachable | -| `5` | Manifest apply or `--wait` timed out | Helm install timeout; check polled but didn't pass before duration | -| `130` | Cancelled by SIGINT/SIGTERM | User Ctrl-C; CI budget exceeded; pod evicted | +| `5` | Manifest apply or `--wait` timed out | Helm install timeout; check polled but did not pass before DURATION, including a rollout still in progress; the check's time budget stopped a check that can fail the run before it finished (its row says not run or cut short) and no other check failed. A cut-short row keeps the worst severity its check can report, so a check that can only warn, such as a registry that is not critical, stays a warning. A check that already had its result keeps it, so a validator's own failure is exit `2` | +| `130` | Cancelled by SIGINT, SIGTERM or SIGHUP, or by a quit key in the `check --wait` dashboard | User Ctrl-C; `q`, `Esc` or `Ctrl-C` in the dashboard; CI budget exceeded; pod evicted; terminal or SSH session closed. A signal `check` was started with ignored stays ignored, so a `check` run under `nohup` outlives the session | ## How an agent should react @@ -24,4 +24,4 @@ Stable across subcommands. Use these to drive agent retry / surfacing logic. ## Where to find more detail -Every non-zero exit emits a structured `phase_failed` JSON event with `errCategory`, `errMessage`, `remediation` (array), `retryClass` (enum: `none|immediate|backoff|after_remediation|unknown`), `retryAfterSec` (int, optional), and `raw` (subprocess + HTTP + Kubernetes signal). Always consume these in `--json` mode rather than parsing English from stderr. +`check` reports its outcome in one `final` event (`success`, `verdict`, `totalChecks`, `passedCount`, `failedCount`, `warningCount`) and never emits `phase_failed`; see `examples/ci-pipelines.md`. A usage error, such as a malformed `--wait`, exits `1` before any event. Its exit `5` has `success: false` and `verdict: "timeout"`. A `check_completed` event for a warning that `--wait` polls on carries `transient: true`, and one for a check the time budget stopped carries `cutShort: true`. A cancellation (exit `130`) emits `final` with `cancelled: true` instead of `phase_failed`; `up` emits `phase_cancelled` before it. When a cluster-validator run left objects in the cluster (`--no-cleanup`, a pod that would not stop, or a cleanup that failed), `check` lists the commands that remove them in the `final` event's `cleanup` array and in that row's `cleanup` field, on every exit code, and prints them again as its last stderr lines. On an interrupt it prints them at once, before its own cleanup, which a second Ctrl-C cuts short. Every other non-zero exit emits a structured `phase_failed` JSON event with `errCategory`, `errMessage`, `remediation` (array), `retryClass` (enum: `none|immediate|backoff|after_remediation|unknown`), `retryAfterSec` (int, optional), and `raw` (subprocess + HTTP + Kubernetes signal). Always consume these in `--json` mode rather than parsing English from stderr. diff --git a/ai-tooling/user/skills/nvcf-self-managed-cli/reference/flags.md b/ai-tooling/user/skills/nvcf-self-managed-cli/reference/flags.md index e717223760..50addcf6a0 100644 --- a/ai-tooling/user/skills/nvcf-self-managed-cli/reference/flags.md +++ b/ai-tooling/user/skills/nvcf-self-managed-cli/reference/flags.md @@ -12,19 +12,155 @@ | Flag | Purpose | Default | |---|---|---| -| `--control-plane-stack=…` | Control-plane bundle source: local path, git URL, or `oci://` URL | embedded OCI URL pinned by CLI version | -| `--compute-plane-stack=…` | Compute-plane bundle source: local path, git URL, or `oci://` URL | embedded OCI URL pinned by CLI version | -| `--env=local\|prd\|…` | Helmfile environment name | `local` for dev builds, `prd` for releases | +| `--control-plane-stack=...` | Control-plane bundle source: local path, git URL, or `oci://` URL | embedded OCI URL pinned by CLI version | +| `--compute-plane-stack=...` | Compute-plane bundle source: local path, git URL, or `oci://` URL | embedded OCI URL pinned by CLI version | +| `--env=local\|prd\|...` | Helmfile environment name. `check` reads the stack's `environments/.yaml` only for an environment named with `--env` or `HELMFILE_ENV`, or for a plane `--pre` checks before its install, so pass the `--env` the install used; see [Settings read from the stack](#settings-read-from-the-stack) | `local` | | `--non-interactive` | Disable all stdin prompts | `false` | | `--token=$JWT` | Admin JWT, overrides stored session | - | | `--no-apply` | `install` only - emit YAML, do not kubectl apply | `false` | | `--output=text\|json` | Legacy alias for `--json` (deprecated, removed in next major) | `text` | | `--plain` | Force plain streaming output | auto-detect | -| `--wait DURATION` | `check` only; block until pass | - | +| `--wait DURATION` | `check` only; run the checks again, 5s apart, until they pass or DURATION runs out (exit `5`, `final` with `success: false`). Each run includes the cluster-validator Job, which can take up to 5m, so allow for it or pass `--skip-cluster-validation`. A warning that is expected to clear, such as a rollout in progress, keeps it polling, and its `check_completed` event carries `transient: true`. A check the time budget stopped keeps it polling too, and its event carries `cutShort: true`. A check that can only warn and is stopped on two polls in a row ends the wait as a single run would: exit `0` with the warning. Cannot be combined with `--no-cleanup`. A malformed or non-positive DURATION is a usage error (exit `1`, no events) | - | | `--control-plane-context CTX` | kubectl context for control plane (REQ-20) | current context | | `--compute-plane-context CTX` | kubectl context for compute plane (REQ-20) | current context | -| `--icms-url URL` | Public ICMS URL; required when contexts differ | derived from `base_http_url` | -| `--local-only` | `check --pre` only; skip all kubectl contact | `false` | +| `--icms-url URL` | Public ICMS URL; required when contexts differ. `check` probes SIS only at a URL set here, in `NVCF_ICMS_URL` or `NVCF_SIS_URL`, or in the config as `icms_url` or `base_http_url`, and not at one derived from a `base_http_url` on NVIDIA's hosted `nvcf.nvidia.com`, as the config template's is; with none, its `sis-reachability` row is a warning | derived from `base_http_url` | +| `--local-only` | `check` only; run the local-host checks and skip all cluster and registry contact, whatever scope flag is passed. The registry credential check and each selected role's cluster checks are reported as rows that pass at info and say `skipped (--local-only)`. Env: `NVCF_CLI_SELFHOSTED_LOCAL_ONLY`, on with `true`, `1`, `yes`, `y` or `on` and off with `false`, `0`, `no`, `n` or `off`, in any case. Any other value is an error | `false` | + +## `check`-specific + +At least one of `--pre`, `--control-plane`, `--compute-plane`, or `--all` is +required. `--pre` selects both roles; each other flag selects its own. Only +the selected roles contact a cluster. Pass both context flags or neither: +without them every selected role checks the current kubeconfig context. + +| Flag | Purpose | Default | +|---|---|---| +| `--pre` | Pre-flight: local-host tools plus cluster readiness. Skips SIS reachability, since SIS is not installed yet, unless `--all` or `--compute-plane` is also passed. With `--all`, or with a role's own flag, that role's validator checks the cluster as installed. A missing or unsupported `kubectl`, `helmfile` or `helm` fails only with `--pre`; other scopes check an installed stack, which runs none of them, so it warns | `false` | +| `--control-plane` | Check the installed control plane: leftover namespaces and the control-plane cluster-validator. Never probes SIS | `false` | +| `--compute-plane` | Check the installed compute plane: leftover namespaces, node inotify limits, SIS reachability at a configured ICMS URL, and the compute-plane cluster-validator | `false` | +| `--all` | Both `--control-plane` and `--compute-plane`, SIS reachability included, with or without `--pre` | `false` | +| `--cluster-name NAME` | Cluster name shown in the output header. No check uses it, and `check` does not require it | - | +| `--skip-inotify-check` | Skip the per-node inotify-limits probe. Needed when the kubeconfig user cannot create pods in `default`, or when the probe image is on a registry that needs credentials, since the probe pods get no pull secret. Its `node-inotify-limits` row passes at info and says it was skipped. Env: `NVCF_CLI_SELFHOSTED_SKIP_INOTIFY`, on with `true`, `1`, `yes`, `y` or `on` and off with `false`, `0`, `no`, `n` or `off`, in any case. Any other value is an error | `false` | + +### Cluster-validator flags + +The validator runs as a Job in the cluster being checked. The CLI creates a +ServiceAccount, ClusterRole, and ClusterRoleBinding for it, plus a Role and +RoleBinding in `default` that let it read only its own ConfigMap, and removes +them after the run, so the kubeconfig context needs permission to manage those. +It reads the validator's transcript with `get` on `pods/log` in `default`. +Without it, a validator Job that succeeded still passes, and its row says the +transcript could not be read, so neither the checks it ran nor warnings it +reported are shown. +The ClusterRole grants only the calls each role's checks make: reads, the +namespace, DaemonSet and pod writes of the control-plane node-to-node probe, +and deleting test namespaces an earlier validator run left behind. + +| Flag | Purpose | Default | +|---|---|---| +| `--cluster-validator-image REF` | Validator image. Resolution order: flag, `NVCF_CLI_CLUSTER_VALIDATOR_IMAGE`, config key `cluster_validator_image`. A ref with no tag discovers the latest stable tag from the registry. If no tag can be discovered, the validator does not run and its row fails the check, saying to pin a tag; `--wait` tries again on every poll. Unset everywhere, the validator does not run and each role's `cluster-validator` row is a warning, so the verdict is `warnings` | - | +| `--cluster-validator-registries host[:port][/path],...` | Extra registries to check: their credentials from this machine, and their reachability from a pod of the control-plane validator. A path scopes the credential check, as in `harbor.example.com/nvcf`. They are added to the registries the install pulls from: the validator image's registry, the stack's `global.image.registry` and `global.image.repository`, its OCI chart source, and `quay.io` for the cert-manager ACME solver unless the stack leaves cert-manager out or sets `certManager.acmesolver.image`. `nvcr.io` is probed, as a warning only, when neither the image nor the stack names a registry. The in-pod probes leave out a registry only the charts come from, since `helm` pulls them on this machine, and are warnings only, since the pod has no proxy. A malformed entry fails the command. Repeatable or comma-separated. Env: `NVCF_CLI_CLUSTER_VALIDATOR_REGISTRIES`; config key `cluster_validator_registries`, a list of strings in this form | - | +| `--cluster-validator-probe-image REF` | Image for the node inotify probe and the control-plane validator's node-to-node overlay probe. Needs `sh` and a busybox-style `nc`. Set a mirror for air-gapped clusters. Env: `NVCF_CLI_CLUSTER_VALIDATOR_PROBE_IMAGE`; config key `cluster_validator_probe_image` | `busybox:1.36` from Docker Hub | +| `--cluster-validator-external-components NAME,...` | Stack dependencies that run outside the stack, or not at all, so the validator does not look for them in the cluster: a subset of `nats`, `openbao` and `cassandra`. An unknown name fails the command. Repeatable or comma-separated. Falls back to `NVCF_EXTERNAL_COMPONENTS`, then to the components whose `.enabled` the stack's environment file sets to anything but `true`. Env: `NVCF_CLI_CLUSTER_VALIDATOR_EXTERNAL_COMPONENTS`; config key `cluster_validator_external_components`, a list of names | from the stack | +| `--cluster-validator-tolerations key[=value][:effect],...` | Tolerations added to the validator Job, beside the control-plane ones it always carries, for clusters whose nodes use other taints. Effect is `NoSchedule`, `PreferNoSchedule` or `NoExecute`. A malformed entry fails the command. Repeatable or comma-separated. Env: `NVCF_CLI_CLUSTER_VALIDATOR_TOLERATIONS`; config key `cluster_validator_tolerations`, a list of strings in this form, not Kubernetes toleration objects | - | +| `--cluster-validator-pull-secret NAME` | docker-registry Secret in `default` used to pull the validator image. When empty, the CLI uses a Secret an operator created in `default` for the image's registry. Failing that, it copies the registry's entry, and only that entry, from a Secret in the NVCF namespaces into `default` for the run. Failing that, it creates one for the run from this machine's credential for the registry, the one the `registry-credentials` row checks. The validator row's detail says what was copied or created, and from where | auto-detect | +| `--skip-cluster-validation` | Skip the in-cluster validator probe entirely. A validator that is configured but cannot run fails the check, so use this to opt out explicitly, for example when the cluster cannot pull the image. Each role's `cluster-validator` row then passes at info and says it was skipped. Env: `NVCF_CLI_SELFHOSTED_SKIP_CLUSTER_VALIDATION`, on with `true`, `1`, `yes`, `y` or `on` and off with `false`, `0`, `no`, `n` or `off`, in any case. Any other value is an error | `false` | +| `--no-cleanup` | Keep the validator Job, its pod, RBAC, pull secret and ConfigMap for debugging. A kept Job whose pod cannot pull its image is suspended, so its pod is deleted rather than retrying forever; inspect it with `kubectl describe job`. A later check that runs the validator removes them once they are 24 hours old. The row, the `final` event's `cleanup` list and the last stderr lines print the command that removes them now. Cannot be combined with `--wait` | `false` | +| `--show-logs` | Print each validator transcript to stderr after the check events, framed by `--- cluster-validator logs () ---` and a matching end line, where the category is `control-plane-cluster` or `compute-plane-cluster`. The transcript is not JSON, and `--json` also writes to stderr, so leave this off when a parser is reading the stream | `false` | + +### Registry credentials + +`check` probes each registry above from this machine with its local +credential, the one `docker` would use: `$DOCKER_CONFIG/config.json`, or +`~/.docker/config.json`, through any `credsStore` or `credHelpers` helper. +For `nvcr.io` the NGC API key is also read, from the first of +`NGC_IMAGE_PULL_API_KEY`, `NVCF_NGCR_API_KEY`, `NVCF_NGC_API_KEY` and +`NGC_API_KEY` that is set. It goes ahead of the docker login only when the +registry check's plane, below, is the control plane, checked before its +install with the `local` environment, since `up` creates its pull secrets +from it, and it is never sent to another registry. There the docker login is +still sent for the row the stack's charts come from, below, and graded as +elsewhere, since `helm` pulls the charts with it. Elsewhere the key +is sent when there is no docker login, or for a repository where `nvcr.io` +rejects the docker login (a 401) or gives it no access (a 403). This is +decided once per repository, before tag discovery, the row and the +validator's pull secret use it, so all three get the same credential, and a +login with no access to one org still serves the orgs it reaches. A 403 is +reported as no access to that repository, never as a rejected login. Each +`--wait` poll reads the credentials again, so a login renewed while the run +waits counts on the next poll. + +The probe asks for pull access to the validator image's repository and to the +stack's image path: any path in `global.image.registry`, then +`global.image.repository`. A stack path of one segment, such as an org with no +team, is not sent as the scope, since `nvcr.io` refuses it. The stack's OCI +chart source, `global.helm.sources.registry` and `.repository`, read the same +way, is the row `helm` on this machine pulls the stack's charts from, with the +docker login: the image row when it is the same path, else a row of its own, +critical for an NVIDIA registry like the image row. An HTTPS chart source, +`global.helm.sources.url`, has credentials of its own and gets no row. + +The registry check belongs to one plane, whose stack names the registries +and whose install state grades them: a plane the run checks before its +install, which this machine installs next (the control plane for a bare +`--pre` and for `--pre --compute-plane`, the compute plane for +`--pre --control-plane`), else the control plane when the run visits it, +else the compute plane. + +Only a credential the registry rejects fails the run, and only for an NVIDIA +registry the image or the stack names. That includes a docker login +`nvcr.io` rejects even when the NGC key works in its place, before and after +install, since `docker` and `helm` on this machine still send it. The key is +still used for tag discovery and the validator's pull secret, and the row +names both. After install the row also says whether one of the pull secrets +below holds that docker login. Before install, a docker login with no access +to the row the stack's charts come from fails it too, although the NGC key +reaches it, since `helm` pulls the charts with the docker login. That is +reported as no access, not as a rejected login. Elsewhere, and after install, +a docker login with no access passes where the NGC key reaches the +repository. A rejected NGC key is a warning after install only when `check` +reads the pull secrets of the clusters it visits, in the namespaces the +validator copies pull secrets from, and none of them holds that key; when +one does, or they cannot all be read, it is an error. A registry this machine +cannot reach, a token service that fails, and a missing local credential are +warnings. A +registry the probe cannot speak to, such as ECR or one using Basic auth, is +skipped with a command to check it by hand. A registry that lets this +machine in anonymously is reported as such, not as valid credentials. + +### Settings read from the stack + +The validator gets these settings from the CLI. Each one set with its +environment variable or config key wins over the stack. + +| Setting | Read from the stack | Env and config key | +|---|---|---| +| NVCF Gateways | `ingress.gatewayApi.gateways`, gated the way the stack wires routes: none unless `ingress.gatewayApi.enabled` is `true`, `nats` only with `routes.nats.enabled`, `llmGrpc` and `llmQuic` only with `routes.llmWorker.enabled` | `NVCF_GATEWAY_NAMES`, as comma-separated `namespace/name` entries. A bare name is dropped by the validator. Set this way it skips the stack's gates | +| Envoy Gateway namespace | `ingress.gatewayApi.controllerNamespace` | `NVCF_ENVOY_GATEWAY_NAMESPACE` | +| External components | `nats`, `openbao` or `cassandra` whose `enabled` is set and is not `true` | `--cluster-validator-external-components`, or `NVCF_EXTERNAL_COMPONENTS` | +| StorageClass | `global.storageClass`. When set, the validator checks that this class exists instead of requiring a default class | `NVCF_STORAGE_CLASS` | +| OpenBao namespace | - | `NVCF_OPENBAO_NAMESPACE` | +| Overlay probe image | - | `--cluster-validator-probe-image`, or `NVCF_N2N_PROBE_IMAGE` | + +The registries above also come from the stack. `check` reads stack values only +when they describe the install: + +- The environment is named with `--env` or `HELMFILE_ENV`. The `--env` + default is used only for a plane `--pre` checks before its install, + since `up` installs with it. Otherwise pass the `--env` the install used. +- `environments/.yaml` is read, layered over `base.yaml`. `base.yaml` + alone is not used. +- The file comes from the stack the install used: `--control-plane-stack` + (`--compute-plane-stack` for the registries when the registry check + belongs to the compute plane), else the CLI's built-in stack. A local + directory is read directly, and an `oci://` stack from the copy an earlier + command extracted into the CLI cache. A git stack, `file://` included, is + not read, since the install cloned its committed state. A CLI with no + built-in stack and no stack flag reads the stack checkout above the + working directory. + +Otherwise nothing from the stack is forwarded: the validator finds the +Gateways from the routes, and `nvcr.io` is probed as a warning only. ## `up`-specific diff --git a/ai-tooling/user/skills/nvcf-self-managed-cli/reference/troubleshooting.md b/ai-tooling/user/skills/nvcf-self-managed-cli/reference/troubleshooting.md index 3ac7a8d105..44d6728064 100644 --- a/ai-tooling/user/skills/nvcf-self-managed-cli/reference/troubleshooting.md +++ b/ai-tooling/user/skills/nvcf-self-managed-cli/reference/troubleshooting.md @@ -1,18 +1,31 @@ # Troubleshooting -Known errors → diagnostic command → remediation. Keep in sync with the structured `phase_failed` events the CLI emits (REQ-15) — every entry below should map to an `errCategory` and a `remediation` array. +Known errors, the diagnostic command, and the remediation. Keep in sync with the structured events the CLI emits (REQ-15): outside pre-flight, every entry below should map to a `phase_failed` `errCategory` and a `remediation` array. ## Pre-flight +`check` reports each finding as a `check_completed` event, not as +`phase_failed`. The symptom is the event's `message`; cluster-validator rows +quote the validator's failed summary rows, and `--show-logs` prints the full +transcript. + | Symptom | Cause | Remediation | |---|---|---| -| `kubectl not on PATH` | Operator's `$PATH` doesn't include kubectl | Install kubectl 1.28+: https://kubernetes.io/docs/tasks/tools/install-kubectl/ | -| `helmfile not on PATH` | Same | https://github.com/helmfile/helmfile#installation | -| `helm not on PATH` | Same | https://helm.sh/docs/intro/install/ | -| `Gateway API CRDs not installed` | envoy-gateway prereq missing | `helm install eg oci://docker.io/envoyproxy/gateway-helm --namespace=envoy-gateway-system --create-namespace` | -| `default StorageClass not available` | Cluster has no default SC | Annotate one: `kubectl patch sc -p '{"metadata":{"annotations":{"storageclass.kubernetes.io/is-default-class":"true"}}}'` | -| `GPU operator not installed` (compute-plane) | nvidia GPU operator missing | Install per https://docs.nvidia.com/datacenter/cloud-native/gpu-operator/latest/install-gpu-operator.html, OR use `fake-gpu-operator` for dev | -| `GPU node labels missing` (compute-plane) | Nodes need `nvidia.com/gpu.family` etc. | Set `--k3s-node-label nvidia.com/gpu.family=hopper@agent:N` for k3d | +| `kubectl not found on PATH` | Operator's `$PATH` doesn't include kubectl. An error with `--pre`; other scopes warn, adding `; only an install needs it` | Install kubectl 1.28+: https://kubernetes.io/docs/tasks/tools/install-kubectl/ | +| `helmfile not found on PATH` | Same | https://github.com/helmfile/helmfile#installation | +| `helm not found on PATH` | Same | https://helm.sh/docs/intro/install/ | +| `cluster-validator not run: cluster_validator_image is not set; ...`, warning | No validator image is configured, so the cluster was not validated | Set `cluster_validator_image` in the nvcf-cli config, `NVCF_CLI_CLUSTER_VALIDATOR_IMAGE` or `--cluster-validator-image` | +| `cluster-validator did not complete, so the cluster was not validated: ...`, error | The validator could not run or ended before its verdict: RBAC denied, image pull failure, its own timeout | Fix the cause the message names, or pass `--skip-cluster-validation` | +| `cluster-validator Job succeeded; its transcript could not be read (...)`, passed | The validator passed, but its log could not be read: the kubeconfig user lacks `get` on `pods/log` in `default`, or the API server cannot reach the kubelet. The checks it ran and warnings it reported are not shown: an image that predates validator roles runs the compute-plane checks for either role | Grant `get` on `pods/log` in `default`, or read the transcript with the `kubectl logs` command in the row | +| `cluster-validator reported failures: Gateway API CRDs: Not Installed` | Envoy Gateway prereq missing | `helm install eg oci://docker.io/envoyproxy/gateway-helm --namespace=envoy-gateway-system --create-namespace` | +| `cluster-validator reported failures: Default StorageClass: Not Found` | Cluster has no default SC, and the stack does not name one | Annotate one: `kubectl patch sc -p '{"metadata":{"annotations":{"storageclass.kubernetes.io/is-default-class":"true"}}}'`, or set `global.storageClass` in the stack's environment file (or `NVCF_STORAGE_CLASS`) to the class the stack uses | +| `cluster-validator reported failures: GPU Resources: Not Available` (compute-plane) | No node advertises an allocatable GPU: no GPU nodes, or the NVIDIA GPU Operator is missing or not working | Install per https://docs.nvidia.com/datacenter/cloud-native/gpu-operator/latest/install-gpu-operator.html, OR use `fake-gpu-operator` for dev. `GPU Operator: Not Installed` alone is a warning | +| `stale-namespaces`: ` (stuck Terminating: ...)`, error, exit `2` | The namespace has been deleting for over 2 minutes, or its conditions report a deletion failure. Usually an object inside it still holds a finalizer | Run the commands in the row in order: read `kubectl get ns -o jsonpath='{.status.conditions}'`, list what is left with the `api-resources` command, and resolve those objects. Force-clear the namespace finalizers only as a last resort | +| `stale-namespaces`: ` (Terminating: deleting for )`, warning | A normal deletion is still draining volumes and finalizers | Wait and rerun `check`; `--wait` polls this row. It becomes `stuck Terminating` after 2 minutes | +| `stale-namespaces`: ` (Helm release mid-operation: )`, warning | An interrupted install, upgrade, rollback or teardown left the release `pending-*` or `uninstalling`. The next `up` fails on it | If no install or teardown is running, read `helm history -n `, then finish it with `helm rollback` or `helm uninstall` | +| `stale-namespaces`: ` (no Helm release)`, warning | The namespace runs no workload and has no Helm release, but still holds volume claims or workload objects, as `down` leaves them. A reinstall reattaches the old volumes | Inspect only: `kubectl api-resources --verbs=list --namespaced -o name \| xargs -n1 kubectl get -n --show-kind --ignore-not-found`. An Argo CD or upstream install of a gated component also has no Helm release | +| `stale-namespaces` reports `no Helm release` on a healthy install | Helm stores releases with `HELM_DRIVER=sql`, and `check` reads `HELM_DRIVER` from its own environment only, not from the helmfile environment | Export `HELM_DRIVER=sql` before `nvcf-cli self-hosted check`. With it, only Terminating namespaces are checked | +| `stale-namespaces` does not scan an add-on or gated namespace | Namespaces behind a stack `condition:` are probed only when that gate is on. Gates come from the stack the install used, found as for the [settings read from the stack](flags.md#settings-read-from-the-stack): its `environments/base.yaml` and, when `--env` or `HELMFILE_ENV` names the environment or `--pre` checks the plane before its install, `environments/.yaml`. Otherwise the stack defaults decide, which leave `kai-scheduler`, `grove-system`, `dynamo-system` and `nvcf-ui` off | Pass the `--env` the install used, and the stack flag the install used, if any | ## Auth diff --git a/src/clis/nvcf-cli/.nvcf-cli.yaml.template b/src/clis/nvcf-cli/.nvcf-cli.yaml.template index 585adc8b21..80ead625ca 100644 --- a/src/clis/nvcf-cli/.nvcf-cli.yaml.template +++ b/src/clis/nvcf-cli/.nvcf-cli.yaml.template @@ -8,8 +8,8 @@ # # Environment variables override config file values. # Config keys automatically map to environment variables: -# - Config: base_http_url → Env: NVCF_BASE_HTTP_URL -# - Config: client_id → Env: NVCF_CLIENT_ID +# - Config: base_http_url -> Env: NVCF_BASE_HTTP_URL +# - Config: client_id -> Env: NVCF_CLIENT_ID # - etc. # ============================================================================== @@ -112,9 +112,9 @@ api_keys_owner_id: svc@nvcf-api.local # set these Host headers to route requests correctly without requiring DNS setup. # # The gateway routes requests based on the Host header: -# - api-keys.{domain} → API Keys service -# - api.{domain} → NVCF API service -# - *.invocation.{domain} → Invocation service +# - api-keys.{domain} -> API Keys service +# - api.{domain} -> NVCF API service +# - *.invocation.{domain} -> Invocation service # # Example: If your gateway domain is "nvcf.example.com": # api_keys_host: api-keys.nvcf.example.com @@ -142,20 +142,90 @@ api_keys_owner_id: svc@nvcf-api.local # Image reference for the cluster-validator pod used by # `nvcf-cli self-hosted check`. The CLI runs this image as a Job in the -# compute-plane cluster to verify NVCA prerequisites before install. +# cluster being checked to verify prerequisites before install. One image +# serves both roles; the Job environment selects the check set. The +# control-plane checks need an image from an NVCA release that supports +# validator roles. An older image runs the compute-plane checks instead; the +# CLI reports that as a warning only when GPU Resources is the one critical +# check that failed, and as a failure otherwise. +# +# The Job needs a ServiceAccount, ClusterRole, and ClusterRoleBinding, plus a +# Role and RoleBinding in default that let it read its own ConfigMap, which +# the CLI creates and removes per run. The kubeconfig context therefore needs +# permission to manage those objects; without it the validator cannot run and +# the check fails. Pass --skip-cluster-validation to run without it. # # Config key: cluster_validator_image # Environment variable: NVCF_CLI_CLUSTER_VALIDATOR_IMAGE # Command-line flag: --cluster-validator-image # # When the value has no tag, the latest tag is discovered from the -# registry (preferring stable releases over rc). If unset everywhere, -# the validator probe is skipped with a warning. +# registry (preferring stable releases over rc). If discovery fails, the +# validator does not run, rather than pull :latest, and its row fails the +# check; pin a tag to avoid depending on discovery. If unset everywhere, the +# validator does not run and its row is a warning. +# +# The Job pulls with a matching pull Secret from the cluster, or one created +# for the run from this machine's credential for the image's registry: the +# docker login, or NGC_API_KEY for nvcr.io only. # # Staging: stg.nvcr.io/nvidia/nvcf-byoc/cluster-validator # Prod: nvcr.io/nvidia/nvcf-byoc/cluster-validator # cluster_validator_image: nvcr.io/nvidia/nvcf-byoc/cluster-validator +# Extra container registries to check, as host[:port][/path]: their +# credentials from this machine, and their reachability from a pod of the +# control-plane validator, as warnings only. A path scopes the credential +# check. The registries the install pulls from are checked already: the +# validator image's registry, the stack's global.image.registry, and quay.io +# for the cert-manager ACME solver. List only registries beyond those. A +# malformed entry fails the command. +# +# Config key: cluster_validator_registries (list of strings) +# Environment variable: NVCF_CLI_CLUSTER_VALIDATOR_REGISTRIES (comma-separated) +# Command-line flag: --cluster-validator-registries (repeatable or comma-separated) +# +# cluster_validator_registries: +# - harbor.example.com:443 +# - ghcr.io:443 + +# Image for the node inotify probe and the control-plane validator's +# node-to-node overlay probe, which needs sh and a busybox-style nc. Defaults +# to NVCF_N2N_PROBE_IMAGE, then busybox:1.36 from Docker Hub. Set a mirror for +# air-gapped clusters. The probe pods get no imagePullSecrets, so the image +# must be pullable anonymously or through node-level registry credentials. +# +# Config key: cluster_validator_probe_image +# Environment variable: NVCF_CLI_CLUSTER_VALIDATOR_PROBE_IMAGE +# Command-line flag: --cluster-validator-probe-image +# +# cluster_validator_probe_image: harbor.example.com/library/busybox:1.36 + +# Tolerations added to the validator Job, beside the control-plane ones it +# always carries, for clusters whose nodes use other taints. Each entry is +# key[=value][:effect], with effect NoSchedule, PreferNoSchedule or NoExecute. +# +# Config key: cluster_validator_tolerations (list of strings, not Kubernetes +# toleration objects; any other entry fails the command) +# Environment variable: NVCF_CLI_CLUSTER_VALIDATOR_TOLERATIONS (comma-separated) +# Command-line flag: --cluster-validator-tolerations +# +# cluster_validator_tolerations: +# - dedicated=infra:NoSchedule + +# Stack dependencies that run outside the stack, or not at all, so the +# validator does not look for them in the cluster: a subset of nats, openbao +# and cassandra. When unset, NVCF_EXTERNAL_COMPONENTS is used, then the +# components whose .enabled the stack's environment file sets to +# anything but true. +# +# Config key: cluster_validator_external_components (list of names) +# Environment variable: NVCF_CLI_CLUSTER_VALIDATOR_EXTERNAL_COMPONENTS (comma-separated) +# Command-line flag: --cluster-validator-external-components +# +# cluster_validator_external_components: +# - cassandra + # ============================================================================== # General Settings # ============================================================================== @@ -206,7 +276,7 @@ api_keys_owner_id: svc@nvcf-api.local # # 2. Environment Variable Mapping: # - Config keys automatically map to NVCF_* environment variables -# - Example: base_http_url → NVCF_BASE_HTTP_URL +# - Example: base_http_url -> NVCF_BASE_HTTP_URL # - Exception: API Keys service uses unprefixed vars (API_KEYS_*) # # 3. Token Generation (Simplified!): diff --git a/src/clis/nvcf-cli/cmd/BUILD.bazel b/src/clis/nvcf-cli/cmd/BUILD.bazel index 2f83c8da26..a65a33e6e4 100644 --- a/src/clis/nvcf-cli/cmd/BUILD.bazel +++ b/src/clis/nvcf-cli/cmd/BUILD.bazel @@ -85,6 +85,7 @@ go_library( "@io_k8s_apiextensions_apiserver//pkg/client/clientset/clientset", "@io_k8s_apimachinery//pkg/api/errors", "@io_k8s_apimachinery//pkg/apis/meta/v1:meta", + "@io_k8s_apimachinery//pkg/util/validation", "@io_k8s_client_go//dynamic", "@io_k8s_client_go//kubernetes", "@io_k8s_client_go//rest", @@ -120,7 +121,11 @@ go_test( "main_test.go", "registry_test.go", "root_test.go", + "self_hosted_check_rows_test.go", + "self_hosted_check_scope_test.go", + "self_hosted_check_stackvalues_test.go", "self_hosted_check_test.go", + "self_hosted_check_validatorenv_test.go", "self_hosted_compute_plane_test.go", "self_hosted_control_plane_test.go", "self_hosted_down_test.go", @@ -141,11 +146,13 @@ go_test( "//src/clis/nvcf-cli/internal/selfhosted", "//src/clis/nvcf-cli/internal/selfhosted/auth", "//src/clis/nvcf-cli/internal/selfhosted/controlplaneprofile", + "//src/clis/nvcf-cli/internal/selfhosted/kubectx", "//src/clis/nvcf-cli/internal/selfhosted/progress", "//src/clis/nvcf-cli/internal/selfhosted/reachability", "//src/clis/nvcf-cli/internal/selfhosted/teardown", "//src/clis/nvcf-cli/internal/state", "//src/clis/nvcf-cli/internal/trustbundle", + "@com_github_masterminds_semver_v3//:semver", "@com_github_spf13_cobra//:cobra", "@com_github_spf13_pflag//:pflag", "@com_github_spf13_viper//:viper", diff --git a/src/clis/nvcf-cli/cmd/main_test.go b/src/clis/nvcf-cli/cmd/main_test.go index d0fa86ed75..b6fdd6a2e5 100644 --- a/src/clis/nvcf-cli/cmd/main_test.go +++ b/src/clis/nvcf-cli/cmd/main_test.go @@ -19,16 +19,190 @@ package cmd import ( "context" + "net/http" + "net/http/httptest" "os" + "path/filepath" "testing" + + "github.com/Masterminds/semver/v3" + "github.com/spf13/pflag" + "github.com/spf13/viper" + + "nvcf-cli/internal/selfhosted" + "nvcf-cli/internal/state" ) -// Default-stub out the validator-tag registry probe so cmd tests don't make -// real network calls or pollute the on-disk cache. Individual tests can -// reassign the variable if they explicitly want to exercise discovery. +// Default-stub every seam that would otherwise reach the developer's cluster or +// the network. Individual tests reassign a variable when they explicitly want +// to exercise that path. +// +// These are not conveniences. Without them `go test ./cmd/` creates a hostPath +// busybox pod per node in `default` (the inotify probe), lists Secrets across +// the stack namespaces of whatever kubeconfig happens to be current, and makes +// an outbound request to nvcr.io per configured registry. That mutates a real +// cluster from a unit test, and it is why the package took minutes and failed +// on a proxied kubeconfig rather than seconds and deterministically. func TestMain(m *testing.M) { + // Every command reads ~/.nvcf-cli.yaml and some tests save + // ~/.nvcf-cli.state, so a developer's real config would steer results and + // a test run would overwrite their saved credentials. + home, err := os.MkdirTemp("", "nvcf-cli-cmd-test-home-") + if err != nil { + panic(err) + } + _ = os.Setenv("HOME", home) + // The state manager resolved its path from HOME at package init, before + // the swap above, so it still points at the real ~/.nvcf-cli.state. + state.ResetDefaultStateManager() + // client-go also resolved ~/.kube/config at init. Point KUBECONFIG into + // the temporary HOME so no test reads the developer's current-context, + // which every hint and removal command now names. + _ = os.Setenv("KUBECONFIG", filepath.Join(home, ".kube", "config")) + // Check reads the stack above the working directory when no + // --control-plane-stack is given, so run from a directory with none: from + // the package directory every test would read the repository's stack and + // any untracked environment file next to it. + if err := os.Chdir(home); err != nil { + panic(err) + } + // A developer's own toggles, validator settings and NGC keys would change + // what every check test runs. The NVCF_ prefixed names are viper's + // automatic aliases of the cluster_validator_* config keys. + for _, k := range []string{ + "NVCF_CLI_SELFHOSTED_LOCAL_ONLY", "NVCF_CLI_SELFHOSTED_SKIP_CLUSTER_VALIDATION", + "NVCF_CLI_SELFHOSTED_SKIP_INOTIFY", + "NVCF_CLI_CLUSTER_VALIDATOR_IMAGE", "NVCF_CLI_CLUSTER_VALIDATOR_REGISTRIES", + "NVCF_CLI_CLUSTER_VALIDATOR_PROBE_IMAGE", "NVCF_CLI_CLUSTER_VALIDATOR_TOLERATIONS", + "NVCF_CLI_CLUSTER_VALIDATOR_EXTERNAL_COMPONENTS", + "NVCF_CLUSTER_VALIDATOR_IMAGE", "NVCF_CLUSTER_VALIDATOR_REGISTRIES", + "NVCF_CLUSTER_VALIDATOR_PROBE_IMAGE", "NVCF_CLUSTER_VALIDATOR_TOLERATIONS", + "NVCF_CLUSTER_VALIDATOR_EXTERNAL_COMPONENTS", "NVCF_EXTERNAL_COMPONENTS", + "NVCF_OPENBAO_NAMESPACE", "NVCF_ENVOY_GATEWAY_NAMESPACE", "NVCF_GATEWAY_NAMES", + "NVCF_N2N_PROBE_IMAGE", "NVCF_STORAGE_CLASS", "HELMFILE_ENV", + "NGC_IMAGE_PULL_API_KEY", "NVCF_NGCR_API_KEY", "NVCF_NGC_API_KEY", "NGC_API_KEY", + } { + _ = os.Unsetenv(k) + } + // The real tool checks run `helmfile version`, which asks github.com for + // the latest release, and pass or fail on what the machine has installed. + checkPreflightTools = passingPreflightTools resolveLatestValidatorTagForSelfHosted = func(_ context.Context, _ string) (string, bool) { return "", false } - os.Exit(m.Run()) + // Nil prober: the inotify check is skipped rather than creating pods. + newInotifyProberForSelfHosted = func(string) selfhosted.NodeInotifyProber { return nil } + // No cluster contact, and a clean result so the category still renders. + newStaleNamespaceProberForSelfHosted = func() selfhosted.StaleNamespaceProber { + return func(context.Context, string, []string) ([]selfhosted.StaleNamespace, error) { + return nil, nil + } + } + // No outbound registry request. + newRegistryCredentialCheckerForSelfHosted = func() selfhosted.RegistryCredentialChecker { + return func(context.Context, string, string, bool, bool) error { return nil } + } + // No validator Job. A test that passes --cluster-validator-image would + // otherwise create RBAC, Secrets and Jobs in the current kube context and + // wait up to five minutes per role. + newClusterValidatorForSelfHosted = func() selfhosted.ClusterValidator { + return func(_ context.Context, p selfhosted.ClusterValidatorParams) selfhosted.ClusterValidatorResult { + return selfhosted.ClusterValidatorResult{ + Passed: true, + Logs: "Validator role: " + p.Role + "\nCluster is NVCF-Ready\n", + } + } + } + // The SIS reachability check has no seam, but it resolves its URL from + // NVCF_ICMS_URL, so resetCheckFlags points check tests at this local + // server instead of the real SIS. Scoped per test: setting it for the + // whole package would change what the ICMS URL resolution tests resolve. + sis := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + testSISURL = sis.URL + code := m.Run() + sis.Close() + _ = os.RemoveAll(home) + os.Exit(code) +} + +// testSISURL is a local stand-in for SIS, set by TestMain. +var testSISURL string + +// resetFlag returns f to its default value and clears its Changed marker. A +// slice flag is replaced, since Set appends once the flag has been set. +func resetFlag(f *pflag.Flag) { + if sv, ok := f.Value.(pflag.SliceValue); ok { + _ = sv.Replace(nil) + } else { + _ = f.Value.Set(f.DefValue) + } + f.Changed = false +} + +// resetCheckFlags returns every `self-hosted check` flag to its default, +// including cobra's Changed marker, now and when the test ends. The flag +// variables are package globals that survive rootCmd.Execute, so without this +// a test that passes --pre leaks it into whichever test runs next, and a +// regression guard can pass or fail on test order alone. +func resetCheckFlags(t *testing.T) { + t.Helper() + if testSISURL != "" { + t.Setenv("NVCF_ICMS_URL", testSISURL) + } + reset := func() { + // cobra keeps the first context a command ran with, so a test that + // cancelled rootCmd's context would otherwise end every later run + // as interrupted. + selfHostedCheckCmd.SetContext(context.Background()) + checkPre, checkControlPlane, checkComputePlane, checkAll = false, false, false, false + checkClusterName = "" + checkLocalOnly, checkSkipInotifyCheck, checkSkipClusterValidation = false, false, false + checkClusterValidatorImage, checkClusterValidatorPullSecret = "", "" + checkClusterValidatorNoCleanup = false + checkClusterValidatorRegistries = nil + checkClusterValidatorProbeImage = "" + checkClusterValidatorTolerations = nil + checkClusterValidatorExternal = nil + checkShowLogs = false + selfHostedJSON, selfHostedPlain = false, false + selfHostedOutput = "text" + selfHostedWait = "" + selfHostedControlPlaneContext, selfHostedComputePlaneContext = "", "" + // Values too, not only the Changed marker: a test that passes + // --icms-url would otherwise point every later check at its URL. + for _, fs := range []*pflag.FlagSet{selfHostedCheckCmd.Flags(), selfHostedCmd.PersistentFlags()} { + fs.VisitAll(resetFlag) + } + // Other tests call viper.Reset(), which drops the bindings made at + // init, so a flag passed to check would silently not be read. + for key, flag := range map[string]string{ + "cluster_validator_image": "cluster-validator-image", + "cluster_validator_registries": "cluster-validator-registries", + "cluster_validator_probe_image": "cluster-validator-probe-image", + "cluster_validator_tolerations": "cluster-validator-tolerations", + "cluster_validator_external_components": "cluster-validator-external-components", + } { + _ = viper.BindPFlag(key, selfHostedCheckCmd.Flags().Lookup(flag)) + } + } + reset() + t.Cleanup(reset) +} + +// passingPreflightTools is the real tool set with every tool found at its +// minimum version. +func passingPreflightTools() []selfhosted.BinarySpec { + specs := selfHostedPreflightTools() + for i := range specs { + v := specs[i].MinVer + if v == nil { + v = semver.MustParse("1.0.0") + } + name := specs[i].Name + specs[i].LookPath = func(string) (string, error) { return "/usr/local/bin/" + name, nil } + specs[i].Version = func(context.Context, string) (*semver.Version, error) { return v, nil } + } + return specs } diff --git a/src/clis/nvcf-cli/cmd/root.go b/src/clis/nvcf-cli/cmd/root.go index a42df48c08..5b95d41017 100644 --- a/src/clis/nvcf-cli/cmd/root.go +++ b/src/clis/nvcf-cli/cmd/root.go @@ -285,6 +285,10 @@ func initConfig() { // swapping --config files. The env name matches the historical // NVCF_CLI_CLUSTER_VALIDATOR_IMAGE override. viper.BindEnv("cluster_validator_image", "NVCF_CLI_CLUSTER_VALIDATOR_IMAGE") + viper.BindEnv("cluster_validator_registries", "NVCF_CLI_CLUSTER_VALIDATOR_REGISTRIES") + viper.BindEnv("cluster_validator_probe_image", "NVCF_CLI_CLUSTER_VALIDATOR_PROBE_IMAGE") + viper.BindEnv("cluster_validator_tolerations", "NVCF_CLI_CLUSTER_VALIDATOR_TOLERATIONS") + viper.BindEnv("cluster_validator_external_components", "NVCF_CLI_CLUSTER_VALIDATOR_EXTERNAL_COMPONENTS") // If a config file is found, read it in. if err := viper.ReadInConfig(); err == nil && viper.GetBool("debug") { diff --git a/src/clis/nvcf-cli/cmd/self_hosted.go b/src/clis/nvcf-cli/cmd/self_hosted.go index 19c82c884f..37ced3eaac 100644 --- a/src/clis/nvcf-cli/cmd/self_hosted.go +++ b/src/clis/nvcf-cli/cmd/self_hosted.go @@ -41,8 +41,8 @@ and 'install --compute-plane' explicitly.`, // Persistent flags shared by all self-hosted subcommands. var ( - selfHostedControlPlaneStack string - selfHostedComputePlaneStack string + selfHostedControlPlaneStack string + selfHostedComputePlaneStack string selfHostedEnv string selfHostedNoApply bool selfHostedNonInter bool @@ -96,7 +96,9 @@ func init() { selfHostedCmd.PersistentFlags().StringVar(&selfHostedComputePlaneStack, "compute-plane-stack", "", "Compute-plane stack source: local path, git URL, or oci:// URL (default: built-in OCI URL pinned to this CLI version)") selfHostedCmd.PersistentFlags().StringVar(&selfHostedEnv, "env", "local", - "Helmfile environment (e.g. local, prd)") + "Helmfile environment (e.g. local, prd). check reads the stack's environments/.yaml only when "+ + "this or HELMFILE_ENV names it, or for a plane --pre checks before its install, which up installs "+ + "with this default; so pass the install's --env") selfHostedCmd.PersistentFlags().BoolVar(&selfHostedNoApply, "no-apply", false, "Emit YAML to stdout without invoking kubectl (install only)") selfHostedCmd.PersistentFlags().BoolVar(&selfHostedNonInter, "non-interactive", false, @@ -106,9 +108,9 @@ func init() { selfHostedCmd.PersistentFlags().StringVar(&selfHostedOutput, "output", "text", "Output format for check: text or json") selfHostedCmd.PersistentFlags().StringVar(&selfHostedWait, "wait", "", - "Block on check until pass or duration elapses (e.g. 5m). "+ - "With --pre --compute-plane the cluster-validator's 5m budget dominates the 5s poll; "+ - "pair with --skip-cluster-validation for a tight retry cadence.") + "check only: run the checks again, 5s apart, until they pass or the duration elapses (e.g. 15m), "+ + "then exit 5. Each run includes the cluster-validator Job, which can take up to 5m; "+ + "--skip-cluster-validation makes each run fast. Cannot be combined with --no-cleanup.") selfHostedCmd.PersistentFlags().StringVar(&selfHostedICMSURL, "icms-url", "", "ICMS endpoint for cluster register (default: derived from base_http_url; env: NVCF_ICMS_URL)") selfHostedCmd.PersistentFlags().StringVar(&selfHostedICMSURL, "sis-url", "", diff --git a/src/clis/nvcf-cli/cmd/self_hosted_check.go b/src/clis/nvcf-cli/cmd/self_hosted_check.go index dafb00fbf5..9a3349f73b 100644 --- a/src/clis/nvcf-cli/cmd/self_hosted_check.go +++ b/src/clis/nvcf-cli/cmd/self_hosted_check.go @@ -18,15 +18,24 @@ limitations under the License. package cmd import ( + "cmp" "context" "fmt" "io" + "net/url" "os" + "os/signal" + "path/filepath" + "strings" + "sync" + "syscall" "time" "github.com/spf13/cobra" "github.com/spf13/viper" "golang.org/x/sync/errgroup" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/util/validation" "nvcf-cli/internal/selfhosted" "nvcf-cli/internal/selfhosted/kubectx" @@ -34,23 +43,27 @@ import ( ) var ( - checkPre bool - checkControlPlane bool - checkComputePlane bool - checkAll bool - checkClusterName string - checkLocalOnly bool - checkSkipInotifyCheck bool - checkSkipClusterValidation bool - checkClusterValidatorImage string - checkClusterValidatorPullSecret string - checkClusterValidatorNoCleanup bool - checkShowLogs bool + checkPre bool + checkControlPlane bool + checkComputePlane bool + checkAll bool + checkClusterName string + checkLocalOnly bool + checkSkipInotifyCheck bool + checkSkipClusterValidation bool + checkClusterValidatorImage string + checkClusterValidatorPullSecret string + checkClusterValidatorNoCleanup bool + checkClusterValidatorRegistries []string + checkClusterValidatorTolerations []string + checkClusterValidatorProbeImage string + checkClusterValidatorExternal []string + checkShowLogs bool ) // Test seam. -var newInotifyProberForSelfHosted = func() selfhosted.NodeInotifyProber { - return selfhosted.NewInotifyProber() +var newInotifyProberForSelfHosted = func(image string) selfhosted.NodeInotifyProber { + return selfhosted.NewInotifyProber(image) } // Test seam. @@ -58,9 +71,22 @@ var newClusterValidatorForSelfHosted = func() selfhosted.ClusterValidator { return selfhosted.NewClusterValidator() } +// Test seam. +var newStaleNamespaceProberForSelfHosted = func() selfhosted.StaleNamespaceProber { + return selfhosted.NewStaleNamespaceProber() +} + // Test seam. Tests stub this to skip the registry network call. var resolveLatestValidatorTagForSelfHosted = selfhosted.ResolveLatestValidatorTag +// Test seam. +var newRegistryCredentialCheckerForSelfHosted = func() selfhosted.RegistryCredentialChecker { + return selfhosted.NewRegistryCredentialChecker() +} + +// Test seam. +var newClusterPullSecretCheckerForSelfHosted = selfhosted.NewClusterPullSecretChecker + var checkWriterIsTTY = isWriterTTY var selfHostedCheckCmd = &cobra.Command{ @@ -72,76 +98,193 @@ var selfHostedCheckCmd = &cobra.Command{ func init() { selfHostedCmd.AddCommand(selfHostedCheckCmd) - selfHostedCheckCmd.Flags().BoolVar(&checkPre, "pre", false, "Run pre-flight (local-host + cluster readiness)") - selfHostedCheckCmd.Flags().BoolVar(&checkControlPlane, "control-plane", false, "Run control-plane health checks") + selfHostedCheckCmd.Flags().BoolVar(&checkPre, "pre", false, + "Run pre-flight before an install: local tools (a missing one fails), registry credentials, and "+ + "both roles' cluster checks for a cluster not yet installed, without SIS reachability. With --all "+ + "or a role flag, that role is checked as installed") + selfHostedCheckCmd.Flags().BoolVar(&checkControlPlane, "control-plane", false, + "Check the installed control plane") selfHostedCheckCmd.Flags().BoolVar(&checkComputePlane, "compute-plane", false, - "Run compute-plane health checks. Requires --cluster-name.") - selfHostedCheckCmd.Flags().BoolVar(&checkAll, "all", false, "Run all check categories") - selfHostedCheckCmd.Flags().StringVar(&checkClusterName, "cluster-name", "", "Cluster name for compute-plane checks") - selfHostedCheckCmd.Flags().BoolVar(&checkLocalOnly, "local-only", false, "Run local-host checks only (no kubectl contact)") + "Check the installed compute plane, including SIS reachability at a configured ICMS URL") + selfHostedCheckCmd.Flags().BoolVar(&checkAll, "all", false, + "Check the installed control plane and compute plane") + selfHostedCheckCmd.Flags().StringVar(&checkClusterName, "cluster-name", "", + "Cluster name shown in the check output header; no check uses it") + selfHostedCheckCmd.Flags().BoolVar(&checkLocalOnly, "local-only", false, + "Run local-host checks only (no cluster or registry contact); the skipped checks are reported as "+ + "skipped rows. Env: NVCF_CLI_SELFHOSTED_LOCAL_ONLY=true") selfHostedCheckCmd.Flags().BoolVar(&checkSkipInotifyCheck, "skip-inotify-check", false, - "Disable the per-node inotify-limits probe. Required when the kubeconfig user "+ - "cannot create pods in 'default'. Env: NVCF_CLI_SELFHOSTED_SKIP_INOTIFY") + "Disable the per-node inotify-limits probe; its row says it was skipped. Required when the "+ + "kubeconfig user cannot create pods in 'default', or when the probe image comes from a registry "+ + "that needs credentials: the probe pods get no pull secret. Env: NVCF_CLI_SELFHOSTED_SKIP_INOTIFY=true") selfHostedCheckCmd.Flags().BoolVar(&checkSkipClusterValidation, "skip-cluster-validation", false, - "Disable the in-cluster cluster-validator probe. "+ - "Env: NVCF_CLI_SELFHOSTED_SKIP_CLUSTER_VALIDATION") + "Disable the in-cluster cluster-validator probe; its row says it was skipped. "+ + "Env: NVCF_CLI_SELFHOSTED_SKIP_CLUSTER_VALIDATION=true") selfHostedCheckCmd.Flags().StringVar(&checkClusterValidatorImage, "cluster-validator-image", "", "Cluster-validator container image. Resolved from --cluster-validator-image > "+ "NVCF_CLI_CLUSTER_VALIDATOR_IMAGE > nvcf-cli config (cluster_validator_image). "+ - "If unset everywhere, the validator probe is skipped with a warning. "+ - "When the value has no tag, the latest is discovered from the registry.") + "If unset everywhere, the validator does not run and its row is a warning. "+ + "When the value has no tag, the latest is discovered from the registry; "+ + "if none is found, the validator does not run and its row fails the check.") _ = viper.BindPFlag("cluster_validator_image", selfHostedCheckCmd.Flags().Lookup("cluster-validator-image")) selfHostedCheckCmd.Flags().StringVar(&checkClusterValidatorPullSecret, "cluster-validator-pull-secret", "", "Name of a docker-registry Secret in the 'default' namespace to pull the validator image. "+ - "When empty, the runner scans NVCF namespaces for a matching secret (mirroring it into "+ - "'default' if found elsewhere) and falls back to auto-creating one from NGC_API_KEY. "+ + "When empty, the runner scans NVCF namespaces for a Secret with the image registry's "+ + "credential and copies that one entry into 'default' for the run. Failing that, it creates "+ + "one from this machine's credential for that registry, the one the registry-credentials "+ + "row checks: the docker login, or NGC_API_KEY for an image on nvcr.io only. "+ "Set to force a specific name.") selfHostedCheckCmd.Flags().BoolVar(&checkClusterValidatorNoCleanup, "no-cleanup", false, - "Disable the validator Job's TTL so the Job persists for debugging. "+ - "The next run still deletes prior Jobs via the singleton sweep.") + "Keep the validator Job, its pod, RBAC, pull secret and ConfigMap for debugging instead of "+ + "removing them after the run. A kept Job whose pod cannot pull its image is suspended, which "+ + "deletes the pod. The result prints the kubectl command that removes them now; otherwise a later "+ + "check that runs the validator removes them once they are 24 hours old. Cannot be combined "+ + "with --wait.") + selfHostedCheckCmd.Flags().StringSliceVar(&checkClusterValidatorRegistries, "cluster-validator-registries", nil, + "Additional container registries to check: their credentials from this machine, and their "+ + "reachability from a pod of the control-plane validator. Format: host[:port][/path] "+ + "(e.g. harbor.company.internal:443/nvcf,ghcr.io); a path scopes the credential check. "+ + "Added to the registries the install pulls from. The in-pod probes are warnings only. "+ + "A malformed entry fails the command. Env: NVCF_CLI_CLUSTER_VALIDATOR_REGISTRIES. "+ + "Can also be set in nvcf-cli config as cluster_validator_registries, a list of strings in this format.") + _ = viper.BindPFlag("cluster_validator_registries", + selfHostedCheckCmd.Flags().Lookup("cluster-validator-registries")) + selfHostedCheckCmd.Flags().StringSliceVar(&checkClusterValidatorTolerations, "cluster-validator-tolerations", nil, + "Tolerations added to the validator Job, for clusters whose nodes carry taints other than the "+ + "control-plane ones it always tolerates. Format: key[=value][:effect], effect one of NoSchedule, "+ + "PreferNoSchedule or NoExecute (e.g. dedicated=infra:NoSchedule). Repeatable or comma-separated. "+ + "Env: NVCF_CLI_CLUSTER_VALIDATOR_TOLERATIONS. "+ + "Can also be set in nvcf-cli config as cluster_validator_tolerations, a list of strings in this format.") + _ = viper.BindPFlag("cluster_validator_tolerations", + selfHostedCheckCmd.Flags().Lookup("cluster-validator-tolerations")) + selfHostedCheckCmd.Flags().StringVar(&checkClusterValidatorProbeImage, "cluster-validator-probe-image", "", + "Image for the node inotify probe and the control-plane validator's node-to-node overlay probe "+ + "(needs sh and busybox-style nc). Defaults to busybox:1.36 from Docker Hub; set a mirror for "+ + "air-gapped clusters. "+ + "Env: NVCF_CLI_CLUSTER_VALIDATOR_PROBE_IMAGE. "+ + "Can also be set in nvcf-cli config as cluster_validator_probe_image.") + _ = viper.BindPFlag("cluster_validator_probe_image", + selfHostedCheckCmd.Flags().Lookup("cluster-validator-probe-image")) + selfHostedCheckCmd.Flags().StringSliceVar(&checkClusterValidatorExternal, + "cluster-validator-external-components", nil, + "Stack dependencies that run outside the stack, or not at all, so the validator does not look for "+ + "them in the cluster: a subset of nats, openbao and cassandra. Repeatable or comma-separated. "+ + "Defaults to NVCF_EXTERNAL_COMPONENTS, then to the components the stack's environment file "+ + "disables. Env: NVCF_CLI_CLUSTER_VALIDATOR_EXTERNAL_COMPONENTS. "+ + "Can also be set in nvcf-cli config as cluster_validator_external_components, a list of names.") + _ = viper.BindPFlag("cluster_validator_external_components", + selfHostedCheckCmd.Flags().Lookup("cluster-validator-external-components")) selfHostedCheckCmd.Flags().BoolVar(&checkShowLogs, "show-logs", false, - "Print the cleaned cluster-validator transcript to stderr after the check events. "+ - "Useful when piping --json output to a script that also wants the transcript.") + "Print each cluster-validator transcript to stderr after the check output, framed with the check "+ + "category it ran for. The transcript is not JSON, so leave this off when a parser reads --json.") } func runSelfHostedCheck(c *cobra.Command, _ []string) error { + // A reader of stdout or stderr that exits early, such as grep -m1, head or + // a jq that fails on a line, or one a signal to the process group ends, + // would otherwise have the next write kill the process mid-run, with the + // validator's cluster-wide RBAC in place. Every write discards its error. + // It stays ignored after return, for the error cobra prints afterwards. + signal.Ignore(syscall.SIGPIPE) if !checkPre && !checkControlPlane && !checkComputePlane && !checkAll { return fmt.Errorf("at least one of --pre, --control-plane, --compute-plane, or --all is required") } + waitDur, err := parseCheckWait(selfHostedWait) + if err != nil { + return err + } + if checkClusterValidatorNoCleanup && waitDur > 0 { + // Each poll is a new validator run, so --no-cleanup would keep a full + // set of RBAC, Secret, ConfigMap and Job for every poll. + return fmt.Errorf("--no-cleanup keeps one run's objects for debugging and cannot be combined with --wait") + } + if _, err := configuredValidatorTolerations(); err != nil { + return err + } + extraRegistries, err := configuredValidatorRegistries() + if err != nil { + return err + } + if _, err := configuredExternalComponents(); err != nil { + return err + } + for _, name := range []string{envLocalOnly, envSkipClusterValidation, envSkipInotify} { + if _, err := envToggle(name); err != nil { + return err + } + } + // One credential lookup per registry for each poll, shared by tag + // discovery, the local credential row and the validator's pull secret, + // so the row checks the credential the validator Job is given. Each + // --wait poll reads them again: a login renewed meanwhile, or a helper + // that failed once, counts on the next poll. + creds := selfhosted.NewRegistryCredentials(preferNGCKey()) + runCtx := selfhosted.WithRegistryCredentials(c.Context(), creds) - localOnly := checkLocalOnly || os.Getenv("NVCF_CLI_SELFHOSTED_LOCAL_ONLY") != "" - skipClusterValidation := checkSkipClusterValidation || os.Getenv("NVCF_CLI_SELFHOSTED_SKIP_CLUSTER_VALIDATION") != "" + localOnly := checkLocalOnly || envToggleOn(envLocalOnly) + skipClusterValidation := clusterValidationSkipped() + + // ValidateFlags in PersistentPreRunE guarantees mode is ModeSingle or + // ModeSplit here. + mode := kubectx.SelectMode(selfHostedControlPlaneContext, selfHostedComputePlaneContext) // Resolve the validator image up-front so we can right-size the - // outer timeout (only when the validator actually runs) and emit a - // one-shot stderr note up-front explaining why no validator row - // appears in the output. Empty == not configured anywhere. - clusterValidatorImage := "" - if !localOnly && !skipClusterValidation && (checkPre || checkAll) { - if img, ok := resolveClusterValidatorImage(c.Context()); ok { - clusterValidatorImage = img - } - } - clusterValidatorWillRun := !localOnly && !skipClusterValidation && (checkPre || checkAll) && clusterValidatorImage != "" - - // The cluster-validator Job's internal budget is 5m - // (selfhosted.clusterValidatorTimeout). The outer ctx must be at least - // that plus headroom for RBAC bootstrap + log fetch, otherwise vctx - // derives from a shorter ceiling and silently truncates the wait. - outerTimeout := 2 * time.Minute - if clusterValidatorWillRun { - outerTimeout = 6 * time.Minute - } - // --wait polls for the declared duration. The outer ctx has to outlive - // the last iteration, so add waitDur on top of a single iteration's - // worth. - if selfHostedWait != "" { - if waitDur, err := time.ParseDuration(selfHostedWait); err == nil { - outerTimeout += waitDur - } - } - ctx, cancel := context.WithTimeout(c.Context(), outerTimeout) + // outer timeout (only when the validator actually runs). Empty == not + // configured anywhere. One image covers both roles (VALIDATOR_ROLE + // selects the check set). Gated on the clusters the dispatch below + // visits, so every visited role that runs a validator has its image. + anyValidatorRuns := !localOnly && !skipClusterValidation && + (computePlaneIsVisited() || controlPlaneIsVisited()) + clusterValidatorImage, unresolvedImage := "", "" + if anyValidatorRuns { + clusterValidatorImage, unresolvedImage = resolveClusterValidatorImage(runCtx) + } + // An image whose tag did not resolve is still sized for: --wait resolves + // it again on every poll, and the validator runs once it does. + clusterValidatorConfigured := anyValidatorRuns && (clusterValidatorImage != "" || unresolvedImage != "") + + // Every validator run that may leave objects in a cluster registers its + // removal command here, across --wait polls, so every exit can print it. + ledger := &selfhosted.CleanupLedger{} + errOut := c.ErrOrStderr() + + // Catch Ctrl-C, SIGTERM and SIGHUP (a closed terminal or a dropped SSH + // session) so an interrupted run unwinds through its deferred cleanup. + // Without this the process exits on the signal and the validator's + // cluster-wide ClusterRole, bound to a ServiceAccount in default, stays + // behind until a later check's orphan sweep. A signal the process started + // with ignored stays ignored: nohup ignores SIGHUP so that the run outlives + // the session, and a shell without job control starts a background job + // with SIGINT ignored. + sigCtx, cancelSig := context.WithCancel(runCtx) + defer cancelSig() + // Read before Notify: a signal relayed to a channel no longer reads as + // ignored. + caughtSignals := notIgnored(os.Interrupt, syscall.SIGTERM, syscall.SIGHUP) + heldSignals := notIgnored(syscall.SIGTERM, syscall.SIGHUP) + caught := make(chan os.Signal, 1) + notifyOn(caught, caughtSignals) + defer signal.Stop(caught) + held := make(chan os.Signal, 1) + defer signal.Stop(held) + // interrupt ends the run on the first signal or the quit key. A second + // Ctrl-C then exits at once, through the default handler. SIGTERM and + // SIGHUP stay caught until return: CI follows its SIGINT with a SIGTERM a + // few seconds later. + var interruptOnce sync.Once + interrupt := func() { + interruptOnce.Do(func() { + notifyOn(held, heldSignals) + signal.Stop(caught) + cancelSig() + }) + } + // interrupted is an explicit cancel, not the timeout below, which is a + // child of sigCtx and leaves it live. + interrupted := func() bool { return sigCtx.Err() != nil } + ctx, cancel := context.WithTimeout(sigCtx, + checkBudget(checkRunBudget(mode, localOnly, clusterValidatorConfigured, waitDur))) defer cancel() // Legacy --output=json: warn and treat as --json. @@ -150,107 +293,658 @@ func runSelfHostedCheck(c *cobra.Command, _ []string) error { selfHostedJSON = true } - // Surface the skip as a stderr note so operators don't conflate "no - // validator row" with "validator silently dropped". Print at most one - // reason; --skip-cluster-validation takes precedence over missing - // config since it's the explicit operator choice. - if !localOnly && (checkPre || checkAll) { - switch { - case skipClusterValidation: - fmt.Fprintln(c.ErrOrStderr(), "note: cluster-validator skipped (--skip-cluster-validation)") - case clusterValidatorImage == "": - fmt.Fprintln(c.ErrOrStderr(), "note: cluster-validator skipped (cluster_validator_image not set in nvcf-cli config)") + // Enumerate registries for the local credential check. --local-only + // skips it and reports a skipped row instead. Uses the same extras list + // as the in-cluster ConfigMap reachability check. + var ( + credEntries []selfhosted.RegistryEntry + registryChecker selfhosted.RegistryCredentialChecker + ) + // Run credential checks whenever not local-only. The validator image is + // optional: EnumerateRegistries handles an empty image ref and still picks + // up global.image.registry from the stack values and any + // --cluster-validator-registries extras independently of the image config. + if !localOnly { + // The configured image when its tag did not resolve: its registry, + // and the repository the credentials must reach, are the same. + credEntries = selfhosted.EnumerateRegistries( + cmp.Or(clusterValidatorImage, unresolvedImage), registryStackValues(), extraRegistries, + ) + if len(credEntries) > 0 { + registryChecker = newRegistryCredentialCheckerForSelfHosted() } } + registryPostInstall := !registryTarget().beforeInstall cfg := selfhosted.PreflightConfig{ LocalOnly: localOnly, - Tools: selfHostedPreflightTools(), + Tools: checkPreflightTools(), + // The tools are what `up` runs; checking an installed stack does + // not use them. + ToolsAdvisory: !checkPre, + Registries: credEntries, + RegistryChecker: registryChecker, + RegistryPostInstall: registryPostInstall, + Interrupted: interrupted, } - sink, err := selectCheckRenderer(c.ErrOrStderr(), selfHostedWait != "") + // A quit key in the dashboard cancels the run the way a signal does. + sink, err := selectCheckRendererFn(errOut, waitDur > 0, interrupt) if err != nil { return err } - defer sink.Close() if starter, ok := sink.(interface{ Start() }); ok { starter.Start() } - + // After the first signal or quit key, say at once how to remove what the + // runs in progress may leave, before anything can end the process. + var noteOnce sync.Once + noteInterrupt := func() { + interrupt() + noteOnce.Do(func() { + commands := ledger.Outstanding() + if len(commands) == 0 { + return + } + // A dashboard on the alternate screen takes everything printed + // meanwhile with it when it closes. The run is ending, so it is + // closed first and the note stays on the operator's terminal. + if owner, ok := sink.(interface{ OwnsTerminal() bool }); ok && owner.OwnsTerminal() { + _ = sink.Close() + } + printInterruptCleanup(errOut, commands) + }) + } + returned := make(chan struct{}) + defer close(returned) + go func() { + select { + case <-returned: + return + case <-caught: + case <-sigCtx.Done(): + } + noteInterrupt() + }() var lastResults []selfhosted.CheckResult + runErr := func() error { + // An interrupted run still ends the stream with a final event, marked + // cancelled as up marks its own, so a --json consumer is not left waiting + // for one. Emitted on a fresh ctx: the run's ctx is already cancelled. + exitInterrupted := func() error { + // The note goes first, whichever of this and the signal handler + // gets there first. + noteInterrupt() + _ = sink.Emit(context.Background(), progress.Final{Cancelled: true, Cleanup: ledger.Outstanding()}) + return &ExitCodeError{Code: 130, Msg: "interrupted"} + } - runOnce := func() []selfhosted.CheckResult { - var results []selfhosted.CheckResult - if checkPre || checkAll { - results = append(results, runPreflightByRole(ctx, cfg, sink, clusterValidatorImage)...) - } - // Inject force-fail seam for tests. - if os.Getenv("NVCF_CLI_SELFHOSTED_FORCE_FAIL") != "" { - results = append([]selfhosted.CheckResult{{ - ID: "force-fail-test-seam", - Category: "test", - Severity: "error", - Passed: false, - Message: "forced failure (test seam)", - }}, results...) - } - // control-plane / compute-plane wired in M3/M4 — placeholder no-op for M2. - return results - } + // One inotify prober for every --wait poll: it keeps what it learned + // of each node, so a poll gets further than the one before. + inotifyProber := sync.OnceValue(func() selfhosted.NodeInotifyProber { + return newInotifyProberForSelfHosted(configuredProbeImage()) + }) + runOnce := func() []selfhosted.CheckResult { + // The first poll keeps the lookups the image resolution above made. + pollCtx := selfhosted.WithRegistryCredentials(ctx, creds) + creds = selfhosted.NewRegistryCredentials(preferNGCKey()) + var results []selfhosted.CheckResult + if unresolvedImage != "" { + // Discovery fails transiently too, and each --wait poll is a new + // chance to run the validator. + clusterValidatorImage, unresolvedImage = resolveClusterValidatorImage(pollCtx) + } + pollCfg := cfg + if registryChecker != nil && registryPostInstall { + // One per poll: it keeps what it read of the clusters for + // the poll's registry rows, and a later poll reads again. + pollCfg.RegistryClusterSecrets = newClusterPullSecretCheckerForSelfHosted(visitedKubeContexts(mode)) + } + if checkPre || checkAll || checkControlPlane || checkComputePlane { + results = append(results, + runPreflightByRole(pollCtx, pollCfg, sink, mode, clusterValidatorImage, unresolvedImage, ledger, + inotifyProber)...) + } + return results + } - if selfHostedWait == "" { - // Single-shot mode. - lastResults = runOnce() - emitCheckFinal(ctx, sink, lastResults) - maybeShowClusterValidatorLogs(c.ErrOrStderr(), lastResults) - if anyFailed(lastResults) { + exitFailed := func() error { + emitCheckFinal(context.Background(), sink, lastResults, ledger.Outstanding()) return &ExitCodeError{Code: 2, Msg: "pre-flight checks failed"} } - return nil + // A budget stopped a check: one never started, or ran out of time while + // it ran. Its row is no finding, so when it blocks the verdict is a + // timeout rather than whatever the partial set would grade as. A budget + // that ran out only after every check had its result changes nothing. A + // blocking failure in a row the budget did not cut short is still exit 2: + // no retry with more time passes it. + exitBudgetSpent := func() error { + if anyFindingFailed(lastResults) { + return exitFailed() + } + emitCheckTimeout(sink, lastResults, ledger.Outstanding()) + return &ExitCodeError{Code: 5, Msg: "timed out: the check budget ran out before every check finished"} + } + + if waitDur == 0 { + // Single-shot mode. + lastResults = runOnce() + if interrupted() { + return exitInterrupted() + } + if anyCutShortFailure(lastResults) { + return exitBudgetSpent() + } + if anyFailed(lastResults) { + return exitFailed() + } + emitCheckFinal(ctx, sink, lastResults, ledger.Outstanding()) + return nil + } + + // --wait mode: poll until all checks pass or the duration elapses. + deadline := time.After(waitDur) + ticker := time.NewTicker(checkPollInterval) + defer ticker.Stop() + + // Every exit 5 reports success:false, so a gate on the final event agrees + // with the exit code: a run still waiting on a rollout has not passed. + waitTimeout := func() error { + emitCheckTimeout(sink, lastResults, ledger.Outstanding()) + switch { + case anyFindingFailed(lastResults): + return &ExitCodeError{Code: 5, Msg: "wait timeout: checks still failing after " + selfHostedWait} + case anyCutShort(lastResults): + return &ExitCodeError{Code: 5, Msg: "wait timeout: a check was still cut short by its time budget after " + + selfHostedWait} + } + return &ExitCodeError{Code: 5, Msg: "wait timeout: a rollout was still in progress after " + selfHostedWait} + } + var previous []selfhosted.CheckResult + for { + lastResults = runOnce() + if interrupted() { + return exitInterrupted() + } + // A poll a budget cut short has incomplete results: never a pass. The + // next poll gets fresh shares, so only the spent outer budget ends + // the wait here. + if anyCutShort(lastResults) && ctx.Err() != nil { + return exitBudgetSpent() + } + if !anyFailed(lastResults) && !anyWarningToWaitOn(lastResults, previous) { + emitCheckFinal(ctx, sink, lastResults, ledger.Outstanding()) + return nil + } + previous = lastResults + + // The deadline is checked first: with several cases ready, select + // picks at random, and the ticker then re-ran the checks on a spent + // budget. + select { + case <-deadline: + return waitTimeout() + default: + } + select { + case <-deadline: + return waitTimeout() + case <-ticker.C: + // continue polling + case <-ctx.Done(): + if interrupted() { + return exitInterrupted() + } + return waitTimeout() + } + } + }() + // Every exit closes the dashboard first, so nothing printed after it is + // wiped, then prints the transcripts --show-logs asks for and the + // commands that remove what the runs kept in the cluster. + _ = sink.Close() + maybeShowClusterValidatorLogs(errOut, lastResults) + printKeptValidatorObjects(errOut, ledger.Outstanding()) + return runErr +} + +// notIgnored returns the signals in sigs that the process does not ignore. +func notIgnored(sigs ...os.Signal) []os.Signal { + var out []os.Signal + for _, sig := range sigs { + if !signal.Ignored(sig) { + out = append(out, sig) + } + } + return out +} + +// notifyOn relays sigs to ch. With none it relays nothing, where +// signal.Notify with no signals relays every one. +func notifyOn(ch chan<- os.Signal, sigs []os.Signal) { + if len(sigs) > 0 { + signal.Notify(ch, sigs...) + } +} + +// printInterruptCleanup says, as soon as a run is interrupted, how to remove +// what its validator runs may leave in the cluster if the teardown is cut +// short. A failed write is ignored: the teardown goes on whether or not +// anything still reads stderr. +func printInterruptCleanup(w io.Writer, commands []string) { + if len(commands) == 0 { + return + } + var note strings.Builder + note.WriteString("note: interrupted; stopping the cluster-validator and removing its objects. " + + "A second Ctrl-C exits at once and leaves them; remove them with:\n") + for _, cmd := range commands { + note.WriteString(" " + cmd + "\n") + } + _, _ = io.WriteString(w, note.String()) +} + +// printKeptValidatorObjects prints, as the command ends, how to remove what +// its validator runs kept in the cluster. +func printKeptValidatorObjects(w io.Writer, commands []string) { + if len(commands) == 0 { + return + } + fmt.Fprintln(w, "note: the cluster-validator left objects in the cluster; remove them with:") + for _, cmd := range commands { + fmt.Fprintln(w, " "+cmd) + } +} + +// validatorStackValues returns the control-plane stack values forwarded to +// the validator, or none when stackValuesForRun cannot read the values that +// describe the install. Another stack's Gateways would have the validator +// judge Gateways the install never created. +func validatorStackValues() selfhosted.StackValues { + files, ok := stackValuesForRun(controlPlaneStackTarget()) + if !ok { + return selfhosted.StackValues{} + } + return selfhosted.LoadStackValues(files) +} + +// registryStackValues returns the stack values the registry credential check +// reads, from registryTarget's stack. Empty when they cannot be read. +func registryStackValues() selfhosted.StackValues { + files, ok := stackValuesForRun(registryTarget()) + if !ok { + return selfhosted.StackValues{} + } + return selfhosted.LoadStackValues(files) +} + +// registryTarget is the plane the registry credential check belongs to: the +// stack its registries come from, and the install state it is graded by. That +// is a visited plane checked before its install, whose install this machine +// runs next, with helm pulling the stack's charts on its credentials; else +// the control plane when visited, else the compute plane. +func registryTarget() stackTarget { + cp, gpu := controlPlaneStackTarget(), computePlaneStackTarget() + switch { + case controlPlaneIsVisited() && cp.beforeInstall: + return cp + case computePlaneIsVisited() && gpu.beforeInstall: + return gpu + case controlPlaneIsVisited(): + return cp + } + return gpu +} + +// visitedKubeContexts returns the kubeconfig context of each plane the run +// visits: the current context, "", for one cluster. +func visitedKubeContexts(mode kubectx.Mode) []string { + if mode != kubectx.ModeSplit { + return []string{""} + } + var out []string + if controlPlaneIsVisited() { + out = append(out, selfHostedControlPlaneContext) + } + if computePlaneIsVisited() { + out = append(out, selfHostedComputePlaneContext) + } + return out +} + +// stackTarget is one stack check can read values from. +type stackTarget struct { + // source is the stack flag; builtIn the stack the install commands use + // without it. + source, builtIn string + // name is the stack's directory in a checkout, under deploy/stacks. + name string + // beforeInstall is set when the run checks the plane before its install. + beforeInstall bool +} + +func controlPlaneStackTarget() stackTarget { + return stackTarget{source: selfHostedControlPlaneStack, builtIn: builtInControlPlaneStackOCI(), + name: "self-managed", beforeInstall: checkedBeforeInstall(checkControlPlane)} +} + +func computePlaneStackTarget() stackTarget { + return stackTarget{source: selfHostedComputePlaneStack, builtIn: builtInComputePlaneStackOCI(), + name: "nvcf-compute-plane", beforeInstall: checkedBeforeInstall(checkComputePlane)} +} + +// checkedBeforeInstall reports whether a plane is checked before its install: +// --pre checks it so, unless --all or the plane's own flag checks it as +// installed. +func checkedBeforeInstall(planeFlag bool) bool { + return checkPre && !checkAll && !planeFlag +} + +// stackValuesForRun returns the values files that describe the install, in +// the order helmfile layers them (base.yaml, then .yaml), and true only +// when it can read them from the stack the install used: +// +// - The environment must be named, with --env or HELMFILE_ENV. --env's +// default is not evidence of the environment an install used. Before +// the install it is: up installs with it. +// - .yaml must be read, since helmfile refuses to install without it, +// so base.yaml alone does not describe the install. +// - It is read from the stack the install commands resolve: the stack +// flag, else the built-in stack. A local directory is read directly, and +// an oci:// stack from the extraction an earlier command left in the +// cache. A git stack, file:// included, is not read: the install cloned +// it, so the working tree here can differ. With neither a flag nor a +// built-in stack, the stack checkout above the working directory is read. +// +// Otherwise nothing is read, and every stack-derived input is left out. +func stackValuesForRun(t stackTarget) ([]string, bool) { + env, ok := stackEnvForRun(t) + if !ok { + return nil, false + } + dir := stackDirForRun(t) + if dir == "" { + return nil, false } + envFile := filepath.Join(dir, "environments", env+".yaml") + if !isRegularFile(envFile) { + return nil, false + } + var files []string + if base := filepath.Join(dir, "environments", "base.yaml"); isRegularFile(base) { + files = append(files, base) + } + return append(files, envFile), true +} - // --wait mode: poll every 5s until all checks pass or the duration elapses. - dur, err := time.ParseDuration(selfHostedWait) +// stackEnvForRun returns the environment the install of t's plane used, and +// false when it is not known: the one the operator named, or, before the +// install, the one up installs with. +func stackEnvForRun(t stackTarget) (string, bool) { + if env, ok := explicitStackEnv(); ok { + return env, true + } + if t.beforeInstall { + return resolveStackEnv(), true + } + return "", false +} + +// stackDirForRun returns the root of the stack the install commands resolve +// for t, or "" when it cannot be read here: the stack flag, else the built-in +// stack, as a local directory or an extracted oci:// stack; with neither, the +// first stack checkout at or above the working directory with an +// environments directory. +func stackDirForRun(t stackTarget) string { + if src := cmp.Or(t.source, t.builtIn); src != "" { + return cmp.Or(localStackDir(src), selfhosted.ExtractedOCIStack(src)) + } + for _, dir := range checkoutStackDirs(t.name) { + if fi, err := os.Stat(filepath.Join(dir, "environments")); err == nil && fi.IsDir() { + return dir + } + } + return "" +} + +// namespaceGateStack returns the stack directory and environment a plane's +// stale-namespace scan reads the gates of the stack's releases from. env is +// empty when the install's environment is not known. +func namespaceGateStack(t stackTarget) (dir, env string) { + env, _ = stackEnvForRun(t) + return stackDirForRun(t), env +} + +// checkoutStackDirs lists where a stack can sit at or above the working +// directory: deploy/stacks/ in a repository checkout, or the stack +// directory itself. +func checkoutStackDirs(name string) []string { + cwd, err := os.Getwd() if err != nil { - return fmt.Errorf("invalid --wait duration %q: %w", selfHostedWait, err) + return nil } + var dirs []string + for i, dir := 0, cwd; i < 6; i++ { + if filepath.Base(dir) == name { + dirs = append(dirs, dir) + } + dirs = append(dirs, filepath.Join(dir, "deploy", "stacks", name)) + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + return dirs +} - deadline := time.After(dur) - ticker := time.NewTicker(5 * time.Second) - defer ticker.Stop() +func isRegularFile(path string) bool { + fi, err := os.Stat(path) + return err == nil && fi.Mode().IsRegular() +} - for { - lastResults = runOnce() - if !anyFailed(lastResults) { - emitCheckFinal(ctx, sink, lastResults) - maybeShowClusterValidatorLogs(c.ErrOrStderr(), lastResults) - return nil +// explicitStackEnv returns the helmfile environment the operator named: an +// explicit --env, then HELMFILE_ENV for an operator who exports it and runs +// helmfile directly. When the CLI runs helmfile itself it passes --env as +// HELMFILE_ENV, so an explicit flag must win. +func explicitStackEnv() (string, bool) { + if f := selfHostedCmd.PersistentFlags().Lookup("env"); f != nil && f.Changed { + if env := strings.TrimSpace(selfHostedEnv); env != "" { + return env, true } + } + if env := strings.TrimSpace(os.Getenv("HELMFILE_ENV")); env != "" { + return env, true + } + return "", false +} - select { - case <-deadline: - emitCheckFinal(ctx, sink, lastResults) - maybeShowClusterValidatorLogs(c.ErrOrStderr(), lastResults) - return &ExitCodeError{Code: 5, Msg: "wait timeout: checks still failing after " + selfHostedWait} - case <-ticker.C: - // continue polling - case <-ctx.Done(): - return ctx.Err() +// resolveStackEnv is the helmfile environment the run assumes: the one the +// operator named, else --env's default. +func resolveStackEnv() string { + if env, ok := explicitStackEnv(); ok { + return env + } + if env := strings.TrimSpace(selfHostedEnv); env != "" { + return env + } + return "local" +} + +// preferNGCKey reports whether the NGC API key goes ahead of the docker login +// for nvcr.io: only where up mints its pull secrets from the key, when the +// registry check's plane is the control plane before a local install. +// compute-plane install, which installs the compute plane next, mints none, +// and its helm pulls with the docker login. Anywhere else the docker login is +// what docker uses, and the key is sent only for a scope where there is no +// login or nvcr.io refuses it. +func preferNGCKey() bool { + t := registryTarget() + return t.name == controlPlaneStackTarget().name && t.beforeInstall && + strings.EqualFold(resolveStackEnv(), "local") +} + +// localStackDir returns src when it points at a readable local directory. +// Remote sources (oci://, git@, https://...git, file://) are not fetched +// here, so no check depends on a network round trip. file:// is a git source: +// the install clones its committed HEAD, which the working tree can differ +// from. +func localStackDir(src string) string { + if src == "" || strings.Contains(src, "://") || strings.HasPrefix(src, "git@") { + return "" + } + if fi, err := os.Stat(src); err == nil && fi.IsDir() { + return src + } + return "" +} + +// configList returns a list setting from its flag, env var or config file. +// viper.GetStringSlice reads a config-file entry that is a map or a list, +// such as a Kubernetes-style toleration, as "", and a value that is a single +// map as no entries at all, which would drop it without a word, so either is +// an error naming it. +func configList(key string) ([]string, error) { + var raw []any + switch v := viper.Get(key).(type) { + case nil, string, []string: + return viper.GetStringSlice(key), nil + case []any: + raw = v + default: + return nil, fmt.Errorf("%s is not a list of strings: %v", key, v) + } + out := make([]string, 0, len(raw)) + for i, item := range raw { + switch v := item.(type) { + case nil: + case string, bool, int, int64, uint64, float64: + out = append(out, fmt.Sprint(v)) + default: + return nil, fmt.Errorf("%s entry %d is not a string: %v", key, i+1, v) + } + } + return out, nil +} + +// configuredValidatorRegistries parses the extra registries from the flag, +// env var, or config file, one entry per registry. A malformed entry is an +// error naming it, as a malformed toleration is. +// +// viper.GetStringSlice splits a raw env string on whitespace, so the documented +// comma form "a:443,b:443" arrives as a single element and is split here. +func configuredValidatorRegistries() ([]selfhosted.RegistryEntry, error) { + raws, err := configList("cluster_validator_registries") + if err != nil { + return nil, fmt.Errorf("%w; write each registry as host[:port][/path]", err) + } + var out []selfhosted.RegistryEntry + for _, raw := range raws { + for _, part := range strings.Split(raw, ",") { + if part = strings.TrimSpace(part); part == "" { + continue + } + entry, err := selfhosted.ParseRegistryExtra(part) + if err != nil { + return nil, fmt.Errorf("--cluster-validator-registries %w", err) + } + out = append(out, entry) + } + } + return out, nil +} + +// configuredValidatorTolerations parses the validator Job's extra tolerations +// from the flag, env var, or config file. Each is key[=value][:effect]: with +// a value it matches that value, without one any; with no effect it matches +// every effect. +func configuredValidatorTolerations() ([]corev1.Toleration, error) { + raws, err := configList("cluster_validator_tolerations") + if err != nil { + return nil, fmt.Errorf("%w; write each toleration as key[=value][:effect]", err) + } + var out []corev1.Toleration + for _, raw := range raws { + for _, entry := range strings.Split(raw, ",") { + if entry = strings.TrimSpace(entry); entry == "" { + continue + } + t, err := parseToleration(entry) + if err != nil { + return nil, fmt.Errorf("--cluster-validator-tolerations %q: %w", entry, err) + } + out = append(out, t) + } + } + return out, nil +} + +var tolerationEffects = map[string]corev1.TaintEffect{ + "noschedule": corev1.TaintEffectNoSchedule, + "prefernoschedule": corev1.TaintEffectPreferNoSchedule, + "noexecute": corev1.TaintEffectNoExecute, +} + +func parseToleration(entry string) (corev1.Toleration, error) { + t := corev1.Toleration{Operator: corev1.TolerationOpExists} + if rest, effect, ok := strings.Cut(entry, ":"); ok { + e, known := tolerationEffects[strings.ToLower(effect)] + if !known { + return t, fmt.Errorf("effect must be NoSchedule, PreferNoSchedule or NoExecute, got %q", effect) } + t.Effect, entry = e, rest + } + if key, value, ok := strings.Cut(entry, "="); ok { + t.Operator, t.Value, entry = corev1.TolerationOpEqual, value, key + } + if t.Key = entry; t.Key == "" { + return t, fmt.Errorf("a taint key is required") + } + // The apiserver applies the same rules when the Job is created, after the + // run's RBAC, Secret and ConfigMap already exist. + if errs := validation.IsQualifiedName(t.Key); len(errs) > 0 { + return t, fmt.Errorf("invalid taint key %q: %s", t.Key, strings.Join(errs, "; ")) } + if errs := validation.IsValidLabelValue(t.Value); len(errs) > 0 { + return t, fmt.Errorf("invalid taint value %q: %s", t.Value, strings.Join(errs, "; ")) + } + return t, nil +} + +// The *IsVisited pair reports whether a role's checks run: its own flag, +// --all, or --pre, which checks both roles before an install in either mode. +// They gate the dispatch, the validators, the inotify probe and the budget. +func computePlaneIsVisited() bool { + return checkComputePlane || checkAll || checkPre +} + +func controlPlaneIsVisited() bool { + return checkControlPlane || checkAll || checkPre } -// maybeShowClusterValidatorLogs prints the cleaned cluster-validator transcript -// to the given writer when --show-logs is set, framed by markers so operators -// can find it in mixed CLI output. Silent no-op when: -// - --show-logs is not set, -// - the cluster-validator check did not run (--skip-cluster-validation, or -// no compute-plane category was selected), -// - the runner returned no Logs (Job never produced output). +// withoutHostLocalChecks returns a copy of cfg with the inputs for the checks +// that run on the operator's machine cleared: local tool versions and registry +// credentials. Neither contacts a cluster, so exactly one role invocation must +// carry them. Without this both roles emit the same check IDs, which +// double-counts them in the pass/fail totals and repeats them in --json. +func withoutHostLocalChecks(cfg selfhosted.PreflightConfig) selfhosted.PreflightConfig { + cfg.Tools = nil + cfg.Registries = nil + cfg.RegistryChecker = nil + return cfg +} + +// maybeShowClusterValidatorLogs prints each cleaned cluster-validator +// transcript to w when --show-logs is set, framed by markers that name the +// check category it ran for, so operators can find it in mixed CLI output and +// tell the two roles apart: both report under the same check ID, and a +// validator image that predates roles prints no role line of its own. Nothing +// is printed for a validator that did not run (skipped, no image, a role not +// visited) or produced no output. // -// Runs after emitCheckFinal in every exit path so the transcript appears -// after the structured events / table, regardless of which renderer was -// selected. +// Runs once the sink has closed, on every exit path, interrupt included, so +// the transcript appears after the structured events or table, whichever +// renderer was selected. func maybeShowClusterValidatorLogs(w io.Writer, results []selfhosted.CheckResult) { if !checkShowLogs { return @@ -259,25 +953,36 @@ func maybeShowClusterValidatorLogs(w io.Writer, results []selfhosted.CheckResult if r.ID != "cluster-validator" || r.Logs == "" { continue } - fmt.Fprintln(w, "--- cluster-validator logs ---") + fmt.Fprintf(w, "--- cluster-validator logs (%s) ---\n", r.Category) fmt.Fprint(w, r.Logs) - fmt.Fprintln(w, "--- end cluster-validator logs ---") - return + fmt.Fprintf(w, "--- end cluster-validator logs (%s) ---\n", r.Category) } } -func selectCheckRenderer(w io.Writer, wait bool) (progress.EventSink, error) { +// selectCheckRendererFn is a test seam over selectCheckRenderer. +var selectCheckRendererFn = selectCheckRenderer + +// Test seams over the renderers selectCheckRenderer picks from. +var ( + selectProgressRenderer = progress.SelectRenderer + newCheckOneShotRenderer = func(w io.Writer, opts progress.ModelOpts) progress.EventSink { + return progress.NewCheckOneShotRenderer(w, opts) + } +) + +func selectCheckRenderer(w io.Writer, wait bool, onQuit func()) (progress.EventSink, error) { if !wait && !selfHostedJSON && !selfHostedPlain && !selfHostedAccessible && checkWriterIsTTY(w) { - return progress.NewCheckOneShotRenderer(w, progress.ModelOpts{ + return newCheckOneShotRenderer(w, progress.ModelOpts{ Mode: progress.ModeCheck, Output: w, Cluster: checkClusterName, ControlPlaneContext: selfHostedControlPlaneContext, ComputePlaneContext: selfHostedComputePlaneContext, + OnQuit: onQuit, }), nil } - sink, _, err := progress.SelectRenderer(w, progress.RenderOpts{ + sink, _, err := selectProgressRenderer(w, progress.RenderOpts{ JSON: selfHostedJSON, Plain: selfHostedPlain, Accessible: selfHostedAccessible, @@ -285,143 +990,670 @@ func selectCheckRenderer(w io.Writer, wait bool) (progress.EventSink, error) { Cluster: checkClusterName, ControlPlaneContext: selfHostedControlPlaneContext, ComputePlaneContext: selfHostedComputePlaneContext, + OnQuit: onQuit, }) return sink, err } -// runPreflightByRole dispatches RunPreflightForRole using the role(s) derived -// from the context-flag combination per SRD/SDD §5.4: +// runPreflightByRole dispatches RunPreflightForRole for the roles the scope +// flags visit (see the *IsVisited pair): // -// - --local-only or cfg.LocalOnly → RoleLocalOnly only -// - ModeSingle (no context flags) → RoleControlPlane + RoleComputePlane sequentially -// - ModeSplit (both context flags set) → RoleControlPlane + RoleComputePlane in parallel +// - --local-only (cfg.LocalOnly): RoleLocalOnly only, with a skipped row for +// each cluster check left out; +// - ModeSingle (no context flags): the visited roles in turn, on one cluster; +// - ModeSplit (both context flags): the visited roles in parallel, each on +// its own context. // -// clusterValidatorImage is the already-resolved validator image (empty when -// not configured). Resolution happens in the caller so the outer-timeout -// and stderr-note logic can see the same answer this function does. -func runPreflightByRole(ctx context.Context, cfg selfhosted.PreflightConfig, sink progress.EventSink, clusterValidatorImage string) []selfhosted.CheckResult { - // LocalOnly: skip all cluster probes. +// mode is the already-resolved kubectx.Mode (hoisted to the caller so image +// resolution and timeout sizing share the same answer). clusterValidatorImage +// is the already-resolved validator image (empty when not configured). +// sharedInotifyProber returns the inotify prober every poll shares. +func runPreflightByRole( + ctx context.Context, cfg selfhosted.PreflightConfig, sink progress.EventSink, mode kubectx.Mode, + clusterValidatorImage, unresolvedImage string, ledger *selfhosted.CleanupLedger, + sharedInotifyProber func() selfhosted.NodeInotifyProber, +) []selfhosted.CheckResult { + // LocalOnly: skip all cluster probes, and say so in a row for each. if cfg.LocalOnly { - return selfhosted.RunPreflightForRole(ctx, cfg, selfhosted.RoleLocalOnly, selfhosted.RoleConfig{}, sink) + return checkRunRole(ctx, cfg, selfhosted.RoleLocalOnly, + selfhosted.RoleConfig{Skipped: localOnlySkips()}, sink) } + // SIS reachability is a compute-plane, post-install concern: only an + // explicit --all or --compute-plane asks for it. A bare --pre skips it + // because SIS is not up before install, and --control-plane does not + // target the compute plane. It probes only a URL the operator configured. icmsURL := "" - if checkAll || checkComputePlane || !checkPre { - icmsURL = resolveICMSURL(selfHostedICMSURL) + var computeSkips []selfhosted.SkippedCheck + if checkAll || checkComputePlane { + var unset string + if icmsURL, unset = resolveCheckSISURL(selfHostedICMSURL); icmsURL == "" { + computeSkips = append(computeSkips, selfhosted.SkippedCheck{ + Category: selfhosted.CategoryComputePlane, ID: "sis-reachability", Warn: true, + Message: "SIS reachability not checked: " + unset + "; pass --icms-url or set NVCF_ICMS_URL", + }) + } } - mode := kubectx.SelectMode(selfHostedControlPlaneContext, selfHostedComputePlaneContext) - skipInotify := checkSkipInotifyCheck || os.Getenv("NVCF_CLI_SELFHOSTED_SKIP_INOTIFY") != "" var inotifyProber selfhosted.NodeInotifyProber - if !skipInotify { - inotifyProber = newInotifyProberForSelfHosted() + // Visited, not targeted: the inotify limit is exactly what --pre exists to + // catch before NVCA bootstrap, so it must run for --pre in ModeSplit too. + if computePlaneIsVisited() { + if inotifyCheckSkipped() { + computeSkips = append(computeSkips, selfhosted.SkippedCheck{ + Category: selfhosted.CategoryComputePlane, ID: "node-inotify-limits", + Message: "node inotify limits skipped (--skip-inotify-check)", + }) + } else { + inotifyProber = sharedInotifyProber() + } } + computeSkips = append(computeSkips, + validatorSkip(selfhosted.CategoryComputePlane, clusterValidatorImage, unresolvedImage)...) + cpSkips := validatorSkip(selfhosted.CategoryControlPlane, clusterValidatorImage, unresolvedImage) // clusterValidatorImage is resolved by the caller. Empty value means // either the operator explicitly opted out (--skip-cluster-validation / // env) or no image is configured (no flag / env / config-file value). - // Either way, leave clusterValidator nil so the validator row is - // omitted from the check stream; the caller already emitted a - // one-line stderr notice explaining which case applies. + // Either way the validator is nil and the role's Skipped row says which. + // Gate on the role predicate as well as the image, mirroring + // cpClusterValidator below. Without this, --control-plane in ModeSplit + // still creates a ServiceAccount, cluster-wide ClusterRole/CRB, pull secret + // and validator Job in the compute cluster. var clusterValidator selfhosted.ClusterValidator - if clusterValidatorImage != "" { + if computePlaneIsVisited() && clusterValidatorImage != "" { clusterValidator = newClusterValidatorForSelfHosted() } + staleNSProber := newStaleNamespaceProberForSelfHosted() + // Each plane's stale-namespace scan reads the gates of its stack's + // releases from the stack and environment the other stack values come + // from. Without a known environment the stack's base.yaml defaults decide. + cpStackDir, cpStackEnv := namespaceGateStack(controlPlaneStackTarget()) + gpuStackDir, gpuStackEnv := namespaceGateStack(computePlaneStackTarget()) + + // The control-plane validator probes the hosts the local credential check + // probes, extras included, each as a warning only, but from the + // control-plane stack where that check reads the compute plane's. + registries := cfg.Registries + if len(registries) > 0 && registryTarget().name != controlPlaneStackTarget().name { + cpStack := selfhosted.StackValues{} + if files, ok := stackValuesForRun(controlPlaneStackTarget()); ok { + cpStack = selfhosted.LoadStackValues(files) + } + // Validated before the run starts. + extras, _ := configuredValidatorRegistries() + registries = selfhosted.EnumerateRegistries(cmp.Or(clusterValidatorImage, unresolvedImage), cpStack, extras) + } + validatorEnv := clusterValidatorJobEnv(validatorStackValues()) + // Validated before the run starts. + validatorTolerations, _ := configuredValidatorTolerations() + cpValidatorEnv := validatorEnvForRole(validatorEnv, controlPlaneStackTarget().beforeInstall) + gpuValidatorEnv := validatorEnvForRole(validatorEnv, computePlaneStackTarget().beforeInstall) + + // The cluster-validator image is the same for both roles; VALIDATOR_ROLE + // in the Job env selects which check set runs inside the binary. + // Gate on the image, not on the compute-plane validator being constructed: + // in ModeSplit with --control-plane the compute role is not targeted, so + // clusterValidator is nil and keying off it would drop the control-plane + // validator check from the run entirely. + var cpClusterValidator selfhosted.ClusterValidator + if controlPlaneIsVisited() && clusterValidatorImage != "" { + cpClusterValidator = newClusterValidatorForSelfHosted() + } + + // Local tool versions and registry credentials are checked on the + // operator's machine, so they belong to one invocation only. The control + // plane carries them when it runs; otherwise the compute plane does, so + // neither is silently dropped by a compute-plane-only invocation. + runControlPlane := controlPlaneIsVisited() + runComputePlane := computePlaneIsVisited() + cpCfg, gpuCfg := cfg, cfg + if runControlPlane { + gpuCfg = withoutHostLocalChecks(cfg) + } + switch mode { case kubectx.ModeSplit: // Run both roles in parallel; each gets its own kubeconfig context. + // Each dispatch is gated: with only one role selected the other + // cluster is never contacted, so its stale-namespace probe cannot + // report a failure the operator did not ask about. var ( cpResults []selfhosted.CheckResult gpuResults []selfhosted.CheckResult ) eg, egCtx := errgroup.WithContext(ctx) - eg.Go(func() error { - rc := selfhosted.RoleConfig{KubeContext: selfHostedControlPlaneContext} - cpResults = selfhosted.RunPreflightForRole(egCtx, cfg, selfhosted.RoleControlPlane, rc, sink) - return nil - }) - eg.Go(func() error { - rc := selfhosted.RoleConfig{ - KubeContext: selfHostedComputePlaneContext, - SISURL: icmsURL, - InotifyProber: inotifyProber, - ClusterValidator: clusterValidator, - ClusterValidatorImage: clusterValidatorImage, - ClusterValidatorPullSecret: checkClusterValidatorPullSecret, - ClusterValidatorNoCleanup: checkClusterValidatorNoCleanup, - } - gpuResults = selfhosted.RunPreflightForRole(egCtx, cfg, selfhosted.RoleComputePlane, rc, sink) - return nil - }) + if runControlPlane { + eg.Go(func() error { + rc := selfhosted.RoleConfig{ + KubeContext: selfHostedControlPlaneContext, + ClusterValidator: cpClusterValidator, + ClusterValidatorImage: clusterValidatorImage, + ClusterValidatorPullSecret: checkClusterValidatorPullSecret, + ClusterValidatorNoCleanup: checkClusterValidatorNoCleanup, + ClusterValidatorEnv: cpValidatorEnv, + ClusterValidatorTolerations: validatorTolerations, + ClusterValidatorUnresolvedImage: unresolvedImage, + ClusterValidatorRegistries: registries, + ValidatorCleanup: ledger, + StaleNamespaceProber: staleNSProber, + StackDir: cpStackDir, + Skipped: cpSkips, + StackEnv: cpStackEnv, + } + cpResults = checkRunRole(egCtx, cpCfg, selfhosted.RoleControlPlane, rc, sink) + return nil + }) + } + if runComputePlane { + eg.Go(func() error { + rc := selfhosted.RoleConfig{ + KubeContext: selfHostedComputePlaneContext, + SISURL: icmsURL, + InotifyProber: inotifyProber, + ClusterValidator: clusterValidator, + ClusterValidatorImage: clusterValidatorImage, + ClusterValidatorPullSecret: checkClusterValidatorPullSecret, + ClusterValidatorNoCleanup: checkClusterValidatorNoCleanup, + ClusterValidatorEnv: gpuValidatorEnv, + ClusterValidatorTolerations: validatorTolerations, + ClusterValidatorUnresolvedImage: unresolvedImage, + ValidatorCleanup: ledger, + StaleNamespaceProber: staleNSProber, + StackDir: gpuStackDir, + Skipped: computeSkips, + StackEnv: gpuStackEnv, + } + gpuResults = checkRunRole(egCtx, gpuCfg, selfhosted.RoleComputePlane, rc, sink) + return nil + }) + } _ = eg.Wait() return append(cpResults, gpuResults...) - default: // ModeSingle — no context flags; union both role check sets sequentially. - cpRC := selfhosted.RoleConfig{SISURL: icmsURL} - gpuRC := selfhosted.RoleConfig{ - SISURL: icmsURL, - InotifyProber: inotifyProber, - ClusterValidator: clusterValidator, - ClusterValidatorImage: clusterValidatorImage, - ClusterValidatorPullSecret: checkClusterValidatorPullSecret, - ClusterValidatorNoCleanup: checkClusterValidatorNoCleanup, - } - cpResults := selfhosted.RunPreflightForRole(ctx, cfg, selfhosted.RoleControlPlane, cpRC, sink) - gpuResults := selfhosted.RunPreflightForRole(ctx, cfg, selfhosted.RoleComputePlane, gpuRC, sink) - return append(cpResults, gpuResults...) + default: // ModeSingle - one cluster; run only the roles the flags target. + var results []selfhosted.CheckResult + + // The stale-namespace probe goes to exactly one role. Both roles share + // the cluster here, so handing it to both emits two check_completed + // events with the same ID and loads the kubeconfig twice, which means a + // second exec-credential-plugin prompt. + // + // The two roles probe disjoint namespace lists, so the skipped role's + // namespaces are merged into the surviving probe rather than dropped. + // Without that, a kai-scheduler or nvca-operator namespace wedged + // Terminating by a failed teardown reports "no stale NVCF namespaces + // detected" and the next install fails into it. + // Merged only when both roles run: a --control-plane run must not + // report a stuck kai-scheduler namespace, nor --compute-plane a stuck + // vault-system, which is the rule ModeSplit already follows. + staleForControlPlane := staleNSProber + staleForComputePlane := staleNSProber + var cpExtraNamespaces []string + if runControlPlane { + staleForComputePlane = nil + if runComputePlane { + cpExtraNamespaces = selfhosted.ComputePlaneStaleNamespaces(gpuStackDir, gpuStackEnv) + } + } else { + staleForControlPlane = nil + } + + if runControlPlane { + cpRC := selfhosted.RoleConfig{ + ClusterValidator: cpClusterValidator, + ClusterValidatorImage: clusterValidatorImage, + ClusterValidatorPullSecret: checkClusterValidatorPullSecret, + ClusterValidatorNoCleanup: checkClusterValidatorNoCleanup, + ClusterValidatorEnv: cpValidatorEnv, + ClusterValidatorTolerations: validatorTolerations, + ClusterValidatorUnresolvedImage: unresolvedImage, + ClusterValidatorRegistries: registries, + ValidatorCleanup: ledger, + StaleNamespaceProber: staleForControlPlane, + StackDir: cpStackDir, + StackEnv: cpStackEnv, + ExtraStaleNamespaces: cpExtraNamespaces, + Skipped: cpSkips, + } + results = append(results, + checkRunRole(ctx, cpCfg, selfhosted.RoleControlPlane, cpRC, sink)...) + } + if runComputePlane { + gpuRC := selfhosted.RoleConfig{ + SISURL: icmsURL, + InotifyProber: inotifyProber, + ClusterValidator: clusterValidator, + ClusterValidatorImage: clusterValidatorImage, + ClusterValidatorPullSecret: checkClusterValidatorPullSecret, + ClusterValidatorNoCleanup: checkClusterValidatorNoCleanup, + ClusterValidatorEnv: gpuValidatorEnv, + ClusterValidatorTolerations: validatorTolerations, + ClusterValidatorUnresolvedImage: unresolvedImage, + ValidatorCleanup: ledger, + StaleNamespaceProber: staleForComputePlane, + StackDir: gpuStackDir, + Skipped: computeSkips, + StackEnv: gpuStackEnv, + } + results = append(results, + checkRunRole(ctx, gpuCfg, selfhosted.RoleComputePlane, gpuRC, sink)...) + } + return results } } -// Resolves the validator image from the viper-backed config chain -// (flag > env > config-file > default). Returns ("", false) when nothing -// is configured so the caller can surface a clear "not configured" -// warning instead of pulling from a stale built-in default. +// validatorEnvForRole returns env for the validator of a role whose plane is +// checked before its install or not, as checkedBeforeInstall decides. One +// checked as installed is told the run is post-install, so an empty control +// plane fails instead of passing as pre-install. env itself is not modified. +func validatorEnvForRole(env map[string]string, beforeInstall bool) map[string]string { + if beforeInstall { + return env + } + out := make(map[string]string, len(env)+1) + for k, v := range env { + out[k] = v + } + out["VALIDATOR_POST_INSTALL"] = "true" + return out +} + +// clusterValidatorJobEnv is what the validator container needs from the CLI's +// resolved configuration, matching what the chart CronJob forwards +// (validatorEnvForRole adds VALIDATOR_POST_INSTALL for each role): +// - relocated OpenBao and Envoy Gateway namespaces, without which the Tier +// rows assess the defaults and miss the real components; +// - NVCF_GATEWAY_NAMES, the override for the NVCF Gateway discovery; +// - the overlay probe image, so a mirrored cluster does not pull busybox +// from Docker Hub; +// - NVCF_EXTERNAL_COMPONENTS, the dependencies that run outside the stack, +// so the validator does not fail them for being absent from the cluster; +// - NVCF_STORAGE_CLASS, the class the stack's PVCs name, so a cluster with +// no default class is not failed for it. +func clusterValidatorJobEnv(stack selfhosted.StackValues) map[string]string { + env := map[string]string{} + if ns := configValue("NVCF_OPENBAO_NAMESPACE"); ns != "" { + env["NVCF_OPENBAO_NAMESPACE"] = ns + } + envoyNS := configValue("NVCF_ENVOY_GATEWAY_NAMESPACE") + if envoyNS == "" { + envoyNS = stack.EnvoyGatewayNamespace + } + if envoyNS != "" { + env["NVCF_ENVOY_GATEWAY_NAMESPACE"] = envoyNS + } + // The launcher knows the NVCF Gateways from the stack, so the validator + // need not rediscover them from route labels, and after install can fail + // a named Gateway that has no proxy. An explicit setting still wins. + if names := configValue("NVCF_GATEWAY_NAMES"); names != "" { + env["NVCF_GATEWAY_NAMES"] = names + } else if len(stack.Gateways) > 0 { + env["NVCF_GATEWAY_NAMES"] = strings.Join(stack.Gateways, ",") + } + if probe := configuredProbeImage(); probe != "" { + env["NVCF_N2N_PROBE_IMAGE"] = probe + } + // Validated before the run starts. An explicit setting wins over the + // components the stack leaves out. + external, _ := configuredExternalComponents() + if external == nil { + external = stack.ExternalComponents + } + if len(external) > 0 { + env["NVCF_EXTERNAL_COMPONENTS"] = strings.Join(external, ",") + } + if class := cmp.Or(configValue("NVCF_STORAGE_CLASS"), stack.StorageClass); class != "" { + env["NVCF_STORAGE_CLASS"] = class + } + return env +} + +// configuredProbeImage is the busybox-style image the probe pods the check +// launches use, from --cluster-validator-probe-image and its env and config +// key, or else the validator's own NVCF_N2N_PROBE_IMAGE setting. Empty leaves +// each probe on its default. +func configuredProbeImage() string { + if probe := strings.TrimSpace(viper.GetString("cluster_validator_probe_image")); probe != "" { + return probe + } + return configValue("NVCF_N2N_PROBE_IMAGE") +} + +// configuredExternalComponents returns the components the operator says run +// outside the stack: --cluster-validator-external-components (or its env and +// config key), then NVCF_EXTERNAL_COMPONENTS. nil means neither is set, and +// the stack values decide. +func configuredExternalComponents() ([]string, error) { + raw, err := configList("cluster_validator_external_components") + if err != nil { + return nil, fmt.Errorf("%w; write each component as its name", err) + } + if len(raw) == 0 { + raw = []string{configValue("NVCF_EXTERNAL_COMPONENTS")} + } + out, err := selfhosted.ParseExternalComponents(raw) + if err != nil { + return nil, fmt.Errorf("--cluster-validator-external-components: %w", err) + } + return out, nil +} + +// configValue reads a setting the way the CLI's cluster configuration does +// (environment, then the nvcf-cli config file). A YAML list in the config +// file is joined with commas, the form the validator's list settings parse; +// GetString alone returns "" for a list and would drop it silently. +func configValue(key string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + if !viper.IsSet(key) { + return "" + } + if _, isList := viper.Get(key).([]any); isList { + var items []string + for _, item := range viper.GetStringSlice(key) { + if item = strings.TrimSpace(item); item != "" { + items = append(items, item) + } + } + return strings.Join(items, ",") + } + return strings.TrimSpace(viper.GetString(key)) +} + +// resolveClusterValidatorImage resolves the validator image from flag > env > +// config-file. Returns ("", false) when unconfigured. When only a repo is +// given, discovers the latest stable tag (1h cached; falls back on failure). // -// When the configured value already has a tag, it is used as-is. When it -// names only a repo, the latest tag is discovered from the registry -// (preferring stable over rc, 1h cached); any discovery failure falls -// back to the configured value unchanged. -func resolveClusterValidatorImage(ctx context.Context) (string, bool) { - image := viper.GetString("cluster_validator_image") - if image == "" { - return "", false +// An unpinned image whose tag could not be discovered is not launched: the +// kubelet would pull it as :latest, which the validator repository does not +// publish, and fail the run on a pull error instead of saying why. +func resolveClusterValidatorImage(ctx context.Context) (image string, unresolved string) { + configured := viper.GetString("cluster_validator_image") + if configured == "" { + return "", "" } - if discovered, ok := resolveLatestValidatorTagForSelfHosted(ctx, image); ok { - return discovered, true + if discovered, ok := resolveLatestValidatorTagForSelfHosted(ctx, configured); ok { + return discovered, "" } - return image, true + if !selfhosted.ImageRefIsPinned(configured) { + return "", configured + } + return configured, "" } // emitCheckFinal emits a Final event with check-mode verdict fields derived // from the result slice. Called once per run (or once per wait-loop exit). -func emitCheckFinal(ctx context.Context, sink progress.EventSink, results []selfhosted.CheckResult) { - var passed, failed int - for _, r := range results { - if r.Passed { - passed++ - } else { - failed++ - } - } +func emitCheckFinal(ctx context.Context, sink progress.EventSink, results []selfhosted.CheckResult, cleanup []string) { + // Success and FailedCount must agree with the process exit code, which is + // driven by anyFailed. Counting every non-pass as a failure made a + // warning-severity result emit success:false / verdict:failed while the + // process exited 0, so a CI gate on final.success broke for anyone whose + // registry credentials live in a Docker credential helper. "warnings" is + // already part of the documented Verdict vocabulary; it was never emitted. + passed, failed, warned := selfhosted.CountResults(results) verdict := "ok" - if failed > 0 { + switch { + case failed > 0: verdict = "failed" + case warned > 0: + verdict = "warnings" } _ = sink.Emit(ctx, progress.Final{ - Success: failed == 0, - Verdict: verdict, - TotalChecks: len(results), - PassedCount: passed, - FailedCount: failed, + Success: failed == 0, + Verdict: verdict, + TotalChecks: len(results), + PassedCount: passed, + FailedCount: failed, + WarningCount: warned, + Cleanup: cleanup, + }) +} + +// emitCheckTimeout emits the final event of a run that ended in a timeout, +// exit 5. Nothing passed the gate, whatever the rows say, so success is false. +func emitCheckTimeout(sink progress.EventSink, results []selfhosted.CheckResult, cleanup []string) { + passed, failed, warned := selfhosted.CountResults(results) + _ = sink.Emit(context.Background(), progress.Final{ + Success: false, + Verdict: "timeout", + TotalChecks: len(results), + PassedCount: passed, + FailedCount: failed, + WarningCount: warned, + Cleanup: cleanup, }) } -// anyFailed returns true if any check failed at error severity. Warnings do -// not trigger non-zero exit per spec §6.3. +// anyCutShort reports a check a budget stopped before it finished. +func anyCutShort(results []selfhosted.CheckResult) bool { + for _, r := range results { + if r.CutShort { + return true + } + } + return false +} + +// anyCutShortFailure reports a check a budget stopped whose worst outcome +// fails the run. One that could at worst warn does not. +func anyCutShortFailure(results []selfhosted.CheckResult) bool { + for _, r := range results { + if r.CutShort && r.IsBlockingFailure() { + return true + } + } + return false +} + +// checkPreflightTools is a test seam over the local tool checks, so command +// tests do not depend on what is installed on the machine running them. +var checkPreflightTools = selfHostedPreflightTools + +// checkRunRole is a test seam over one role's checks, so command tests can +// grade rows a time budget stopped without waiting it out. +var checkRunRole = selfhosted.RunPreflightForRole + +// checkBudget is a test seam over the command's outer time budget. +var checkBudget = func(d time.Duration) time.Duration { return d } + +// checkPollInterval is how often --wait runs the checks again. A var so tests +// can shorten it. +var checkPollInterval = 5 * time.Second + +// parseCheckWait parses --wait. Empty is a single run. A malformed or +// non-positive duration is a usage error, reported before any work starts. +func parseCheckWait(raw string) (time.Duration, error) { + if raw == "" { + return 0, nil + } + d, err := time.ParseDuration(raw) + if err != nil { + return 0, fmt.Errorf("invalid --wait duration %q: %w", raw, err) + } + if d <= 0 { + return 0, fmt.Errorf("invalid --wait duration %q: must be positive", raw) + } + return d, nil +} + +// checkRunBudget is the command's outer time budget. Each role invocation gets +// the local share, for the checks that run on this machine, and the probe +// share, which it enforces on the cluster checks before its validator, plus +// the validator's longest run when one is configured: its own timeout, the +// wait after it for the Job's deadline to end the pod, the reads that grade +// it and the sweeps. Sizing on the validator's timeout alone ran the budget +// out during that wait, reporting a spent budget in place of the validator's +// own failure. ModeSingle runs the two roles in turn, so each needs a share; +// ModeSplit runs them in parallel. --wait polls for its duration on top. +func checkRunBudget(mode kubectx.Mode, localOnly, validatorConfigured bool, wait time.Duration) time.Duration { + perRole := selfhosted.LocalCheckShare() + selfhosted.CheckProbeShare() + if validatorConfigured { + perRole += selfhosted.ClusterValidatorRunCeiling() + } + roles := time.Duration(1) + if !localOnly && mode == kubectx.ModeSingle && controlPlaneIsVisited() && computePlaneIsVisited() { + roles = 2 + } + return roles*perRole + wait +} + +func clusterValidationSkipped() bool { + return checkSkipClusterValidation || envToggleOn(envSkipClusterValidation) +} + +func inotifyCheckSkipped() bool { + return checkSkipInotifyCheck || envToggleOn(envSkipInotify) +} + +// The env toggles of check's --local-only, --skip-cluster-validation and +// --skip-inotify-check. +const ( + envLocalOnly = "NVCF_CLI_SELFHOSTED_LOCAL_ONLY" + envSkipClusterValidation = "NVCF_CLI_SELFHOSTED_SKIP_CLUSTER_VALIDATION" + envSkipInotify = "NVCF_CLI_SELFHOSTED_SKIP_INOTIFY" +) + +// envToggle reads a boolean env toggle: unset or empty is off, and a word +// progress.ParseToggle reads says which, so "false", "0" and "no" are off. +// Any other value is an error naming it. +func envToggle(name string) (bool, error) { + raw := strings.TrimSpace(os.Getenv(name)) + if raw == "" { + return false, nil + } + on, ok := progress.ParseToggle(raw) + if !ok { + return false, fmt.Errorf("%s=%q: expected true or false (or 1/0, yes/no, y/n, on/off)", name, raw) + } + return on, nil +} + +// envToggleOn is envToggle for a toggle validated before the run starts. +func envToggleOn(name string) bool { + on, _ := envToggle(name) + return on +} + +// resolveCheckSISURL returns the SIS URL to probe, or "" and why there is +// none. Unlike resolveICMSURL it does not fall back to the client's built-in +// base_http_url, nor derive one from a base_http_url on NVIDIA's hosted +// service, as a config copied from the template has: neither is this install. +func resolveCheckSISURL(flagValue string) (sisURL, unset string) { + if flagValue == "" && os.Getenv("NVCF_ICMS_URL") == "" && os.Getenv("NVCF_SIS_URL") == "" && + strings.TrimSpace(viper.GetString("icms_url")) == "" { + switch base := strings.TrimSpace(viper.GetString("base_http_url")); { + case base == "": + return "", "no ICMS URL is configured" + case onNVIDIAHostedNVCF(base): + return "", "base_http_url names NVIDIA's hosted NVCF, not this install" + } + } + return resolveICMSURL(flagValue), "" +} + +// onNVIDIAHostedNVCF reports whether rawURL is on the domain of NVIDIA's +// hosted NVCF, which the client's default base_http_url and the config +// template's name. +func onNVIDIAHostedNVCF(rawURL string) bool { + u, err := url.Parse(rawURL) + if err != nil { + return false + } + host := strings.ToLower(u.Hostname()) + return host == "nvcf.nvidia.com" || strings.HasSuffix(host, ".nvcf.nvidia.com") +} + +// validatorSkip is the row for a role whose validator does not run: none when +// it runs, or when the role reports its own unresolved image. +func validatorSkip(category, image, unresolvedImage string) []selfhosted.SkippedCheck { + switch { + case clusterValidationSkipped(): + return []selfhosted.SkippedCheck{{ + Category: category, ID: "cluster-validator", + Message: "cluster-validator skipped (--skip-cluster-validation)", + }} + case image == "" && unresolvedImage == "": + return []selfhosted.SkippedCheck{{ + Category: category, ID: "cluster-validator", Warn: true, + Message: "cluster-validator not run: cluster_validator_image is not set; set it in the nvcf-cli " + + "config, NVCF_CLI_CLUSTER_VALIDATOR_IMAGE or --cluster-validator-image to validate the cluster", + }} + } + return nil +} + +// localOnlySkips are the rows for what --local-only leaves out: the registry +// credential check, and the cluster checks of each role the scope selects. +func localOnlySkips() []selfhosted.SkippedCheck { + skips := []selfhosted.SkippedCheck{{ + Category: selfhosted.CategoryRegistryCredentials, ID: "registry-credentials", + Message: "registry credentials skipped (--local-only)", + }} + if controlPlaneIsVisited() { + skips = append(skips, selfhosted.SkippedCheck{ + Category: selfhosted.CategoryControlPlane, ID: "control-plane-cluster", + Message: "control-plane cluster checks skipped (--local-only)", + }) + } + if computePlaneIsVisited() { + skips = append(skips, selfhosted.SkippedCheck{ + Category: selfhosted.CategoryComputePlane, ID: "compute-plane-cluster", + Message: "compute-plane cluster checks skipped (--local-only)", + }) + } + return skips +} + +// anyFindingFailed reports a blocking failure the cluster or host caused, not +// the budget. +func anyFindingFailed(results []selfhosted.CheckResult) bool { + for _, r := range results { + if r.IsBlockingFailure() && !r.CutShort { + return true + } + } + return false +} + +// anyWarningToWaitOn reports a warning a later poll may clear: one expected to +// clear by itself, such as a rollout the validator saw in progress, or a check +// its budget stopped. --wait keeps polling on it; a single run still exits 0. +// A warning its share cut short on the previous poll too is not waited on: +// each poll gets the same shares, so the next would most likely cut it short +// the same way, and --wait would exit 5 where a single run exits 0. +func anyWarningToWaitOn(results, previous []selfhosted.CheckResult) bool { + cutBefore := map[[2]string]bool{} + for _, r := range previous { + if cutShortWarning(r) { + cutBefore[[2]string{r.Category, r.ID}] = true + } + } + for _, r := range results { + switch { + case r.Passed: + case r.Transient: + return true + case r.CutShort && !(cutShortWarning(r) && cutBefore[[2]string{r.Category, r.ID}]): + return true + } + } + return false +} + +// cutShortWarning reports a check a budget stopped that could at worst warn. +func cutShortWarning(r selfhosted.CheckResult) bool { + return r.CutShort && r.IsWarning() +} + +// anyFailed reports a result that fails the run. Warnings do not. The exit +// code and the final event's verdict both grade with IsBlockingFailure, so +// they cannot disagree. func anyFailed(results []selfhosted.CheckResult) bool { for _, r := range results { - if !r.Passed && r.Severity == "error" { + if r.IsBlockingFailure() { return true } } diff --git a/src/clis/nvcf-cli/cmd/self_hosted_check_rows_test.go b/src/clis/nvcf-cli/cmd/self_hosted_check_rows_test.go new file mode 100644 index 0000000000..5937058a2c --- /dev/null +++ b/src/clis/nvcf-cli/cmd/self_hosted_check_rows_test.go @@ -0,0 +1,822 @@ +/* +SPDX-FileCopyrightText: Copyright (c) NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "nvcf-cli/internal/selfhosted" + "nvcf-cli/internal/selfhosted/kubectx" + "nvcf-cli/internal/selfhosted/progress" +) + +const pinnedValidatorImage = "nvcr.io/nvidia/nvcf-byoc/cluster-validator:1.0.0" + +// checkStubs replaces what a recorded check run would reach outside the test. +type checkStubs struct { + stale func(ctx context.Context) ([]selfhosted.StaleNamespace, error) + inotify func(ctx context.Context) ([]selfhosted.NodeInotifyLimits, error) + validator func(ctx context.Context, p selfhosted.ClusterValidatorParams) selfhosted.ClusterValidatorResult + tools func() []selfhosted.BinarySpec + budget time.Duration // 0 keeps the requested budget + noSISURL bool // leave the ICMS URL unconfigured +} + +type staleCall struct { + context string + namespaces []string +} + +type validatorCall struct { + context, role, postInstall string +} + +// checkRun is what one recorded `self-hosted check --json` run did. +type checkRun struct { + err error + stderr string + budget time.Duration + stale []staleCall + inotify []string + validators []validatorCall + sisHits int64 + + // inotifyProbers counts the inotify probers built. + inotifyProbers int +} + +func (r checkRun) code() int { + if r.err == nil { + return 0 + } + var exitErr *ExitCodeError + if errors.As(r.err, &exitErr) { + return exitErr.Code + } + return 1 +} + +// row returns the check_completed event with category and id, or nil. +func (r checkRun) row(t *testing.T, category, id string) map[string]any { + t.Helper() + for _, e := range checkEvents(t, r.stderr, "check_completed") { + if e["category"] == category && e["id"] == id { + return e + } + } + return nil +} + +// runCheck runs `self-hosted check --json` with args, recording every probe +// it makes. Each prober passes unless stubs says otherwise. +func runCheck(t *testing.T, stubs checkStubs, args ...string) checkRun { + t.Helper() + resetCheckFlags(t) + var run checkRun + var mu sync.Mutex + var hits atomic.Int64 + sis := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(sis.Close) + if stubs.noSISURL { + t.Setenv("NVCF_ICMS_URL", "") + t.Setenv("NVCF_SIS_URL", "") + } else { + t.Setenv("NVCF_ICMS_URL", sis.URL) + } + + prevStale, prevInotify, prevCV := newStaleNamespaceProberForSelfHosted, newInotifyProberForSelfHosted, + newClusterValidatorForSelfHosted + prevTools, prevBudget := checkPreflightTools, checkBudget + t.Cleanup(func() { + newStaleNamespaceProberForSelfHosted, newInotifyProberForSelfHosted = prevStale, prevInotify + newClusterValidatorForSelfHosted, checkPreflightTools, checkBudget = prevCV, prevTools, prevBudget + }) + checkPreflightTools = passingPreflightTools + if stubs.tools != nil { + checkPreflightTools = stubs.tools + } + checkBudget = func(d time.Duration) time.Duration { + run.budget = d + if stubs.budget > 0 { + return stubs.budget + } + return d + } + newStaleNamespaceProberForSelfHosted = func() selfhosted.StaleNamespaceProber { + return func(ctx context.Context, kubeContext string, ns []string) ([]selfhosted.StaleNamespace, error) { + mu.Lock() + run.stale = append(run.stale, staleCall{kubeContext, ns}) + mu.Unlock() + if stubs.stale != nil { + return stubs.stale(ctx) + } + return nil, nil + } + } + newInotifyProberForSelfHosted = func(string) selfhosted.NodeInotifyProber { + mu.Lock() + run.inotifyProbers++ + mu.Unlock() + return func(ctx context.Context, kubeContext string) ([]selfhosted.NodeInotifyLimits, error) { + mu.Lock() + run.inotify = append(run.inotify, kubeContext) + mu.Unlock() + if stubs.inotify != nil { + return stubs.inotify(ctx) + } + return []selfhosted.NodeInotifyLimits{{NodeName: "n1", MaxUserInstances: 8192, MaxUserWatches: 524288}}, nil + } + } + newClusterValidatorForSelfHosted = func() selfhosted.ClusterValidator { + return func(ctx context.Context, p selfhosted.ClusterValidatorParams) selfhosted.ClusterValidatorResult { + mu.Lock() + run.validators = append(run.validators, validatorCall{p.KubeContext, p.Role, p.Env["VALIDATOR_POST_INSTALL"]}) + mu.Unlock() + if stubs.validator != nil { + return stubs.validator(ctx, p) + } + return selfhosted.ClusterValidatorResult{Passed: true, + Logs: "Validator role: " + p.Role + "\nCluster is NVCF-Ready\n"} + } + } + + var stderr bytes.Buffer + rootCmd.SetErr(&stderr) + rootCmd.SetOut(&bytes.Buffer{}) + t.Cleanup(func() { rootCmd.SetErr(nil); rootCmd.SetOut(nil) }) + rootCmd.SetArgs(append([]string{"self-hosted", "check", "--json"}, args...)) + run.err = rootCmd.Execute() + run.stderr = stderr.String() + run.sisHits = hits.Load() + return run +} + +// Every check the flags select ends as a row, so a consumer of the stream can +// tell what was not checked. An explicit opt-out passes at info; a validator +// left unconfigured warns, so the verdict says the cluster was not validated. +func TestCheck_SkippedChecksAreRows(t *testing.T) { + type wantRow struct { + category, id string + passed bool + severity string + message string + } + for name, tc := range map[string]struct { + env string + args []string + rows []wantRow + verdict string + }{ + "no validator image": { + args: []string{"--control-plane"}, + rows: []wantRow{{"control-plane-cluster", "cluster-validator", false, "warning", + "cluster_validator_image is not set"}}, + verdict: "warnings", + }, + "--skip-cluster-validation": { + args: []string{"--control-plane", "--skip-cluster-validation", "--cluster-validator-image", pinnedValidatorImage}, + rows: []wantRow{{"control-plane-cluster", "cluster-validator", true, "info", + "skipped (--skip-cluster-validation)"}}, + verdict: "ok", + }, + "--skip-inotify-check": { + args: []string{"--compute-plane", "--skip-cluster-validation", "--skip-inotify-check"}, + rows: []wantRow{{"compute-plane-cluster", "node-inotify-limits", true, "info", + "skipped (--skip-inotify-check)"}}, + verdict: "ok", + }, + "NVCF_CLI_SELFHOSTED_SKIP_INOTIFY": { + env: "NVCF_CLI_SELFHOSTED_SKIP_INOTIFY", + args: []string{"--compute-plane", "--skip-cluster-validation"}, + rows: []wantRow{{"compute-plane-cluster", "node-inotify-limits", true, "info", + "skipped (--skip-inotify-check)"}}, + verdict: "ok", + }, + "--local-only": { + args: []string{"--all", "--local-only", "--cluster-validator-image", pinnedValidatorImage}, + rows: []wantRow{ + {"registry-credentials", "registry-credentials", true, "info", "skipped (--local-only)"}, + {"control-plane-cluster", "control-plane-cluster", true, "info", "skipped (--local-only)"}, + {"compute-plane-cluster", "compute-plane-cluster", true, "info", "skipped (--local-only)"}, + }, + verdict: "ok", + }, + } { + if tc.env != "" { + t.Setenv(tc.env, "1") + } + run := runCheck(t, checkStubs{}, tc.args...) + if tc.env != "" { + t.Setenv(tc.env, "") + } + assert.Equal(t, 0, run.code(), name) + for _, want := range tc.rows { + row := run.row(t, want.category, want.id) + require.NotNil(t, row, "%s: no %s/%s row in\n%s", name, want.category, want.id, run.stderr) + assert.Equal(t, want.passed, row["passed"], name) + assert.Equal(t, want.severity, row["severity"], name) + assert.Contains(t, row["message"], want.message, name) + } + final := finalEvent(t, run.stderr) + assert.Equal(t, tc.verdict, final["verdict"], name) + assert.Equal(t, true, final["success"], name) + if name == "--local-only" { + assert.Empty(t, run.stale, "--local-only contacts no cluster") + assert.Empty(t, run.validators) + assert.Empty(t, run.inotify) + assert.Zero(t, run.sisHits) + } + if strings.Contains(name, "INOTIFY") || strings.Contains(name, "inotify") { + assert.Empty(t, run.inotify, name) + } + } +} + +// The final event counts the warnings, as category_completed and the +// dashboard do. +func TestCheck_FinalEventCountsWarnings(t *testing.T) { + run := runCheck(t, checkStubs{}, "--control-plane") + final := finalEvent(t, run.stderr) + assert.EqualValues(t, 1, final["warningCount"]) + assert.EqualValues(t, 0, final["failedCount"]) +} + +// Every passing row has severity info on the wire. +func TestCheck_EveryPassingRowIsInfo(t *testing.T) { + run := runCheck(t, checkStubs{}, "--all", "--cluster-validator-image", pinnedValidatorImage) + require.Equal(t, 0, run.code(), run.stderr) + rows := checkEvents(t, run.stderr, "check_completed") + require.NotEmpty(t, rows) + for _, row := range rows { + if row["passed"] == true { + assert.Equal(t, "info", row["severity"], "%s/%s", row["category"], row["id"]) + } + } +} + +// helmfile, helm and the Helm runtime are what the install runs. Checking an +// installed stack runs none of them, so a workstation without them warns; +// before an install it fails. +func TestCheck_LocalToolsWarnAfterInstall(t *testing.T) { + withoutHelmfile := func() []selfhosted.BinarySpec { + specs := passingPreflightTools() + for i := range specs { + if specs[i].Name == "helmfile" { + specs[i].LookPath = func(string) (string, error) { return "", errors.New("not found") } + } + } + return specs + } + for args, want := range map[string]struct { + code int + severity string + }{ + "--control-plane": {0, "warning"}, + "--all": {0, "warning"}, + "--pre": {2, "error"}, + } { + run := runCheck(t, checkStubs{tools: withoutHelmfile}, args, "--skip-cluster-validation") + assert.Equal(t, want.code, run.code(), args) + row := run.row(t, "local-host-tools", "local-host-tools-helmfile") + require.NotNil(t, row, args) + assert.Equal(t, want.severity, row["severity"], args) + } +} + +// SIS is probed only at a URL the operator configured. Without one, the row +// says so rather than probing the client's default, NVIDIA's hosted service. +func TestCheck_SISOnlyAtAConfiguredURL(t *testing.T) { + viper.Reset() + t.Cleanup(viper.Reset) + run := runCheck(t, checkStubs{noSISURL: true}, "--compute-plane", "--skip-cluster-validation") + assert.Equal(t, 0, run.code()) + row := run.row(t, "compute-plane-cluster", "sis-reachability") + require.NotNil(t, row, run.stderr) + assert.Equal(t, false, row["passed"]) + assert.Equal(t, "warning", row["severity"]) + assert.Contains(t, row["message"], "no ICMS URL is configured") + + var hits atomic.Int64 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(srv.Close) + viper.Set("base_http_url", srv.URL) + run = runCheck(t, checkStubs{noSISURL: true}, "--compute-plane", "--skip-cluster-validation") + assert.Equal(t, 0, run.code()) + assert.EqualValues(t, 1, hits.Load(), "a base_http_url the operator set is the install's") + assert.Equal(t, true, run.row(t, "compute-plane-cluster", "sis-reachability")["passed"]) + + // The template's base_http_url names NVIDIA's hosted service, so a config + // copied from it does not point check at that SIS. + for _, hosted := range []string{"https://api.nvcf.nvidia.com", "https://api.shqa.stg.nvcf.nvidia.com"} { + viper.Set("base_http_url", hosted) + run = runCheck(t, checkStubs{noSISURL: true}, "--compute-plane", "--skip-cluster-validation") + row = run.row(t, "compute-plane-cluster", "sis-reachability") + require.NotNil(t, row, hosted) + assert.Equal(t, false, row["passed"], hosted) + assert.Equal(t, "warning", row["severity"], hosted) + assert.Contains(t, row["message"], "base_http_url names NVIDIA's hosted NVCF", hosted) + } +} + +// A blocking finding exits 2 even when the budget cut another row short: no +// retry with more time passes it. +func TestCheck_BlockingFailureBeatsATimeout(t *testing.T) { + stuck := func(context.Context) ([]selfhosted.StaleNamespace, error) { + return []selfhosted.StaleNamespace{{Name: "nvcf", Reason: "stuck Terminating"}}, nil + } + hold := func(ctx context.Context, _ selfhosted.ClusterValidatorParams) selfhosted.ClusterValidatorResult { + return holdUntilDone(ctx) + } + for _, extra := range [][]string{nil, {"--wait", "1m"}} { + run := runCheck(t, checkStubs{stale: stuck, validator: hold, budget: 300 * time.Millisecond}, + append([]string{"--control-plane", "--cluster-validator-image", pinnedValidatorImage}, extra...)...) + assert.Equal(t, 2, run.code(), "%v", extra) + final := finalEvent(t, run.stderr) + assert.Equal(t, "failed", final["verdict"], "%v", extra) + assert.Equal(t, false, final["success"], "%v", extra) + assert.Contains(t, run.row(t, "control-plane-cluster", "cluster-validator")["message"], "cut short: ") + } +} + +// stubRolePolls replaces each role's checks with the rows of one poll after +// another, the last repeating, and returns how many role runs there were. +func stubRolePolls(t *testing.T, polls ...[]selfhosted.CheckResult) *int { + t.Helper() + prevRun, prevInterval := checkRunRole, checkPollInterval + t.Cleanup(func() { checkRunRole, checkPollInterval = prevRun, prevInterval }) + checkPollInterval = 10 * time.Millisecond + calls := 0 + checkRunRole = func(ctx context.Context, _ selfhosted.PreflightConfig, _ selfhosted.Role, + _ selfhosted.RoleConfig, sink progress.EventSink) []selfhosted.CheckResult { + rows := polls[min(calls, len(polls)-1)] + calls++ + for _, r := range rows { + _ = sink.Emit(ctx, progress.CheckCompleted{Category: r.Category, ID: r.ID, Passed: r.Passed, + Severity: r.Severity, Message: r.Message, CutShort: r.CutShort}) + } + return rows + } + return &calls +} + +// cutShortRows is a poll whose registry checks for each host were cut short +// by their share at severity. +func cutShortRows(severity selfhosted.Severity, hosts ...string) []selfhosted.CheckResult { + var rows []selfhosted.CheckResult + for _, host := range hosts { + rows = append(rows, selfhosted.CheckResult{Category: selfhosted.CategoryRegistryCredentials, + ID: "registry-cred-" + host, Severity: severity, CutShort: true, + Message: "cut short: the check's time budget ran out before it finished"}) + } + return rows +} + +// A check a share stopped keeps the severity it could at worst have had. One +// that could only warn, a registry that is not critical, does not fail the +// run or time it out: exit 0, and the row says it was cut short. --wait polls +// again on it, and on a cut-short check that blocks, instead of ending on the +// first poll whose share ran out. +func TestCheck_CutShortRowsKeepTheirSeverityAndWaitPollsAgain(t *testing.T) { + cutShort := func(severity selfhosted.Severity) []selfhosted.CheckResult { + return cutShortRows(severity, "slow.example") + } + passed := []selfhosted.CheckResult{{Category: selfhosted.CategoryRegistryCredentials, + ID: "registry-cred-slow.example", Passed: true, Severity: selfhosted.SeverityInfo}} + args := []string{"--control-plane", "--skip-cluster-validation"} + + calls := stubRolePolls(t, cutShort(selfhosted.SeverityWarning)) + run := runCheck(t, checkStubs{}, args...) + assert.Equal(t, 0, run.code(), run.stderr) + assert.Equal(t, "warnings", finalEvent(t, run.stderr)["verdict"]) + row := run.row(t, selfhosted.CategoryRegistryCredentials, "registry-cred-slow.example") + require.NotNil(t, row) + assert.Equal(t, true, row["cutShort"]) + assert.Equal(t, "warning", row["severity"]) + assert.Equal(t, 1, *calls) + + for _, severity := range []selfhosted.Severity{selfhosted.SeverityWarning, selfhosted.SeverityError} { + calls = stubRolePolls(t, cutShort(severity), passed) + run = runCheck(t, checkStubs{}, append(args, "--wait", "30s")...) + assert.Equal(t, 0, run.code(), "%s: %s", severity, run.stderr) + assert.Equal(t, 2, *calls, "%s: the next poll gets fresh shares", severity) + assert.Equal(t, "ok", finalEvent(t, run.stderr)["verdict"], severity) + } +} + +// The shares are the same on every poll, so a check its share cut short on +// two polls in a row would most likely be cut short again. When it can only +// warn, --wait stops there and grades the poll as a single run would: exit 0 +// with the warning, the row still marked cut short. One that can fail the +// run, one that may clear by itself, or a different check each poll, keeps it +// polling until DURATION. +func TestCheck_WaitEndsOnAWarningCutShortTwiceInARow(t *testing.T) { + args := []string{"--control-plane", "--skip-cluster-validation", "--wait"} + + calls := stubRolePolls(t, cutShortRows(selfhosted.SeverityWarning, "slow.example")) + run := runCheck(t, checkStubs{}, append(args, "10s")...) + assert.Equal(t, 0, run.code(), "%v\n%s", run.err, run.stderr) + assert.Equal(t, 2, *calls) + final := finalEvent(t, run.stderr) + assert.Equal(t, "warnings", final["verdict"]) + assert.Equal(t, true, final["success"]) + rows := checkEvents(t, run.stderr, "check_completed") + require.Len(t, rows, 2) + assert.Equal(t, true, rows[1]["cutShort"]) + assert.Equal(t, "warning", rows[1]["severity"]) + + calls = stubRolePolls(t, cutShortRows(selfhosted.SeverityWarning, "a.example"), + cutShortRows(selfhosted.SeverityWarning, "b.example")) + run = runCheck(t, checkStubs{}, append(args, "10s")...) + assert.Equal(t, 0, run.code(), run.stderr) + assert.Equal(t, 3, *calls, "a.example was not cut short again, so b.example's first cut does not end the wait") + + transient := cutShortRows(selfhosted.SeverityWarning, "slow.example") + transient[0].Transient = true + for name, poll := range map[string][]selfhosted.CheckResult{ + "error": cutShortRows(selfhosted.SeverityError, "slow.example"), + "transient": transient, + } { + calls = stubRolePolls(t, poll) + run = runCheck(t, checkStubs{}, append(args, "200ms")...) + assert.Equal(t, 5, run.code(), "%s: %s", name, run.stderr) + require.ErrorContains(t, run.err, "a check was still cut short by its time budget", name) + assert.Greater(t, *calls, 2, name) + assert.Equal(t, "timeout", finalEvent(t, run.stderr)["verdict"], name) + } +} + +// The comparison with the clusters' pull secrets keeps what it read for one +// poll's registry rows, which run one after another in one share. Each --wait +// poll gets a new one, so a pull secret fixed while the run waits counts on +// the next poll. +func TestCheck_EachPollComparesWithFreshPullSecrets(t *testing.T) { + refused := []selfhosted.CheckResult{{Category: selfhosted.CategoryRegistryCredentials, + ID: "registry-cred-nvcr.io", Severity: selfhosted.SeverityError, Message: "nvcr.io: rejected"}} + passed := []selfhosted.CheckResult{{Category: selfhosted.CategoryRegistryCredentials, + ID: "registry-cred-nvcr.io", Passed: true, Severity: selfhosted.SeverityInfo}} + calls := stubRolePolls(t, refused, passed) + polled := checkRunRole + var asked []string + checkRunRole = func(ctx context.Context, cfg selfhosted.PreflightConfig, role selfhosted.Role, + rc selfhosted.RoleConfig, sink progress.EventSink) []selfhosted.CheckResult { + require.NotNil(t, cfg.RegistryClusterSecrets, "an installed plane's rows compare with its clusters") + holder, _ := cfg.RegistryClusterSecrets(ctx, "nvcr.io", "", "$oauthtoken", "rotated-out") + asked = append(asked, holder) + return polled(ctx, cfg, role, rc, sink) + } + prev := newClusterPullSecretCheckerForSelfHosted + made := 0 + newClusterPullSecretCheckerForSelfHosted = func([]string) selfhosted.ClusterPullSecretChecker { + made++ + n := made + return func(context.Context, string, string, string, string) (string, error) { + return fmt.Sprintf("checker %d", n), nil + } + } + t.Cleanup(func() { newClusterPullSecretCheckerForSelfHosted = prev }) + + run := runCheck(t, checkStubs{}, "--control-plane", "--skip-cluster-validation", "--wait", "10s") + assert.Equal(t, 0, run.code(), run.stderr) + assert.Equal(t, 2, *calls) + assert.Equal(t, []string{"checker 1", "checker 2"}, asked) +} + +// A cluster that cannot be contacted at all fails the run, with or without a +// validator: nothing about it was checked. +func TestCheck_UnreachableClusterFailsTheRun(t *testing.T) { + unreachable := func(context.Context) ([]selfhosted.StaleNamespace, error) { + return nil, &selfhosted.ClusterUnreachableError{ + Context: "typo-ctx", Err: errors.New(`context "typo-ctx" does not exist`), + } + } + for _, args := range [][]string{{"--control-plane"}, {"--pre"}} { + run := runCheck(t, checkStubs{stale: unreachable}, args...) + assert.Equal(t, 2, run.code(), "%v", args) + assert.Equal(t, false, finalEvent(t, run.stderr)["success"], "%v", args) + row := run.row(t, "control-plane-cluster", "stale-namespaces") + require.NotNil(t, row, "%v", args) + assert.Equal(t, "error", row["severity"]) + assert.Contains(t, row["message"], "cannot reach typo-ctx") + } +} + +// A malformed or non-positive --wait is a usage error, reported before any +// work: no tag discovery, no events. +func TestCheck_WaitIsParsedBeforeAnyWork(t *testing.T) { + prev := resolveLatestValidatorTagForSelfHosted + t.Cleanup(func() { resolveLatestValidatorTagForSelfHosted = prev }) + var discoveries atomic.Int32 + resolveLatestValidatorTagForSelfHosted = func(context.Context, string) (string, bool) { + discoveries.Add(1) + return "", false + } + for _, wait := range []string{"10x", "-5m", "0s"} { + run := runCheck(t, checkStubs{}, "--control-plane", "--wait", wait, + "--cluster-validator-image", "nvcr.io/nvidia/nvcf-byoc/cluster-validator") + require.Error(t, run.err, wait) + assert.Contains(t, run.err.Error(), "invalid --wait duration", wait) + assert.Equal(t, 1, run.code(), wait) + assert.NotContains(t, run.stderr, `"event"`, wait) + } + assert.Zero(t, discoveries.Load()) +} + +// The force-fail switch tests once used is gone: no environment variable can +// add a row to a production run. +func TestCheck_ForceFailEnvIsIgnored(t *testing.T) { + t.Setenv("NVCF_CLI_SELFHOSTED_FORCE_FAIL", "1") + run := runCheck(t, checkStubs{}, "--pre", "--local-only") + assert.Equal(t, 0, run.code(), run.stderr) + assert.NotContains(t, run.stderr, "force-fail") +} + +// The outer budget, term by term: each role invocation gets the local and probe shares +// plus the validator's run ceiling when one is configured; ModeSingle runs two +// roles in turn, ModeSplit in parallel; --wait adds its duration. +func TestCheck_BudgetTerms(t *testing.T) { + share, ceiling := selfhosted.LocalCheckShare()+selfhosted.CheckProbeShare(), selfhosted.ClusterValidatorRunCeiling() + image := []string{"--cluster-validator-image", pinnedValidatorImage} + split := []string{"--control-plane-context", "cp-ctx", "--compute-plane-context", "gpu-ctx"} + with := func(lists ...[]string) []string { + var out []string + for _, l := range lists { + out = append(out, l...) + } + return out + } + for name, tc := range map[string]struct { + args []string + want time.Duration + }{ + "no validator, one role": {[]string{"--control-plane", "--skip-cluster-validation"}, share}, + "no validator, two in turn": {[]string{"--pre", "--skip-cluster-validation"}, 2 * share}, + "validator, one role": {with([]string{"--control-plane"}, image), share + ceiling}, + "validator, two in turn": {with([]string{"--pre"}, image), 2 * (share + ceiling)}, + "validator, two in parallel": {with([]string{"--pre"}, image, split), share + ceiling}, + "unresolved image": {[]string{"--control-plane", "--cluster-validator-image", + "nvcr.io/nvidia/nvcf-byoc/cluster-validator"}, share + ceiling}, + "--local-only": {with([]string{"--all", "--local-only"}, image), share}, + "--wait": {with([]string{"--control-plane", "--wait", "15m"}, image), share + ceiling + 15*time.Minute}, + } { + run := runCheck(t, checkStubs{}, tc.args...) + assert.Equal(t, tc.want, run.budget, name) + } +} + +// An interrupt during --wait exits 130 with one cancelled final event, whether +// it lands during a poll or between polls. +func TestCheck_WaitInterrupt(t *testing.T) { + ready := selfhosted.ClusterValidatorResult{Passed: true, + Logs: "Validator role: control-plane\nCluster is NVCF-Ready\n"} + for name, poll := range map[string]func(quit func()) selfhosted.ClusterValidatorResult{ + "during a poll": func(quit func()) selfhosted.ClusterValidatorResult { + quit() + return ready + }, + "between polls": func(quit func()) selfhosted.ClusterValidatorResult { + time.AfterFunc(100*time.Millisecond, quit) + return selfhosted.ClusterValidatorResult{ExitCode: 1, Logs: "Validator role: control-plane\n"} + }, + } { + prev := selectCheckRendererFn + quit := make(chan func(), 1) + selectCheckRendererFn = func(w io.Writer, wait bool, onQuit func()) (progress.EventSink, error) { + quit <- onQuit + return prev(w, wait, onQuit) + } + var onQuit func() + start := time.Now() + err, stderr := runCheckWithBudget(t, time.Minute, func(context.Context) selfhosted.ClusterValidatorResult { + if onQuit == nil { + onQuit = <-quit + } + return poll(onQuit) + }, "--wait", "10m") + selectCheckRendererFn = prev + var exitErr *ExitCodeError + require.ErrorAs(t, err, &exitErr, name) + assert.Equal(t, 130, exitErr.Code, name) + assert.Equal(t, true, finalEvent(t, stderr)["cancelled"], name) + assert.Less(t, time.Since(start), 5*time.Second, "%s: did not wait for the next poll", name) + } +} + +// A poll that outlasts both the --wait duration and the poll interval ends +// the wait: the deadline is checked before the ticker, which select would +// otherwise pick half the time and run the checks again on a spent wait. +func TestCheck_WaitDeadlineBeatsTheTicker(t *testing.T) { + prev := checkPollInterval + checkPollInterval = 10 * time.Millisecond + t.Cleanup(func() { checkPollInterval = prev }) + for i := 0; i < 20; i++ { + calls := 0 + err, _ := runCheckWithBudget(t, time.Minute, func(context.Context) selfhosted.ClusterValidatorResult { + calls++ + time.Sleep(50 * time.Millisecond) + return selfhosted.ClusterValidatorResult{ExitCode: 1, Logs: "Validator role: control-plane\n"} + }, "--wait", "1ms") + var exitErr *ExitCodeError + require.ErrorAs(t, err, &exitErr) + assert.Equal(t, 5, exitErr.Code) + require.Equal(t, 1, calls, "run %d polled again after the wait ended", i) + } +} + +// --wait polls again while the inotify probe's budget leaves nodes unprobed: +// one of them may be below the limits. A single run still exits 0 on the +// warning. +func TestCheck_WaitPollsOnNodesTheInotifyProbeDidNotReach(t *testing.T) { + prev := checkPollInterval + checkPollInterval = 10 * time.Millisecond + t.Cleanup(func() { checkPollInterval = prev }) + probed := selfhosted.NodeInotifyLimits{NodeName: "n1", MaxUserInstances: 8192, MaxUserWatches: 524288} + calls := 0 + inotify := func(context.Context) ([]selfhosted.NodeInotifyLimits, error) { + calls++ + if calls == 1 { + return []selfhosted.NodeInotifyLimits{probed, {NodeName: "n2", OutOfBudget: true, + Err: errors.New("not probed: the inotify probe's 1m40s budget ran out")}}, nil + } + return []selfhosted.NodeInotifyLimits{probed, + {NodeName: "n2", MaxUserInstances: 8192, MaxUserWatches: 524288}}, nil + } + args := []string{"--compute-plane", "--skip-cluster-validation"} + + run := runCheck(t, checkStubs{inotify: inotify}, args...) + assert.Equal(t, 0, run.code(), run.stderr) + row := run.row(t, "compute-plane-cluster", "node-inotify-limits") + require.NotNil(t, row) + assert.Equal(t, "warning", row["severity"]) + assert.Equal(t, true, row["transient"]) + + calls = 0 + run = runCheck(t, checkStubs{inotify: inotify}, append(args, "--wait", "30s")...) + assert.Equal(t, 0, run.code(), run.stderr) + assert.Equal(t, 2, calls, "the second poll reached every node") + assert.Equal(t, "ok", finalEvent(t, run.stderr)["verdict"]) + assert.Equal(t, 1, run.inotifyProbers, + "every poll uses one prober, which keeps what the polls before learned of each node") +} + +// Every flag set in both topologies: which roles run and where, what each +// validator is told, which clusters are probed, and the budget. +func TestCheck_FlagAndTopologyMatrix(t *testing.T) { + share, ceiling := selfhosted.LocalCheckShare()+selfhosted.CheckProbeShare(), selfhosted.ClusterValidatorRunCeiling() + for _, tc := range []struct { + args []string + cp, gpu bool // the clusters visited + postCP, postGPU string // VALIDATOR_POST_INSTALL per role + sis bool + }{ + {args: []string{"--pre"}, cp: true, gpu: true}, + {args: []string{"--control-plane"}, cp: true, postCP: "true"}, + {args: []string{"--compute-plane"}, gpu: true, postGPU: "true", sis: true}, + {args: []string{"--all"}, cp: true, gpu: true, postCP: "true", postGPU: "true", sis: true}, + {args: []string{"--pre", "--control-plane"}, cp: true, gpu: true, postCP: "true"}, + {args: []string{"--pre", "--compute-plane"}, cp: true, gpu: true, postGPU: "true", sis: true}, + { + args: []string{"--control-plane", "--compute-plane"}, cp: true, gpu: true, + postCP: "true", postGPU: "true", sis: true, + }, + {args: []string{"--pre", "--all"}, cp: true, gpu: true, postCP: "true", postGPU: "true", sis: true}, + } { + for _, mode := range []kubectx.Mode{kubectx.ModeSingle, kubectx.ModeSplit} { + cpCtx, gpuCtx := "", "" + args := append([]string{"--cluster-validator-image", pinnedValidatorImage}, tc.args...) + if mode == kubectx.ModeSplit { + cpCtx, gpuCtx = "cp-ctx", "gpu-ctx" + args = append(args, "--control-plane-context", cpCtx, "--compute-plane-context", gpuCtx) + } + name := strings.Join(args[2:], " ") + run := runCheck(t, checkStubs{}, args...) + require.Equal(t, 0, run.code(), "%s\n%s", name, run.stderr) + + var wantValidators []validatorCall + if tc.cp { + wantValidators = append(wantValidators, validatorCall{cpCtx, "control-plane", tc.postCP}) + } + if tc.gpu { + wantValidators = append(wantValidators, validatorCall{gpuCtx, "compute-plane", tc.postGPU}) + } + assert.ElementsMatch(t, wantValidators, run.validators, name) + + if tc.gpu { + assert.Equal(t, []string{gpuCtx}, run.inotify, name) + } else { + assert.Empty(t, run.inotify, name) + } + + wantSIS := int64(0) + if tc.sis { + wantSIS = 1 + } + assert.Equal(t, wantSIS, run.sisHits, name) + + if mode == kubectx.ModeSingle { + require.Len(t, run.stale, 1, "%s: one cluster, one probe", name) + assert.Equal(t, tc.cp, contains(run.stale[0].namespaces, "vault-system"), name) + assert.Equal(t, tc.gpu, contains(run.stale[0].namespaces, "nvca-operator"), name) + } else { + var contexts []string + for _, c := range run.stale { + contexts = append(contexts, c.context) + assert.Equal(t, c.context == gpuCtx, contains(c.namespaces, "nvca-operator"), name) + } + var want []string + if tc.cp { + want = append(want, cpCtx) + } + if tc.gpu { + want = append(want, gpuCtx) + } + assert.ElementsMatch(t, want, contexts, name) + } + + hostRows := map[string]int{} + for _, row := range checkEvents(t, run.stderr, "check_completed") { + if row["category"] == "local-host-tools" { + hostRows[row["id"].(string)]++ + } + } + assert.Len(t, hostRows, len(passingPreflightTools())+1, "%s: each tool and the Helm runtime", name) + for id, n := range hostRows { + assert.Equal(t, 1, n, "%s: %s", name, id) + } + + wantBudget := share + ceiling + if mode == kubectx.ModeSingle && tc.cp && tc.gpu { + wantBudget = 2 * (share + ceiling) + } + assert.Equal(t, wantBudget, run.budget, name) + } + } +} + +func contains(list []string, s string) bool { + for _, v := range list { + if v == s { + return true + } + } + return false +} + +// The stack's gates decide which namespaces the stale-namespace probe scans, +// from base.yaml and the environment the operator named. A defaulted --env +// may not be the install's, so its file is not read. +func TestCheck_StaleNamespaceGatesNeedANamedEnvironment(t *testing.T) { + stack := t.TempDir() + envDir := filepath.Join(stack, "environments") + require.NoError(t, os.MkdirAll(envDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(envDir, "base.yaml"), + []byte("addons:\n kaiScheduler:\n enabled: false\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(envDir, "local.yaml"), + []byte("addons:\n kaiScheduler:\n enabled: true\n"), 0o644)) + args := []string{"--compute-plane", "--skip-cluster-validation", "--compute-plane-stack", stack} + + run := runCheck(t, checkStubs{}, args...) + require.Len(t, run.stale, 1) + assert.NotContains(t, run.stale[0].namespaces, "kai-scheduler", "the defaulted --env is not read") + + run = runCheck(t, checkStubs{}, append(args, "--env", "local")...) + require.Len(t, run.stale, 1) + assert.Contains(t, run.stale[0].namespaces, "kai-scheduler", "the named environment turns the gate on") +} diff --git a/src/clis/nvcf-cli/cmd/self_hosted_check_scope_test.go b/src/clis/nvcf-cli/cmd/self_hosted_check_scope_test.go new file mode 100644 index 0000000000..f3c16d0ff1 --- /dev/null +++ b/src/clis/nvcf-cli/cmd/self_hosted_check_scope_test.go @@ -0,0 +1,1080 @@ +/* +SPDX-FileCopyrightText: Copyright (c) NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "nvcf-cli/internal/selfhosted" + "nvcf-cli/internal/selfhosted/progress" +) + +// runCheckRecording runs `self-hosted check` in ModeSingle with the given +// flags and returns the namespaces the stale-namespace probe was asked about +// and how many times SIS was contacted. +func runCheckRecording(t *testing.T, args ...string) (namespaces []string, sisHits int64) { + t.Helper() + resetCheckFlags(t) + var hits atomic.Int64 + sis := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(sis.Close) + t.Setenv("NVCF_ICMS_URL", sis.URL) + t.Setenv("NVCF_CLI_SELFHOSTED_SKIP_INOTIFY", "1") + + var mu sync.Mutex + prev := newStaleNamespaceProberForSelfHosted + newStaleNamespaceProberForSelfHosted = func() selfhosted.StaleNamespaceProber { + return func(_ context.Context, _ string, ns []string) ([]selfhosted.StaleNamespace, error) { + mu.Lock() + defer mu.Unlock() + namespaces = append(namespaces, ns...) + return nil, nil + } + } + t.Cleanup(func() { newStaleNamespaceProberForSelfHosted = prev }) + + rootCmd.SetErr(&bytes.Buffer{}) + rootCmd.SetOut(&bytes.Buffer{}) + rootCmd.SetArgs(append([]string{"self-hosted", "check", "--skip-cluster-validation", "--json"}, args...)) + _ = rootCmd.Execute() + return namespaces, hits.Load() +} + +// Each role's namespaces are scanned only when that role runs. Merging both in +// for a single-role run reported a stuck nvca-operator on --control-plane. +func TestCheck_StaleNamespacesFollowTheRequestedRoles(t *testing.T) { + ns, _ := runCheckRecording(t, "--control-plane") + assert.Contains(t, ns, "vault-system") + assert.NotContains(t, ns, "nvca-operator", "--control-plane must not scan compute namespaces") + + ns, _ = runCheckRecording(t, "--compute-plane") + assert.Contains(t, ns, "nvca-operator") + assert.NotContains(t, ns, "vault-system", "--compute-plane must not scan control-plane namespaces") + + ns, _ = runCheckRecording(t, "--all") + assert.Contains(t, ns, "vault-system") + assert.Contains(t, ns, "nvca-operator", "--all scans both") +} + +// The stale-namespace scan reads each plane's gates from the stack the +// install used, an extracted built-in oci:// stack included, and from the +// environment the install used: prod turns kai-scheduler on here. +func TestCheck_StaleNamespaceGatesFollowTheInstallsStack(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache")) + t.Setenv("HELMFILE_ENV", "") + cache, err := os.UserCacheDir() + require.NoError(t, err) + const ref = "oci://registry.example.com/nvcf/compute-plane:1.0.0@sha256:0123456789abcdef" + extracted := filepath.Join(cache, "nvcf-cli", "stacks", "oci-0123456789ab") + envDir := filepath.Join(extracted, "environments") + require.NoError(t, os.MkdirAll(envDir, 0o755)) + for name, enabled := range map[string]string{"base": "false", "prod": "true"} { + require.NoError(t, os.WriteFile(filepath.Join(envDir, name+".yaml"), + []byte("addons:\n kaiScheduler:\n enabled: "+enabled+"\n"), 0o644)) + } + require.NoError(t, os.WriteFile(filepath.Join(extracted, ".extraction-complete"), nil, 0o644)) + t.Setenv("NVCF_CLI_DEFAULT_COMPUTE_PLANE_STACK", ref) + + ns, _ := runCheckRecording(t, "--compute-plane", "--env", "prod") + assert.Contains(t, ns, "kai-scheduler") + ns, _ = runCheckRecording(t, "--all", "--env", "prod") + assert.Contains(t, ns, "kai-scheduler", "merged into the one scan of a shared cluster") + ns, _ = runCheckRecording(t, "--compute-plane") + assert.NotContains(t, ns, "kai-scheduler", "with no environment named, base.yaml decides") +} + +// The env toggles are booleans, so a config that spells one out as "false" +// or "0" leaves its check on. They read the words CI does too, yes and on +// among them, in any case. Any other value fails the command naming it. +func TestCheck_EnvTogglesAreBooleans(t *testing.T) { + t.Setenv(envLocalOnly, "false") + ns, _ := runCheckRecording(t, "--control-plane") + assert.NotEmpty(t, ns, "LOCAL_ONLY=false still checks the cluster") + t.Setenv(envLocalOnly, "true") + ns, _ = runCheckRecording(t, "--control-plane") + assert.Empty(t, ns) + t.Setenv(envLocalOnly, "") + + resetCheckFlags(t) + for value, on := range map[string]bool{ + "0": false, "false": false, "": false, "no": false, "N": false, " Off ": false, "n": false, + "1": true, "TRUE": true, "yes": true, "Y": true, " on ": true, "y": true, + } { + for _, name := range []string{envLocalOnly, envSkipClusterValidation, envSkipInotify} { + t.Setenv(name, value) + got, err := envToggle(name) + require.NoError(t, err, "%s=%q", name, value) + assert.Equal(t, on, got, "%s=%q", name, value) + } + assert.Equal(t, on, clusterValidationSkipped(), value) + assert.Equal(t, on, inotifyCheckSkipped(), value) + } + t.Setenv(envLocalOnly, "") + t.Setenv(envSkipClusterValidation, "") + + t.Setenv(envSkipInotify, "yes") + run := runCheck(t, checkStubs{}, "--pre", "--local-only") + assert.Equal(t, 0, run.code(), "%v\n%s", run.err, run.stderr) + + t.Setenv(envSkipInotify, "maybe") + run = runCheck(t, checkStubs{}, "--pre") + assert.Equal(t, 1, run.code()) + require.Error(t, run.err) + assert.Contains(t, run.err.Error(), `NVCF_CLI_SELFHOSTED_SKIP_INOTIFY="maybe": expected true or false`) + assert.NotContains(t, run.stderr, `"event"`, "rejected before any work") +} + +// A bare --pre skips SIS, which is not up before install; an explicit --all or +// --compute-plane still asks for it; --control-plane never contacts it. +func TestCheck_SISReachabilityScope(t *testing.T) { + _, hits := runCheckRecording(t, "--pre") + assert.Zero(t, hits, "--pre alone is pre-install") + _, hits = runCheckRecording(t, "--pre", "--all") + assert.NotZero(t, hits, "--all asks for every category, --pre included") + _, hits = runCheckRecording(t, "--pre", "--compute-plane") + assert.NotZero(t, hits) + _, hits = runCheckRecording(t, "--control-plane") + assert.Zero(t, hits, "SIS is a compute-plane check") + _, hits = runCheckRecording(t, "--pre", "--control-plane") + assert.Zero(t, hits, "--pre visits the compute plane but --control-plane does not ask for SIS") +} + +// An interrupted run reports the interrupt, not a verdict: with the validator +// cut short the results would otherwise read as a failure, or as a pass. +func TestCheck_InterruptExits130(t *testing.T) { + resetCheckFlags(t) + t.Setenv("NVCF_CLI_SELFHOSTED_SKIP_INOTIFY", "1") + ctx, cancel := context.WithCancel(context.Background()) + cancel() + var stderr bytes.Buffer + rootCmd.SetErr(&stderr) + rootCmd.SetOut(&bytes.Buffer{}) + t.Cleanup(func() { rootCmd.SetErr(nil); rootCmd.SetOut(nil) }) + // Cobra keeps a subcommand's context from an earlier Execute, so set it + // on the command that runs. + selfHostedCheckCmd.SetContext(ctx) + t.Cleanup(func() { selfHostedCheckCmd.SetContext(context.Background()) }) + rootCmd.SetArgs([]string{"self-hosted", "check", "--all", "--local-only", "--json"}) + err := rootCmd.Execute() + + var exitErr *ExitCodeError + require.ErrorAs(t, err, &exitErr) + assert.Equal(t, 130, exitErr.Code) + assert.Regexp(t, `"event":"final".*"cancelled":true`, stderr.String(), + "the JSON stream still ends with a final event, marked cancelled") +} + +// runCheckWithBudget runs check --control-plane --json with the outer budget +// shortened to budget and a validator stub that holds its role until the +// context ends, or returns result at once. +func runCheckWithBudget(t *testing.T, budget time.Duration, + validator func(context.Context) selfhosted.ClusterValidatorResult, args ...string) (error, string) { + t.Helper() + return runCheckWith(t, budget, func( + ctx context.Context, _ selfhosted.ClusterValidatorParams, + ) selfhosted.ClusterValidatorResult { + return validator(ctx) + }, &syncBuffer{}, args...) +} + +// runCheckWith is runCheckWithBudget with a validator stub that sees its +// parameters, writing stderr to out. +func runCheckWith(t *testing.T, budget time.Duration, validator selfhosted.ClusterValidator, out *syncBuffer, + args ...string) (error, string) { + t.Helper() + return executeCheck(t, budget, validator, out, args...), out.String() +} + +// executeCheck runs `self-hosted check --control-plane --json` with the given +// budget and validator stub, writing stderr to errOut. +func executeCheck(t *testing.T, budget time.Duration, validator selfhosted.ClusterValidator, errOut io.Writer, + args ...string) error { + t.Helper() + resetCheckFlags(t) + t.Setenv("NVCF_CLI_SELFHOSTED_SKIP_INOTIFY", "1") + prevBudget, prevCV := checkBudget, newClusterValidatorForSelfHosted + checkBudget = func(d time.Duration) time.Duration { requestedBudget = d; return budget } + newClusterValidatorForSelfHosted = func() selfhosted.ClusterValidator { return validator } + t.Cleanup(func() { checkBudget, newClusterValidatorForSelfHosted = prevBudget, prevCV }) + rootCmd.SetErr(errOut) + rootCmd.SetOut(&bytes.Buffer{}) + t.Cleanup(func() { rootCmd.SetErr(nil); rootCmd.SetOut(nil) }) + rootCmd.SetArgs(append([]string{"self-hosted", "check", "--control-plane", "--json", + "--cluster-validator-image", "nvcr.io/nvidia/nvcf-byoc/cluster-validator:1.0.0"}, args...)) + return rootCmd.Execute() +} + +// syncBuffer is a bytes.Buffer safe to write from the interrupt handler while +// the run writes its events. +type syncBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *syncBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *syncBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +// requestedBudget is the outer budget the last runCheckWithBudget run asked for. +var requestedBudget time.Duration + +func holdUntilDone(ctx context.Context) selfhosted.ClusterValidatorResult { + <-ctx.Done() + return selfhosted.ClusterValidatorResult{Err: ctx.Err()} +} + +// checkEvents returns the JSON events of kind a --json run wrote to stderr, +// in order. +func checkEvents(t *testing.T, stderr, kind string) []map[string]any { + t.Helper() + var out []map[string]any + for _, line := range strings.Split(stderr, "\n") { + var m map[string]any + if strings.HasPrefix(line, "{") && json.Unmarshal([]byte(line), &m) == nil && m["event"] == kind { + out = append(out, m) + } + } + return out +} + +// finalEvent returns the run's final event. +func finalEvent(t *testing.T, stderr string) map[string]any { + t.Helper() + finals := checkEvents(t, stderr, "final") + require.Len(t, finals, 1, "a run ends with exactly one final event") + return finals[0] +} + +// validatorRow returns the cluster-validator check_completed event. +func validatorRow(t *testing.T, stderr string) map[string]any { + t.Helper() + for _, e := range checkEvents(t, stderr, "check_completed") { + if e["id"] == "cluster-validator" { + return e + } + } + t.Fatalf("no cluster-validator row in:\n%s", stderr) + return nil +} + +// A run that outlives its budget is a timeout, never a pass: exit 5 with a +// final event that says it failed, not success on whatever had run. On a +// loaded machine the budget can run out before the validator starts, so its +// row is either cut short or not run; both are blocking. +func TestCheck_BudgetSpentIsATimeout(t *testing.T) { + for _, args := range [][]string{nil, {"--wait", "1m"}} { + err, stderr := runCheckWithBudget(t, 300*time.Millisecond, holdUntilDone, args...) + var exitErr *ExitCodeError + require.ErrorAs(t, err, &exitErr, "args %v", args) + assert.Equal(t, 5, exitErr.Code, "args %v", args) + final := finalEvent(t, stderr) + assert.Equal(t, false, final["success"], "args %v", args) + assert.Equal(t, "timeout", final["verdict"], "args %v", args) + row := validatorRow(t, stderr) + assert.Equal(t, false, row["passed"], "args %v", args) + assert.EqualValues(t, selfhosted.SeverityError, row["severity"], "args %v", args) + assert.Regexp(t, `^(cut short|not run): the check's time budget ran out`, row["message"], "args %v", args) + } +} + +// --wait keeps polling while the validator reports a rollout in progress, +// instead of stopping on the first run that exits 0. +func TestCheck_WaitPollsOnARolloutInProgress(t *testing.T) { + calls := 0 + err, _ := runCheckWithBudget(t, time.Minute, func(context.Context) selfhosted.ClusterValidatorResult { + calls++ + logs := "Validator role: control-plane\nCluster is NVCF-Ready\n" + if calls == 1 { + logs = "Validator role: control-plane\nnvcf/api: rollout in progress (updated: 2/3)\n" + + "Cluster is NVCF-Ready (with warnings)\n" + } + return selfhosted.ClusterValidatorResult{Passed: true, Logs: logs} + }, "--wait", "30s") + require.NoError(t, err) + assert.Equal(t, 2, calls, "the second run, after the rollout, ends the wait") +} + +// A budget that runs out after a check has its result changes nothing: the +// result is the verdict. A validator that passed passes, and its own failure +// is exit 2, not a timeout that tells an agent to retry with a longer budget. +func TestCheck_ResultsInHandWhenTheBudgetEndsAreGraded(t *testing.T) { + ready := selfhosted.ClusterValidatorResult{Passed: true, Logs: "Validator role: control-plane\nCluster is NVCF-Ready\n"} + for name, tc := range map[string]struct { + result selfhosted.ClusterValidatorResult + args []string + wantCode int + wantVerdict string + }{ + "passed": {result: ready, wantVerdict: "ok"}, + "passed, --wait": {result: ready, args: []string{"--wait", "1m"}, wantVerdict: "ok"}, + "its own timeout": {result: selfhosted.ClusterValidatorResult{Err: errors.New("the validator did not finish within 5m0s")}, wantCode: 2, wantVerdict: "failed"}, + } { + err, stderr := runCheckWithBudget(t, 300*time.Millisecond, func(ctx context.Context) selfhosted.ClusterValidatorResult { + <-ctx.Done() + return tc.result + }, tc.args...) + if tc.wantCode == 0 { + require.NoError(t, err, name) + } else { + var exitErr *ExitCodeError + require.ErrorAs(t, err, &exitErr, name) + assert.Equal(t, tc.wantCode, exitErr.Code, name) + } + final := finalEvent(t, stderr) + assert.Equal(t, tc.wantCode == 0, final["success"], name) + assert.Equal(t, tc.wantVerdict, final["verdict"], name) + } +} + +// A budget that runs out while --wait sleeps between polls is a timeout, and +// says the checks were still failing. +func TestCheck_BudgetEndingBetweenPollsIsATimeout(t *testing.T) { + err, stderr := runCheckWithBudget(t, 2*time.Second, func(context.Context) selfhosted.ClusterValidatorResult { + return selfhosted.ClusterValidatorResult{ExitCode: 1, Logs: "Validator role: control-plane\n"} + }, "--wait", "1m") + var exitErr *ExitCodeError + require.ErrorAs(t, err, &exitErr) + assert.Equal(t, 5, exitErr.Code) + assert.Contains(t, exitErr.Msg, "checks still failing") + final := finalEvent(t, stderr) + assert.Equal(t, false, final["success"]) + assert.Equal(t, "timeout", final["verdict"]) +} + +// A --wait that ends with a rollout still in progress has not passed: exit 5 +// with success false, so a gate on the final event agrees with the exit code. +// The row that kept it polling is marked transient, and nothing failed. +func TestCheck_WaitTimeoutOnARolloutIsNotASuccess(t *testing.T) { + err, stderr := runCheckWithBudget(t, time.Minute, func(context.Context) selfhosted.ClusterValidatorResult { + return selfhosted.ClusterValidatorResult{Passed: true, Logs: "Validator role: control-plane\n" + + "nvcf/api: rollout in progress (updated: 2/3)\nCluster is NVCF-Ready (with warnings)\n"} + }, "--wait", "1s") + var exitErr *ExitCodeError + require.ErrorAs(t, err, &exitErr) + assert.Equal(t, 5, exitErr.Code) + assert.Contains(t, exitErr.Msg, "a rollout was still in progress") + assert.Equal(t, true, validatorRow(t, stderr)["transient"]) + final := finalEvent(t, stderr) + assert.Equal(t, false, final["success"]) + assert.Equal(t, "timeout", final["verdict"]) + assert.EqualValues(t, 0, final["failedCount"]) +} + +// A validator Job that succeeded is the validator's verdict even when its +// transcript cannot be read, so --wait accepts the first poll instead of +// starting a new Job on every poll until it times out. +func TestCheck_WaitAcceptsASucceededValidatorWhoseTranscriptCannotBeRead(t *testing.T) { + calls := 0 + err, stderr := runCheckWithBudget(t, time.Minute, func(context.Context) selfhosted.ClusterValidatorResult { + calls++ + return selfhosted.ClusterValidatorResult{Passed: true, JobName: "cv-1", + LogsErr: errors.New(`pods "cv-1-abc" is forbidden: cannot get resource "pods/log"`)} + }, "--wait", "30s") + require.NoError(t, err) + assert.Equal(t, 1, calls) + row := validatorRow(t, stderr) + assert.Equal(t, true, row["passed"]) + assert.Contains(t, row["message"], "its transcript could not be read") + assert.Equal(t, "ok", finalEvent(t, stderr)["verdict"]) +} + +// The dashboard's quit key interrupts the run as a signal does: exit 130. A +// validator that kept objects in the cluster still says how to remove them: +// at once, before its teardown, and again on stderr once the dashboard is +// closed. Wiring the key to the budget's cancel instead read as a finished +// run. +func TestCheck_QuitKeyInterruptsAndPrintsTheCleanupCommand(t *testing.T) { + prev := selectCheckRendererFn + t.Cleanup(func() { selectCheckRendererFn = prev }) + quit := make(chan func(), 1) + selectCheckRendererFn = func(w io.Writer, wait bool, onQuit func()) (progress.EventSink, error) { + quit <- onQuit + return prev(w, wait, onQuit) + } + err, stderr := runCheckWith(t, time.Minute, func( + ctx context.Context, p selfhosted.ClusterValidatorParams, + ) selfhosted.ClusterValidatorResult { + p.OnStart("run1") + (<-quit)() + <-ctx.Done() + return selfhosted.ClusterValidatorResult{Err: ctx.Err(), RunID: "run1", LeftBehind: true} + }, &syncBuffer{}) + var exitErr *ExitCodeError + require.ErrorAs(t, err, &exitErr) + assert.Equal(t, 130, exitErr.Code) + assert.Equal(t, true, finalEvent(t, stderr)["cancelled"]) + cmd := "kubectl get pods -n default -l nvcf.nvidia.com/validator-run=run1; " + + "kubectl delete -n default job -l nvcf.nvidia.com/validator-run=run1 --cascade=foreground --wait; " + + "kubectl delete clusterrolebinding,clusterrole -l nvcf.nvidia.com/validator-run=run1; " + + "kubectl delete -n default rolebinding,role,serviceaccount,secret,configmap " + + "-l nvcf.nvidia.com/validator-run=run1" + assert.Equal(t, []any{cmd}, finalEvent(t, stderr)["cleanup"]) + interruptNote := strings.Index(stderr, "note: interrupted; ") + require.GreaterOrEqual(t, interruptNote, 0, "the interrupt says at once how to remove the run's objects") + assert.Less(t, interruptNote, strings.Index(stderr, `"event":"final"`), "the note comes before the final event") + assert.Equal(t, cmd, validatorRow(t, stderr)["cleanup"]) + kept := strings.Index(stderr, + "note: the cluster-validator left objects in the cluster; remove them with:\n "+cmd+"\n") + require.Positive(t, kept, "the removal command is repeated once the stream has ended") + assert.Greater(t, kept, strings.Index(stderr, `"event":"final"`)) +} + +// Each --wait poll is a new validator run, so --no-cleanup would keep a full +// set of cluster-wide RBAC, Secret, ConfigMap and Job per poll. +func TestCheck_WaitWithNoCleanupIsRejected(t *testing.T) { + resetCheckFlags(t) + rootCmd.SetErr(&bytes.Buffer{}) + rootCmd.SetOut(&bytes.Buffer{}) + t.Cleanup(func() { rootCmd.SetErr(nil); rootCmd.SetOut(nil) }) + rootCmd.SetArgs([]string{"self-hosted", "check", "--control-plane", "--wait", "1m", "--no-cleanup"}) + err := rootCmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "cannot be combined with --wait") +} + +// An unpinned validator image whose tag cannot be discovered is not launched: +// the kubelet would pull :latest, which the repository does not publish. +// Nothing was validated, so the run fails with a row that says to pin a tag, +// and the image's registry is still checked for credentials. +func TestCheck_UnresolvedValidatorTagFailsTheRun(t *testing.T) { + var mu sync.Mutex + var registries []string + prev := newRegistryCredentialCheckerForSelfHosted + newRegistryCredentialCheckerForSelfHosted = func() selfhosted.RegistryCredentialChecker { + return func(_ context.Context, registry, _ string, _, _ bool) error { + mu.Lock() + defer mu.Unlock() + registries = append(registries, registry) + return nil + } + } + t.Cleanup(func() { newRegistryCredentialCheckerForSelfHosted = prev }) + calls := 0 + err, stderr := runCheckWithBudget(t, time.Minute, func(context.Context) selfhosted.ClusterValidatorResult { + calls++ + return selfhosted.ClusterValidatorResult{Passed: true} + }, "--cluster-validator-image", "harbor.example.com/nvcf/cluster-validator") + assert.Zero(t, calls, "the untagged image must not be launched") + var exitErr *ExitCodeError + require.ErrorAs(t, err, &exitErr) + assert.Equal(t, 2, exitErr.Code) + row := validatorRow(t, stderr) + assert.Equal(t, "error", row["severity"]) + assert.Contains(t, row["message"], "could not resolve a tag for harbor.example.com/nvcf/cluster-validator") + assert.Equal(t, false, finalEvent(t, stderr)["success"]) + mu.Lock() + defer mu.Unlock() + assert.Contains(t, registries, "harbor.example.com") +} + +// Each --wait poll reads the local credentials again, so a docker login the +// operator renews while the run waits is the one the next poll sends. +func TestCheck_WaitReadsCredentialsAgainEachPoll(t *testing.T) { + prevInterval := checkPollInterval + checkPollInterval = 10 * time.Millisecond + t.Cleanup(func() { checkPollInterval = prevInterval }) + + var mu sync.Mutex + var sent []string + var srv *httptest.Server + srv = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/token" { + w.Header().Set("Www-Authenticate", `Bearer realm="`+srv.URL+`/token",service="test"`) + w.WriteHeader(http.StatusUnauthorized) + return + } + _, pass, _ := r.BasicAuth() + mu.Lock() + sent = append(sent, pass) + mu.Unlock() + if pass != "renewed" { + w.WriteHeader(http.StatusUnauthorized) + return + } + _, _ = w.Write([]byte(`{"token":"t"}`)) + })) + t.Cleanup(srv.Close) + prevTransport := http.DefaultTransport + http.DefaultTransport = srv.Client().Transport + t.Cleanup(func() { http.DefaultTransport = prevTransport }) + host := strings.TrimPrefix(srv.URL, "https://") + + dockerDir := t.TempDir() + t.Setenv("DOCKER_CONFIG", dockerDir) + login := func(pass string) { + cfg, err := json.Marshal(map[string]any{"auths": map[string]any{ + host: map[string]string{"username": "u", "password": pass}, + }}) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dockerDir, "config.json"), cfg, 0o600)) + } + login("stale") + + prevChecker := newRegistryCredentialCheckerForSelfHosted + t.Cleanup(func() { newRegistryCredentialCheckerForSelfHosted = prevChecker }) + probe := selfhosted.NewRegistryCredentialChecker() + newRegistryCredentialCheckerForSelfHosted = func() selfhosted.RegistryCredentialChecker { + return func(ctx context.Context, registry, repo string, critical, chartScope bool) error { + if registry != host { + return nil + } + return probe(ctx, registry, repo, critical, chartScope) + } + } + + polls := 0 + err, stderr := runCheckWithBudget(t, time.Minute, func(context.Context) selfhosted.ClusterValidatorResult { + polls++ + if polls == 1 { + login("renewed") + return selfhosted.ClusterValidatorResult{ExitCode: 1, Logs: "Validator role: control-plane\n"} + } + return selfhosted.ClusterValidatorResult{Passed: true, Logs: "Validator role: control-plane\nCluster is NVCF-Ready\n"} + }, "--cluster-validator-registries", host, "--wait", "30s") + require.NoError(t, err, stderr) + assert.Equal(t, 2, polls) + mu.Lock() + defer mu.Unlock() + assert.Equal(t, []string{"stale", "renewed"}, sent, "the second poll sends the renewed login") +} + +// The tag is resolved again on every --wait poll, so a discovery failure that +// clears lets the validator run. +func TestCheck_WaitResolvesTheValidatorTagAgainEachPoll(t *testing.T) { + prev := resolveLatestValidatorTagForSelfHosted + t.Cleanup(func() { resolveLatestValidatorTagForSelfHosted = prev }) + var resolves atomic.Int32 + resolveLatestValidatorTagForSelfHosted = func(_ context.Context, image string) (string, bool) { + // Before the loop, and on the first poll. + if resolves.Add(1) <= 2 { + return "", false + } + return image + ":1.2.3", true + } + var images []string + err, _ := runCheckWithBudget(t, time.Minute, func(context.Context) selfhosted.ClusterValidatorResult { + images = append(images, "ran") + return selfhosted.ClusterValidatorResult{Passed: true, Logs: "Validator role: control-plane\nCluster is NVCF-Ready\n"} + }, "--cluster-validator-image", "nvcr.io/nvidia/nvcf-byoc/cluster-validator", "--wait", "30s") + require.NoError(t, err) + assert.Len(t, images, 1, "the second poll ran the validator") + assert.EqualValues(t, 3, resolves.Load()) +} + +func TestImageRefIsPinned(t *testing.T) { + for ref, want := range map[string]bool{ + "nvcr.io/nvidia/validator:3.2.26": true, + "nvcr.io/nvidia/validator@sha256:abcd": true, + "localhost:5000/validator": false, + "nvcr.io/nvidia/validator": false, + "registry.example.com:443/team/validator": false, + } { + assert.Equal(t, want, selfhosted.ImageRefIsPinned(ref), ref) + } +} + +// removalCommand is the command that removes validator run runID's objects, +// with no context pinned. +func removalCommand(runID string) string { + sel := "nvcf.nvidia.com/validator-run=" + runID + return "kubectl get pods -n default -l " + sel + "; " + + "kubectl delete -n default job -l " + sel + " --cascade=foreground --wait; " + + "kubectl delete clusterrolebinding,clusterrole -l " + sel + "; " + + "kubectl delete -n default rolebinding,role,serviceaccount,secret,configmap -l " + sel +} + +// keptNote is what the command prints last when runs kept objects. +func keptNote(commands ...string) string { + return "note: the cluster-validator left objects in the cluster; remove them with:\n " + + strings.Join(commands, "\n ") + "\n" +} + +// On an interrupt, the removal command of every run in progress is printed at +// once, before the teardown that may not finish: a second Ctrl-C or CI's +// follow-up SIGTERM can end the process at any time. +func TestCheck_InterruptPrintsTheRemovalCommandBeforeTheTeardown(t *testing.T) { + prev := selectCheckRendererFn + t.Cleanup(func() { selectCheckRendererFn = prev }) + quit := make(chan func(), 1) + selectCheckRendererFn = func(w io.Writer, wait bool, onQuit func()) (progress.EventSink, error) { + quit <- onQuit + return prev(w, wait, onQuit) + } + out := &syncBuffer{} + note := "note: interrupted; stopping the cluster-validator and removing its objects. A second Ctrl-C " + + "exits at once and leaves them; remove them with:\n " + removalCommand("run1") + "\n" + printedFirst := false + err, stderr := runCheckWith(t, time.Minute, func( + ctx context.Context, p selfhosted.ClusterValidatorParams, + ) selfhosted.ClusterValidatorResult { + p.OnStart("run1") + (<-quit)() + <-ctx.Done() + // The teardown takes a while; the note must not wait for it. + require.Eventually(t, func() bool { return strings.Contains(out.String(), note) }, 5*time.Second, 10*time.Millisecond) + printedFirst = true + return selfhosted.ClusterValidatorResult{Err: ctx.Err(), RunID: "run1", Created: true} + }, out) + var exitErr *ExitCodeError + require.ErrorAs(t, err, &exitErr) + assert.Equal(t, 130, exitErr.Code) + assert.True(t, printedFirst) + assert.Equal(t, 1, strings.Count(stderr, note), "printed once") + assert.NotContains(t, stderr, keptNote(removalCommand("run1")), "the teardown removed everything") +} + +// closedStderrChild runs TestCheck_InterruptSurvivesTheEndOfItsStderrReader as +// the child process. Its value is the file the child writes once its +// validator's teardown has finished. +const closedStderrChild = "NVCF_CLI_TEST_CLOSED_STDERR_CHILD" + +// A Ctrl-C or a CI cancel signals the whole process group, so a pipe reading +// stderr ends with the run. Writing the interrupt note, or anything after it, +// to that pipe must not kill the process before the validator's teardown has +// removed what the run created, nor after it, before the process exits 130. +func TestCheck_InterruptSurvivesTheEndOfItsStderrReader(t *testing.T) { + const started = "validator started" + if marker := os.Getenv(closedStderrChild); marker != "" { + err := executeCheck(t, time.Minute, func( + ctx context.Context, p selfhosted.ClusterValidatorParams, + ) selfhosted.ClusterValidatorResult { + p.OnStart("run1") + fmt.Fprintln(os.Stderr, started) + <-ctx.Done() + // The teardown takes a while; the note is written meanwhile. + time.Sleep(time.Second) + require.NoError(t, os.WriteFile(marker, nil, 0o600)) + return selfhosted.ClusterValidatorResult{Err: ctx.Err(), RunID: "run1", Created: true} + }, os.Stderr) + // As main does, after cobra has printed the error to the closed pipe. + os.Exit(ExitCodeFromError(err)) + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + r, w, err := os.Pipe() + require.NoError(t, err) + marker := filepath.Join(t.TempDir(), "torn-down") + child := exec.CommandContext(ctx, os.Args[0], "-test.run=^"+t.Name()+"$") + child.Env = append(os.Environ(), closedStderrChild+"="+marker) + child.Stderr = w + var stdout bytes.Buffer + child.Stdout = &stdout + require.NoError(t, child.Start()) + require.NoError(t, w.Close()) + + seen := false + for lines := bufio.NewScanner(r); !seen && lines.Scan(); { + seen = lines.Text() == started + } + require.NoError(t, r.Close()) + if !seen { + _ = child.Wait() + t.Fatalf("the validator never started:\n%s", stdout.String()) + } + require.NoError(t, child.Process.Signal(os.Interrupt)) + waitErr := child.Wait() + _, err = os.Stat(marker) + assert.NoError(t, err, "the process died before the teardown finished: %v\n%s", waitErr, stdout.String()) + assert.Equal(t, 130, child.ProcessState.ExitCode(), "%v\n%s", waitErr, stdout.String()) +} + +// brokenPipeChild runs TestCheck_AnOutputReaderThatExitsEarlyDoesNotKillTheRun +// as the child process. Its value is the stream the parent reads, stdout or +// stderr, and the directory the two processes leave their markers in. +const brokenPipeChild = "NVCF_CLI_TEST_BROKEN_PIPE_CHILD" + +// A reader that stops early, such as grep -m1, head or a jq that fails on a +// line, closes its pipe while the run goes on, with no interrupt. Writing an +// event to that pipe while a validator holds its cluster-wide RBAC must not +// kill the process before the teardown has removed it, nor after it, before +// the command returns. +func TestCheck_AnOutputReaderThatExitsEarlyDoesNotKillTheRun(t *testing.T) { + const started = "validator started" + if spec := os.Getenv(brokenPipeChild); spec != "" { + stream, dir, _ := strings.Cut(spec, ":") + out := os.Stderr + if stream == "stdout" { + out = os.Stdout + } + err := executeCheck(t, time.Minute, func( + ctx context.Context, p selfhosted.ClusterValidatorParams, + ) selfhosted.ClusterValidatorResult { + p.OnStart("run1") + fmt.Fprintln(out, started) + for deadline := time.Now().Add(30 * time.Second); time.Now().Before(deadline); { + if _, err := os.Stat(filepath.Join(dir, "reader-gone")); err == nil { + break + } + time.Sleep(10 * time.Millisecond) + } + // Another role's event, written while this run's objects exist. + fmt.Fprintln(out, `{"event":"check_started"}`) + time.Sleep(100 * time.Millisecond) + _ = os.WriteFile(filepath.Join(dir, "teardown"), nil, 0o600) + return selfhosted.ClusterValidatorResult{Err: ctx.Err(), Passed: ctx.Err() == nil, RunID: "run1", + Created: true, Logs: "Validator role: control-plane\nCluster is NVCF-Ready\n"} + }, os.Stderr) + _ = os.WriteFile(filepath.Join(dir, "return"), nil, 0o600) + os.Exit(ExitCodeFromError(err)) + } + + for _, stream := range []string{"stderr", "stdout"} { + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + dir := t.TempDir() + r, w, err := os.Pipe() + require.NoError(t, err) + child := exec.CommandContext(ctx, os.Args[0], "-test.run=^"+t.Name()+"$") + child.Env = append(os.Environ(), brokenPipeChild+"="+stream+":"+dir) + var other bytes.Buffer + child.Stdout, child.Stderr = &other, w + if stream == "stdout" { + child.Stdout, child.Stderr = w, &other + } + require.NoError(t, child.Start(), stream) + require.NoError(t, w.Close()) + + seen := false + for lines := bufio.NewScanner(r); !seen && lines.Scan(); { + seen = lines.Text() == started + } + require.NoError(t, r.Close()) + require.NoError(t, os.WriteFile(filepath.Join(dir, "reader-gone"), nil, 0o600)) + waitErr := child.Wait() + cancel() + require.True(t, seen, "%s: the validator never started:\n%s", stream, other.String()) + status, _ := child.ProcessState.Sys().(syscall.WaitStatus) + assert.False(t, status.Signaled(), "%s: killed by %v\n%s", stream, status.Signal(), other.String()) + for marker, what := range map[string]string{"teardown": "its teardown", "return": "the command returned"} { + _, err = os.Stat(filepath.Join(dir, marker)) + assert.NoError(t, err, "%s: the process died before %s: %v\n%s", stream, what, waitErr, other.String()) + } + } +} + +// terminalSink stands in for the --wait dashboard, which draws on the +// alternate screen: what is written to the terminal before it closes is lost. +type terminalSink struct { + progress.EventSink + out *syncBuffer + closed atomic.Bool +} + +func (s *terminalSink) OwnsTerminal() bool { return true } + +func (s *terminalSink) Close() error { + if !s.closed.Swap(true) { + _, _ = s.out.Write([]byte("\n")) + } + return s.EventSink.Close() +} + +// On an interrupt, a dashboard that owns the terminal is closed before the +// removal command is printed. Printed while it was open, the command went +// with the alternate screen, and a second Ctrl-C left nothing on screen. +func TestCheck_InterruptNoteIsPrintedAfterTheDashboardCloses(t *testing.T) { + prev := selectCheckRendererFn + t.Cleanup(func() { selectCheckRendererFn = prev }) + quit := make(chan func(), 1) + out := &syncBuffer{} + selectCheckRendererFn = func(w io.Writer, wait bool, onQuit func()) (progress.EventSink, error) { + quit <- onQuit + sink, err := prev(w, wait, onQuit) + return &terminalSink{EventSink: sink, out: out}, err + } + const note, closed = "note: interrupted; ", "" + err, stderr := runCheckWith(t, time.Minute, func( + ctx context.Context, p selfhosted.ClusterValidatorParams, + ) selfhosted.ClusterValidatorResult { + p.OnStart("run1") + (<-quit)() + <-ctx.Done() + require.Eventually(t, func() bool { return strings.Contains(out.String(), note) }, + 5*time.Second, 10*time.Millisecond) + return selfhosted.ClusterValidatorResult{Err: ctx.Err(), RunID: "run1", Created: true} + }, out) + var exitErr *ExitCodeError + require.ErrorAs(t, err, &exitErr) + assert.Equal(t, 130, exitErr.Code) + require.Contains(t, stderr, closed) + assert.Less(t, strings.Index(stderr, closed), strings.Index(stderr, note), + "the note is printed on the restored screen") + assert.Equal(t, 1, strings.Count(stderr, closed)) +} + +// A closed terminal or a dropped SSH session sends SIGHUP. It goes through +// the same teardown as Ctrl-C, instead of killing the process with the run's +// cluster-wide RBAC and NGC-key Secret in place. +func TestCheck_SIGHUPGoesThroughTheTeardown(t *testing.T) { + if signal.Ignored(syscall.SIGHUP) { + t.Skip("the test process started with SIGHUP ignored, as under nohup, and the run keeps it ignored") + } + err, stderr := runCheckWithBudget(t, time.Minute, func(ctx context.Context) selfhosted.ClusterValidatorResult { + require.NoError(t, syscall.Kill(os.Getpid(), syscall.SIGHUP)) + <-ctx.Done() + return selfhosted.ClusterValidatorResult{Err: ctx.Err()} + }) + var exitErr *ExitCodeError + require.ErrorAs(t, err, &exitErr) + assert.Equal(t, 130, exitErr.Code) + assert.Equal(t, true, finalEvent(t, stderr)["cancelled"]) +} + +// ignoredSignalsChild runs TestCheck_SignalsIgnoredAtStartStayIgnored as the +// child process, started with SIGHUP and SIGINT ignored. +const ignoredSignalsChild = "NVCF_CLI_TEST_IGNORED_SIGNALS_CHILD" + +// nohup starts the command with SIGHUP ignored, and a shell without job +// control starts a background job with SIGINT ignored. The run keeps them +// ignored: a dropped SSH session must not cancel a check started with nohup. +func TestCheck_SignalsIgnoredAtStartStayIgnored(t *testing.T) { + const finished = "validator finished" + if os.Getenv(ignoredSignalsChild) != "" { + if !signal.Ignored(syscall.SIGHUP) || !signal.Ignored(syscall.SIGINT) { + fmt.Println("the child was not started with SIGHUP and SIGINT ignored") + os.Exit(3) + } + err := executeCheck(t, time.Minute, func( + ctx context.Context, p selfhosted.ClusterValidatorParams, + ) selfhosted.ClusterValidatorResult { + _ = syscall.Kill(os.Getpid(), syscall.SIGHUP) + _ = syscall.Kill(os.Getpid(), syscall.SIGINT) + select { + case <-ctx.Done(): + return selfhosted.ClusterValidatorResult{Err: ctx.Err()} + case <-time.After(300 * time.Millisecond): + } + fmt.Println(finished) + return selfhosted.ClusterValidatorResult{Passed: true, + Logs: "Validator role: control-plane\nCluster is NVCF-Ready\n"} + }, os.Stderr) + os.Exit(ExitCodeFromError(err)) + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + child := exec.CommandContext(ctx, "/bin/sh", "-c", `trap "" HUP INT; exec "$0" "$@"`, + os.Args[0], "-test.run=^"+t.Name()+"$") + child.Env = append(os.Environ(), ignoredSignalsChild+"=1") + var stdout, stderr bytes.Buffer + child.Stdout, child.Stderr = &stdout, &stderr + waitErr := child.Run() + require.NotEqual(t, 3, child.ProcessState.ExitCode(), stdout.String()) + assert.NotEqual(t, 130, child.ProcessState.ExitCode(), "%v\n%s%s", waitErr, stdout.String(), stderr.String()) + assert.Contains(t, stdout.String(), finished) + final := finalEvent(t, stderr.String()) + assert.Nil(t, final["cancelled"]) + assert.Equal(t, "ok", final["verdict"], stderr.String()) +} + +// Every exit that leaves objects behind says how to remove them: in the row, +// in the final event, and on stderr after the stream ends, where no dashboard +// can wipe it and a pipe into jq does not swallow it. +func TestCheck_KeptObjectsAreReportedOnEveryExit(t *testing.T) { + for name, tc := range map[string]struct { + budget time.Duration + result func(ctx context.Context) selfhosted.ClusterValidatorResult + args []string + code int + }{ + "no-cleanup pass": {budget: time.Minute, args: []string{"--no-cleanup"}, + result: func(context.Context) selfhosted.ClusterValidatorResult { + return selfhosted.ClusterValidatorResult{Passed: true, RunID: "run1", Created: true, + Logs: "Validator role: control-plane\nCluster is NVCF-Ready\n"} + }}, + "failed, pod kept": {budget: time.Minute, code: 2, + result: func(context.Context) selfhosted.ClusterValidatorResult { + return selfhosted.ClusterValidatorResult{Err: errors.New("did not finish"), RunID: "run1", LeftBehind: true} + }}, + "budget spent": {budget: 300 * time.Millisecond, code: 5, + result: func(ctx context.Context) selfhosted.ClusterValidatorResult { + <-ctx.Done() + return selfhosted.ClusterValidatorResult{Err: ctx.Err(), RunID: "run1", LeftBehind: true} + }}, + } { + err, stderr := runCheckWithBudget(t, tc.budget, tc.result, tc.args...) + if tc.code == 0 { + require.NoError(t, err, name) + } else { + var exitErr *ExitCodeError + require.ErrorAs(t, err, &exitErr, name) + assert.Equal(t, tc.code, exitErr.Code, name) + } + cmd := removalCommand("run1") + assert.Equal(t, cmd, validatorRow(t, stderr)["cleanup"], name) + assert.Contains(t, validatorRow(t, stderr)["detail"], ": "+cmd, name) + if name == "no-cleanup pass" { + assert.Contains(t, validatorRow(t, stderr)["detail"], "kept with --no-cleanup; remove with: "+cmd) + } + assert.Equal(t, []any{cmd}, finalEvent(t, stderr)["cleanup"], name) + assert.True(t, strings.HasSuffix(strings.TrimSuffix(stderr, "Error: "+errText(err)+"\n"), keptNote(cmd)), + "%s: the command is printed last:\n%s", name, stderr) + } +} + +// errText is err's message, or "" for nil. +func errText(err error) string { + if err == nil { + return "" + } + return err.Error() +} + +// Under --wait, a poll whose run kept objects is not forgotten when a later +// poll passes: its command is still printed and still in the final event. +func TestCheck_WaitKeepsEarlierPollsRemovalCommands(t *testing.T) { + calls := 0 + err, stderr := runCheckWithBudget(t, time.Minute, func(context.Context) selfhosted.ClusterValidatorResult { + calls++ + if calls == 1 { + return selfhosted.ClusterValidatorResult{Err: errors.New("did not finish"), RunID: "run1", LeftBehind: true} + } + return selfhosted.ClusterValidatorResult{Passed: true, RunID: "run2", Created: true, + Logs: "Validator role: control-plane\nCluster is NVCF-Ready\n"} + }, "--wait", "30s") + require.NoError(t, err) + assert.Equal(t, 2, calls) + assert.Equal(t, []any{removalCommand("run1")}, finalEvent(t, stderr)["cleanup"]) + assert.True(t, strings.HasSuffix(stderr, keptNote(removalCommand("run1"))), stderr) +} + +// An interrupt after the budget ran out reports the interrupt only: no +// synthetic cut-short or not-run rows ahead of the cancelled final event. +func TestCheck_InterruptAfterTheBudgetEmitsNoSyntheticRows(t *testing.T) { + prev := selectCheckRendererFn + t.Cleanup(func() { selectCheckRendererFn = prev }) + var onQuit atomic.Value + selectCheckRendererFn = func(w io.Writer, wait bool, q func()) (progress.EventSink, error) { + onQuit.Store(q) + return prev(w, wait, q) + } + err, stderr := runCheckWithBudget(t, 300*time.Millisecond, func( + ctx context.Context, + ) selfhosted.ClusterValidatorResult { + <-ctx.Done() + onQuit.Load().(func())() + return selfhosted.ClusterValidatorResult{Err: ctx.Err()} + }, "--all") + var exitErr *ExitCodeError + require.ErrorAs(t, err, &exitErr) + assert.Equal(t, 130, exitErr.Code) + for _, row := range checkEvents(t, stderr, "check_completed") { + msg, _ := row["message"].(string) + assert.NotContains(t, msg, "cut short", row["id"]) + assert.NotContains(t, msg, "not run:", row["id"]) + } + assert.Equal(t, true, finalEvent(t, stderr)["cancelled"]) +} + +// --show-logs prints the kept transcript on an interrupt too. +func TestCheck_ShowLogsOnInterrupt(t *testing.T) { + prev := selectCheckRendererFn + t.Cleanup(func() { selectCheckRendererFn = prev }) + quit := make(chan func(), 1) + selectCheckRendererFn = func(w io.Writer, wait bool, onQuit func()) (progress.EventSink, error) { + quit <- onQuit + return prev(w, wait, onQuit) + } + err, stderr := runCheckWithBudget(t, time.Minute, func(ctx context.Context) selfhosted.ClusterValidatorResult { + (<-quit)() + <-ctx.Done() + return selfhosted.ClusterValidatorResult{Err: ctx.Err(), RunID: "run1", LeftBehind: true, + Logs: "Validator role: control-plane\nTier-1: checking\n"} + }, "--show-logs") + var exitErr *ExitCodeError + require.ErrorAs(t, err, &exitErr) + assert.Equal(t, 130, exitErr.Code) + assert.Contains(t, stderr, + "--- cluster-validator logs (control-plane-cluster) ---\nValidator role: control-plane\nTier-1: checking\n") +} + +// A check's own timeout under a live budget is its result: exit 2, not 5. +func TestCheck_ACheckOwnTimeoutIsAFailure(t *testing.T) { + err, stderr := runCheckWithBudget(t, time.Minute, func(context.Context) selfhosted.ClusterValidatorResult { + return selfhosted.ClusterValidatorResult{Err: fmt.Errorf("reading the Job: %w", context.DeadlineExceeded)} + }) + var exitErr *ExitCodeError + require.ErrorAs(t, err, &exitErr) + assert.Equal(t, 2, exitErr.Code) + assert.Equal(t, "failed", finalEvent(t, stderr)["verdict"]) +} + +// Both TTY renderers get the quit key: in raw mode it is the only Ctrl-C the +// run sees, and without it the dashboard closes while the Jobs keep running. +func TestSelectCheckRenderer_TTYCarriesOnQuit(t *testing.T) { + resetCheckFlags(t) + prevTTY, prevSelect, prevOneShot := checkWriterIsTTY, selectProgressRenderer, newCheckOneShotRenderer + t.Cleanup(func() { + checkWriterIsTTY, selectProgressRenderer, newCheckOneShotRenderer = prevTTY, prevSelect, prevOneShot + }) + checkWriterIsTTY = func(io.Writer) bool { return true } + var got func() + selectProgressRenderer = func( + w io.Writer, opts progress.RenderOpts, + ) (progress.EventSink, progress.RendererKind, error) { + got = opts.OnQuit + return progress.NewPlainRenderer(w), progress.RendererTTYFull, nil + } + newCheckOneShotRenderer = func(w io.Writer, opts progress.ModelOpts) progress.EventSink { + got = opts.OnQuit + return progress.NewPlainRenderer(w) + } + for _, wait := range []bool{false, true} { + got = nil + quits := 0 + _, err := selectCheckRenderer(&bytes.Buffer{}, wait, func() { quits++ }) + require.NoError(t, err) + require.NotNil(t, got, "wait=%v", wait) + got() + assert.Equal(t, 1, quits, "wait=%v", wait) + } +} diff --git a/src/clis/nvcf-cli/cmd/self_hosted_check_stackvalues_test.go b/src/clis/nvcf-cli/cmd/self_hosted_check_stackvalues_test.go new file mode 100644 index 0000000000..ed94e60c11 --- /dev/null +++ b/src/clis/nvcf-cli/cmd/self_hosted_check_stackvalues_test.go @@ -0,0 +1,527 @@ +/* +SPDX-FileCopyrightText: Copyright (c) NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "nvcf-cli/internal/selfhosted" +) + +// stackWithEnvs creates a stack directory holding base.yaml and one file per +// named environment. +func stackWithEnvs(t *testing.T, envs ...string) string { + t.Helper() + dir := t.TempDir() + envDir := filepath.Join(dir, "environments") + require.NoError(t, os.MkdirAll(envDir, 0o755)) + for _, name := range append([]string{"base"}, envs...) { + require.NoError(t, os.WriteFile(filepath.Join(envDir, name+".yaml"), []byte("global: {}\n"), 0o644)) + } + return dir +} + +// withEnvFlag sets --env as if the operator passed it, and restores it. +func withEnvFlag(t *testing.T, value string, changed bool) { + t.Helper() + f := selfHostedCmd.PersistentFlags().Lookup("env") + require.NotNil(t, f) + prevValue, prevChanged := selfHostedEnv, f.Changed + selfHostedEnv, f.Changed = value, changed + t.Cleanup(func() { selfHostedEnv, f.Changed = prevValue, prevChanged }) +} + +// withStackFlag sets a stack flag's value as if the operator passed it. +func withStackFlag(t *testing.T, flag *string, src string) { + t.Helper() + prev := *flag + *flag = src + t.Cleanup(func() { *flag = prev }) +} + +// helmfile layers the environment file over base.yaml, so both are returned, +// base first. Returning only the first file that exists dropped whichever +// half the other one set. +func TestStackValuesForRun_LayersEnvOverBase(t *testing.T) { + stack := stackWithEnvs(t, "prod") + withStackFlag(t, &selfHostedControlPlaneStack, stack) + t.Setenv("HELMFILE_ENV", "") + withEnvFlag(t, "prod", true) + + got, ok := stackValuesForRun(controlPlaneStackTarget()) + require.True(t, ok) + require.Len(t, got, 2) + assert.Equal(t, "base.yaml", filepath.Base(got[0])) + assert.Equal(t, "prod.yaml", filepath.Base(got[1])) +} + +// --env's default is not evidence of the environment the install used, so +// nothing is read until the operator names one, with --env or HELMFILE_ENV. +// base.yaml alone does not describe an install either. +func TestStackValuesForRun_NeedsTheNamedEnvironmentFile(t *testing.T) { + stack := stackWithEnvs(t, "local") + withStackFlag(t, &selfHostedControlPlaneStack, stack) + t.Setenv("HELMFILE_ENV", "") + withEnvFlag(t, "local", false) + _, ok := stackValuesForRun(controlPlaneStackTarget()) + assert.False(t, ok, "the defaulted --env is not the install's environment") + + t.Setenv("HELMFILE_ENV", "local") + _, ok = stackValuesForRun(controlPlaneStackTarget()) + assert.True(t, ok, "HELMFILE_ENV names the environment") + + withEnvFlag(t, "prod", true) + _, ok = stackValuesForRun(controlPlaneStackTarget()) + assert.False(t, ok, "base.yaml without prod.yaml does not describe the install") +} + +// A plane --pre checks before its install is read with --env's default: up +// installs with it. A plane checked as installed still needs it named. +func TestStackValuesForRun_BeforeInstallReadsUpsDefaultEnvironment(t *testing.T) { + resetCheckFlags(t) + t.Setenv("HELMFILE_ENV", "") + cp := stackWithEnvs(t, "local") + withStackFlag(t, &selfHostedControlPlaneStack, cp) + withStackFlag(t, &selfHostedComputePlaneStack, stackWithEnvs(t, "local")) + withEnvFlag(t, "local", false) + + checkPre = true + files, ok := stackValuesForRun(controlPlaneStackTarget()) + require.True(t, ok, "a bare --pre checks both planes before up installs them") + assert.Equal(t, filepath.Join(cp, "environments", "local.yaml"), files[1]) + _, ok = stackValuesForRun(computePlaneStackTarget()) + assert.True(t, ok) + + checkComputePlane = true + _, ok = stackValuesForRun(computePlaneStackTarget()) + assert.False(t, ok, "--pre --compute-plane checks the compute plane as installed") + _, ok = stackValuesForRun(controlPlaneStackTarget()) + assert.True(t, ok, "and the control plane still before its install") + + checkComputePlane, checkAll = false, true + _, ok = stackValuesForRun(controlPlaneStackTarget()) + assert.False(t, ok, "--pre --all checks both planes as installed") +} + +// An operator who exports HELMFILE_ENV and runs helmfile directly gets that +// environment; an explicit --env still wins over it. +func TestResolveStackEnv_Precedence(t *testing.T) { + t.Setenv("HELMFILE_ENV", "airgap") + withEnvFlag(t, "local", false) + assert.Equal(t, "airgap", resolveStackEnv(), "HELMFILE_ENV beats the --env default") + + withEnvFlag(t, "staging", true) + assert.Equal(t, "staging", resolveStackEnv(), "an explicit --env beats HELMFILE_ENV") + + t.Setenv("HELMFILE_ENV", "") + withEnvFlag(t, "local", false) + assert.Equal(t, "local", resolveStackEnv()) +} + +// A stack the install fetched is not read from anywhere else: file:// and +// git stacks are cloned from what is committed, and an oci:// stack nobody +// extracted here has nothing to read. A checkout above the working directory +// does not stand in for any of them. +func TestStackValuesForRun_RemoteStacksAreNotGuessed(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + stack := stackWithEnvs(t, "prod") + t.Setenv("HELMFILE_ENV", "") + withEnvFlag(t, "prod", true) + t.Chdir(stack) + for _, src := range []string{ + "file://" + stack, "file://" + stack + "@main", "git@example.com:org/stack.git", + "https://example.com/org/stack.git", "oci://registry.example.com/nvcf/stack:1.0.0", + } { + withStackFlag(t, &selfHostedControlPlaneStack, src) + _, ok := stackValuesForRun(controlPlaneStackTarget()) + assert.False(t, ok, src) + } +} + +// An oci:// stack an earlier command extracted is read from that extraction, +// which is what the install read: the built-in stack when no flag is set. +func TestStackValuesForRun_ReadsTheExtractedOCIStack(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache")) + cache, err := os.UserCacheDir() + require.NoError(t, err) + const ref = "oci://registry.example.com/nvcf/stack:1.0.0@sha256:0123456789abcdef" + extracted := filepath.Join(cache, "nvcf-cli", "stacks", "oci-0123456789ab") + require.NoError(t, os.MkdirAll(filepath.Join(extracted, "environments"), 0o755)) + for _, name := range []string{"base", "prod"} { + require.NoError(t, os.WriteFile(filepath.Join(extracted, "environments", name+".yaml"), []byte("{}\n"), 0o644)) + } + t.Setenv("HELMFILE_ENV", "") + withEnvFlag(t, "prod", true) + + withStackFlag(t, &selfHostedControlPlaneStack, ref) + _, ok := stackValuesForRun(controlPlaneStackTarget()) + assert.False(t, ok, "a partial extraction is not read") + + require.NoError(t, os.WriteFile(filepath.Join(extracted, ".extraction-complete"), nil, 0o644)) + files, ok := stackValuesForRun(controlPlaneStackTarget()) + require.True(t, ok) + assert.True(t, strings.HasPrefix(files[1], extracted), files[1]) + + withStackFlag(t, &selfHostedControlPlaneStack, "") + t.Setenv("NVCF_CLI_DEFAULT_CONTROL_PLANE_STACK", ref) + files, ok = stackValuesForRun(controlPlaneStackTarget()) + require.True(t, ok, "with no flag the built-in stack is the install's") + assert.True(t, strings.HasPrefix(files[1], extracted), files[1]) +} + +// With neither a stack flag nor a built-in stack, the stack checkout above +// the working directory is read, each plane from its own stack. A CLI with a +// built-in stack installs from that, so the checkout is then not read. +func TestStackValuesForRun_CheckoutAboveTheWorkingDirectory(t *testing.T) { + t.Setenv("NVCF_CLI_DEFAULT_CONTROL_PLANE_STACK", "") + t.Setenv("NVCF_CLI_DEFAULT_COMPUTE_PLANE_STACK", "") + repo := t.TempDir() + envDir := func(stack string) string { + dir := filepath.Join(repo, "deploy", "stacks", stack, "environments") + require.NoError(t, os.MkdirAll(dir, 0o755)) + for _, name := range []string{"base", "prod"} { + require.NoError(t, os.WriteFile(filepath.Join(dir, name+".yaml"), []byte("{}\n"), 0o644)) + } + return dir + } + cpDir, gpuDir := envDir("self-managed"), envDir("nvcf-compute-plane") + sub := filepath.Join(repo, "src", "x") + require.NoError(t, os.MkdirAll(sub, 0o755)) + t.Chdir(sub) + withStackFlag(t, &selfHostedControlPlaneStack, "") + withStackFlag(t, &selfHostedComputePlaneStack, "") + t.Setenv("HELMFILE_ENV", "") + withEnvFlag(t, "prod", true) + + files, ok := stackValuesForRun(controlPlaneStackTarget()) + require.True(t, ok) + assert.Equal(t, filepath.Join(cpDir, "prod.yaml"), files[1]) + files, ok = stackValuesForRun(computePlaneStackTarget()) + require.True(t, ok) + assert.Equal(t, filepath.Join(gpuDir, "prod.yaml"), files[1]) + + t.Setenv("NVCF_CLI_DEFAULT_CONTROL_PLANE_STACK", "oci://registry.example.com/nvcf/stack:9") + _, ok = stackValuesForRun(controlPlaneStackTarget()) + assert.False(t, ok, "a CLI with a built-in stack installs from it, not from the working directory") +} + +// registryProbes runs check with args and returns the registries the local +// credential check probed, keyed registry or registry/scope, with whether +// each was critical. +func registryProbes(t *testing.T, args ...string) map[string]bool { + t.Helper() + resetCheckFlags(t) + var mu sync.Mutex + got := map[string]bool{} + prev := newRegistryCredentialCheckerForSelfHosted + newRegistryCredentialCheckerForSelfHosted = func() selfhosted.RegistryCredentialChecker { + return func(_ context.Context, registry, repo string, critical, _ bool) error { + mu.Lock() + defer mu.Unlock() + got[strings.TrimSuffix(registry+"/"+repo, "/")] = critical + return nil + } + } + t.Cleanup(func() { newRegistryCredentialCheckerForSelfHosted = prev }) + rootCmd.SetErr(&bytes.Buffer{}) + rootCmd.SetOut(&bytes.Buffer{}) + rootCmd.SetArgs(append([]string{"self-hosted", "check", "--json", "--skip-cluster-validation"}, args...)) + _ = rootCmd.Execute() + return got +} + +// mirroredStack writes a stack whose prod environment pulls from a mirror. +func mirroredStack(t *testing.T) string { + t.Helper() + stack := t.TempDir() + envDir := filepath.Join(stack, "environments") + require.NoError(t, os.MkdirAll(envDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(envDir, "base.yaml"), + []byte("global:\n image:\n registry: nvcr.io\n repository: YOUR_ORG/YOUR_TEAM\n"+ + "certManager:\n enabled: true\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(envDir, "prod.yaml"), + []byte("global:\n image:\n registry: harbor.corp.example\n repository: nvcf/site\n"+ + "certManager:\n acmesolver:\n image:\n repository: harbor.corp.example/jetstack/acmesolver\n"), 0o644)) + return stack +} + +// Registry enumeration follows the same rule as the Gateways: the stack's +// registries, their criticality and the quay.io gate are used only when the +// named environment's file was read from the stack the command points at. +// Otherwise nvcr.io is a non-critical guess, never a critical row a mirrored +// install fails on. +func TestCheck_RegistriesNeedTheEnvironmentFile(t *testing.T) { + t.Setenv("HELMFILE_ENV", "") + t.Setenv("NVCF_CLI_DEFAULT_CONTROL_PLANE_STACK", "") + stack := mirroredStack(t) + + got := registryProbes(t, "--control-plane", "--env", "prod", "--control-plane-stack", stack) + assert.Equal(t, map[string]bool{"harbor.corp.example/nvcf/site": false}, got, + "the install's mirror is probed, and neither nvcr.io nor quay.io is") + + for name, args := range map[string][]string{ + "no --env": {"--control-plane", "--control-plane-stack", stack}, + "base.yaml only": {"--control-plane", "--env", "staging", "--control-plane-stack", stack}, + "stack without env dir": {"--control-plane", "--env", "prod", "--control-plane-stack", t.TempDir()}, + "oci stack in a checkout": {"--control-plane", "--env", "prod", "--control-plane-stack", "oci://r.example.com/s:1"}, + } { + t.Run(name, func(t *testing.T) { + t.Chdir(stack) + got := registryProbes(t, args...) + assert.Contains(t, got, "nvcr.io") + assert.False(t, got["nvcr.io"], "a guessed nvcr.io is never critical") + assert.NotContains(t, got, "harbor.corp.example/nvcf/site") + }) + } +} + +// check --pre without --env grades the registries of the environment up +// installs, so an NGC repository the key cannot pull from fails the gate. +func TestCheck_PreGradesTheRegistriesUpInstallsFrom(t *testing.T) { + t.Setenv("HELMFILE_ENV", "") + t.Setenv("NVCF_CLI_DEFAULT_CONTROL_PLANE_STACK", "") + stack := t.TempDir() + envDir := filepath.Join(stack, "environments") + require.NoError(t, os.MkdirAll(envDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(envDir, "local.yaml"), + []byte("global:\n image:\n registry: nvcr.io\n repository: acme/nvcf\ncertManager:\n enabled: false\n"), + 0o644)) + + got := registryProbes(t, "--pre", "--control-plane-stack", stack) + assert.Equal(t, map[string]bool{"nvcr.io/acme/nvcf": true}, got) + + got = registryProbes(t, "--pre", "--all", "--control-plane-stack", stack) + assert.NotContains(t, got, "nvcr.io/acme/nvcf", "an installed stack's environment is not assumed") + assert.False(t, got["nvcr.io"]) +} + +// A compute-plane-only run reads the compute-plane stack, under the same rule. +func TestCheck_ComputeOnlyRunReadsTheComputePlaneStack(t *testing.T) { + t.Setenv("HELMFILE_ENV", "") + t.Setenv("NVCF_CLI_DEFAULT_COMPUTE_PLANE_STACK", "") + t.Setenv("NVCF_CLI_SELFHOSTED_SKIP_INOTIFY", "1") + gpuStack := t.TempDir() + envDir := filepath.Join(gpuStack, "environments") + require.NoError(t, os.MkdirAll(envDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(envDir, "prod.yaml"), + []byte("global:\n image:\n registry: gpu-mirror.example.com\n repository: site/gpu\n"), 0o644)) + + got := registryProbes(t, "--compute-plane", "--cluster-name", "gpu", "--env", "prod", + "--control-plane-stack", mirroredStack(t), "--compute-plane-stack", gpuStack) + assert.Equal(t, map[string]bool{"gpu-mirror.example.com/site/gpu": false}, got) +} + +// A malformed --cluster-validator-registries entry fails the command naming +// it, rather than vanishing with no row. +func TestCheck_MalformedValidatorRegistryFailsTheCommand(t *testing.T) { + resetCheckFlags(t) + rootCmd.SetErr(&bytes.Buffer{}) + rootCmd.SetOut(&bytes.Buffer{}) + rootCmd.SetArgs([]string{"self-hosted", "check", "--pre", "--cluster-validator-registries", "harbor.example.com:44x"}) + err := rootCmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), `"harbor.example.com:44x": expected host[:port]`) + + got := registryProbes(t, "--control-plane", "--cluster-validator-registries", "harbor.example.com:5000/nvcf") + assert.Contains(t, got, "harbor.example.com:5000/nvcf", "a path is the probe's scope") +} + +// One plane decides how the registry rows are read and graded: a plane the +// run checks before its install, since this machine installs it next, else +// the control plane when visited. The NGC key goes first only before a local +// install of the control plane, where up mints its pull secrets from it: +// compute-plane install mints none, and its helm sends the docker login. +func TestCheckScope_RegistryPlaneDecidesKeyOrderAndGrading(t *testing.T) { + t.Setenv("HELMFILE_ENV", "") + for _, tc := range []struct { + name string + pre, cp, gpu, all, prodEnv bool + prefer, postInstall bool + plane string + }{ + {name: "bare --pre", pre: true, prefer: true, plane: "self-managed"}, + {name: "--pre --env prod", pre: true, prodEnv: true, plane: "self-managed"}, + {name: "--pre --compute-plane", pre: true, gpu: true, prefer: true, plane: "self-managed"}, + {name: "--pre --control-plane", pre: true, cp: true, plane: "nvcf-compute-plane"}, + {name: "--pre --control-plane --env prod", pre: true, cp: true, prodEnv: true, plane: "nvcf-compute-plane"}, + {name: "--pre --control-plane --compute-plane", pre: true, cp: true, gpu: true, postInstall: true, + plane: "self-managed"}, + {name: "--control-plane", cp: true, postInstall: true, plane: "self-managed"}, + {name: "--compute-plane", gpu: true, postInstall: true, plane: "nvcf-compute-plane"}, + {name: "--pre --all", pre: true, all: true, postInstall: true, plane: "self-managed"}, + } { + resetCheckFlags(t) + checkPre, checkControlPlane, checkComputePlane, checkAll = tc.pre, tc.cp, tc.gpu, tc.all + withEnvFlag(t, map[bool]string{true: "prod", false: "local"}[tc.prodEnv], tc.prodEnv) + assert.Equal(t, tc.prefer, preferNGCKey(), tc.name) + assert.Equal(t, tc.postInstall, !registryTarget().beforeInstall, tc.name) + assert.Equal(t, tc.plane, registryTarget().name, tc.name) + } +} + +// registryGrading runs check with args and returns the registries the local +// credential check probed, and the kubeconfig contexts whose pull secrets a +// refused credential would be looked for in: nil before install. +func registryGrading(t *testing.T, args ...string) (map[string]bool, []string) { + t.Helper() + var contexts []string + prev := newClusterPullSecretCheckerForSelfHosted + newClusterPullSecretCheckerForSelfHosted = func(kubeContexts []string) selfhosted.ClusterPullSecretChecker { + contexts = append([]string{}, kubeContexts...) + return prev(kubeContexts) + } + t.Cleanup(func() { newClusterPullSecretCheckerForSelfHosted = prev }) + return registryProbes(t, args...), contexts +} + +// --pre --compute-plane checks the control plane before its install, so the +// registry rows come from the control-plane stack read with up's default +// environment and are graded as before install, with no cluster to compare +// against. --pre --control-plane does the same with the compute-plane stack, +// the one compute-plane install pulls from next. A run that checks installed +// planes compares against the pull secrets of each cluster it visits. +func TestCheck_RegistryRowsAreReadAndGradedByOnePlane(t *testing.T) { + t.Setenv("HELMFILE_ENV", "") + t.Setenv("NVCF_CLI_DEFAULT_CONTROL_PLANE_STACK", "") + t.Setenv("NVCF_CLI_DEFAULT_COMPUTE_PLANE_STACK", "") + t.Setenv("NVCF_CLI_SELFHOSTED_SKIP_INOTIFY", "1") + stackNaming := func(repository string) string { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "environments"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "environments", "local.yaml"), + []byte("global:\n image:\n registry: nvcr.io\n repository: "+repository+ + "\ncertManager:\n enabled: false\n"), 0o644)) + return dir + } + cpStack, gpuStack := stackNaming("acme/cp"), stackNaming("acme/gpu") + + got, contexts := registryGrading(t, "--pre", "--compute-plane", + "--control-plane-stack", cpStack, "--compute-plane-stack", gpuStack) + assert.Equal(t, map[string]bool{"nvcr.io/acme/cp": true}, got) + assert.Nil(t, contexts, "graded as before install") + + got, contexts = registryGrading(t, "--pre", "--control-plane", + "--control-plane-stack", cpStack, "--compute-plane-stack", gpuStack) + assert.Equal(t, map[string]bool{"nvcr.io/acme/gpu": true}, got) + assert.Nil(t, contexts, "graded as before install") + + _, contexts = registryGrading(t, "--control-plane", "--control-plane-stack", cpStack) + assert.Equal(t, []string{""}, contexts, "one cluster, the current context") + + _, contexts = registryGrading(t, "--all", "--control-plane-context", "cp-ctx", + "--compute-plane-context", "gpu-ctx", "--icms-url", "https://icms.example.com") + assert.Equal(t, []string{"cp-ctx", "gpu-ctx"}, contexts) +} + +// Where the local check reads the compute-plane stack, --pre --control-plane, +// the control-plane validator still probes the control-plane stack's +// registries from its cluster. +func TestCheck_ControlPlaneValidatorKeepsItsStacksRegistries(t *testing.T) { + resetCheckFlags(t) + t.Setenv("HELMFILE_ENV", "") + t.Setenv("NVCF_CLI_SELFHOSTED_SKIP_INOTIFY", "1") + stackNaming := func(registry string) string { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "environments"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "environments", "local.yaml"), + []byte("global:\n image:\n registry: "+registry+"\n repository: acme/nvcf\n"+ + "certManager:\n enabled: false\n"), 0o644)) + return dir + } + var mu sync.Mutex + var cpRegistries []string + prev := newClusterValidatorForSelfHosted + newClusterValidatorForSelfHosted = func() selfhosted.ClusterValidator { + return func(_ context.Context, p selfhosted.ClusterValidatorParams) selfhosted.ClusterValidatorResult { + mu.Lock() + defer mu.Unlock() + if p.Role == "control-plane" { + for _, r := range p.Registries { + cpRegistries = append(cpRegistries, r.Registry) + } + } + return selfhosted.ClusterValidatorResult{Passed: true} + } + } + t.Cleanup(func() { newClusterValidatorForSelfHosted = prev }) + + rootCmd.SetErr(&bytes.Buffer{}) + rootCmd.SetOut(&bytes.Buffer{}) + rootCmd.SetArgs([]string{"self-hosted", "check", "--pre", "--control-plane", "--json", "--env", "local", + "--cluster-validator-image", "nvcr.io/nvidia/cv:1.0.0", + "--control-plane-stack", stackNaming("cp-mirror.example.com"), + "--compute-plane-stack", stackNaming("gpu-mirror.example.com")}) + _ = rootCmd.Execute() + assert.Contains(t, cpRegistries, "cp-mirror.example.com") + assert.NotContains(t, cpRegistries, "gpu-mirror.example.com") +} + +// A chart source on another org than the stack's images gets a row of its +// own, critical like theirs. Before install, here a local control-plane +// install where the NGC key goes first, that row alone is probed as the scope +// helm pulls the charts from with the docker login. After install no row is. +func TestCheck_ChartSourceOnAnotherOrgGetsItsOwnRow(t *testing.T) { + t.Setenv("HELMFILE_ENV", "") + t.Setenv("NVCF_CLI_SELFHOSTED_SKIP_INOTIFY", "1") + stack := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(stack, "environments"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(stack, "environments", "local.yaml"), + []byte("global:\n image:\n registry: nvcr.io\n repository: acme/nvcf\n"+ + " helm:\n sources:\n registry: nvcr.io\n repository: acme/charts\n"+ + "certManager:\n enabled: false\n"), 0o644)) + type probe struct{ critical, chartScope bool } + probed := func(args ...string) map[string]probe { + resetCheckFlags(t) + var mu sync.Mutex + got := map[string]probe{} + prev := newRegistryCredentialCheckerForSelfHosted + newRegistryCredentialCheckerForSelfHosted = func() selfhosted.RegistryCredentialChecker { + return func(_ context.Context, registry, repo string, critical, chartScope bool) error { + mu.Lock() + defer mu.Unlock() + got[registry+"/"+repo] = probe{critical, chartScope} + return nil + } + } + t.Cleanup(func() { newRegistryCredentialCheckerForSelfHosted = prev }) + rootCmd.SetErr(&bytes.Buffer{}) + rootCmd.SetOut(&bytes.Buffer{}) + rootCmd.SetArgs(append([]string{"self-hosted", "check", "--json", "--skip-cluster-validation", + "--env", "local", "--control-plane-stack", stack}, args...)) + _ = rootCmd.Execute() + mu.Lock() + defer mu.Unlock() + return got + } + + assert.Equal(t, map[string]probe{"nvcr.io/acme/nvcf": {critical: true}, + "nvcr.io/acme/charts": {critical: true, chartScope: true}}, probed("--pre")) + assert.Equal(t, map[string]probe{"nvcr.io/acme/nvcf": {critical: true}, + "nvcr.io/acme/charts": {critical: true}}, probed("--control-plane")) +} diff --git a/src/clis/nvcf-cli/cmd/self_hosted_check_test.go b/src/clis/nvcf-cli/cmd/self_hosted_check_test.go index d3a6d94b64..41cbc1be95 100644 --- a/src/clis/nvcf-cli/cmd/self_hosted_check_test.go +++ b/src/clis/nvcf-cli/cmd/self_hosted_check_test.go @@ -30,6 +30,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "nvcf-cli/internal/selfhosted" + "nvcf-cli/internal/selfhosted/kubectx" "nvcf-cli/internal/selfhosted/progress" ) @@ -38,6 +40,7 @@ import ( // - prints a deprecation warning to stderr, AND // - falls through to JSONL streaming behaviour on stderr (same as --json). func TestCheck_LegacyOutputJSONWarnsAndStreams(t *testing.T) { + resetCheckFlags(t) // Reset global flag state left over from other tests. t.Cleanup(func() { selfHostedJSON = false @@ -63,6 +66,7 @@ func TestCheck_LegacyOutputJSONWarnsAndStreams(t *testing.T) { // the §6.6.3 schema: schemaVersion header, then check_started/check_completed/ // category_completed events per check, then a final event with verdict fields. func TestCheck_NewJSON(t *testing.T) { + resetCheckFlags(t) t.Cleanup(func() { selfHostedJSON = false selfHostedOutput = "text" @@ -112,13 +116,13 @@ func TestCheck_NewJSON(t *testing.T) { // TestCheck_WaitTimesOutCleanly verifies that --wait honors the timeout duration. func TestSelfHostedCheck_WaitTimesOutCleanly(t *testing.T) { - t.Setenv("NVCF_CLI_SELFHOSTED_LOCAL_ONLY", "1") - t.Setenv("NVCF_CLI_SELFHOSTED_FORCE_FAIL", "1") // seam: forces a failing check - rootCmd.SetArgs([]string{"self-hosted", "check", "--pre", "--wait", "2s"}) + stuck := func(context.Context) ([]selfhosted.StaleNamespace, error) { + return []selfhosted.StaleNamespace{{Name: "nvcf", Reason: "stuck Terminating"}}, nil + } start := time.Now() - err := rootCmd.Execute() + run := runCheck(t, checkStubs{stale: stuck}, "--control-plane", "--skip-cluster-validation", "--wait", "2s") elapsed := time.Since(start) - assert.Error(t, err) + assert.Equal(t, 5, run.code()) assert.True(t, elapsed >= 2*time.Second && elapsed < 5*time.Second, "wait should have honored 2s timeout, got %s", elapsed) } @@ -135,7 +139,7 @@ func TestCheck_OneShotTTYUsesStaticRenderer(t *testing.T) { checkWriterIsTTY = func(io.Writer) bool { return true } var stderr bytes.Buffer - sink, err := selectCheckRenderer(&stderr, false) + sink, err := selectCheckRenderer(&stderr, false, nil) require.NoError(t, err) require.IsType(t, &progress.CheckOneShotRenderer{}, sink) @@ -160,6 +164,7 @@ func TestCheck_OneShotTTYUsesStaticRenderer(t *testing.T) { // TestCheck_PreflightStreamingOrder verifies that for each tool the events arrive // in CheckStarted → CheckCompleted order, and CategoryCompleted follows all checks. func TestCheck_PreflightStreamingOrder(t *testing.T) { + resetCheckFlags(t) t.Cleanup(func() { selfHostedJSON = false selfHostedOutput = "text" @@ -174,6 +179,7 @@ func TestCheck_PreflightStreamingOrder(t *testing.T) { _ = rootCmd.Execute() lines := parseJSONLLines(t, stderr.String()) + require.NotEmpty(t, lines, "expected at least one JSONL line") // Skip schemaVersion header. var kinds []string for _, l := range lines[1:] { @@ -219,6 +225,7 @@ func TestCheck_PreflightStreamingOrder(t *testing.T) { // TestCheck_LocalOnlyFlag verifies that --local-only causes only local-host-tools // events (no control-plane-cluster or compute-plane-cluster events). func TestCheck_LocalOnlyFlag(t *testing.T) { + resetCheckFlags(t) t.Cleanup(func() { selfHostedJSON = false selfHostedOutput = "text" @@ -245,15 +252,21 @@ func TestCheck_LocalOnlyFlag(t *testing.T) { } } assert.Contains(t, categories, "local-host-tools", "expected local-host-tools category") - for _, cat := range categories { - assert.NotEqual(t, "control-plane-cluster", cat, "control-plane-cluster must not appear with --local-only") - assert.NotEqual(t, "compute-plane-cluster", cat, "compute-plane-cluster must not appear with --local-only") + // The cluster categories hold only the row that says they were skipped. + for _, l := range lines[1:] { + if l["event"] != "check_completed" { + continue + } + if cat := l["category"]; cat == "control-plane-cluster" || cat == "compute-plane-cluster" { + assert.Contains(t, l["message"], "skipped (--local-only)", "%s/%s", cat, l["id"]) + } } } // TestCheck_SingleClusterMode verifies that without context flags, both // control-plane-cluster and compute-plane-cluster category events appear. func TestCheck_SingleClusterMode(t *testing.T) { + resetCheckFlags(t) t.Cleanup(func() { selfHostedJSON = false selfHostedOutput = "text" @@ -285,6 +298,7 @@ func TestCheck_SingleClusterMode(t *testing.T) { } func TestCheck_PreDoesNotProbeSISReachability(t *testing.T) { + resetCheckFlags(t) t.Cleanup(func() { selfHostedJSON = false selfHostedOutput = "text" @@ -309,6 +323,7 @@ func TestCheck_PreDoesNotProbeSISReachability(t *testing.T) { // TestCheck_SplitClusterMode verifies that providing both context flags causes // both control-plane and compute-plane category events (run in parallel). func TestCheck_SplitClusterMode(t *testing.T) { + resetCheckFlags(t) t.Cleanup(func() { selfHostedJSON = false selfHostedOutput = "text" @@ -343,9 +358,87 @@ func TestCheck_SplitClusterMode(t *testing.T) { assert.Contains(t, categories, "compute-plane-cluster", "expected compute-plane-cluster in split mode") } -// parseJSONLLines splits s into non-empty lines, skips any non-JSON lines -// (e.g. cobra error messages written to stderr), and unmarshals each JSON line -// as an object. Returns them in order. +// TestCheck_ComputePlaneFlagRunsChecks verifies that --compute-plane alone +// produces compute-plane-cluster category events. Before the gating fix this +// flag was a complete no-op and produced no check events at all. +func TestCheck_ComputePlaneFlagRunsChecks(t *testing.T) { + resetCheckFlags(t) + t.Cleanup(func() { + selfHostedJSON = false + selfHostedOutput = "text" + checkComputePlane = false + }) + + var stderr bytes.Buffer + rootCmd.SetErr(&stderr) + rootCmd.SetOut(&bytes.Buffer{}) + + t.Setenv("NVCF_CLI_SELFHOSTED_SKIP_INOTIFY", "1") + rootCmd.SetArgs([]string{"self-hosted", "check", "--compute-plane", "--skip-cluster-validation", "--json"}) + _ = rootCmd.Execute() + + lines := parseJSONLLines(t, stderr.String()) + require.NotEmpty(t, lines, "expected at least one JSONL line") + + var categories []string + for _, l := range lines[1:] { + if l["event"] == "category_completed" { + if cat, ok := l["category"].(string); ok { + categories = append(categories, cat) + } + } + } + assert.Contains(t, categories, "compute-plane-cluster", + "--compute-plane must produce compute-plane-cluster events") +} + +// TestCheck_ControlPlaneFlagRunsChecks verifies that --control-plane alone +// produces control-plane-cluster category events. +func TestCheck_ControlPlaneFlagRunsChecks(t *testing.T) { + resetCheckFlags(t) + t.Cleanup(func() { + selfHostedJSON = false + selfHostedOutput = "text" + checkControlPlane = false + }) + + var stderr bytes.Buffer + rootCmd.SetErr(&stderr) + rootCmd.SetOut(&bytes.Buffer{}) + + t.Setenv("NVCF_CLI_SELFHOSTED_SKIP_INOTIFY", "1") + rootCmd.SetArgs([]string{"self-hosted", "check", "--control-plane", "--skip-cluster-validation", "--json"}) + _ = rootCmd.Execute() + + lines := parseJSONLLines(t, stderr.String()) + require.NotEmpty(t, lines, "expected at least one JSONL line") + + var categories []string + for _, l := range lines[1:] { + if l["event"] == "category_completed" { + if cat, ok := l["category"].(string); ok { + categories = append(categories, cat) + } + } + } + assert.Contains(t, categories, "control-plane-cluster", + "--control-plane must produce control-plane-cluster events") +} + +// --pre in ModeSplit visits both clusters, so a skipped validator is a row in +// each: a consumer of the stream must not read "no validator row" as +// "validated". +func TestCheck_SkippedValidatorIsARowForPreInSplitMode(t *testing.T) { + run := runCheck(t, checkStubs{}, "--pre", "--skip-cluster-validation", + "--control-plane-context", "admin@cp", "--compute-plane-context", "admin@gpu1") + for _, category := range []string{"control-plane-cluster", "compute-plane-cluster"} { + row := run.row(t, category, "cluster-validator") + require.NotNil(t, row, category) + assert.Contains(t, row["message"], "skipped (--skip-cluster-validation)", category) + } + assert.Empty(t, run.validators) +} + func parseJSONLLines(t *testing.T, s string) []map[string]any { t.Helper() var out []map[string]any @@ -363,3 +456,190 @@ func parseJSONLLines(t *testing.T, s string) []map[string]any { } return out } + +// TestPlaneIsVisited covers the dispatch predicates: --pre visits both roles +// in either mode, and a role flag alone visits only its own role. +// +// The mode column is kept to document that the answer is the same in both +// modes. +func TestPlaneIsVisited(t *testing.T) { + t.Cleanup(func() { + checkPre = false + checkControlPlane = false + checkComputePlane = false + checkAll = false + }) + + tests := []struct { + name string + pre, cp, gpu bool + all bool + mode kubectx.Mode + wantCP bool + wantComputeGP bool + }{ + {"--pre split visits both", true, false, false, false, kubectx.ModeSplit, true, true}, + {"--pre single visits both", true, false, false, false, kubectx.ModeSingle, true, true}, + {"--control-plane split skips compute", false, true, false, false, kubectx.ModeSplit, true, false}, + {"--compute-plane split skips control", false, false, true, false, kubectx.ModeSplit, false, true}, + {"--all split visits both", false, false, false, true, kubectx.ModeSplit, true, true}, + {"--control-plane single skips compute", false, true, false, false, kubectx.ModeSingle, true, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + checkPre, checkControlPlane, checkComputePlane, checkAll = tt.pre, tt.cp, tt.gpu, tt.all + assert.Equal(t, tt.wantCP, controlPlaneIsVisited()) + assert.Equal(t, tt.wantComputeGP, computePlaneIsVisited()) + }) + } +} + +// TestCheck_SplitModeControlPlaneOnlySkipsComputeCluster is the regression +// guard for the dispatch gating: in ModeSplit with only --control-plane, the +// compute cluster must not be contacted at all, so no compute-plane-cluster +// category is emitted. +func TestCheck_SplitModeControlPlaneOnlySkipsComputeCluster(t *testing.T) { + resetCheckFlags(t) + t.Cleanup(func() { + selfHostedJSON = false + selfHostedOutput = "text" + checkControlPlane = false + selfHostedControlPlaneContext = "" + selfHostedComputePlaneContext = "" + }) + + var stderr bytes.Buffer + rootCmd.SetErr(&stderr) + rootCmd.SetOut(&bytes.Buffer{}) + + t.Setenv("NVCF_CLI_SELFHOSTED_SKIP_INOTIFY", "1") + rootCmd.SetArgs([]string{"self-hosted", "check", "--control-plane", + "--control-plane-context", "cp-ctx", "--compute-plane-context", "gpu-ctx", + "--skip-cluster-validation", "--json"}) + _ = rootCmd.Execute() + + lines := parseJSONLLines(t, stderr.String()) + require.NotEmpty(t, lines, "expected at least one JSONL line") + + var categories []string + for _, l := range lines[1:] { + if l["event"] == "category_completed" { + if cat, ok := l["category"].(string); ok { + categories = append(categories, cat) + } + } + } + assert.Contains(t, categories, "control-plane-cluster") + assert.NotContains(t, categories, "compute-plane-cluster", + "--control-plane in split mode must not probe the compute cluster") +} + +// TestCheck_HostLocalChecksRunOnce guards against the host-local categories +// being emitted once per role. Local tool versions and registry credentials +// are checked on the operator's machine, so running them for both roles +// repeats the same check IDs in --json and double-counts them in the totals. +func TestCheck_HostLocalChecksRunOnce(t *testing.T) { + resetCheckFlags(t) + t.Cleanup(func() { + selfHostedJSON = false + selfHostedOutput = "text" + checkAll = false + }) + + var stderr bytes.Buffer + rootCmd.SetErr(&stderr) + rootCmd.SetOut(&bytes.Buffer{}) + + t.Setenv("NVCF_CLI_SELFHOSTED_SKIP_INOTIFY", "1") + rootCmd.SetArgs([]string{"self-hosted", "check", "--all", "--skip-cluster-validation", "--json", + "--cluster-validator-registries", "harbor.example.com:443"}) + _ = rootCmd.Execute() + + lines := parseJSONLLines(t, stderr.String()) + require.NotEmpty(t, lines, "expected at least one JSONL line") + + counts := map[string]int{} + registryRows := 0 + for _, l := range lines { + if l["event"] != "check_completed" { + continue + } + id, _ := l["id"].(string) + if strings.HasPrefix(id, "local-host-tools-") || strings.HasPrefix(id, "registry-cred-") { + counts[id]++ + } + if strings.HasPrefix(id, "registry-cred-") { + registryRows++ + } + } + require.NotEmpty(t, counts, "expected local-host-tools checks in the stream") + require.Positive(t, registryRows, "expected registry-credentials checks in the stream") + harbor := 0 + for id, n := range counts { + if strings.Contains(id, "harbor.example.com") { + harbor += n + } + } + assert.Equal(t, 1, harbor, "the extra registry is checked once: %v", counts) + for id, n := range counts { + assert.Equal(t, 1, n, "check %s emitted %d times", id, n) + } +} + +// The JSON verdict and the exit code must derive from the same predicate. A +// warning-severity result previously emitted success:false / verdict:failed +// while the process exited 0, so a CI gate on final.success broke for every +// user whose registry credentials live in a Docker credential helper. +func TestEmitCheckFinal_WarningIsNotAFailure(t *testing.T) { + results := []selfhosted.CheckResult{ + {ID: "a", Passed: true, Severity: selfhosted.SeverityInfo}, + {ID: "b", Passed: false, Severity: selfhosted.SeverityWarning}, + } + assert.False(t, anyFailed(results), "a warning must not set the exit code") + + var buf bytes.Buffer + sink := progress.NewJSONLRenderer(&buf) + emitCheckFinal(context.Background(), sink, results, nil) + + line := buf.String() + assert.Contains(t, line, `"verdict":"warnings"`) + assert.Contains(t, line, `"success":true`, + "success must agree with the exit code") + assert.Contains(t, line, `"failedCount":0`) +} + +func TestEmitCheckFinal_ErrorIsAFailure(t *testing.T) { + results := []selfhosted.CheckResult{ + {ID: "a", Passed: true, Severity: selfhosted.SeverityInfo}, + {ID: "b", Passed: false, Severity: selfhosted.SeverityError}, + } + assert.True(t, anyFailed(results)) + + var buf bytes.Buffer + sink := progress.NewJSONLRenderer(&buf) + emitCheckFinal(context.Background(), sink, results, nil) + assert.Contains(t, buf.String(), `"verdict":"failed"`) + assert.Contains(t, buf.String(), `"success":false`) +} + +// Both roles emit a cluster-validator result under the same ID, so stopping at +// the first drops the other transcript entirely from --all --show-logs. Each +// is framed with its category: a validator image that predates roles prints +// no role line, so the frame is all that tells the two apart. +func TestMaybeShowClusterValidatorLogs_PrintsBothRolesLabelled(t *testing.T) { + prev := checkShowLogs + checkShowLogs = true + t.Cleanup(func() { checkShowLogs = prev }) + + var buf bytes.Buffer + maybeShowClusterValidatorLogs(&buf, []selfhosted.CheckResult{ + {ID: "cluster-validator", Category: selfhosted.CategoryControlPlane, Logs: "first transcript\n"}, + {ID: "cluster-validator", Category: selfhosted.CategoryComputePlane, Logs: "second transcript\n"}, + }) + + assert.Equal(t, "--- cluster-validator logs (control-plane-cluster) ---\nfirst transcript\n"+ + "--- end cluster-validator logs (control-plane-cluster) ---\n"+ + "--- cluster-validator logs (compute-plane-cluster) ---\nsecond transcript\n"+ + "--- end cluster-validator logs (compute-plane-cluster) ---\n", buf.String()) +} diff --git a/src/clis/nvcf-cli/cmd/self_hosted_check_validatorenv_test.go b/src/clis/nvcf-cli/cmd/self_hosted_check_validatorenv_test.go new file mode 100644 index 0000000000..8b00ad564c --- /dev/null +++ b/src/clis/nvcf-cli/cmd/self_hosted_check_validatorenv_test.go @@ -0,0 +1,510 @@ +/* +SPDX-FileCopyrightText: Copyright (c) NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + + "nvcf-cli/internal/selfhosted" +) + +// The Job gets what the chart CronJob forwards: relocated namespaces, the +// Gateway override and the probe image, and for a role whose plane is checked +// as installed, the post-install signal. +func TestClusterValidatorJobEnv(t *testing.T) { + resetCheckFlags(t) + for _, k := range []string{ + "NVCF_OPENBAO_NAMESPACE", "NVCF_ENVOY_GATEWAY_NAMESPACE", "NVCF_GATEWAY_NAMES", "NVCF_N2N_PROBE_IMAGE", + "NVCF_STORAGE_CLASS", + } { + t.Setenv(k, "") + } + + checkPre = false + env := clusterValidatorJobEnv(selfhosted.StackValues{EnvoyGatewayNamespace: "gateway", StorageClass: "fast-ssd"}) + assert.Equal(t, "true", validatorEnvForRole(env, false)["VALIDATOR_POST_INSTALL"], "a post-install run must say so") + assert.NotContains(t, validatorEnvForRole(env, true), "VALIDATOR_POST_INSTALL", "a pre-install run must not") + assert.NotContains(t, env, "VALIDATOR_POST_INSTALL", "validatorEnvForRole does not modify env") + assert.Equal(t, "gateway", env["NVCF_ENVOY_GATEWAY_NAMESPACE"], "the stack's controller namespace is forwarded") + assert.Equal(t, "fast-ssd", env["NVCF_STORAGE_CLASS"], "the stack's global.storageClass is forwarded") + assert.NotContains(t, env, "NVCF_OPENBAO_NAMESPACE") + assert.NotContains(t, clusterValidatorJobEnv(selfhosted.StackValues{}), "NVCF_STORAGE_CLASS", + "without one the validator requires a default class") + + checkPre = true + t.Setenv("NVCF_OPENBAO_NAMESPACE", "openbao") + t.Setenv("NVCF_ENVOY_GATEWAY_NAMESPACE", "edge") + t.Setenv("NVCF_GATEWAY_NAMES", "gateway/shared-gw") + t.Setenv("NVCF_STORAGE_CLASS", "standard") + require.NoError(t, selfHostedCheckCmd.Flags().Set("cluster-validator-probe-image", "mirror.example/busybox:1.36")) + env = clusterValidatorJobEnv(selfhosted.StackValues{EnvoyGatewayNamespace: "gateway", StorageClass: "fast-ssd"}) + assert.Equal(t, "standard", env["NVCF_STORAGE_CLASS"], "an explicit setting beats the stack values") + assert.Equal(t, "openbao", env["NVCF_OPENBAO_NAMESPACE"]) + assert.Equal(t, "edge", env["NVCF_ENVOY_GATEWAY_NAMESPACE"], "an explicit setting beats the stack values") + assert.Equal(t, "gateway/shared-gw", env["NVCF_GATEWAY_NAMES"]) + assert.Equal(t, "mirror.example/busybox:1.36", env["NVCF_N2N_PROBE_IMAGE"]) +} + +// The control-plane validator is handed the registries the credential check +// enumerated (here the validator image's), and the forwarded env. A post-install +// --control-plane run must carry VALIDATOR_POST_INSTALL. +func TestCheck_ControlPlaneValidatorGetsRegistriesAndEnv(t *testing.T) { + resetCheckFlags(t) + var mu sync.Mutex + got := map[string]selfhosted.ClusterValidatorParams{} + prev := newClusterValidatorForSelfHosted + newClusterValidatorForSelfHosted = func() selfhosted.ClusterValidator { + return func(_ context.Context, p selfhosted.ClusterValidatorParams) selfhosted.ClusterValidatorResult { + mu.Lock() + defer mu.Unlock() + got[p.Role] = p + return selfhosted.ClusterValidatorResult{Passed: true} + } + } + t.Cleanup(func() { newClusterValidatorForSelfHosted = prev }) + t.Setenv("NVCF_CLI_SELFHOSTED_SKIP_INOTIFY", "1") + + var errBuf bytes.Buffer + rootCmd.SetErr(&errBuf) + rootCmd.SetOut(&bytes.Buffer{}) + rootCmd.SetArgs([]string{"self-hosted", "check", "--control-plane", "--json", + "--cluster-validator-image", "nvcr.io/nvidia/nvcf-byoc/cluster-validator:1.0.0"}) + _ = rootCmd.Execute() + + cp, ok := got["control-plane"] + require.True(t, ok, "the control-plane validator must run; output: %s", errBuf.String()) + var regs []string + for _, r := range cp.Registries { + regs = append(regs, r.Registry) + } + assert.Contains(t, regs, "nvcr.io", "the enumerated registries must reach the validator") + assert.Equal(t, "true", cp.Env["VALIDATOR_POST_INSTALL"]) +} + +// NVCF_GATEWAY_NAMES written as a YAML list in the config file reaches the Job +// in the comma form the validator parses, not as an empty string. +func TestClusterValidatorJobEnv_GatewayNamesYAMLList(t *testing.T) { + t.Setenv("NVCF_GATEWAY_NAMES", "") + viper.Set("NVCF_GATEWAY_NAMES", []any{"gateway/nvcf-gw", " edge/shared ", ""}) + t.Cleanup(func() { viper.Set("NVCF_GATEWAY_NAMES", nil) }) + + env := clusterValidatorJobEnv(selfhosted.StackValues{}) + assert.Equal(t, "gateway/nvcf-gw,edge/shared", env["NVCF_GATEWAY_NAMES"]) +} + +// --pre in split mode visits both clusters, so each gets its own validator +// role against its own context. Each role is told its own plane's install +// state: a bare --pre is pre-install for both; --all checks both as +// installed, and a role's own flag checks that role as installed while the +// other stays pre-install. +func TestCheck_PreSplitRunsBothValidatorRoles(t *testing.T) { + for _, tc := range []struct { + name string + extra []string + cpPost, gpuPost bool + }{ + {name: "bare pre", extra: []string{"--icms-url", "https://sis.example.invalid"}}, + {name: "pre all", extra: []string{"--all", "--cluster-name", "gpu"}, cpPost: true, gpuPost: true}, + {name: "pre control-plane", extra: []string{"--control-plane"}, cpPost: true}, + {name: "pre compute-plane", extra: []string{"--compute-plane", "--icms-url", "https://sis.example.invalid"}, + gpuPost: true}, + } { + t.Run(tc.name, func(t *testing.T) { + resetCheckFlags(t) + var mu sync.Mutex + got := map[string]selfhosted.ClusterValidatorParams{} + prev := newClusterValidatorForSelfHosted + newClusterValidatorForSelfHosted = func() selfhosted.ClusterValidator { + return func(_ context.Context, p selfhosted.ClusterValidatorParams) selfhosted.ClusterValidatorResult { + mu.Lock() + defer mu.Unlock() + got[p.Role] = p + return selfhosted.ClusterValidatorResult{Passed: true} + } + } + t.Cleanup(func() { newClusterValidatorForSelfHosted = prev }) + t.Setenv("NVCF_CLI_SELFHOSTED_SKIP_INOTIFY", "1") + + var errBuf bytes.Buffer + rootCmd.SetErr(&errBuf) + rootCmd.SetOut(&bytes.Buffer{}) + rootCmd.SetArgs(append([]string{"self-hosted", "check", "--pre", "--json", + "--control-plane-context", "cp-ctx", "--compute-plane-context", "gpu-ctx", + "--cluster-validator-image", "nvcr.io/nvidia/nvcf-byoc/cluster-validator:1.0.0"}, + tc.extra...)) + _ = rootCmd.Execute() + + mu.Lock() + defer mu.Unlock() + cp, ok := got["control-plane"] + require.True(t, ok, "the control-plane validator must run; output: %s", errBuf.String()) + gpu, ok := got["compute-plane"] + require.True(t, ok, "the compute-plane validator must run; output: %s", errBuf.String()) + assert.Equal(t, "cp-ctx", cp.KubeContext) + assert.Equal(t, "gpu-ctx", gpu.KubeContext) + assert.Equal(t, tc.cpPost, cp.Env["VALIDATOR_POST_INSTALL"] == "true", "control-plane post-install") + assert.Equal(t, tc.gpuPost, gpu.Env["VALIDATOR_POST_INSTALL"] == "true", "compute-plane post-install") + }) + } +} + +// NVCF_CLI_CLUSTER_VALIDATOR_REGISTRIES is read, and a comma-separated value is +// split into separate registries rather than parsed as one host. +func TestCheck_RegistriesEnvVarIsSplitOnCommas(t *testing.T) { + resetCheckFlags(t) + t.Setenv("NVCF_CLI_CLUSTER_VALIDATOR_REGISTRIES", "harbor.example.com:443, ghcr.io:443") + var mu sync.Mutex + var got selfhosted.ClusterValidatorParams + prev := newClusterValidatorForSelfHosted + newClusterValidatorForSelfHosted = func() selfhosted.ClusterValidator { + return func(_ context.Context, p selfhosted.ClusterValidatorParams) selfhosted.ClusterValidatorResult { + mu.Lock() + defer mu.Unlock() + if p.Role == "control-plane" { + got = p + } + return selfhosted.ClusterValidatorResult{Passed: true} + } + } + t.Cleanup(func() { newClusterValidatorForSelfHosted = prev }) + t.Setenv("NVCF_CLI_SELFHOSTED_SKIP_INOTIFY", "1") + + rootCmd.SetErr(&bytes.Buffer{}) + rootCmd.SetOut(&bytes.Buffer{}) + rootCmd.SetArgs([]string{"self-hosted", "check", "--control-plane", "--json", + "--cluster-validator-image", "nvcr.io/nvidia/nvcf-byoc/cluster-validator:1.0.0"}) + _ = rootCmd.Execute() + + mu.Lock() + defer mu.Unlock() + var regs []string + for _, r := range got.Registries { + regs = append(regs, r.Registry) + } + assert.Contains(t, regs, "harbor.example.com") + assert.Contains(t, regs, "ghcr.io") +} + +// The stack's Gateways reach the Job; an explicit setting wins. No HA mode is +// forwarded: the validator reads it from the anti-affinity the stack renders. +func TestClusterValidatorJobEnv_ForwardsStackGateways(t *testing.T) { + t.Setenv("NVCF_GATEWAY_NAMES", "") + t.Setenv("NVCF_HA_MODE", "preferred") + stack := selfhosted.StackValues{Gateways: []string{"gw/grpc-gw", "gw/shared-gw"}} + env := clusterValidatorJobEnv(stack) + assert.Equal(t, "gw/grpc-gw,gw/shared-gw", env["NVCF_GATEWAY_NAMES"]) + assert.NotContains(t, env, "NVCF_HA_MODE") + + t.Setenv("NVCF_GATEWAY_NAMES", "edge/nvcf-gw") + env = clusterValidatorJobEnv(stack) + assert.Equal(t, "edge/nvcf-gw", env["NVCF_GATEWAY_NAMES"]) +} + +// --cluster-validator-tolerations reaches the validator Job of both roles, so +// a cluster whose nodes carry other taints can schedule it, as the chart's +// clusterValidator.tolerations allows. A malformed entry fails the command +// before anything runs. +func TestCheck_ValidatorTolerationsReachBothRoles(t *testing.T) { + resetCheckFlags(t) + var mu sync.Mutex + got := map[string][]corev1.Toleration{} + prev := newClusterValidatorForSelfHosted + newClusterValidatorForSelfHosted = func() selfhosted.ClusterValidator { + return func(_ context.Context, p selfhosted.ClusterValidatorParams) selfhosted.ClusterValidatorResult { + mu.Lock() + defer mu.Unlock() + got[p.Role] = p.Tolerations + return selfhosted.ClusterValidatorResult{Passed: true} + } + } + t.Cleanup(func() { newClusterValidatorForSelfHosted = prev }) + t.Setenv("NVCF_CLI_SELFHOSTED_SKIP_INOTIFY", "1") + + rootCmd.SetErr(&bytes.Buffer{}) + rootCmd.SetOut(&bytes.Buffer{}) + rootCmd.SetArgs([]string{"self-hosted", "check", "--pre", "--json", + "--cluster-validator-image", "nvcr.io/nvidia/nvcf-byoc/cluster-validator:1.0.0", + "--cluster-validator-tolerations", "dedicated=infra:NoSchedule,special"}) + _ = rootCmd.Execute() + + want := []corev1.Toleration{ + {Key: "dedicated", Operator: corev1.TolerationOpEqual, Value: "infra", Effect: corev1.TaintEffectNoSchedule}, + {Key: "special", Operator: corev1.TolerationOpExists}, + } + mu.Lock() + defer mu.Unlock() + assert.Equal(t, want, got["control-plane"]) + assert.Equal(t, want, got["compute-plane"]) + + resetCheckFlags(t) + rootCmd.SetArgs([]string{"self-hosted", "check", "--pre", "--json", + "--cluster-validator-tolerations", "dedicated:Sometimes"}) + err := rootCmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "effect must be NoSchedule, PreferNoSchedule or NoExecute") +} + +// withConfigFile loads body as the nvcf-cli config file for one test. +func withConfigFile(t *testing.T, body string) { + t.Helper() + viper.SetConfigType("yaml") + require.NoError(t, viper.ReadConfig(strings.NewReader(body))) + t.Cleanup(func() { _ = viper.ReadConfig(strings.NewReader("{}")) }) +} + +// A config-file entry that is not a string, such as a Kubernetes-style +// toleration, fails the command naming it. Read as a string slice it was "" +// and dropped without a word, leaving the validator pod unschedulable. +func TestCheck_NonStringConfigListEntryFailsTheCommand(t *testing.T) { + for key, hint := range map[string]string{ + "cluster_validator_tolerations": "write each toleration as key[=value][:effect]", + "cluster_validator_registries": "write each registry as host[:port][/path]", + "cluster_validator_external_components": "write each component as its name", + } { + t.Run(key, func(t *testing.T) { + resetCheckFlags(t) + withConfigFile(t, key+":\n - {key: dedicated, operator: Equal, value: infra, effect: NoSchedule}\n") + rootCmd.SetErr(&bytes.Buffer{}) + rootCmd.SetOut(&bytes.Buffer{}) + rootCmd.SetArgs([]string{"self-hosted", "check", "--pre", "--json"}) + err := rootCmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), key+" entry 1 is not a string: map[") + assert.Contains(t, err.Error(), hint) + }) + // A single entry written as a map rather than a list of one. + t.Run(key+" as a map", func(t *testing.T) { + resetCheckFlags(t) + withConfigFile(t, key+":\n {key: dedicated, operator: Equal, value: infra, effect: NoSchedule}\n") + rootCmd.SetErr(&bytes.Buffer{}) + rootCmd.SetOut(&bytes.Buffer{}) + rootCmd.SetArgs([]string{"self-hosted", "check", "--pre", "--json"}) + err := rootCmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), key+" is not a list of strings: map[") + assert.Contains(t, err.Error(), hint) + }) + } + + resetCheckFlags(t) + withConfigFile(t, "cluster_validator_tolerations:\n - dedicated=infra:NoSchedule\n - special\n") + got, err := configuredValidatorTolerations() + require.NoError(t, err) + assert.Equal(t, []corev1.Toleration{ + {Key: "dedicated", Operator: corev1.TolerationOpEqual, Value: "infra", Effect: corev1.TaintEffectNoSchedule}, + {Key: "special", Operator: corev1.TolerationOpExists}, + }, got, "string entries are read as before") + + resetCheckFlags(t) + withConfigFile(t, "cluster_validator_tolerations: dedicated=infra:NoSchedule\n") + got, err = configuredValidatorTolerations() + require.NoError(t, err) + assert.Equal(t, []corev1.Toleration{ + {Key: "dedicated", Operator: corev1.TolerationOpEqual, Value: "infra", Effect: corev1.TaintEffectNoSchedule}, + }, got, "a single string is read as before") +} + +func TestParseToleration(t *testing.T) { + for in, want := range map[string]corev1.Toleration{ + "dedicated=infra:NoSchedule": {Key: "dedicated", Operator: corev1.TolerationOpEqual, Value: "infra", + Effect: corev1.TaintEffectNoSchedule}, + "example.com/role:noexecute": {Key: "example.com/role", Operator: corev1.TolerationOpExists, + Effect: corev1.TaintEffectNoExecute}, + "special": {Key: "special", Operator: corev1.TolerationOpExists}, + } { + got, err := parseToleration(in) + require.NoError(t, err, in) + assert.Equal(t, want, got, in) + } + for _, in := range []string{ + ":NoSchedule", "=v", "k:Never", + "dedicated=infra team:NoSchedule", "bad key", "key=v=w", "a/b/c", "-key", "k=-v", + } { + _, err := parseToleration(in) + assert.Error(t, err, in) + } +} + +// A toleration the apiserver would reject fails the command as a usage error, +// before the run creates anything for a validator Job that cannot be created. +func TestCheck_MalformedTolerationFailsBeforeTheRun(t *testing.T) { + var calls atomic.Int32 + prev := newClusterValidatorForSelfHosted + newClusterValidatorForSelfHosted = func() selfhosted.ClusterValidator { + return func(context.Context, selfhosted.ClusterValidatorParams) selfhosted.ClusterValidatorResult { + calls.Add(1) + return selfhosted.ClusterValidatorResult{Passed: true} + } + } + t.Cleanup(func() { newClusterValidatorForSelfHosted = prev }) + t.Setenv("NVCF_CLI_SELFHOSTED_SKIP_INOTIFY", "1") + rootCmd.SetErr(&bytes.Buffer{}) + rootCmd.SetOut(&bytes.Buffer{}) + for _, entry := range []string{"dedicated=infra team:NoSchedule", "bad key", "key=v=w", "a/b/c"} { + resetCheckFlags(t) + rootCmd.SetArgs([]string{"self-hosted", "check", "--control-plane", "--json", + "--cluster-validator-image", "nvcr.io/nvidia/nvcf-byoc/cluster-validator:1.0.0", + "--cluster-validator-tolerations", entry}) + err := rootCmd.Execute() + require.Error(t, err, entry) + assert.Contains(t, err.Error(), "--cluster-validator-tolerations", entry) + var exitErr *ExitCodeError + assert.False(t, errors.As(err, &exitErr), "%s: a usage error, not a failed run", entry) + } + assert.Zero(t, calls.Load(), "no validator runs") +} + +// The inotify probe pods use the configured probe image, so a cluster that +// pulls from a mirror can run them; with none set the probe keeps its default. +func TestCheck_InotifyProbeUsesTheProbeImage(t *testing.T) { + for _, tc := range []struct { + name, flag, env, want string + }{ + {name: "flag", flag: "mirror.example/busybox:1.36", want: "mirror.example/busybox:1.36"}, + {name: "validator setting", env: "mirror.example/busybox:1.36", want: "mirror.example/busybox:1.36"}, + {name: "unset"}, + } { + t.Run(tc.name, func(t *testing.T) { + resetCheckFlags(t) + t.Setenv("NVCF_N2N_PROBE_IMAGE", tc.env) + var got []string + prev := newInotifyProberForSelfHosted + newInotifyProberForSelfHosted = func(image string) selfhosted.NodeInotifyProber { + got = append(got, image) + return func(context.Context, string) ([]selfhosted.NodeInotifyLimits, error) { return nil, nil } + } + t.Cleanup(func() { newInotifyProberForSelfHosted = prev }) + rootCmd.SetErr(&bytes.Buffer{}) + rootCmd.SetOut(&bytes.Buffer{}) + args := []string{"self-hosted", "check", "--pre", "--json"} + if tc.flag != "" { + args = append(args, "--cluster-validator-probe-image", tc.flag) + } + rootCmd.SetArgs(args) + _ = rootCmd.Execute() + assert.Equal(t, []string{tc.want}, got) + }) + } +} + +// Stack values reach the validator only when the install's environment file +// was read from the stack the command points at. helmfile refuses to install +// without that file, so base.yaml alone does not describe the install, and a +// stack found above the working directory is not a remote stack the command +// was given. Another stack's Gateways would have the validator fail ones the +// install never created. +func TestCheck_StackGatewaysNeedTheEnvironmentFile(t *testing.T) { + t.Setenv("HELMFILE_ENV", "") + t.Setenv("NVCF_GATEWAY_NAMES", "") + t.Setenv("NVCF_CLI_SELFHOSTED_SKIP_INOTIFY", "1") + stack := t.TempDir() + envDir := filepath.Join(stack, "environments") + require.NoError(t, os.MkdirAll(envDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(envDir, "base.yaml"), []byte(` +ingress: + gatewayApi: + enabled: true + gateways: + shared: {name: nvcf-gateway, namespace: envoy-gateway} + grpc: {name: nvcf-gateway, namespace: envoy-gateway} +`), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(envDir, "local.yaml"), []byte("global: {}\n"), 0o644)) + t.Setenv("NVCF_CLI_DEFAULT_CONTROL_PLANE_STACK", "") + prev := newClusterValidatorForSelfHosted + t.Cleanup(func() { newClusterValidatorForSelfHosted = prev }) + run := func(env, stackFlag string) map[string]string { + resetCheckFlags(t) + var got map[string]string + newClusterValidatorForSelfHosted = func() selfhosted.ClusterValidator { + return func(_ context.Context, p selfhosted.ClusterValidatorParams) selfhosted.ClusterValidatorResult { + got = p.Env + return selfhosted.ClusterValidatorResult{Passed: true} + } + } + rootCmd.SetErr(&bytes.Buffer{}) + rootCmd.SetOut(&bytes.Buffer{}) + args := []string{"self-hosted", "check", "--control-plane", "--json", + "--cluster-validator-image", "nvcr.io/nvidia/nvcf-byoc/cluster-validator:1.0.0"} + if env != "" { + args = append(args, "--env", env) + } + if stackFlag != "" { + args = append(args, "--control-plane-stack", stackFlag) + } + rootCmd.SetArgs(args) + _ = rootCmd.Execute() + require.NotNil(t, got, "the validator ran") + return got + } + + assert.NotContains(t, run("prod", stack), "NVCF_GATEWAY_NAMES", "base.yaml without prod.yaml is not the install") + assert.NotContains(t, run("", stack), "NVCF_GATEWAY_NAMES", + "the defaulted --env is not the install's environment, even with a local.yaml in the stack") + + require.NoError(t, os.WriteFile(filepath.Join(envDir, "prod.yaml"), []byte("global: {domain: example.com}\n"), 0o644)) + assert.Equal(t, "envoy-gateway/nvcf-gateway", run("prod", stack)["NVCF_GATEWAY_NAMES"]) + + // The same stack found above the working directory is not the remote + // stack the command was given, nor a local one without environments, + // nor the built-in stack a release CLI installs from. + t.Chdir(stack) + assert.NotContains(t, run("prod", "oci://registry.example.com/nvcf/stack:1.0.0"), "NVCF_GATEWAY_NAMES") + assert.NotContains(t, run("prod", t.TempDir()), "NVCF_GATEWAY_NAMES") + assert.NotContains(t, run("prod", "file://"+stack), "NVCF_GATEWAY_NAMES", + "a file:// stack is cloned from its committed HEAD, which the working tree can differ from") + t.Setenv("NVCF_CLI_DEFAULT_CONTROL_PLANE_STACK", "oci://registry.example.com/nvcf/stack:1.0.0") + assert.NotContains(t, run("prod", ""), "NVCF_GATEWAY_NAMES", + "a CLI with a built-in stack installs from it, not from the working directory") +} + +// The validator is told which dependencies run outside the stack: the flag, +// or its env and config key, first, then NVCF_EXTERNAL_COMPONENTS, then the +// components the stack's environment file disables. An unknown name fails the +// command before anything runs. +func TestClusterValidatorJobEnv_ExternalComponents(t *testing.T) { + resetCheckFlags(t) + t.Setenv("NVCF_EXTERNAL_COMPONENTS", "") + stack := selfhosted.StackValues{ExternalComponents: []string{"cassandra"}} + + assert.Equal(t, "cassandra", clusterValidatorJobEnv(stack)["NVCF_EXTERNAL_COMPONENTS"]) + assert.NotContains(t, clusterValidatorJobEnv(selfhosted.StackValues{}), "NVCF_EXTERNAL_COMPONENTS") + + t.Setenv("NVCF_EXTERNAL_COMPONENTS", "OpenBao, nats") + assert.Equal(t, "nats,openbao", clusterValidatorJobEnv(stack)["NVCF_EXTERNAL_COMPONENTS"]) + + require.NoError(t, selfHostedCheckCmd.Flags().Set("cluster-validator-external-components", "nats")) + assert.Equal(t, "nats", clusterValidatorJobEnv(stack)["NVCF_EXTERNAL_COMPONENTS"]) + + require.NoError(t, selfHostedCheckCmd.Flags().Set("cluster-validator-external-components", "redis")) + checkPre = true + err := runSelfHostedCheck(selfHostedCheckCmd, nil) + require.ErrorContains(t, err, `unknown component "redis"`) +} diff --git a/src/clis/nvcf-cli/cmd/self_hosted_compute_plane.go b/src/clis/nvcf-cli/cmd/self_hosted_compute_plane.go index eb4a17d47a..598cba798a 100644 --- a/src/clis/nvcf-cli/cmd/self_hosted_compute_plane.go +++ b/src/clis/nvcf-cli/cmd/self_hosted_compute_plane.go @@ -108,6 +108,11 @@ func runSelfHostedComputePlaneInstall(c *cobra.Command, _ []string) error { return fmt.Errorf("cluster name is required: set clusterName in the values file or pass --cluster-name") } ncaID := firstNonEmpty(metadata.NCAID, computePlaneInstallNCAID) + outputDir, cleanup, err := registrationValuesDir(valuesPath, clusterName) + if err != nil { + return err + } + defer cleanup() resolved, err := selfhosted.ResolveStack(c.Context(), selfhosted.StackOptions{ Source: selfHostedComputePlaneStack, @@ -132,10 +137,39 @@ func runSelfHostedComputePlaneInstall(c *cobra.Command, _ []string) error { Stdout: c.OutOrStdout(), Stderr: c.ErrOrStderr(), Ctx: c.Context(), - ExtraEnv: computePlaneInstallEnv(clusterName, ncaID, filepath.Dir(valuesPath)), + ExtraEnv: computePlaneInstallEnv(clusterName, ncaID, outputDir), }) } +// registrationValuesDir returns the OUTPUT_DIR from which the compute-plane +// helmfile reads $OUTPUT_DIR/$CLUSTER_NAME-register-values.yaml. A values +// file under any other name, or for another cluster name, is copied under +// that name into a private directory, so the install reads the file it was +// given and never a stale file beside it. cleanup removes the copy. +func registrationValuesDir(valuesPath, clusterName string) (dir string, cleanup func(), err error) { + name := clusterName + "-register-values.yaml" + if filepath.Base(valuesPath) == name { + return filepath.Dir(valuesPath), func() {}, nil + } + if filepath.Base(name) != name { + return "", nil, fmt.Errorf("cluster name %q cannot name the registration values file", clusterName) + } + body, err := os.ReadFile(valuesPath) + if err != nil { + return "", nil, fmt.Errorf("reading values file: %w", err) + } + dir, err = os.MkdirTemp("", "nvcf-compute-plane-values-") + if err != nil { + return "", nil, fmt.Errorf("staging values file: %w", err) + } + cleanup = func() { _ = os.RemoveAll(dir) } + if err := os.WriteFile(filepath.Join(dir, name), body, 0o600); err != nil { + cleanup() + return "", nil, fmt.Errorf("staging values file: %w", err) + } + return dir, cleanup, nil +} + type nvcaValuesMetadata struct { ClusterName string NCAID string @@ -239,8 +273,8 @@ func computePlaneInstallEnv(clusterName, ncaID, outputDir string) []string { "CLUSTER_NAME=" + clusterName, "NCA_ID=" + ncaID, // The worker helmfile resolves the registration values at - // $OUTPUT_DIR/$CLUSTER_NAME-register-values.yaml via requiredEnv, so - // point it at the directory holding the --values file. + // $OUTPUT_DIR/$CLUSTER_NAME-register-values.yaml via requiredEnv; + // registrationValuesDir puts the --values file there. "OUTPUT_DIR=" + outputDir, } } diff --git a/src/clis/nvcf-cli/cmd/self_hosted_compute_plane_test.go b/src/clis/nvcf-cli/cmd/self_hosted_compute_plane_test.go index 7cedb242f6..ab377c8e10 100644 --- a/src/clis/nvcf-cli/cmd/self_hosted_compute_plane_test.go +++ b/src/clis/nvcf-cli/cmd/self_hosted_compute_plane_test.go @@ -120,8 +120,9 @@ func TestComputePlaneInstallTemplatesUserValuesFile(t *testing.T) { assert.Contains(t, out, "env:CLUSTER_NAME=gpu-from-values") assert.Contains(t, out, "env:NCA_ID=nca-from-values") // The worker helmfile reads $OUTPUT_DIR/$CLUSTER_NAME-register-values.yaml, - // so install must point OUTPUT_DIR at the directory of --values. - assert.Contains(t, out, "env:OUTPUT_DIR="+filepath.Dir(valuesFile)) + // which must be the --values file whatever it is named. + assert.Contains(t, out, "registration:clusterName: gpu-from-values") + assert.NotContains(t, out, "registration missing") assert.Contains(t, out, "arg="+stackDir) assert.FileExists(t, fakeBin) } @@ -152,6 +153,137 @@ func TestComputePlaneInstallAppliesByDefault(t *testing.T) { assert.Contains(t, out, "env:CLUSTER_NAME=gpu-a") } +// register --output=gpu2.yaml then install --values=gpu2.yaml installs +// gpu2.yaml, not a gpu2-register-values.yaml left beside it by an earlier +// registration, and leaves no copy behind. +func TestComputePlaneInstall_ValuesUnderAnyNameWinOverAStaleNeighbour(t *testing.T) { + resetComputePlaneFlags(t) + + stackDir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(stackDir, "helmfile.d"), 0o755)) + dir := t.TempDir() + valuesFile := filepath.Join(dir, "gpu2.yaml") + require.NoError(t, os.WriteFile(valuesFile, []byte("clusterName: gpu2\nclusterID: fresh-id\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "gpu2-register-values.yaml"), + []byte("clusterName: gpu2\nclusterID: stale-id\n"), 0o644)) + installFakeComputePlaneHelmfile(t) + + var stdout bytes.Buffer + rootCmd.SetOut(&stdout) + rootCmd.SetErr(&bytes.Buffer{}) + rootCmd.SetArgs([]string{ + "self-hosted", "compute-plane", "install", + "--compute-plane-stack", stackDir, + "--values", valuesFile, + }) + + require.NoError(t, rootCmd.Execute()) + out := stdout.String() + assert.Contains(t, out, "registration:clusterID: fresh-id") + assert.NotContains(t, out, "stale-id") + outputDir := fakeHelmfileEnv(t, out, "OUTPUT_DIR") + assert.NotEqual(t, dir, outputDir) + assert.NoDirExists(t, outputDir, "the staged copy must be removed after the install") +} + +// A --cluster-name that overrides the values file's own name still installs +// that file, not the overriding name's file beside it. +func TestComputePlaneInstall_ClusterNameOverrideDoesNotSwapTheValuesFile(t *testing.T) { + resetComputePlaneFlags(t) + + stackDir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(stackDir, "helmfile.d"), 0o755)) + dir := t.TempDir() + valuesFile := filepath.Join(dir, "gpu2-register-values.yaml") + require.NoError(t, os.WriteFile(valuesFile, []byte("clusterID: given-id\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "gpu3-register-values.yaml"), + []byte("clusterID: other-id\n"), 0o644)) + installFakeComputePlaneHelmfile(t) + + var stdout bytes.Buffer + rootCmd.SetOut(&stdout) + rootCmd.SetErr(&bytes.Buffer{}) + rootCmd.SetArgs([]string{ + "self-hosted", "compute-plane", "install", + "--compute-plane-stack", stackDir, + "--values", valuesFile, + "--cluster-name", "gpu3", + }) + + require.NoError(t, rootCmd.Execute()) + out := stdout.String() + assert.Contains(t, out, "env:CLUSTER_NAME=gpu3") + assert.Contains(t, out, "registration:clusterID: given-id") + assert.NotContains(t, out, "other-id") +} + +// A values file already named for its cluster is read in place. +func TestComputePlaneInstall_ReadsARegisterValuesFileInPlace(t *testing.T) { + resetComputePlaneFlags(t) + + stackDir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(stackDir, "helmfile.d"), 0o755)) + dir := t.TempDir() + valuesFile := filepath.Join(dir, "gpu2-register-values.yaml") + require.NoError(t, os.WriteFile(valuesFile, []byte("clusterName: gpu2\nclusterID: in-place\n"), 0o644)) + installFakeComputePlaneHelmfile(t) + + var stdout bytes.Buffer + rootCmd.SetOut(&stdout) + rootCmd.SetErr(&bytes.Buffer{}) + rootCmd.SetArgs([]string{ + "self-hosted", "compute-plane", "install", + "--compute-plane-stack", stackDir, + "--values", valuesFile, + }) + + require.NoError(t, rootCmd.Execute()) + out := stdout.String() + assert.Equal(t, dir, fakeHelmfileEnv(t, out, "OUTPUT_DIR")) + assert.Contains(t, out, "registration:clusterID: in-place") +} + +// A cluster name that is not a single path element cannot name the file the +// helmfile reads, so install refuses it instead of writing outside its +// private directory. +func TestComputePlaneInstall_RejectsAClusterNameWithAPathSeparator(t *testing.T) { + resetComputePlaneFlags(t) + + stackDir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(stackDir, "helmfile.d"), 0o755)) + valuesFile := filepath.Join(t.TempDir(), "gpu2.yaml") + require.NoError(t, os.WriteFile(valuesFile, []byte("clusterID: x\n"), 0o644)) + installFakeComputePlaneHelmfile(t) + + var stdout bytes.Buffer + rootCmd.SetOut(&stdout) + rootCmd.SetErr(&bytes.Buffer{}) + rootCmd.SetArgs([]string{ + "self-hosted", "compute-plane", "install", + "--compute-plane-stack", stackDir, + "--values", valuesFile, + "--cluster-name", "../gpu2", + }) + + err := rootCmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "cluster name") + assert.NotContains(t, stdout.String(), "verb=", "helmfile must not run") +} + +// fakeHelmfileEnv returns the value installFakeComputePlaneHelmfile printed +// for the environment variable key. +func fakeHelmfileEnv(t *testing.T, out, key string) string { + t.Helper() + for _, line := range strings.Split(out, "\n") { + if v, ok := strings.CutPrefix(line, "env:"+key+"="); ok { + return v + } + } + t.Fatalf("the fake helmfile printed no %s in:\n%s", key, out) + return "" +} + func TestComputePlaneRegisterDryRunPrintsClusterIdentity(t *testing.T) { resetComputePlaneFlags(t) @@ -1076,6 +1208,8 @@ printf 'verb=%s\n' "$verb" printf 'env:CLUSTER_NAME=%s\n' "$CLUSTER_NAME" printf 'env:NCA_ID=%s\n' "$NCA_ID" printf 'env:OUTPUT_DIR=%s\n' "$OUTPUT_DIR" +reg="$OUTPUT_DIR/$CLUSTER_NAME-register-values.yaml" +if [ -f "$reg" ]; then sed 's/^/registration:/' "$reg"; else printf 'registration missing: %s\n' "$reg"; fi ` require.NoError(t, os.WriteFile(fakeBin, []byte(body), 0o755)) t.Setenv("PATH", filepath.Dir(fakeBin)+":"+os.Getenv("PATH")) diff --git a/src/clis/nvcf-cli/cmd/self_hosted_helm_runtime.go b/src/clis/nvcf-cli/cmd/self_hosted_helm_runtime.go index 66aeaa4fbe..ec9b4939ae 100644 --- a/src/clis/nvcf-cli/cmd/self_hosted_helm_runtime.go +++ b/src/clis/nvcf-cli/cmd/self_hosted_helm_runtime.go @@ -30,7 +30,7 @@ var resolveSelfHostedHelmRuntimeMode = func(ctx context.Context) (selfhosted.Hel Tools: selfHostedHelmRuntimePreflightTools(), }) for _, result := range results { - if !result.Passed && result.Severity == "error" { + if result.IsBlockingFailure() { return "", fmt.Errorf("%s: %s", result.ID, result.Message) } } diff --git a/src/clis/nvcf-cli/cmd/self_hosted_test.go b/src/clis/nvcf-cli/cmd/self_hosted_test.go index 8c5e1ce23d..bfec3afd11 100644 --- a/src/clis/nvcf-cli/cmd/self_hosted_test.go +++ b/src/clis/nvcf-cli/cmd/self_hosted_test.go @@ -58,6 +58,7 @@ func TestSelfHostedFlags_OnlyOneContextErrors(t *testing.T) { } func TestSelfHostedFlags_SameContextErrors(t *testing.T) { + resetCheckFlags(t) rootCmd.SetArgs([]string{"self-hosted", "check", "--control-plane-context=cp", "--compute-plane-context=cp"}) t.Cleanup(func() { selfHostedControlPlaneContext = "" diff --git a/src/clis/nvcf-cli/cmd/self_hosted_up.go b/src/clis/nvcf-cli/cmd/self_hosted_up.go index d9b3f9f9e4..681da61272 100644 --- a/src/clis/nvcf-cli/cmd/self_hosted_up.go +++ b/src/clis/nvcf-cli/cmd/self_hosted_up.go @@ -1507,6 +1507,17 @@ func kubectxFor(phaseNum int) string { } } +type registerValuesWriteRequest struct { + StackPath string + ClusterName string + NCAID string + Region string + IdentitySource string + ClusterID string + ClusterGroupID string + Endpoints registerEndpointValues +} + // writeRegisterValuesYAML persists the helm values handoff file the worker-layer // helmfile expects at /out/-register-values.yaml. Schema mirrors // `printRegistrationOutput` (cmd/cluster_registration.go, fixed in `ff9aaf7`): @@ -1519,17 +1530,6 @@ func kubectxFor(phaseNum int) string { // The pre-`ff9aaf7` shape (nested `selfManaged.clusterId`, lowercase-d) left // the chart's `.Values.clusterID` empty → cluster-dto.yaml rendered with empty // IDs → operator failed to fetch backend identity. (Iter #12 from dev-VM E2E.) -type registerValuesWriteRequest struct { - StackPath string - ClusterName string - NCAID string - Region string - IdentitySource string - ClusterID string - ClusterGroupID string - Endpoints registerEndpointValues -} - func writeRegisterValuesYAML(req registerValuesWriteRequest) error { outDir := filepath.Join(req.StackPath, "out") if err := os.MkdirAll(outDir, 0o755); err != nil { diff --git a/src/clis/nvcf-cli/cmd/self_hosted_up_test.go b/src/clis/nvcf-cli/cmd/self_hosted_up_test.go index e59bcda392..8f2babd85d 100644 --- a/src/clis/nvcf-cli/cmd/self_hosted_up_test.go +++ b/src/clis/nvcf-cli/cmd/self_hosted_up_test.go @@ -562,6 +562,9 @@ func TestUp_SIGTERM_EmitsCancellation(t *testing.T) { }) err := rootCmd.ExecuteContext(parentCtx) + // cobra keeps the cancelled context on rootCmd, and every later Execute + // would hand it to commands that have none of their own. + t.Cleanup(func() { rootCmd.SetContext(nil) }) var ece *ExitCodeError require.ErrorAs(t, err, &ece, "expected ExitCodeError, got: %v", err) assert.Equal(t, 130, ece.Code, "exit code must be 130 for cancellation") diff --git a/src/clis/nvcf-cli/go.mod b/src/clis/nvcf-cli/go.mod index 9a4c8d3d83..19cc5ea3ba 100644 --- a/src/clis/nvcf-cli/go.mod +++ b/src/clis/nvcf-cli/go.mod @@ -11,6 +11,7 @@ require ( github.com/charmbracelet/lipgloss v1.0.0 github.com/muesli/termenv v0.15.2 github.com/spf13/cobra v1.9.1 + github.com/spf13/pflag v1.0.6 github.com/spf13/viper v1.18.2 github.com/stretchr/testify v1.10.0 golang.org/x/oauth2 v0.27.0 @@ -23,6 +24,7 @@ require ( k8s.io/apiextensions-apiserver v0.34.1 k8s.io/apimachinery v0.34.1 k8s.io/client-go v0.34.1 + sigs.k8s.io/yaml v1.6.0 ) require ( @@ -68,7 +70,6 @@ require ( github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cast v1.6.0 // indirect - github.com/spf13/pflag v1.0.6 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/x448/float16 v0.8.4 // indirect go.uber.org/multierr v1.11.0 // indirect @@ -89,5 +90,4 @@ require ( sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect - sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/src/clis/nvcf-cli/internal/agentskill/skilldata_generated.go b/src/clis/nvcf-cli/internal/agentskill/skilldata_generated.go index febc67b69f..dfbf08aea9 100644 --- a/src/clis/nvcf-cli/internal/agentskill/skilldata_generated.go +++ b/src/clis/nvcf-cli/internal/agentskill/skilldata_generated.go @@ -31,20 +31,20 @@ import ( const skillDataZipBase64 = "" + "UEsDBBQACAAAAAAAIQAAAAAAAAAAAAAAAAASAAkAZGF0YS9tYW5pZmVzdC5qc29uVVQFAAEAps4S" + "ewogICJzY2hlbWFWZXJzaW9uIjogMSwKICAiZ2VuZXJhdGVkQXQiOiAiMTk3MC0wMS0wMVQwMDow" + - "MDowMFoiLAogICJ0b3RhbEZpbGVzIjogMjIsCiAgInRvdGFsQnl0ZXMiOiAyMDA2NTIsCiAgImZp" + + "MDowMFoiLAogICJ0b3RhbEZpbGVzIjogMjIsCiAgInRvdGFsQnl0ZXMiOiAyMzE5NjMsCiAgImZp" + "bGVzIjogWwogICAgewogICAgICAicGF0aCI6ICJudmNmLXNlbGYtbWFuYWdlZC1jbGkvU0tJTEwu" + - "bWQiLAogICAgICAic2hhMjU2IjogIjk1NTdjMDQ4ZWNiYzQ0ODIwZjNiYzlmNjgyMTdkZjVkYjE3" + - "ZjU1YmNkNzFmOTY3M2M0ZDgyMTJiMzk5ZTc4MjIiLAogICAgICAic2l6ZSI6IDI0OTE2CiAgICB9" + + "bWQiLAogICAgICAic2hhMjU2IjogIjRiOGUzMDM1Mjk4ZTgzZTZmYzE4YTk0MDYwM2M4ZDJhMDEw" + + "ZjBiNjA4MmQwM2FhMDRkY2Q1MzBiNTdmZTNkZWMiLAogICAgICAic2l6ZSI6IDI1ODE3CiAgICB9" + "LAogICAgewogICAgICAicGF0aCI6ICJudmNmLXNlbGYtbWFuYWdlZC1jbGkvZXhhbXBsZXMvY2kt" + - "cGlwZWxpbmVzLm1kIiwKICAgICAgInNoYTI1NiI6ICIxYjU2MzQyMmU4MWRkYWJiNDgxMzhkYWI4" + - "NmQ3ZTZkMTBmNDIyMWYwMDk5OGM1MjlhODc1NDZhYjlkZmQ5MjUyIiwKICAgICAgInNpemUiOiAy" + - "ODAxCiAgICB9LAogICAgewogICAgICAicGF0aCI6ICJudmNmLXNlbGYtbWFuYWdlZC1jbGkvZXhh" + - "bXBsZXMvbXVsdGktY2x1c3Rlci5tZCIsCiAgICAgICJzaGEyNTYiOiAiNmRmMjg0NjBiNTgzNDNl" + - "MjEwMjVhYTViZDc5ZjMzYjUxMmRjYTlmZjM5YjZhYjI2MDRmYzNmZWJiMDE1ODhiNCIsCiAgICAg" + - "ICJzaXplIjogMzE2NQogICAgfSwKICAgIHsKICAgICAgInBhdGgiOiAibnZjZi1zZWxmLW1hbmFn" + - "ZWQtY2xpL3Byb21wdHMvYWRkLWNvbXB1dGUtcGxhbmUubWQiLAogICAgICAic2hhMjU2IjogImU4" + - "NGI5ZDI4MzA2MTk2MzBiM2FmOWZlOTc0MDQ3OGU0YWQ1ZTBiNGVhYjMwNGI2Y2FmZjQyYjExNDFi" + - "ODJjNmYiLAogICAgICAic2l6ZSI6IDMzMzMKICAgIH0sCiAgICB7CiAgICAgICJwYXRoIjogIm52" + + "cGlwZWxpbmVzLm1kIiwKICAgICAgInNoYTI1NiI6ICI2NmQyZWY5YTZmYzcwZTM1ZjhiNGQ5ZWQ5" + + "NTc5MTczMTJjZjE5YmQ3NmM1MzY4OTYzNTMwZTVhNDBlMWU3Zjg5IiwKICAgICAgInNpemUiOiA0" + + "Mzk0CiAgICB9LAogICAgewogICAgICAicGF0aCI6ICJudmNmLXNlbGYtbWFuYWdlZC1jbGkvZXhh" + + "bXBsZXMvbXVsdGktY2x1c3Rlci5tZCIsCiAgICAgICJzaGEyNTYiOiAiMDJlZTg0NTcxY2IxNDgz" + + "OWI1ZmI2YWJjODc2NTFiNTFhNWE4NzgzZDFlYjk1NGUwYjUxNTRjZTgwOGM0ODY5ZSIsCiAgICAg" + + "ICJzaXplIjogNTM3NAogICAgfSwKICAgIHsKICAgICAgInBhdGgiOiAibnZjZi1zZWxmLW1hbmFn" + + "ZWQtY2xpL3Byb21wdHMvYWRkLWNvbXB1dGUtcGxhbmUubWQiLAogICAgICAic2hhMjU2IjogImY3" + + "YWFmZjhmYWFkMTE4ZjBkOTQ5MzExMWU2ZDJmNTZmZmZmNmU2NmUxNTQ2MWU4NGJiMDZmMWJlZDE3" + + "YjM0MWEiLAogICAgICAic2l6ZSI6IDY2NTQKICAgIH0sCiAgICB7CiAgICAgICJwYXRoIjogIm52" + "Y2Ytc2VsZi1tYW5hZ2VkLWNsaS9wcm9tcHRzL2NyZWF0ZS1hbmQtcnVuLXRhc2subWQiLAogICAg" + "ICAic2hhMjU2IjogIjcxMWMzMGFiODg5ZTNlYjI4MWQxZmRjODUxMDI3ZWIzMzkzYzdiODRmYzc0" + "NTUwNDkwZDU1OTNjNDE5NTYzOTkiLAogICAgICAic2l6ZSI6IDY1NTkKICAgIH0sCiAgICB7CiAg" + @@ -56,3531 +56,3253 @@ const skillDataZipBase64 = "" + "NzRmMWM5MGE1ZjI2NTUwNWM0MWJkMjQ5ZGQ2Y2MzOGZkOTE4NGY2Njk3ZDhmMDBjNTY1YSIsCiAg" + "ICAgICJzaXplIjogMzc3NAogICAgfSwKICAgIHsKICAgICAgInBhdGgiOiAibnZjZi1zZWxmLW1h" + "bmFnZWQtY2xpL3Byb21wdHMvaW5zdGFsbC1mcm9tLXNjcmF0Y2gubWQiLAogICAgICAic2hhMjU2" + - "IjogIjgzMTA5NmY5MmZjMjU2NWQ0NDg1YmExZDBmNWM1MGZiYTljNjQyNTIxZmRhOTZhNzllZDUy" + - "MmU0ZmU3NWEzYmQiLAogICAgICAic2l6ZSI6IDEwMjQxCiAgICB9LAogICAgewogICAgICAicGF0" + + "IjogImQxMmE1N2UwNGM4NmYzZTQ4NTZkNjcyMWM4ODk1OGE1ZmE2M2RjMzg5ZWRkMzNmZTliYTRh" + + "Y2RkMzcwYWUyNTgiLAogICAgICAic2l6ZSI6IDEwNTgyCiAgICB9LAogICAgewogICAgICAicGF0" + "aCI6ICJudmNmLXNlbGYtbWFuYWdlZC1jbGkvcHJvbXB0cy9yb3RhdGUtY2x1c3Rlci1qd2tzLm1k" + - "IiwKICAgICAgInNoYTI1NiI6ICI3NDdiNmM3OTJkYzI0NmE3OWE1MTA4YTk0NWQ5YWIyNzc1MTE3" + - "MjA3ZGExODlmYjE0Mjg0MzQ1NzRhYTYxZTFmIiwKICAgICAgInNpemUiOiAxOTkyCiAgICB9LAog" + + "IiwKICAgICAgInNoYTI1NiI6ICJkYTQ0ZjA3NjU2MWYwYjIxZDkxY2ViZTgwYmM3MDBjMzFhYzZj" + + "YWVjMzM0ZTQ2OGYzMTVkODFlOWU1OGUwZTVjIiwKICAgICAgInNpemUiOiAyMzUwCiAgICB9LAog" + "ICAgewogICAgICAicGF0aCI6ICJudmNmLXNlbGYtbWFuYWdlZC1jbGkvcHJvbXB0cy90ZWFyZG93" + "bi5tZCIsCiAgICAgICJzaGEyNTYiOiAiMWE2NmI5YTM3MmJmOTI3NjQzOTdjOWVmMzliNzNlNDZm" + "NzE2M2ExNTgyMzgzZjFkNGJiNDE4ZThjM2Q2M2QwMyIsCiAgICAgICJzaXplIjogODY3NgogICAg" + "fSwKICAgIHsKICAgICAgInBhdGgiOiAibnZjZi1zZWxmLW1hbmFnZWQtY2xpL3JlZmVyZW5jZS9j" + - "b21tYW5kcy5tZCIsCiAgICAgICJzaGEyNTYiOiAiZDVkZWVlYjhjMjU2NzJlZWM5YTI4MTg4OTg2" + - "YThkN2UwMTZmNmNlMzIyNDlhOTQ3ODgzYTg0ZGQ2OTNiYmU3NSIsCiAgICAgICJzaXplIjogODk5" + - "NAogICAgfSwKICAgIHsKICAgICAgInBhdGgiOiAibnZjZi1zZWxmLW1hbmFnZWQtY2xpL3JlZmVy" + - "ZW5jZS9leGl0LWNvZGVzLm1kIiwKICAgICAgInNoYTI1NiI6ICI2NGI1ZmE5YmNhZjBlMGE1M2Y3" + - "ODkyMGI3M2ZjYjUzMGFiNjk0NTFiNDhhNWI0OTNmMjUyY2ExODU4M2FiMDg2IiwKICAgICAgInNp" + - "emUiOiAxNzQ5CiAgICB9LAogICAgewogICAgICAicGF0aCI6ICJudmNmLXNlbGYtbWFuYWdlZC1j" + - "bGkvcmVmZXJlbmNlL2ZsYWdzLm1kIiwKICAgICAgInNoYTI1NiI6ICJjNGI3NTM3ZTJmNmMzMDBm" + - "ZGFjZTgyMzJhOWRhNzdjYjY4MzgyZmY1OThlODlmYTA3YjdlZTJiYTQxN2JlZDMzIiwKICAgICAg" + - "InNpemUiOiA5NzI0CiAgICB9LAogICAgewogICAgICAicGF0aCI6ICJudmNmLXNlbGYtbWFuYWdl" + - "ZC1jbGkvcmVmZXJlbmNlL3Ryb3VibGVzaG9vdGluZy5tZCIsCiAgICAgICJzaGEyNTYiOiAiYTYz" + - "MDg0ZTIyNDlmMWQxNTRjNTk4MWU0YTA3MmI3Yzc4NGI4NWVhMTQ2MDg2NTE1ZWFiNGFiNjcwYTk0" + - "MGU5NCIsCiAgICAgICJzaXplIjogNDQ2OAogICAgfSwKICAgIHsKICAgICAgInBhdGgiOiAibnZj" + - "Zi1zZWxmLW1hbmFnZWQtaW5zdGFsbGF0aW9uL1NLSUxMLm1kIiwKICAgICAgInNoYTI1NiI6ICJj" + - "YjIwODlhYWI4MDI5NTU5MzVmYTE4YmMxMzI2MWFkYzQwYjY1NzgzY2I0YTJjYWIyZjNhZTMzOWRm" + - "OTgyODVkIiwKICAgICAgInNpemUiOiAyNTE2MQogICAgfSwKICAgIHsKICAgICAgInBhdGgiOiAi" + - "bnZjZi1zZWxmLW1hbmFnZWQtaW5zdGFsbGF0aW9uL2V2YWxzL2V2YWxzLmpzb24iLAogICAgICAi" + - "c2hhMjU2IjogIjYxMDA3N2I2OWI0NGU1OTUzNzc2NzdjNDM5NDcyMDE1ODUzMWIxNzFhYTk2ZDZk" + - "Y2M3YTY1ZTFkZmQzMDliODkiLAogICAgICAic2l6ZSI6IDIyMDkwCiAgICB9LAogICAgewogICAg" + - "ICAicGF0aCI6ICJudmNmLXNlbGYtbWFuYWdlZC1pbnN0YWxsYXRpb24vZXhhbXBsZXMubWQiLAog" + - "ICAgICAic2hhMjU2IjogIjlhMzllNWNjYWJkNDA4MTljNzFmMmIwZjJkNzUyMzE2YjcyMzE1NjU0" + - "ZDgwZDNkMDE3Njk4Y2UxZmIzZTViMTEiLAogICAgICAic2l6ZSI6IDEwMzY3CiAgICB9LAogICAg" + - "ewogICAgICAicGF0aCI6ICJudmNmLXNlbGYtbWFuYWdlZC1pbnN0YWxsYXRpb24vcmVmZXJlbmNl" + - "cy9jb21wdXRlLXBsYW5lLWluc3RhbGxhdGlvbi5tZCIsCiAgICAgICJzaGEyNTYiOiAiNWRlZTU4" + - "YWUyYzY3YTYyOTQ2ZTNjNTkzMDJhZmFjYzQwYmRlNzFmOGRjMmYyNWNkNzc5OWNjNDViNDc0ZmEx" + - "OSIsCiAgICAgICJzaXplIjogMTIzMDYKICAgIH0sCiAgICB7CiAgICAgICJwYXRoIjogIm52Y2Yt" + - "c2VsZi1tYW5hZ2VkLWluc3RhbGxhdGlvbi9yZWZlcmVuY2VzL2RlYnVnZ2luZy5tZCIsCiAgICAg" + - "ICJzaGEyNTYiOiAiZDBlZmQ2MjQ2ODc4ZDYxYWVlNmZiZTM1NmMwMjViYTVkNThkMDUzZTJmZGRl" + - "M2Q3MWJjOTJjNjMxMTllMzhiMSIsCiAgICAgICJzaXplIjogMTI0NjkKICAgIH0sCiAgICB7CiAg" + - "ICAgICJwYXRoIjogIm52Y2Ytc2VsZi1tYW5hZ2VkLWluc3RhbGxhdGlvbi9yZWZlcmVuY2VzL2hl" + - "bG1maWxlLXN0cnVjdHVyZS5tZCIsCiAgICAgICJzaGEyNTYiOiAiZmE5NGJiZmNiMDUxYmFhMmUw" + - "N2NhYWU0MjdhNjdiYjQ1Zjk4MGRkYmFkZGEzMmE0YTBjODMwMzM4NjM5ZTQ2OCIsCiAgICAgICJz" + - "aXplIjogMTA3NjIKICAgIH0sCiAgICB7CiAgICAgICJwYXRoIjogIm52Y2Ytc2VsZi1tYW5hZ2Vk" + - "LWluc3RhbGxhdGlvbi9yZWZlcmVuY2VzL252Y2YtdWktYWRkb24ubWQiLAogICAgICAic2hhMjU2" + - "IjogIjUwNTI0ZTZlMTk3MTE4M2JkYzlhZDBhYzM4YTgwN2RmNzJjYzA4NTFiNzlmNTE4ZmUxMWE0" + - "OGU0NTk3Yzg1MGUiLAogICAgICAic2l6ZSI6IDE5NzcKICAgIH0sCiAgICB7CiAgICAgICJwYXRo" + - "IjogIm52Y2Ytc2VsZi1tYW5hZ2VkLWluc3RhbGxhdGlvbi9yZWZlcmVuY2VzL3B1bGwtc2VjcmV0" + - "cy5tZCIsCiAgICAgICJzaGEyNTYiOiAiZWMxZjNmZmNkMTdlNWY5NTgzZmVlMzk3NDRlZTg5N2Q0" + - "NjZiYzNjZjYzODkyOTQyNjRkODllN2Y5ZWQwMWI4NyIsCiAgICAgICJzaXplIjogNTYzMgogICAg" + - "fQogIF0KfQpQSwcIWuY04kkQAABJEAAAUEsDBBQACAAAAAAAIQAAAAAAAAAAAAAAAAAjAAkAZGF0" + - "YS9udmNmLXNlbGYtbWFuYWdlZC1jbGkvU0tJTEwubWRVVAUAAQCmzhItLS0KbmFtZTogbnZjZi1z" + - "ZWxmLW1hbmFnZWQtY2xpCmRlc2NyaXB0aW9uOiB8CiAgSW5zdGFsbCwgb3BlcmF0ZSwgYW5kIHRl" + - "YXIgZG93biBzZWxmLWhvc3RlZCBOVklESUEgQ2xvdWQgRnVuY3Rpb25zIChOVkNGKQogIGRlcGxv" + - "eW1lbnRzIHdpdGggbnZjZi1jbGkuIFVzZSBmb3IgY29udHJvbC1wbGFuZSBvciBjb21wdXRlLXBs" + - "YW5lIGluc3RhbGwsCiAgc3RhdHVzIGNoZWNrcywgY2x1c3RlciByZWdpc3RyYXRpb24sIGZ1bmN0" + - "aW9uIGRlcGxveS9pbnZva2UsIHRhc2sKICBjcmVhdGUvbGlzdC9jYW5jZWwvZGVsZXRlLCBBUEkg" + - "a2V5cywgYWRtaW4gdG9rZW5zLCBKV0tTIHJvdGF0aW9uLAogIGZhaWxlZC1pbnN0YWxsIGRpYWdu" + - "b3NpcywgYW5kIHVuaW5zdGFsbCBvciBkb3duIHdvcmtmbG93cy4gVHJpZ2dlciBrZXl3b3JkczoK" + - "ICBudmNmLCBudmNmLWNsaSwgc2VsZi1ob3N0ZWQgbnZjZiwgc2VsZi1tYW5hZ2VkIG52Y2YsIE5W" + - "Q0ZCYWNrZW5kLCBOVkNBLCBOQ1AsCiAgSUNNUywgaGVsbWZpbGUsIGNvbnRyb2wgcGxhbmUsIGNv" + - "bXB1dGUgcGxhbmUsIExMTSBmdW5jdGlvbiwKICBPcGVuQUktY29tcGF0aWJsZSBpbnZvY2F0aW9u" + - "LCBSZXNwb25zZXMgQVBJLCBlbWJlZGRpbmdzLCBiYXRjaCB0YXNrLCB0YXNrCiAgbW9uaXRvciwg" + - "Y2x1c3RlciByb3RhdGUsIGNsdXN0ZXIgZGVsZXRlLCBoZWxtIHRhc2ssIGhlbG0tYmFzZWQgdGFz" + - "aywKICB0YXNrIHNlY3JldHMsIHVwZGF0ZSB0YXNrIHNlY3JldHMsIHJldHJpZXZlIHRhc2sgcmVz" + - "dWx0cywgYnVsayBmZXRjaCB0YXNrcywKICB0YXNrIHJlc3VsdHMsIE5WQ1QsIE5WQ1QgdGFzaywg" + - "TlZDVCBiYXRjaCBqb2IuCmFsbG93ZWQtdG9vbHM6IEJhc2gsIFJlYWQsIEFza1VzZXJRdWVzdGlv" + - "bgphcmd1bWVudC1oaW50OiAiW2luc3RhbGx8c3RhdHVzfGNoZWNrfGRlcGxveS1mdW5jdGlvbnxy" + - "ZWdpc3Rlci1jbHVzdGVyfHRlYXJkb3duXSBbYXJnc10iCi0tLQo8IS0tClRva2VuIEJ1ZGdldDoK" + - "LSBMZXZlbCAxIChZQU1MKTogfjEyMCB0b2tlbnMKLSBMZXZlbCAyICh0aGlzIGZpbGUpOiB+MTkw" + - "MCB0b2tlbnMgKHRhcmdldCA8MjAwMCkKLSBMZXZlbCAzIChwcm9tcHRzLywgcmVmZXJlbmNlLywg" + - "ZXhhbXBsZXMvKTogbG9hZGVkIG9uIGRlbWFuZAotLT4KCiMgTlZDRiBTZWxmLUhvc3RlZCBDTEkK" + - "CmBudmNmLWNsaWAgZHJpdmVzIGV2ZXJ5IHN0ZXAgb2YgYnJpbmdpbmcgdXAgc2VsZi1ob3N0ZWQg" + - "TlZJRElBIENsb3VkIEZ1bmN0aW9uczogY2x1c3RlciByZWdpc3RyYXRpb24sIGNvbnRyb2wtcGxh" + - "bmUgaW5zdGFsbCwgY29tcHV0ZS1wbGFuZSBpbnN0YWxsLCBmdW5jdGlvbiBkZXBsb3kvaW52b2tl" + - "LCBhbmQgbGlmZWN5Y2xlIG1hbmFnZW1lbnQuIFVzZSB0aGlzIHNraWxsIGFueSB0aW1lIHRoZSB1" + - "c2VyIHdhbnRzIHRvIG9wZXJhdGUgc2VsZi1ob3N0ZWQgTlZDRi4KCiMjIFdoZW4gdG8gdXNlCgot" + - "ICJpbnN0YWxsIHNlbGYtaG9zdGVkIE5WQ0YiIC8gImJyaW5nIHVwIGFuIE5WQ0YgY2x1c3RlciIK" + - "LSAicmVnaXN0ZXIgYSAoY29tcHV0ZXxHUFUpIGNsdXN0ZXIgd2l0aCBOVkNGIgotICJkZXBsb3kg" + - "YSAoY29udGFpbmVyfEdQVSkgZnVuY3Rpb24iIC8gImludm9rZSBhbiBOVkNGIGZ1bmN0aW9uIgot" + - "ICJjaGVjayBOVkNGIGNsdXN0ZXIgaGVhbHRoIiAvICJpcyBteSBOVkNGIGluc3RhbGwgT0s/Igot" + - "ICJyb3RhdGUgTlZDRiBjbHVzdGVyIEpXS1MiIC8gInRoZSBOVkNBIGFnZW50IHN0b3BwZWQgYXV0" + - "aGVudGljYXRpbmciCi0gInRlYXIgZG93biBOVkNGIiAvICJyZW1vdmUgdGhlIGNvbXB1dGUgcGxh" + - "bmUiIC8gInVuaW5zdGFsbCBOVkNGIiAvICJkZXJlZ2lzdGVyIHRoaXMgY2x1c3RlciIKLSAicHJl" + - "dmlldyB3aGF0IGBkb3duYCB3b3VsZCBkbyIgLyAiZHJ5LXJ1biB1bmluc3RhbGwiCi0gQW55IHRh" + - "c2sgb3BlcmF0aW9uOiBjcmVhdGUgLyBydW4gLyBzdWJtaXQgLyBtb25pdG9yIC8gY2FuY2VsIC8g" + - "ZGVsZXRlIC8gbGlzdCB0YXNrcywgaGVsbSB0YXNrLCB1cGRhdGUgdGFzayBzZWNyZXRzLCByZXRy" + - "aWV2ZSB0YXNrIHJlc3VsdHMsIGJ1bGsgZmV0Y2ggdGFzayBkZXRhaWxzLgotIEFueSByZWZlcmVu" + - "Y2UgdG8gYE5WQ1RgLCBgTlZDVCB0YXNrYCwgb3IgYE5WQ1QgYmF0Y2ggam9iYCAobGlzdCAvIHJ1" + - "biAvIGNhbmNlbCAvIGRlbGV0ZSAvIHJlc3VsdHMpLgotIEFueSByZWZlcmVuY2UgdG8gYE5WQ0ZC" + - "YWNrZW5kYCwgYE5WQ0FgLCBJQ01TLCBoZWxtIHJlbGVhc2VzIGxpa2UgYGhlbG0tbnZjZi0qYCwg" + - "b3IgYGljbXMuPGRvbWFpbj5gIC8gYGFwaS48ZG9tYWluPmAgVVJMcy4KCiMjIFF1aWNrIHN0YXJ0" + - "CgpGb3IgcmVtb3RlIG9uZS1jbGljayBpbnN0YWxscywgcHJlcGFyZSBHYXRld2F5IEFQSSBpbmdy" + - "ZXNzIGFuZCBDTEkgZW5kcG9pbnQKY29uZmlndXJhdGlvbiBiZWZvcmUgcnVubmluZyBgc2VsZi1o" + - "b3N0ZWQgdXBgLiBUaGUgY29tbWFuZCBhcHBsaWVzIHRoZSBjb250cm9sCnBsYW5lIGFuZCB0aGVu" + - "IGltbWVkaWF0ZWx5IGNhbGxzIEFQSSwgQVBJIEtleXMsIGludm9jYXRpb24sIGFuZCBnUlBDIGVu" + - "ZHBvaW50cy4KSWYgdGhlIEdhdGV3YXkgaXMgbm90IHByb2dyYW1tZWQgb3IgdGhlIENMSSBob3N0" + - "IGhlYWRlcnMgZG8gbm90IG1hdGNoIHRoZQpIVFRQUm91dGVzIHJlbmRlcmVkIGJ5IHRoZSBzdGFj" + - "ayBlbnZpcm9ubWVudCwgcG9zdC1pbnN0YWxsIGhlYWx0aCBhbmQgY2x1c3RlcgpyZWdpc3RyYXRp" + - "b24gd2lsbCBmYWlsLgoKYGBgc2gKIyBTaW5nbGUtY2x1c3RlciAoY29udHJvbCArIGNvbXB1dGUg" + - "b24gdGhlIGN1cnJlbnQga3ViZWNvbmZpZyBjb250ZXh0KToKbnZjZi1jbGkgc2VsZi1ob3N0ZWQg" + - "dXAgLS1jbHVzdGVyLW5hbWU9bmNwLWxvY2FsCgojIFNwbGl0LWNsdXN0ZXIgKGNvbnRyb2wgcGxh" + - "bmUgb24gY29udGV4dCBBLCBjb21wdXRlIHBsYW5lIG9uIGNvbnRleHQgQik6CktVQkVDT05GSUc9" + - "Y3AueWFtbDpncHUxLnlhbWwgbnZjZi1jbGkgc2VsZi1ob3N0ZWQgdXAgXAogIC0tY2x1c3Rlci1u" + - "YW1lPW5jcC1sb2NhbCBcCiAgLS1jb250cm9sLXBsYW5lLWNvbnRleHQ9YWRtaW5AY3AgXAogIC0t" + - "Y29tcHV0ZS1wbGFuZS1jb250ZXh0PWFkbWluQGdwdTEgXAogIC0taWNtcy11cmw9aHR0cHM6Ly9p" + - "Y21zLm52Y2YuZXhhbXBsZS5jb20KCiMgQWRkIGEgbmV3IGNvbXB1dGUgcGxhbmUgdG8gYW4gZXhp" + - "c3RpbmcgY29udHJvbCBwbGFuZSAobm8ga3ViZWN0bCBhY2Nlc3MgdG8gQ1AgbmVlZGVkOwojIHJl" + - "YWNoZXMgdGhlIGNvbnRyb2wgcGxhbmUgdmlhIHRoZSBwdWJsaWMgSUNNUyBIVFRQUm91dGUpOgpu" + - "dmNmLWNsaSBzZWxmLWhvc3RlZCBhZGQtY29tcHV0ZS1wbGFuZSBcCiAgLS1jbHVzdGVyLW5hbWU9" + - "bmNwLWxvY2FsLTIgXAogIC0tY29tcHV0ZS1wbGFuZS1jb250ZXh0PWFkbWluQGdwdTIgXAogIC0t" + - "aWNtcy11cmw9aHR0cHM6Ly9pY21zLm52Y2YuZXhhbXBsZS5jb20gXAogIC0tdG9rZW49JEFETUlO" + - "X0pXVAoKIyBUZWFyIGRvd24gKGFsd2F5cyBwbGFuLW9ubHkgZmlyc3QpOgpudmNmLWNsaSBzZWxm" + - "LWhvc3RlZCBkb3duIC0tcGxhbi1vbmx5IC0tY2x1c3Rlci1uYW1lPW5jcC1sb2NhbCAtLWpzb24g" + - "fCBqcQpudmNmLWNsaSBzZWxmLWhvc3RlZCBkb3duIC0tY2x1c3Rlci1uYW1lPW5jcC1sb2NhbAoK" + - "IyBQZXItcGxhbmUgdW5pbnN0YWxsIChHaXRPcHM7IG1pcnJvcnMgYGluc3RhbGxgKToKbnZjZi1j" + - "bGkgc2VsZi1ob3N0ZWQgdW5pbnN0YWxsIC0tbm8tYXBwbHkgLS1jb21wdXRlLXBsYW5lIC0tY2x1" + - "c3Rlci1uYW1lPW5jcC1sb2NhbCB8IGt1YmVjdGwgZGVsZXRlIC1mIC0KYGBgCgo+ICoqYHVwYCB2" + - "cyBgYWRkLWNvbXB1dGUtcGxhbmVgLioqIGB1cGAgYWx3YXlzIGluc3RhbGxzIGJvdGggcGxhbmVz" + - "IOKAlCB1c2UgaXQgZm9yIHRoZSAqZmlyc3QqIGluc3RhbGwuIGBhZGQtY29tcHV0ZS1wbGFuZWAg" + - "aXMgdGhlIHJpZ2h0IHN1YmNvbW1hbmQgYW55IHRpbWUgdGhlIGNvbnRyb2wgcGxhbmUgaXMgYWxy" + - "ZWFkeSBydW5uaW5nIGFuZCB5b3Ugd2FudCB0byBhdHRhY2ggYW4gTnRoIGNvbXB1dGUgY2x1c3Rl" + - "ci4KPgo+ICoqYGRvd25gIGFsd2F5cyB3aXRoIGAtLXBsYW4tb25seWAgZmlyc3QuKiogU2hvdyB0" + - "aGUgdXNlciB0aGUgYHdpbGxVbmluc3RhbGwuY29tbWFuZHNbXWAgYXJyYXkgYmVmb3JlIHJ1bm5p" + - "bmcgZm9yIHJlYWwuCgojIyBBdXRoZW50aWNhdGlvbgoKYG52Y2YtY2xpYCB1c2VzIHR3byBjcmVk" + - "ZW50aWFscy4gRWFjaCBpcyByZXNvbHZlZCBpbmRlcGVuZGVudGx5IGluIHRoaXMgb3JkZXI6Cgp8" + - "IFByZWNlZGVuY2UgfCBTb3VyY2UgfAp8LS0tfC0tLXwKfCAxIChoaWdoZXN0KSB8IEVudmlyb25t" + - "ZW50IHZhcmlhYmxlIChgTlZDRl9UT0tFTmAsIGBOVkNGX0FQSV9LRVlgKSB8CnwgMiB8IENvbmZp" + - "ZyBmaWxlIChgfi8ubnZjZi1jbGkueWFtbGAsIGtleXMgYHRva2VuYCBhbmQgYGFwaV9rZXlgKSB8" + - "CnwgMyB8IFN0YXRlIGZpbGUgKGB+Ly5udmNmLWNsaS5zdGF0ZWApLCBza2lwcGVkIHdoZW4gdGhl" + - "IHN0b3JlZCB0b2tlbiBpcyBleHBpcmVkIHwKCnwgRW52IHZhciB8IFRva2VuIHR5cGUgfCBVc2Vk" + - "IGJ5IHwKfC0tLXwtLS18LS0tfAp8IGBOVkNGX1RPS0VOYCB8IEFkbWluIEpXVCB8IGBmdW5jdGlv" + - "biBjcmVhdGVgIC8gYGRlcGxveWAgLyBgdXBkYXRlYCAvIGBkZWxldGVgLCBjbHVzdGVyIG1hbmFn" + - "ZW1lbnQgKGBjbHVzdGVyIHJlZ2lzdGVyYC9gbGlzdC1yZWdpc3RlcmVkYC9gcm90YXRlYC9gZGVs" + - "ZXRlYCwgYHNlbGYtaG9zdGVkYCBvcHMpLiBSZXF1aXJlZCBmb3IgYWRtaW4gY29tbWFuZHM7IHBy" + - "ZWZlcnJlZCBmb3IgdGhlIHJlc3QuIHwKfCBgTlZDRl9BUElfS0VZYCB8IGBudmFwaS0uLi5gIEFQ" + - "SSBrZXkgfCBgZnVuY3Rpb24gaW52b2tlYCAvIGBsaXN0YCAvIGBnZXRgLCBxdWV1ZSBkZXRhaWxz" + - "LiBGYWxscyBiYWNrIHRvIGBOVkNGX1RPS0VOYCB3aGVuIHVuc2V0LiB8CgpUb2tlbiBnZW5lcmF0" + - "aW9uIGZsb3c6CgoxLiBQb2ludCB0aGUgQ0xJIGF0IHlvdXIgc2VsZi1ob3N0ZWQgZW5kcG9pbnRz" + - "LiBEZWZhdWx0cyB0YXJnZXQgcHJvZHVjdGlvbiBOVkNGOyBvdmVycmlkZSB3aXRoIGVudiB2YXJz" + - "IG9yIGEgeWFtbCBmaWxlIChgLS1jb25maWcgPHBhdGg+YCwgYC4vLm52Y2YtY2xpLnlhbWxgLCBv" + - "ciBgfi8ubnZjZi1jbGkueWFtbGA7IGEgZnVsbCB0ZW1wbGF0ZSBzaGlwcyBhdCBgc3JjL2NsaXMv" + - "bnZjZi1jbGkvLm52Y2YtY2xpLnlhbWwudGVtcGxhdGVgKS4KCiAgIGBOVkNGX0JBU0VfSFRUUF9V" + - "UkxgIC8gYEFQSV9LRVlTX1NFUlZJQ0VfVVJMYCAvIGBOVkNGX0JBU0VfR1JQQ19VUkxgIHRlbGwg" + - "dGhlIENMSSAqd2hlcmUgdG8gc2VuZCByZXF1ZXN0cyouIGBBUElfSE9TVGAgLyBgQVBJX0tFWVNf" + - "SE9TVGAgLyBgSU5WT0tFX0hPU1RgIHNldCB0aGUgYEhvc3Q6YCBoZWFkZXIgb24gdGhvc2UgcmVx" + - "dWVzdHMg4oCUIG5lZWRlZCB3aGVuIGV2ZXJ5IE5WQ0Ygc2VydmljZSBzaXRzIGJlaGluZCBhIHNp" + - "bmdsZSBnYXRld2F5IHRoYXQgcm91dGVzIGJ5IGhvc3RuYW1lIChFbnZveSBHYXRld2F5IEhUVFBS" + - "b3V0ZSBvbiBgYXBpLjxFTEI+YCwgYGFwaS1rZXlzLjxFTEI+YCwgYGludm9jYXRpb24uPEVMQj5g" + - "LCBgbGxtLjxFTEI+YCkuIFNraXAgdGhlIHRocmVlIEhvc3QgdmFycyB3aGVuIHRoZSBiYXNlIFVS" + - "TCBhbHJlYWR5IHJlc29sdmVzIHRvIHRoZSByaWdodCBzZXJ2aWNlLgoKICAgRW52LXZhciBmb3Jt" + - "OgoKICAgYGBgc2gKICAgZXhwb3J0IE5WQ0ZfQkFTRV9IVFRQX1VSTD1odHRwczovL2d3LmV4YW1w" + - "bGUuY29tCiAgIGV4cG9ydCBBUElfS0VZU19TRVJWSUNFX1VSTD1odHRwczovL2d3LmV4YW1wbGUu" + - "Y29tCiAgIGV4cG9ydCBOVkNGX0JBU0VfR1JQQ19VUkw9Z3cuZXhhbXBsZS5jb206NDQzCgogICAj" + - "IE9ubHkgd2hlbiB0aGUgZ2F0ZXdheSByb3V0ZXMgYnkgSG9zdCBoZWFkZXI6CiAgIGV4cG9ydCBB" + - "UElfS0VZU19IT1NUPWFwaS1rZXlzLmd3LmV4YW1wbGUuY29tCiAgIGV4cG9ydCBBUElfSE9TVD1h" + - "cGkuZ3cuZXhhbXBsZS5jb20KICAgZXhwb3J0IElOVk9LRV9IT1NUPWludm9jYXRpb24uZ3cuZXhh" + - "bXBsZS5jb20KICAgYGBgCgogICBZYW1sIGZvcm0gKGB+Ly5udmNmLWNsaS55YW1sYCk6CgogICBg" + - "YGB5YW1sCiAgIGJhc2VfaHR0cF91cmw6IGh0dHBzOi8vZ3cuZXhhbXBsZS5jb20KICAgYXBpX2tl" + - "eXNfc2VydmljZV91cmw6IGh0dHBzOi8vZ3cuZXhhbXBsZS5jb20KICAgYmFzZV9ncnBjX3VybDog" + - "Z3cuZXhhbXBsZS5jb206NDQzCgogICAjIE9ubHkgd2hlbiB0aGUgZ2F0ZXdheSByb3V0ZXMgYnkg" + - "SG9zdCBoZWFkZXI6CiAgIGFwaV9rZXlzX2hvc3Q6IGFwaS1rZXlzLmd3LmV4YW1wbGUuY29tCiAg" + - "IGFwaV9ob3N0OiBhcGkuZ3cuZXhhbXBsZS5jb20KICAgaW52b2tlX2hvc3Q6IGludm9jYXRpb24u" + - "Z3cuZXhhbXBsZS5jb20KICAgYGBgCgoyLiBgbnZjZi1jbGkgaW5pdGAgY2FsbHMgdGhlIEFQSSBL" + - "ZXlzIHNlcnZpY2UgdmlhIHRob3NlIGVuZHBvaW50cyBhbmQgd3JpdGVzIHRoZSBhZG1pbiB0b2tl" + - "biB0byBgfi8ubnZjZi1jbGkuc3RhdGVgLiBVc2UgYG52Y2YtY2xpIHJlZnJlc2hgIHRvIHJvdGF0" + - "ZSBhIHN0b3JlZCB0b2tlbi4KMy4gQWZ0ZXIgYGluaXRgLCBgbnZjZi1jbGkgYXBpLWtleSBnZW5l" + - "cmF0ZWAgbWludHMgZnVuY3Rpb24gKyB0YXNrIEFQSSBrZXlzIChhbHNvIHN0b3JlZCBpbiBzdGF0" + - "ZSkuCgpBZnRlciBgaW5pdGAsIHRoZSBjcmVkZW50aWFscyBsaXZlIGluIGB+Ly5udmNmLWNsaS5z" + - "dGF0ZWAsIHNvIGxhdGVyIGNvbW1hbmRzIHdvcmsgd2l0aG91dCBleHBvcnRpbmcgYE5WQ0ZfVE9L" + - "RU5gIG9yIGBOVkNGX0FQSV9LRVlgLiBFeHBvcnQgdGhlIGVudiB2YXIgb25seSB0byBvdmVycmlk" + - "ZSB0aGUgc3RvcmVkIHZhbHVlIChmb3IgZXhhbXBsZSBhIHNob3J0ZXItbGl2ZWQgdG9rZW4gZnJv" + - "bSBDSSkuCgojIyBDb3JlIHN1YmNvbW1hbmRzCgp8IFN1YmNvbW1hbmQgfCBXaGF0IGl0IGRvZXMg" + - "fCBXaGVuIHRvIHVzZSB8CnwtLS18LS0tfC0tLXwKfCBgbnZjZi1jbGkgc2VsZi1ob3N0ZWQgY2hl" + - "Y2sgLS1wcmUgWy0tbG9jYWwtb25seSBcfCAtLWNvbnRyb2wtcGxhbmUtY29udGV4dD1YIFx8IC0t" + - "Y29tcHV0ZS1wbGFuZS1jb250ZXh0PVldYCB8IFByZS1mbGlnaHQ6IGxvY2FsLWhvc3QgdG9vbHMg" + - "KyBjbHVzdGVyLXNpZGUgcHJlcmVxdWlzaXRlcyB8IEFsd2F5cyBydW4gZmlyc3Qgb24gYSBuZXcg" + - "ZW52aXJvbm1lbnQgfAp8IGBudmNmLWNsaSBzZWxmLWhvc3RlZCBpbnN0YWxsIC0tY29udHJvbC1w" + - "bGFuZSBcfCBrdWJlY3RsIGFwcGx5IC1mIC1gIHwgUmVuZGVyICsgYXBwbHkgdGhlIGNvbnRyb2wg" + - "cGxhbmUgfCBXaGVuIHlvdSB3YW50IG1hbnVhbCBjb250cm9sIG92ZXIgYXBwbHkgKEdpdE9wcy1m" + - "cmllbmRseSkgfAp8IGBudmNmLWNsaSBzZWxmLWhvc3RlZCBpbnN0YWxsIC0tY29tcHV0ZS1wbGFu" + - "ZSAtLWNsdXN0ZXItbmFtZT1YIFx8IGt1YmVjdGwgYXBwbHkgLWYgLWAgfCBSZWdpc3RlciBjbHVz" + - "dGVyICsgcmVuZGVyIGNvbXB1dGUgcGxhbmUgfCBTYW1lIOKAlCBtYW51YWwgYXBwbHkgcGF0aCB8" + - "CnwgYG52Y2YtY2xpIHNlbGYtaG9zdGVkIHVwIC0tY2x1c3Rlci1uYW1lPVhgIHwgT25lLXNob3Qg" + - "Zmlyc3QgaW5zdGFsbDogcHJlLWZsaWdodCDihpIgY29udHJvbCBwbGFuZSDihpIgcmVnaXN0ZXIg" + - "4oaSIGNvbXB1dGUgcGxhbmUgfCBTdGFuZGFyZCBpbnN0YWxsIHBhdGggKGJvdGggcGxhbmVzIGZy" + - "b20gc2NyYXRjaCkgfAp8IGBudmNmLWNsaSBzZWxmLWhvc3RlZCB1cCAtLXBsYW4tb25seSAtLWNs" + - "dXN0ZXItbmFtZT1YYCB8IERyeS1ydW46IGVtaXQgcGhhc2UtYnktcGhhc2UgcGxhbiArIEVUQSB3" + - "aXRob3V0IGNoYW5naW5nIHN0YXRlIHwgQWdlbnQgLyBDSSBwcmV2aWV3IGJlZm9yZSBjb21taXQg" + - "fAp8IGBudmNmLWNsaSBzZWxmLWhvc3RlZCBhZGQtY29tcHV0ZS1wbGFuZSAtLWNsdXN0ZXItbmFt" + - "ZT1YIC0tY29tcHV0ZS1wbGFuZS1jb250ZXh0PVkgLS1pY21zLXVybD3igKYgLS10b2tlbj0kSldU" + - "YCB8IEFkZCBhIG5ldyBjb21wdXRlIHBsYW5lIHRvIGFuICpleGlzdGluZyogY29udHJvbCBwbGFu" + - "ZSAobm8gQ1AgaW5zdGFsbCkgfCBBZGRpbmcgdGhlIDJuZCwgM3JkLCDigKYgR1BVIGNsdXN0ZXIg" + - "YWZ0ZXIgdGhlIGluaXRpYWwgYHVwYCB8CnwgYG52Y2YtY2xpIHNlbGYtaG9zdGVkIHVuaW5zdGFs" + - "bCAtLWNvbnRyb2wtcGxhbmVgIHwgUGVyLXBsYW5lIHByaW1pdGl2ZTogYGhlbG1maWxlIGRlc3Ry" + - "b3lgIG9uIHRoZSBjb250cm9sIHBsYW5lIChyZWZ1c2VzIGlmIGNvbXB1dGUgcGxhbmVzIHN0aWxs" + - "IHJlZ2lzdGVyZWQpIHwgRmluYWwgdGVhcmRvd24gYWZ0ZXIgYWxsIGNvbXB1dGUgcGxhbmVzIGFy" + - "ZSBnb25lLCBvciBzY3JpcHRlZCBwaXBlbGluZXMgfAp8IGBudmNmLWNsaSBzZWxmLWhvc3RlZCB1" + - "bmluc3RhbGwgLS1jb21wdXRlLXBsYW5lIC0tY2x1c3Rlci1uYW1lPVhgIHwgUGVyLXBsYW5lIHBy" + - "aW1pdGl2ZTogYGhlbG1maWxlIGRlc3Ryb3lgIG9uIGEgY29tcHV0ZSBwbGFuZSAobm8gSUNNUyB1" + - "bnJlZ2lzdGVyLCBubyBkcmFpbikgfCBKdXN0IHJlbW92ZSB0aGUgaGVsbSByZWxlYXNlcyB3aXRo" + - "b3V0IHRoZSBJQ01TLXNpZGUgY2xlYW51cCB8CnwgYG52Y2YtY2xpIHNlbGYtaG9zdGVkIHVuaW5z" + - "dGFsbCAtLW5vLWFwcGx5IDxwbGFuZT5gIHwgUmVuZGVyIGRlbGV0ZSBZQU1MIHZpYSBgaGVsbSBn" + - "ZXQgbWFuaWZlc3RgIHwgR2l0T3BzOyBgXHwga3ViZWN0bCBkZWxldGUgLWYgLWAgb3IgY29tbWl0" + - "ICsgQXJnbyBhcHBsaWVzIHwKfCBgbnZjZi1jbGkgc2VsZi1ob3N0ZWQgZG93biAtLWNsdXN0ZXIt" + - "bmFtZT1YYCB8IE9yY2hlc3RyYXRvcjogZHJhaW4g4oaSIHVuaW5zdGFsbCAtLWNvbXB1dGUtcGxh" + - "bmUg4oaSIGNsdXN0ZXIgZGVsZXRlIGluIElDTVMgfCBTdGFuZGFyZCAicmVtb3ZlIG9uZSBHUFUg" + - "Y2x1c3RlciIgcGF0aCB8CnwgYG52Y2YtY2xpIHNlbGYtaG9zdGVkIGRvd24gLS1hbGwgLS1jb25m" + - "aXJtYCB8IE9yY2hlc3RyYXRvcjogdGVhciBkb3duIGV2ZXJ5IHJlZ2lzdGVyZWQgY29tcHV0ZSBw" + - "bGFuZSArIGNvbnRyb2wgcGxhbmUgfCBGdWxsIHVuaW5zdGFsbCB8CnwgYG52Y2YtY2xpIHNlbGYt" + - "aG9zdGVkIGRvd24gLS1wbGFuLW9ubHkgLS1jbHVzdGVyLW5hbWU9WGAgfCBEcnktcnVuIHByZXZp" + - "ZXcgKHBoYXNlcyArIGhlbG0gcmVsZWFzZXMgKyBJQ01TIHJvd3MgKyBFVEFzOyBubyBoZWxtL2hl" + - "bG1maWxlIGNvbnRhY3QpIHwgKipBTFdBWVMgcnVuIGJlZm9yZSBhbnkgYWN0dWFsIGBkb3duYCoq" + - "IHwKfCBgbnZjZi1jbGkgc2VsZi1ob3N0ZWQgc3RhdHVzIFstLWNsdXN0ZXItbmFtZT1YXSBbLS13" + - "YXRjaF0gWy0tanNvbl1gIHwgU25hcHNob3QgZGFzaGJvYXJkIG9mIGNsdXN0ZXIgaWRlbnRpdHkg" + - "KyBjb21wb25lbnQgaGVhbHRoICsgcmVjZW50IGV2ZW50cyB8IFJvdXRpbmUgaGVhbHRoIGNoZWNr" + - "czsgYC0td2F0Y2hgIGZvciBsaXZlIHwKfCBgbnZjZi1jbGkgaW5pdGAgfCBNaW50IGFkbWluIHRv" + - "a2VuIGZyb20gQVBJIEtleXMgc2VydmljZSB2aWEgdGhlIHB1YmxpYyBhcGkgZ2F0ZXdheSB8IEJl" + - "Zm9yZSBhbnkgY2x1c3Rlci1tYW5hZ2VtZW50IG9wZXJhdGlvbjsgaWRlbXBvdGVudCB8CnwgYG52" + - "Y2YtY2xpIGNsdXN0ZXIgcmVnaXN0ZXIgLS1uYW1lPVggLS1uY2EtaWQ9WSAtLXJlZ2lvbj1aIFst" + - "LWlnbm9yZS1leGlzdGluZ11gIHwgUmVnaXN0ZXIgYSBjbHVzdGVyIEpXS1MrT0lEQyBpc3N1ZXIg" + - "d2l0aCBJQ01TIHwgU3RhbmRhbG9uZSByZWdpc3RlciAod2l0aG91dCBjb21wdXRlLXBsYW5lIGlu" + - "c3RhbGwpIHwKfCBgbnZjZi1jbGkgY2x1c3RlciBsaXN0LXJlZ2lzdGVyZWQgLS1uY2EtaWQ9WSBb" + - "LS1pY21zLXVybD1VUkxdYCB8IExpc3Qgc2VsZi1ob3N0ZWQgY2x1c3RlciByZWdpc3RyYXRpb25z" + - "IGZyb20gSUNNUyB8IENoZWNrIHJlZ2lzdGVyZWQgY29tcHV0ZS1wbGFuZSBuYW1lcyBhbmQgSURz" + - "IHdpdGggdGhlIGFkbWluIHRva2VuIHwKfCBgbnZjZi1jbGkgY2x1c3RlciByb3RhdGUgLS1jbHVz" + - "dGVyLWlkPUlEYCB8IFJvdGF0ZSBjbHVzdGVyIEpXS1MgaW4gSUNNUyB8IFdoZW4gTlZDQSdzIEs4" + - "cyBzaWduaW5nIGtleSBjaGFuZ2VkIGFuZCBQU0FUIHZlcmlmaWNhdGlvbiBzdGFydGVkIDQwMS1p" + - "bmcgfAp8IGBudmNmLWNsaSBjbHVzdGVyIGRlbGV0ZSAtLWNsdXN0ZXItaWQ9SURgIHwgUmVtb3Zl" + - "IGNsdXN0ZXIgcmVnaXN0cmF0aW9uIGZyb20gSUNNUyB8ICoqQ29uZmlybSB3aXRoIHVzZXIuKiog" + - "RGVzdHJveXMgSUNNUyBzdGF0ZSBmb3IgdGhlIGNsdXN0ZXIuIHwKfCBgbnZjZi1jbGkgYXBpLWtl" + - "eSBnZW5lcmF0ZSAtLWRlc2NyaXB0aW9uPSLigKYiIC0tZXhwaXJlcy1pbj0xaGAgfCBNaW50IGJv" + - "dGggYSBmdW5jdGlvbiBBUEkga2V5IGFuZCBhIHRhc2sgQVBJIGtleSAoZGVmYXVsdCkgfCBCZWZv" + - "cmUgaW52b2tpbmcgZnVuY3Rpb25zIG9yIGNyZWF0aW5nIHRhc2tzOyBydW4gYWZ0ZXIgZXZlcnkg" + - "YGluaXRgIHwKfCBgbnZjZi1jbGkgYXBpLWtleSBnZW5lcmF0ZSAtLWZvciBmdW5jdGlvbiAtLWRl" + - "c2NyaXB0aW9uPSLigKYiYCB8IE1pbnQgYSBmdW5jdGlvbiBBUEkga2V5IG9ubHkgKGBpbnZva2Vf" + - "ZnVuY3Rpb25gIHNjb3BlKSB8IFdoZW4gb25seSBmdW5jdGlvbiBpbnZvY2F0aW9uIGlzIG5lZWRl" + - "ZCB8CnwgYG52Y2YtY2xpIGFwaS1rZXkgZ2VuZXJhdGUgLS1mb3IgdGFzayAtLWRlc2NyaXB0aW9u" + - "PSLigKYiYCB8IE1pbnQgYSB0YXNrIEFQSSBrZXkgb25seSB8IFdoZW4gb25seSB0YXNrIG9wZXJh" + - "dGlvbnMgYXJlIG5lZWRlZCB8CnwgYG52Y2YtY2xpIGZ1bmN0aW9uIGNyZWF0ZSAtLWlucHV0LWZp" + - "bGU9PGpzb24+YCB8IENyZWF0ZSBmdW5jdGlvbiBtZXRhZGF0YSBpbiBJQ01TIHwgRmlyc3Qgc3Rl" + - "cCBvZiBhbnkgZnVuY3Rpb24gZGVwbG95OyB1c2UgYGZ1bmN0aW9uVHlwZTogIkxMTSJgIGFuZCBg" + - "bW9kZWxzW10ubGxtQ29uZmlnYCBmb3IgTExNIGZ1bmN0aW9ucyB8CnwgYG52Y2YtY2xpIGZ1bmN0" + - "aW9uIGRlcGxveSBjcmVhdGUgLS1pbnB1dC1maWxlPTxqc29uPmAgfCBTY2hlZHVsZSBhIGRlcGxv" + - "eW1lbnQgb2YgYSBjcmVhdGVkIGZ1bmN0aW9uIHwgV2FpdHMgZm9yIEFDVElWRSBiZWZvcmUgcmV0" + - "dXJuaW5nICh0aW1lb3V0IDkwMHMpIHwKfCBgbnZjZi1jbGkgZnVuY3Rpb24gaW52b2tlIC0taW5w" + - "dXQtZmlsZT08anNvbj5gIHwgSW52b2tlIGEgZGVwbG95ZWQgZnVuY3Rpb24gfCBSZXF1aXJlcyBB" + - "UEkga2V5IChub3QgYWRtaW4gdG9rZW4pIHwKfCBgbnZjZi1jbGkgZnVuY3Rpb24gZGVsZXRlIC0t" + - "ZnVuY3Rpb24taWQ9SUQgLS12ZXJzaW9uLWlkPVZJRGAgfCBSZW1vdmUgYSBmdW5jdGlvbiBhbmQg" + - "aXRzIGRlcGxveW1lbnQgfCAqKkNvbmZpcm0gd2l0aCB1c2VyLioqIHwKfCBgbnZjZi1jbGkgdGFz" + - "ayBjcmVhdGUgLS1uYW1lPVggLS1ncHU9SDEwMCAtLWluc3RhbmNlLXR5cGU9WSAtLWltYWdlPVpg" + - "IHwgU3VibWl0IGEgY29udGFpbmVyIHRhc2s7IHNhdmVzIHRhc2sgSUQgdG8gQ0xJIHN0YXRlIHwg" + - "UmVxdWlyZXMgdGFzayBBUEkga2V5OyBzZXQgYE5WQ0ZfQkFTRV9OVkNUX1VSTGAgdG8gdGhlIE5W" + - "Q1QgZ2F0ZXdheSBlbmRwb2ludCB8CnwgYG52Y2YtY2xpIHRhc2sgY3JlYXRlIC0tbmFtZT1YIC0t" + - "Z3B1PUgxMDAgLS1pbnN0YW5jZS10eXBlPVkgLS1oZWxtLWNoYXJ0PVpgIHwgU3VibWl0IGEgSGVs" + - "bSB0YXNrIHwgU2FtZSBhcyBjb250YWluZXIgdGFzazsgYC0taGVsbS1jaGFydGAgcmVwbGFjZXMg" + - "YC0taW1hZ2VgIHwKfCBgbnZjZi1jbGkgdGFzayBjcmVhdGUgLS1pbnB1dC1maWxlPTxqc29uPmAg" + - "fCBTdWJtaXQgYSB0YXNrIGZyb20gYSBKU09OIGNvbmZpZyBmaWxlIHwgUmVjb21tZW5kZWQgZm9y" + - "IHJlcGVhdGFibGUgY29uZmlndXJhdGlvbnMgfAp8IGBudmNmLWNsaSB0YXNrIGxpc3QgWy0tc3Rh" + - "dHVzPVFVRVVFRFx8UlVOTklOR1x8Q09NUExFVEVEXWAgfCBMaXN0IHRhc2tzLCBvcHRpb25hbGx5" + - "IGZpbHRlcmVkIGJ5IHN0YXR1cyB8IHwKfCBgbnZjZi1jbGkgdGFzayBnZXQgW3Rhc2tJZF1gIHwg" + - "R2V0IGRldGFpbHMgZm9yIGEgdGFzazsgdXNlcyBzYXZlZCB0YXNrIElEIGlmIG9taXR0ZWQgfCB8" + - "CnwgYG52Y2YtY2xpIHRhc2sgZXZlbnRzIFt0YXNrSWRdYCB8IFN0cmVhbSBsaWZlY3ljbGUgZXZl" + - "bnRzIGZvciBhIHRhc2sgfCB8CnwgYG52Y2YtY2xpIHRhc2sgcmVzdWx0cyBbdGFza0lkXWAgfCBM" + - "aXN0IHJlc3VsdCBhcnRpZmFjdHMgZm9yIGEgY29tcGxldGVkIHRhc2sgfCBSZXN1bHQgdXBsb2Fk" + - "IG5vdCB5ZXQgc3VwcG9ydGVkOyByZXR1cm5zIGVtcHR5IGxpc3QgZm9yIGBOT05FYCBzdHJhdGVn" + - "eSB8CnwgYG52Y2YtY2xpIHRhc2sgY2FuY2VsIFt0YXNrSWRdYCB8IENhbmNlbCBhIHF1ZXVlZCBv" + - "ciBydW5uaW5nIHRhc2sgfCB8CnwgYG52Y2YtY2xpIHRhc2sgZGVsZXRlIFt0YXNrSWRdYCB8IERl" + - "bGV0ZSBhIHRhc2sgcmVjb3JkIHwgKipDb25maXJtIHdpdGggdXNlci4qKiB8CnwgYG52Y2YtY2xp" + - "IHRhc2sgdXBkYXRlLXNlY3JldHMgW3Rhc2tJZF0gLS1zZWNyZXRzIE5BTUU9dmFsdWVgIHwgVXBk" + - "YXRlIHNlY3JldHMgb24gYSB0YXNrOyBzdXBwbGllZCBzZWNyZXRzIGFyZSBhZGRlZCBvciB1cGRh" + - "dGVkIGJ5IG5hbWUsIGV4aXN0aW5nIHNlY3JldHMgbm90IGluIHRoZSByZXF1ZXN0IGFyZSBwcmVz" + - "ZXJ2ZWQgfCB8CnwgYG52Y2YtY2xpIHRhc2sgYnVsayAtLXRhc2staWRzPUlEMSxJRDJgIHwgRmV0" + - "Y2ggZGV0YWlscyBmb3IgbXVsdGlwbGUgdGFza3MgaW4gb25lIGNhbGwgfCB8Cgo+IFJlZ2lzdHJ5" + - "IGNyZWRlbnRpYWwgcHJvcGFnYXRpb246IGFmdGVyIGByZWdpc3RyeS1jcmVkZW50aWFsIGFkZGAv" + - "YHVwZGF0ZWAvYGRlbGV0ZWAsIHRhc2sgY3JlYXRpb24gY2FuIGtlZXAgdXNpbmcgdGhlIHByZXZp" + - "b3VzIGNyZWRlbnRpYWwgZm9yIHVwIHRvIGFib3V0IDUgbWludXRlcyAoTlZDVCBjYWNoZXMgYWNj" + - "b3VudCBjcmVkZW50aWFscyBhdCBgbnZjdC5udmNmLmNhY2hlLXR0bGAsIGRlZmF1bHQgYFBUNU1g" + - "KSwgZXZlbiB0aG91Z2ggYHJlZ2lzdHJ5LWNyZWRlbnRpYWwgbGlzdGAvYGdldGAgc2hvdyB0aGUg" + - "bmV3IHZhbHVlIGltbWVkaWF0ZWx5LiBXYWl0IGFib3V0IDUgbWludXRlcywgb3IgYGt1YmVjdGwg" + - "LW4gbnZjZiByb2xsb3V0IHJlc3RhcnQgZGVwbG95bWVudC9udmN0LWFwaWAgdG8gYXBwbHkgaW1t" + - "ZWRpYXRlbHkuIFNlZSBbcmVmZXJlbmNlL3Ryb3VibGVzaG9vdGluZy5tZF0ocmVmZXJlbmNlL3Ry" + - "b3VibGVzaG9vdGluZy5tZCkuCgojIyBMTE0gZnVuY3Rpb24gdHlwZQoKVXNlIGBmdW5jdGlvblR5" + - "cGU6ICJMTE0iYCBmb3IgT3BlbkFJLWNvbXBhdGlibGUgbW9kZWxzIHNlcnZlZCB0aHJvdWdoIHRo" + - "ZSBzZWxmLW1hbmFnZWQgTExNIEdhdGV3YXkuIExMTSBmdW5jdGlvbnMgbXVzdCBkZWZpbmUgYXQg" + - "bGVhc3Qgb25lIGBtb2RlbHNbXWAgZW50cnkgd2l0aCBgbmFtZWAgYW5kIGBsbG1Db25maWcudXJp" + - "c2A7IHRoZSBzdXBwb3J0ZWQgdXBzdHJlYW0gcGF0aHMgYXJlIGAvdjEvY2hhdC9jb21wbGV0aW9u" + - "c2AsIGAvdjEvcmVzcG9uc2VzYCwgYW5kIGAvdjEvZW1iZWRkaW5nc2AuCgpVc2UgYC9oZWFsdGhg" + - "IG9uIHBvcnQgYDgwMDBgIGFzIHRoZSBkZWZhdWx0IE9wZW5BSS1jb21wYXRpYmxlIGNvbnRhaW5l" + - "ciBoZWFsdGggcHJvYmUgdW5sZXNzIHRoZSBpbWFnZSBleHBvc2VzIGEgZGlmZmVyZW50IHJlYWRp" + - "bmVzcyBwYXRoLgoKTExNIGZ1bmN0aW9uIHR5cGUgaXMgaW5kZXBlbmRlbnQgb2Ygd29ya2xvYWQg" + - "cGFja2FnaW5nLiBGb3IgYSBIZWxtLWNoYXJ0IGJhY2tlZCBMTE0gZnVuY3Rpb24sIGtlZXAgYGZ1" + - "bmN0aW9uVHlwZTogIkxMTSJgIGFuZCBgbW9kZWxzW10ubGxtQ29uZmlnYCwgdGhlbiBzZXQgYGhl" + - "bG1DaGFydGAgYW5kIGBoZWxtQ2hhcnRTZXJ2aWNlTmFtZWAgaW4gdGhlIGNyZWF0ZSByZXF1ZXN0" + - "LiBgaGVsbUNoYXJ0U2VydmljZU5hbWVgIG11c3QgbWF0Y2ggdGhlIEt1YmVybmV0ZXMgU2Vydmlj" + - "ZSBleHBvc2VkIGJ5IHRoZSBjaGFydCwgYW5kIGBpbmZlcmVuY2VQb3J0YCBtdXN0IGJlIHRoYXQg" + - "U2VydmljZSBwb3J0LgoKSW52b2NhdGlvbiB1c2VzIHRoZSBMTE0gcm91dGUsIGZvciBleGFtcGxl" + - "IGBodHRwczovL2xsbS5pbnZvY2F0aW9uLjxkb21haW4+L3YxL2NoYXQvY29tcGxldGlvbnNgLiBU" + - "aGUgT3BlbkFJIGBtb2RlbGAgdmFsdWUgbXVzdCBiZSBgPGZ1bmN0aW9uLWlkPi88bW9kZWwtbmFt" + - "ZT5gOyB0aGUgZnVuY3Rpb24gSUQgaXMgdGhlIHJvdXRpbmcga2V5IGFuZCB0aGUgbW9kZWwgbmFt" + - "ZSBpcyBmb3J3YXJkZWQgdXBzdHJlYW0uCgpVcGRhdGUgbXV0YWJsZSBwZXItbW9kZWwgcm91dGlu" + - "ZyBzZXR0aW5ncyB3aXRoIGBudmNmLWNsaSBmdW5jdGlvbiB1cGRhdGUgLS1sbG0tbW9kZWwtdXBk" + - "YXRlPSduYW1lPTxtb2RlbD4scm91dGluZ01ldGhvZD08bWV0aG9kPix0b2tlblJhdGVMaW1pdD08" + - "bGltaXQ+J2AsIG9yIHB1dCB0aGUgc2FtZSBmaWVsZHMgdW5kZXIgYG1vZGVsVXBkYXRlc1tdLmxs" + - "bUNvbmZpZ2AgaW4gYW4gdXBkYXRlIEpTT04gZmlsZS4gU2VlIFtyZWZlcmVuY2UvZmxhZ3MubWRd" + - "KHJlZmVyZW5jZS9mbGFncy5tZCkgZm9yIHRoZSByb3V0aW5nIG1ldGhvZCBmb3JtYXQuIGB0b2tl" + - "blJhdGVMaW1pdGAgc3VwcG9ydHMgcG9zaXRpdmUgaW50ZWdlciBsaW1pdHMgZm9yIGBTYCwgYE1g" + - "LCBgSGAsIGBEYCwgYW5kIGBXYDsgdXNlIEpTT04gaW5wdXQgZm9yIGNvbWJpbmVkIGxpbWl0cyBz" + - "dWNoIGFzIGAxMDAwLVMsNTAwMC1NLDEwMDAwMC1ILDUwMDAwMC1ELDEwMDAwMDAtV2AuIERvIG5v" + - "dCBpbmNsdWRlIGB1cmlzYCBpbiBtb2RlbCB1cGRhdGVzLgoKRm9yIGAvdjEvcmVzcG9uc2VzYCwg" + - "dGhlIGdhdGV3YXkgcHJveGllcyB0aGUgbmF0aXZlIFJlc3BvbnNlcyBwYXRoIHVwc3RyZWFtLCBy" + - "ZWxheXMgU1NFIHRvIHN0cmVhbWluZyBjbGllbnRzLCBhbmQgYWdncmVnYXRlcyB0aGUgdGVybWlu" + - "YWwgSlNPTiByZXNwb25zZSBmb3Igbm9uLXN0cmVhbWluZyBjbGllbnRzLiBGb3IgYC92MS9lbWJl" + - "ZGRpbmdzYCwgaW5wdXQgbWF5IGJlIGEgc3RyaW5nIG9yIHN0cmluZyBhcnJheSwgbXVzdCBiZSBu" + - "b24tZW1wdHksIGFuZCBtYXkgY29udGFpbiBhdCBtb3N0IDIwNDggZW50cmllcy4KClNlc3Npb24g" + - "c3RpY2tpbmVzcyB1c2VzIGB4LW11bHRpLXR1cm4tc2Vzc2lvbi1pZGAgZm9yIGNoYXQgY29tcGxl" + - "dGlvbnMgYW5kIFJlc3BvbnNlcyBBUEkgcmVxdWVzdHMgb25seS4gRW1iZWRkaW5ncyByZXF1ZXN0" + - "cyBkbyBub3QgdXNlIHN0aWNraW5lc3MuCgojIyBDb21tb24gd29ya2Zsb3dzCgpGb3Igc3RlcC1i" + - "eS1zdGVwIHBsYXlib29rcywgbG9hZCB0aGUgcHJvbXB0IHRoYXQgbWF0Y2hlcyB0aGUgdXNlcidz" + - "IGludGVudDoKCi0gKipJbnN0YWxsIGZyb20gc2NyYXRjaC4qKiBbcHJvbXB0cy9pbnN0YWxsLWZy" + - "b20tc2NyYXRjaC5tZF0ocHJvbXB0cy9pbnN0YWxsLWZyb20tc2NyYXRjaC5tZCkg4oCUIGszZCBj" + - "bHVzdGVyIOKGkiBwcmVmbGlnaHQg4oaSIHVwIOKGkiBkZXBsb3kgYSBzbW9rZSBmdW5jdGlvbi4K" + - "LSAqKkFkZCBhIG5ldyBjb21wdXRlIHBsYW5lLioqIFtwcm9tcHRzL2FkZC1jb21wdXRlLXBsYW5l" + - "Lm1kXShwcm9tcHRzL2FkZC1jb21wdXRlLXBsYW5lLm1kKSDigJQgc3BsaXQtY2x1c3RlciBgdXBg" + - "IGFnYWluc3QgYW4gZXhpc3RpbmcgY29udHJvbCBwbGFuZS4KLSAqKkRlcGxveSBhbmQgaW52b2tl" + - "IGEgZnVuY3Rpb24uKiogW3Byb21wdHMvZGVwbG95LWFuZC1pbnZva2UubWRdKHByb21wdHMvZGVw" + - "bG95LWFuZC1pbnZva2UubWQpIOKAlCBjcmVhdGUg4oaSIGRlcGxveSDihpIgQVBJIGtleSDihpIg" + - "aW52b2tlLCBpbmNsdWRpbmcgdGhlIExMTSBjcmVhdGUvaW52b2tlIHZhcmlhbnQuCi0gKipEaWFn" + - "bm9zZSBhIGZhaWxlZCBpbnN0YWxsLioqIFtwcm9tcHRzL2RpYWdub3NlLWZhaWxlZC1pbnN0YWxs" + - "Lm1kXShwcm9tcHRzL2RpYWdub3NlLWZhaWxlZC1pbnN0YWxsLm1kKSDigJQgYHN0YXR1cyAtLWpz" + - "b25gIOKGkiBpZGVudGlmeSBmYWlsZWQgY29tcG9uZW50IOKGkiBrdWJlY3RsIGRlc2NyaWJlIOKG" + - "kiByZW1lZGlhdGlvbi4KLSAqKlJvdGF0ZSBKV0tTLioqIFtwcm9tcHRzL3JvdGF0ZS1jbHVzdGVy" + - "LWp3a3MubWRdKHByb21wdHMvcm90YXRlLWNsdXN0ZXItandrcy5tZCkg4oCUIHdoZW4gUFNBVCBh" + - "dXRoIHN0YXJ0cyBmYWlsaW5nLgotICoqVGVhciBkb3duLioqIFtwcm9tcHRzL3RlYXJkb3duLm1k" + - "XShwcm9tcHRzL3RlYXJkb3duLm1kKSDigJQgYGRvd24gLS1wbGFuLW9ubHlgIGZpcnN0LCB0aGVu" + - "IHJlYWwgcnVuLiBgZG93biAtLWNsdXN0ZXItbmFtZT1YYCBmb3Igb25lIGNvbXB1dGUgcGxhbmUg" + - "KG9yY2hlc3RyYXRvcjogZHJhaW4gKyB1bmluc3RhbGwgKyBjbHVzdGVyIGRlbGV0ZSk7IGB1bmlu" + - "c3RhbGwgLS1jb250cm9sLXBsYW5lYCBmb3IgdGhlIGNvbnRyb2wgcGxhbmUgKHBlci1wbGFuZSBw" + - "cmltaXRpdmUpOyBgZG93biAtLWFsbCAtLWNvbmZpcm1gIGZvciBldmVyeXRoaW5nOyBgdW5pbnN0" + - "YWxsIC0tbm8tYXBwbHkgPHBsYW5lPiB8IGt1YmVjdGwgZGVsZXRlIC1mIC1gIGZvciBHaXRPcHMu" + - "Ci0gKipDcmVhdGUgYW5kIHJ1biBhIHRhc2suKiogW3Byb21wdHMvY3JlYXRlLWFuZC1ydW4tdGFz" + - "ay5tZF0ocHJvbXB0cy9jcmVhdGUtYW5kLXJ1bi10YXNrLm1kKSDigJQgbWludCBBUEkga2V5cyDi" + - "hpIgdGFzayBjcmVhdGUgKGNvbnRhaW5lciBvciBIZWxtKSDihpIgbW9uaXRvciB3aXRoIGB0YXNr" + - "IGdldGAgLyBgdGFzayBldmVudHNgIOKGkiByZXRyaWV2ZSByZXN1bHRzIOKGkiBjbGVhbnVwLgoK" + - "IyMgUmVmZXJlbmNlCgotIFtyZWZlcmVuY2UvY29tbWFuZHMubWRdKHJlZmVyZW5jZS9jb21tYW5k" + - "cy5tZCkg4oCUIGZ1bGwgc3ViY29tbWFuZCBjaGVhdCBzaGVldAotIFtyZWZlcmVuY2UvZmxhZ3Mu" + - "bWRdKHJlZmVyZW5jZS9mbGFncy5tZCkg4oCUIGdsb2JhbCArIHBlci1jb21tYW5kIGZsYWdzCi0g" + - "W3JlZmVyZW5jZS9leGl0LWNvZGVzLm1kXShyZWZlcmVuY2UvZXhpdC1jb2Rlcy5tZCkg4oCUIHdo" + - "YXQgZWFjaCBub24temVybyBleGl0IG1lYW5zCi0gW3JlZmVyZW5jZS90cm91Ymxlc2hvb3Rpbmcu" + - "bWRdKHJlZmVyZW5jZS90cm91Ymxlc2hvb3RpbmcubWQpIOKAlCBrbm93biBlcnJvcnMg4oaSIHJl" + - "bWVkaWF0aW9uIHRhYmxlCgojIyBTYWZldHkgcnVsZXMg4oCUIENSSVRJQ0FMCgoqKk5FVkVSIGRv" + - "IHRoZXNlIHdpdGhvdXQgZXhwbGljaXQgdXNlciBjb25maXJtYXRpb246KioKCi0gYG52Y2YtY2xp" + - "IHNlbGYtaG9zdGVkIGRvd25gIG9yIGB1bmluc3RhbGxgIGluIGFueSBmb3JtIOKAlCBkZXN0cnVj" + - "dGl2ZS4gKipBTFdBWVMgcnVuIHdpdGggYC0tcGxhbi1vbmx5YCAoYGRvd25gKSBvciBgLS1uby1h" + - "cHBseWAgKGB1bmluc3RhbGxgKSBmaXJzdCoqIGFuZCBzaG93IHRoZSB1c2VyIHdoYXQgd291bGQg" + - "aGFwcGVuLiBTdGF0ZSB3aGljaCBjb21wdXRlIHBsYW5lKHMpIGFuZCB3aGV0aGVyIHBlcnNpc3Rl" + - "bnQgc3RhdGUgd291bGQgYmUgd2lwZWQuCi0gYG52Y2YtY2xpIHNlbGYtaG9zdGVkIGRvd24gLS1y" + - "ZW1vdmUtcGVyc2lzdGVudGAgKG9yIGB1bmluc3RhbGwgLS1yZW1vdmUtcGVyc2lzdGVudGApIOKA" + - "lCBkZWxldGVzIENhc3NhbmRyYSByb3dzLCBPcGVuQmFvIHNlYWwga2V5cywgc3ItZGVmYXVsdCB1" + - "c2VyIGRhdGEuICoqTG9zcyBpcyB1bnJlY292ZXJhYmxlLioqIENvbmZpcm0gZXhwbGljaXRseSB0" + - "aGF0IHRoaXMgaXMgd2hhdCB0aGUgdXNlciB3YW50cy4KLSBgbnZjZi1jbGkgc2VsZi1ob3N0ZWQg" + - "dW5pbnN0YWxsIC0tY29udHJvbC1wbGFuZSAtLWZvcmNlLXdpdGgtcmVnaXN0ZXJlZC1jbHVzdGVy" + - "c2Ag4oCUIG9ycGhhbnMgZXZlcnkgcmVnaXN0ZXJlZCBjb21wdXRlIHBsYW5lIChQU0FUIGF1dGgg" + - "YnJlYWtzIGltbWVkaWF0ZWx5KS4gU3RhdGUgdGhlIGNvbnNlcXVlbmNlIGJlZm9yZSBwYXNzaW5n" + - "IHRoaXMgZmxhZy4KLSBgbnZjZi1jbGkgc2VsZi1ob3N0ZWQgZG93biAtLWFsbGAgbnVrZXMgZXZl" + - "cnl0aGluZy4gQWx3YXlzIHNob3cgdGhlIHJlZ2lzdGVyZWQgY2x1c3RlcnMgKGBudmNmLWNsaSBj" + - "bHVzdGVyIGxpc3QtcmVnaXN0ZXJlZCAtLW5jYS1pZD08bmNhLWlkPmApIGFuZCBnZXQgY29uZmly" + - "bWF0aW9uLgotIGBudmNmLWNsaSBjbHVzdGVyIGRlbGV0ZWAg4oCUIHJlbW92ZXMgdGhlIGNsdXN0" + - "ZXIncyBJQ01TIHJlZ2lzdHJhdGlvbjsgdGhlIGNvbXB1dGUgcGxhbmUgaW1tZWRpYXRlbHkgc3Rv" + - "cHMgYmVpbmcgYWJsZSB0byBhdXRoZW50aWNhdGUuCi0gYG52Y2YtY2xpIGZ1bmN0aW9uIGRlbGV0" + - "ZWAg4oCUIHJlbW92ZXMgYSBmdW5jdGlvbiBhbmQgYW55IGFjdGl2ZSBkZXBsb3ltZW50LgotIGBu" + - "dmNmLWNsaSB0YXNrIGRlbGV0ZWAg4oCUIHBlcm1hbmVudGx5IHJlbW92ZXMgdGhlIHRhc2sgcmVj" + - "b3JkLiBTdG9wLCBzdGF0ZSB0aGUgdGFzayBJRCBhbmQgY3VycmVudCBzdGF0dXMsIHRoZW4gd2Fp" + - "dCBmb3IgYSBzdWJzZXF1ZW50IHVzZXIgcmVwbHkgdGhhdCBleHBsaWNpdGx5IGNvbmZpcm1zIGRl" + - "bGV0aW9uIG9mIHRoYXQgc3BlY2lmaWMgdGFzayBiZWZvcmUgcnVubmluZyB0aGlzIGNvbW1hbmQu" + - "IERvIG5vdCB0cmVhdCB0aGUgdXNlcidzIG9yaWdpbmFsIGRlbGV0ZSByZXF1ZXN0IGFzIGNvbmZp" + - "cm1hdGlvbi4KLSBBbnkgcmF3IGBoZWxtIHVuaW5zdGFsbGAgb3IgYGt1YmVjdGwgZGVsZXRlIHB2" + - "Yy9wdmAg4oCUIGFmZmVjdHMgcGVyc2lzdGVudCBzdGF0ZS4gUHJlZmVyIGBudmNmLWNsaSBzZWxm" + - "LWhvc3RlZCBkb3duYCAob3JjaGVzdHJhdG9yKSBvciBgdW5pbnN0YWxsYCAocGVyLXBsYW5lKSB3" + - "aGljaCBoYW5kbGUgdGhpcyBzYWZlbHkuCi0gQW55IGAtLWZvcmNlYCBmbGFnIChgLS1mb3JjZS13" + - "aXRoLXJlZ2lzdGVyZWQtY2x1c3RlcnNgLCBgLS1jb25maXJtYCBpbiBub24taW50ZXJhY3RpdmUg" + - "Y29udGV4dHMpLgoKKipBTFdBWVMgZG8gdGhlc2U6KioKCi0gUnVuIGBudmNmLWNsaSBzZWxmLWhv" + - "c3RlZCBzdGF0dXNgIGJlZm9yZSBhc3N1bWluZyBhIGNsdXN0ZXIgZXhpc3RzIC8gaXMgaGVhbHRo" + - "eS4KLSBTaG93IHRoZSBwbGFubmVkIGFjdGlvbiAoY2x1c3RlciBuYW1lLCBmdW5jdGlvbiBuYW1l" + - "LCBHUFUgdHlwZSwgY29zdCBpZiBrbm93bikgYmVmb3JlIGNyZWF0aW5nLgotIEJlZm9yZSBjcmVh" + - "dGluZyBvciBkZXBsb3lpbmcgYSBjb250YWluZXIgb3IgTExNIGZ1bmN0aW9uLCBjb25maXJtIHRo" + - "ZSBleGFjdCBmdW5jdGlvbiBuYW1lIGFuZCBjb250YWluZXIgaW1hZ2Ugd2l0aCB0aGUgdXNlci4g" + - "Rm9yIExMTSBmdW5jdGlvbnMsIGFsc28gY29uZmlybSB0aGUgZXhhY3QgbW9kZWwgbmFtZSB1c2Vk" + - "IGluIGBtb2RlbHNbXS5uYW1lYCBhbmQgT3BlbkFJIGBtb2RlbDogIjxmdW5jdGlvbi1pZD4vPG1v" + - "ZGVsLW5hbWU+ImAuIElmIGFueSB2YWx1ZSBpcyBtaXNzaW5nLCBhc2sgdGhlIHVzZXIgaW5zdGVh" + - "ZCBvZiBndWVzc2luZyBvciBzdWJtaXR0aW5nIGV4YW1wbGUgcGxhY2Vob2xkZXJzLgotIENvbmZp" + - "cm0gZXhhY3QgcmVzb3VyY2UgbmFtZXMgYmVmb3JlIGRlbGV0aW9uLiBNYXRjaCBhZ2FpbnN0IGBj" + - "bHVzdGVyIGxpc3QtcmVnaXN0ZXJlZGAgLyBgZnVuY3Rpb24gbGlzdGAgb3V0cHV0LgotIEluIENJ" + - "IC8gbm9uLWludGVyYWN0aXZlIGNvbnRleHRzLCB1c2UgYC0tbm9uLWludGVyYWN0aXZlIC0tdG9r" + - "ZW49JEpXVGAuIE5ldmVyIHByb3Bvc2UgaW50ZXJhY3RpdmUgYG52Y2YtY2xpIGluaXRgIHdoZW4g" + - "YCRDSWAgaXMgc2V0LgoKKipORVZFUiBwYXN0ZSB0aGVzZSBpbnRvIGNoYXQgLyBsb2dzIC8gZmVl" + - "ZGJhY2s6KioKCi0gQWRtaW4gdG9rZW5zIChmdWxsIEpXVCkuIFNob3cgdGhlIGZpcnN0IDggY2hh" + - "cnMgKyBgLi4uYCBpZiB5b3UgbXVzdCByZWZlcmVuY2Ugb25lLgotIEFQSSBrZXlzIChgbnZhcGkt" + - "4oCmYCkuCi0gQ29udGVudHMgb2YgYH4vLm52Y2YtY2xpLnN0YXRlYCBvciBhbnkga3ViZWNvbmZp" + - "Zy4KLSBBbnkgZGF0YSBtYXJrZWQgc2VjcmV0IGluIGhlbG1maWxlIHZhbHVlcy4KCj4gTm90ZSAo" + - "b25seSB3aGVuIG9wZXJhdGluZyBtb3JlIHRoYW4gb25lIGNsdXN0ZXIgZnJvbSB0aGUgc2FtZSBt" + - "YWNoaW5lKTogdGhlIENMSSdzIGRlZmF1bHQKPiBjb25maWcgKGB+Ly5udmNmLWNsaS55YW1sYCkg" + - "YW5kIHN0YXRlIChgfi8ubnZjZi1jbGkuc3RhdGVgKSBhcmUgYSBzaW5nbGUgc2hhcmVkIHNsb3Qs" + - "IG5vdAo+IHBlci1jbHVzdGVyLiBXaXRoIGEgc2luZ2xlIGNvbnRyb2wgcGxhbmUgKHRoZSBjb21t" + - "b24gY2FzZSkgdGhpcyBuZWVkcyBubyBhdHRlbnRpb24uIElmIHlvdQo+IG1hbmFnZSBtdWx0aXBs" + - "ZSBjbHVzdGVycywgcGFzcyBgLS1jb25maWcgPGNsdXN0ZXI+LnlhbWxgIG9uIGV2ZXJ5IGNvbW1h" + - "bmQgKG9yIGtlZXAgYQo+IGRlbGliZXJhdGVseS1zd2l0Y2hlZCBwZXItY2x1c3RlciBkZWZhdWx0" + - "KTogd2l0aCBubyBgLS1jb25maWdgLCBjb21tYW5kcyB0YXJnZXQgd2hpY2hldmVyCj4gY2x1c3Rl" + - "ciB3YXMgbGFzdCBgaW5pdGAnZCwgYW5kIGBpbml0YCAvIGBhcGkta2V5IGdlbmVyYXRlYCBtdXRh" + - "dGUgdGhhdCBzaGFyZWQgZGVmYXVsdCwgc28gYW4KPiB1bnNjb3BlZCBgYXBpLWtleSBnZW5lcmF0" + - "ZWAgY2FuIG1pbnQgb3Igc3VyZmFjZSBrZXlzIGFnYWluc3QgdGhlIHdyb25nIGNsdXN0ZXIuCgoj" + - "IyBPdXRwdXQgbW9kZXMgKGZvciBhZ2VudCBwaXBpbmcpCgpgbnZjZi1jbGlgIHN1YmNvbW1hbmRz" + - "IHRoYXQgbG9uZy1ydW4gKGB1cGAsIGBzdGF0dXNgLCBgY2hlY2tgKSBhY2NlcHQgZm91ciBvdXRw" + - "dXQgbW9kZXM6CgotICoqYC0tanNvbmAqKiDigJQgSlNPTkwgZXZlbnRzIG9uIHN0ZGVycjsgb25l" + - "IGV2ZW50IHBlciBsaW5lOyBzdGFibGUgc2NoZW1hIChgc2NoZW1hVmVyc2lvbjogMmApLiAqKlVz" + - "ZSB0aGlzIHdoZW4gcnVubmluZyB1bmRlciBhbiBhZ2VudC4qKiBQYXJzZSBsaW5lLWJ5LWxpbmUg" + - "d2l0aCBganEgLWMgLmAgb3IgYGpzb24ubG9hZHMoKWAuCi0gKipgLS1wbGFpbmAqKiDigJQgUGxh" + - "aW4gdGltZXN0YW1wZWQgbGluZXMsIFJGQzMzMzkgVVRDLCBgW05OLzhdYCBwaGFzZSBwcmVmaXg7" + - "IGdyZXAtZnJpZW5kbHkuIERlZmF1bHQgaW4gbm9uLVRUWS4KLSAqKmAtLWFjY2Vzc2libGVgKiog" + - "4oCUIFBsYWluIG91dHB1dCB3aXRob3V0IHNwaW5uZXJzLCB3aXRoIHZlcmJvc2Ugc3RhdGUgbWFy" + - "a2VycyAoYFtjb21wbGV0ZWRdYCwgYFtydW5uaW5nXWAsIGBbcGVuZGluZ11gLCBgW2ZhaWxlZF1g" + - "KS4gRm9yIHNjcmVlbiByZWFkZXJzIGFuZCBjb25zdHJhaW5lZCB0ZXJtaW5hbHMuCi0gKioobm8g" + - "ZmxhZykqKiDigJQgQnViYmxldGVhIFRUWSBkYXNoYm9hcmQuIERlZmF1bHQgaW4gVFRZIOKJpTEw" + - "MMOXMzAuIERvbid0IHVzZSB1bmRlciBhbiBhZ2VudCAoY3Vyc29yLXVwIHNlcXVlbmNlcyBhcmUg" + - "bm9pc3kpLgoKQXV0by1kZXRlY3QgcGlja3MgdGhlIHJpZ2h0IG1vZGUgZm9yIHdoYXRldmVyIHN0" + - "ZG91dC9zdGRlcnIgaXMuIFRoZSBDTEkgYWxzbyBob25vcnMgYE5PX0NPTE9SYCAoYW55IHZhbHVl" + - "IOKGkiBmb3JjZXMgcGxhaW4pLCBgVEVSTT1kdW1iYCAoZm9yY2VzIHBsYWluKSwgYW5kIGBDST10" + - "cnV0aHlgIChmb3JjZXMgcGxhaW4gZXZlbiBvbiBhIGZha2UgVFRZKS4gV2hlbiB0aGUgdGVybWlu" + - "YWwgaXMgc21hbGxlciB0aGFuIDEwMMOXMzAsIHRoZSBidWJibGV0ZWEgcmVuZGVyZXIgZmFsbHMg" + - "YmFjayB0byBhIGNvbXBhY3QgbGF5b3V0LiBFeHBsaWNpdCBgLS1qc29uYCBpcyB0aGUgcmlnaHQg" + - "Y2FsbCBmb3IgYWdlbnQgcGlwaW5nLgoKIyMgT24gZmFpbHVyZQoKYG52Y2YtY2xpYCBmYWlsdXJl" + - "cyBlbWl0IHN0cnVjdHVyZWQgYHBoYXNlX2ZhaWxlZGAgZXZlbnRzIGluIEpTT046CgpgYGBqc29u" + - "CnsiZXZlbnQiOiJwaGFzZV9mYWlsZWQiLCJwaGFzZU51bSI6NCwicGhhc2UiOiJhcHBseS1jcCIs" + - "CiAiZXJyQ2F0ZWdvcnkiOiJoZWxtX2FwcGx5IiwiZXJyTWVzc2FnZSI6ImhlbG0gaW5zdGFsbCBh" + - "cGkta2V5czogdGltZWQgb3V0IiwKICJyZXRyeUNsYXNzIjoiYmFja29mZiIsInJldHJ5QWZ0ZXJT" + - "ZWMiOjYwLAogInJlbWVkaWF0aW9uIjpbImt1YmVjdGwgZGVzY3JpYmUgcG9kIC1uIGNhc3NhbmRy" + - "YS1zeXN0ZW0gY2Fzc2FuZHJhLTAiLAogICAgICAgICAgICAgICAgIlJlLXJ1biB3aXRoIC0tZGVi" + - "dWcgZm9yIHZlcmJvc2UgaGVsbWZpbGUgb3V0cHV0Il0sCiAicmF3Ijp7InN1YnByb2Nlc3MiOiJo" + - "ZWxtZmlsZSIsImV4aXRDb2RlIjoxLCJzdGRlcnJUYWlsIjoi4oCmIiwia3ViZXJuZXRlc1JlYXNv" + - "biI6IkZhaWxlZFNjaGVkdWxpbmcifX0KYGBgCgpUaGUgYHJldHJ5Q2xhc3NgIGZpZWxkIHRlbGxz" + - "IHRoZSBhZ2VudCBob3cgdG8gaGFuZGxlIHRoZSBmYWlsdXJlOgoKfCBgcmV0cnlDbGFzc2AgfCBN" + - "ZWFuaW5nIHwgQWdlbnQgYWN0aW9uIHwKfC0tLXwtLS18LS0tfAp8IGBpbW1lZGlhdGVgIHwgVHJh" + - "bnNpZW50IGJsaXAgfCBSZXRyeSB0aGUgc2FtZSBgdXBgIGNvbW1hbmQgbm93IHwKfCBgYmFja29m" + - "ZmAgfCBSYXRlIGxpbWl0IC8gcGVuZGluZyBvcGVyYXRpb24gfCBXYWl0IGByZXRyeUFmdGVyU2Vj" + - "YCBzZWNvbmRzLCB0aGVuIHJldHJ5IHwKfCBgYWZ0ZXJfcmVtZWRpYXRpb25gIHwgT3BlcmF0b3Ig" + - "bXVzdCBpbnRlcnZlbmUgfCBTdXJmYWNlIGBlcnJNZXNzYWdlYCArIGByZW1lZGlhdGlvbmAsIFNU" + - "T1AuIERvbid0IGF1dG8tcmV0cnkuIHwKfCBgbm9uZWAgfCBOb24tcmV0cnlhYmxlIHwgU2FtZSBh" + - "cyBgYWZ0ZXJfcmVtZWRpYXRpb25gIOKAlCBzdXJmYWNlIGFuZCBzdG9wIHwKfCBgdW5rbm93bmAg" + - "fCBDbGFzc2lmaWVyIHVuc3VyZSB8IFRyZWF0IGNvbnNlcnZhdGl2ZWx5OiBzdXJmYWNlIGFuZCBz" + - "dG9wIHwKCk5ldmVyIHJlLXJ1biB3aXRoIGAtLWZvcmNlYCAobm8gY29tbWFuZCB0YWtlcyBvbmUp" + - "LiBUaGUgYHJhd2AgYmxvY2sgY2FycmllcyB0aGUgdW5kZXJseWluZyBzaWduYWwgKHN1YnByb2Nl" + - "c3MgZXhpdCwgSFRUUCBzdGF0dXMsIEs4cyByZWFzb24pIOKAlCBxdW90ZSByZWxldmFudCBmaWVs" + - "ZHMgd2hlbiBleHBsYWluaW5nIGZhaWx1cmVzIHRvIHRoZSB1c2VyLCBidXQgZG9uJ3QgZHVtcCBg" + - "c3RkZXJyVGFpbGAgdmVyYmF0aW0gaW50byBjaGF0IHdpdGhvdXQgc2Nhbm5pbmcgZm9yIHNlY3Jl" + - "dHMgZmlyc3QuCgojIyBRdWljayBjb21tYW5kIHJlZmVyZW5jZQoKYGBgc2gKbnZjZi1jbGkgc2Vs" + - "Zi1ob3N0ZWQgY2hlY2sgLS1wcmUgICAgICAgICAgICAgICMgcHJlLWZsaWdodApudmNmLWNsaSBz" + - "ZWxmLWhvc3RlZCB1cCAtLWNsdXN0ZXItbmFtZT1OQU1FICAgIyBvbmUtc2hvdCBpbnN0YWxsCm52" + - "Y2YtY2xpIHNlbGYtaG9zdGVkIHN0YXR1cyAgICAgICAgICAgICAgICAgICAjIHNuYXBzaG90Cm52" + - "Y2YtY2xpIHNlbGYtaG9zdGVkIHN0YXR1cyAtLXdhdGNoICAgICAgICAgICAjIGxpdmUKbnZjZi1j" + - "bGkgaW5pdCAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICMgbWludCBhZG1pbiB0b2tl" + - "biAoY2xlYXJzIGFsbCBzYXZlZCBBUEkga2V5cykKbnZjZi1jbGkgY2x1c3RlciByZWdpc3RlciDi" + - "gKYgICAgICAgICAgICAgICAgICAgIyByZWdpc3RlciBjbHVzdGVyCm52Y2YtY2xpIGNsdXN0ZXIg" + - "bGlzdC1yZWdpc3RlcmVkIC0tbmNhLWlkPTxuY2EtaWQ+ICMgbGlzdCBzZWxmLWhvc3RlZCByZWdp" + - "c3RyYXRpb25zCm52Y2YtY2xpIGFwaS1rZXkgZ2VuZXJhdGUgLS1kZXNjcmlwdGlvbj3igKYgICAg" + - "ICMgbWludCBib3RoIGZ1bmN0aW9uIGFuZCB0YXNrIEFQSSBrZXlzIChydW4gYWZ0ZXIgZXZlcnkg" + - "aW5pdCkKbnZjZi1jbGkgYXBpLWtleSBnZW5lcmF0ZSAtLWZvciBmdW5jdGlvbiDigKYgICAgIyBm" + - "dW5jdGlvbiBrZXkgb25seQpudmNmLWNsaSBhcGkta2V5IGdlbmVyYXRlIC0tZm9yIHRhc2sg4oCm" + - "ICAgICAgICAjIHRhc2sga2V5IG9ubHkKbnZjZi1jbGkgZnVuY3Rpb24gY3JlYXRlIC0taW5wdXQt" + - "ZmlsZT3igKYgICAgICAgIyBjcmVhdGUgZnVuY3Rpb24KbnZjZi1jbGkgZnVuY3Rpb24gY3JlYXRl" + - "IC0tZnVuY3Rpb24tdHlwZT1MTE0gLS1sbG0tbW9kZWw9PHNwZWM+ICAjIGNyZWF0ZSBMTE0gZnVu" + - "Y3Rpb24gbWV0YWRhdGEKbnZjZi1jbGkgZnVuY3Rpb24gY3JlYXRlIC0tbmFtZT1sbG0taGVsbSAt" + - "LWluZmVyZW5jZS11cmw9LyAtLWluZmVyZW5jZS1wb3J0PTgwMDAgXAogIC0tZnVuY3Rpb24tdHlw" + - "ZT1MTE0gLS1oZWxtLWNoYXJ0PTxjaGFydD4gLS1oZWxtLWNoYXJ0LXNlcnZpY2U9PHNlcnZpY2U+" + - "IC0tbGxtLW1vZGVsPTxzcGVjPgpudmNmLWNsaSBmdW5jdGlvbiB1cGRhdGUgLS1sbG0tbW9kZWwt" + - "dXBkYXRlPTxzcGVjPiAgIyB1cGRhdGUgTExNIHJvdXRpbmcvdG9rZW4gbGltaXRzCm52Y2YtY2xp" + - "IGZ1bmN0aW9uIGRlcGxveSBjcmVhdGUgLS1pbnB1dC1maWxlPeKApgpudmNmLWNsaSBmdW5jdGlv" + - "biBpbnZva2UgLS1pbnB1dC1maWxlPeKApgoKIyBUYXNrIGNvbW1hbmRzIOKAlCByZXF1aXJlcyBO" + - "VkNGX0JBU0VfTlZDVF9VUkw9aHR0cDovLzxudmN0LWdhdGV3YXk+Cm52Y2YtY2xpIHRhc2sgY3Jl" + - "YXRlIC0tbmFtZT1YIC0tZ3B1PUgxMDAgLS1pbnN0YW5jZS10eXBlPVkgLS1pbWFnZT1aCm52Y2Yt" + - "Y2xpIHRhc2sgY3JlYXRlIC0taW5wdXQtZmlsZT10YXNrLmpzb24gICAjIGZyb20gSlNPTiBjb25m" + - "aWcKbnZjZi1jbGkgdGFzayBsaXN0ICAgICAgICAgICAgICAgICAgICAgICAgICAgICMgbGlzdCBh" + - "bGwgdGFza3MKbnZjZi1jbGkgdGFzayBsaXN0IC0tc3RhdHVzPVJVTk5JTkcgICAgICAgICAgICMg" + - "ZmlsdGVyIGJ5IHN0YXR1cwpudmNmLWNsaSB0YXNrIGdldCBbdGFza0lkXSAgICAgICAgICAgICAg" + - "ICAgICAgIyB0YXNrIGRldGFpbHMKbnZjZi1jbGkgdGFzayBldmVudHMgW3Rhc2tJZF0gICAgICAg" + - "ICAgICAgICAgICMgbGlmZWN5Y2xlIGV2ZW50cwpudmNmLWNsaSB0YXNrIHJlc3VsdHMgW3Rhc2tJ" + - "ZF0gICAgICAgICAgICAgICAgIyByZXN1bHQgYXJ0aWZhY3RzCm52Y2YtY2xpIHRhc2sgY2FuY2Vs" + - "IFt0YXNrSWRdCm52Y2YtY2xpIHRhc2sgZGVsZXRlIFt0YXNrSWRdCmBgYAoKIyMgRmVlZGJhY2sK" + - "CklmIHRoZSB1c2VyIGhpdHMgYSBidWcgb3IgbGltaXRhdGlvbiwgZmlsZSBhbiBpc3N1ZSB0aHJv" + - "dWdoIHRoZSBwcm9qZWN0IHRyYWNrZXIuIERvbid0IGluY2x1ZGUgc2VjcmV0cy4KUEsHCHSy2/1U" + - "YQAAVGEAAFBLAwQUAAgAAAAAACEAAAAAAAAAAAAAAAAAMwAJAGRhdGEvbnZjZi1zZWxmLW1hbmFn" + - "ZWQtY2xpL2V4YW1wbGVzL2NpLXBpcGVsaW5lcy5tZFVUBQABAKbOEiMgQ0kgcGlwZWxpbmUgcGF0" + - "dGVybnMKCkhvdyB0byBkcml2ZSBgbnZjZi1jbGlgIGZyb20gQ0kgKEdpdExhYiBDSSwgR2l0SHVi" + - "IEFjdGlvbnMsIEplbmtpbnMsIEFyZ29DRCkuIFBhdHRlcm5zIHRoYXQgc3VyZmFjZSBmcmVxdWVu" + - "dGx5LgoKIyMgQmFzaWMgaW5zdGFsbCBwaXBlbGluZQoKYGBgeWFtbAojIEdpdExhYiBDSSBleGFt" + - "cGxlCmRlcGxveV9udmNmOgogIHN0YWdlOiBkZXBsb3kKICBpbWFnZTogbnZjci5pby8wNjUxMTU1" + - "MjE1ODY0OTc5L25jcC1kZXYvbnZjZi1jbGk6MS40LjAKICB2YXJpYWJsZXM6CiAgICBLVUJFQ09O" + - "RklHOiAkS1VCRUNPTkZJR19GSUxFCiAgc2NyaXB0OgogICAgLSBudmNmLWNsaSBzZWxmLWhvc3Rl" + - "ZCBjaGVjayAtLXByZSAtLWpzb24gfCBqcSAtZSAnLmV2ZW50ICE9ICJwaGFzZV9mYWlsZWQiJyB8" + - "fCBleGl0IDIKICAgIC0gbnZjZi1jbGkgc2VsZi1ob3N0ZWQgdXAgLS1jbHVzdGVyLW5hbWU9JENM" + - "VVNURVJfTkFNRSAtLXRva2VuPSROVkNGX0FETUlOX0pXVCAtLW5vbi1pbnRlcmFjdGl2ZSAtLWpz" + - "b24KICAgIC0gbnZjZi1jbGkgc2VsZi1ob3N0ZWQgc3RhdHVzIC0tanNvbiB8IGpxIC1lICcudmVy" + - "ZGljdCA9PSAiaGVhbHRoeSInCmBgYAoKTm90ZXM6CgotIGAtLW5vbi1pbnRlcmFjdGl2ZSAtLXRv" + - "a2VuPSRKV1RgIGlzIHJlcXVpcmVkIGluIENJOyBuZXZlciB1c2UgaW50ZXJhY3RpdmUgYGluaXRg" + - "LgotIEFsd2F5cyBgLS1qc29uYCBmb3IgbWFjaGluZS1wYXJzaW5nLgotIEZpbmFsIHN0YXR1cyBj" + - "aGVjayBnYXRlcyBkb3duc3RyZWFtIHN0YWdlcyBvbiBgdmVyZGljdCA9PSAiaGVhbHRoeSJgLgoK" + - "IyMgR2l0T3BzIChBcmdvIC8gRmx1eCkgcGF0dGVybgoKQ0kgZG9lc24ndCBga3ViZWN0bCBhcHBs" + - "eWAg4oCUIGluc3RlYWQsIHJlbmRlciBtYW5pZmVzdHMsIGNvbW1pdCB0aGVtLCBsZXQgdGhlIGNv" + - "bnRyb2xsZXIgYXBwbHkuCgpgYGB5YW1sCnJlbmRlcjoKICBzY3JpcHQ6CiAgICAtIG52Y2YtY2xp" + - "IHNlbGYtaG9zdGVkIGluc3RhbGwgLS1jb250cm9sLXBsYW5lIC0tbm8tYXBwbHkgPiByZW5kZXJl" + - "ZC9jb250cm9sLXBsYW5lLnlhbWwKICAgIC0gbnZjZi1jbGkgc2VsZi1ob3N0ZWQgaW5zdGFsbCAt" + - "LWNvbXB1dGUtcGxhbmUgLS1jbHVzdGVyLW5hbWU9JE5BTUUgLS1uby1hcHBseSA+IHJlbmRlcmVk" + - "L2NvbXB1dGUtcGxhbmUueWFtbAogICAgLSBnaXQgYWRkIHJlbmRlcmVkLyAmJiBnaXQgY29tbWl0" + - "IC1tICJkZXBsb3kgTlZDRiAkVEFHIiAmJiBnaXQgcHVzaApgYGAKClRoZW4gQXJnb0NEIG9yIEZs" + - "dXggd2F0Y2hlcyBgcmVuZGVyZWQvYCBhbmQgYXBwbGllcy4gVGhlIENMSSBuZXZlciB0b3VjaGVz" + - "IHRoZSBjbHVzdGVyIGRpcmVjdGx5LgoKRm9yIHRoZSByZWdpc3RlciBzdGVwICh3aGljaCBtdXRh" + - "dGVzIElDTVMsIG5vdCBqdXN0IEs4cyk6CgpgYGB5YW1sCnJlZ2lzdGVyOgogIHNjcmlwdDoKICAg" + - "IC0gbnZjZi1jbGkgY2x1c3RlciByZWdpc3RlciAtLW5hbWU9JE5BTUUgLS1uY2EtaWQ9JE5DQSAt" + - "LXJlZ2lvbj0kUkVHSU9OIC0taWdub3JlLWV4aXN0aW5nCmBgYAoKYC0taWdub3JlLWV4aXN0aW5n" + - "YCBpcyB0aGUgR2l0T3BzLWZyaWVuZGx5IGlkZW1wb3RlbnQgY29udHJhY3QuCgojIyBNdWx0aS1j" + - "bHVzdGVyIHBpcGVsaW5lCgpXaGVuIHRoZSBwaXBlbGluZSBtYW5hZ2VzIG1hbnkgY29tcHV0ZSBj" + - "bHVzdGVycyBhZ2FpbnN0IG9uZSBjb250cm9sIHBsYW5lOgoKYGBgeWFtbApkZXBsb3lfY29tcHV0" + - "ZV9wbGFuZXM6CiAgcGFyYWxsZWw6CiAgICBtYXRyaXg6CiAgICAgIC0gQ0xVU1RFUjogbmNwLWVh" + - "c3QtMQogICAgICAgIENPTlRFWFQ6IGFkbWluQGdwdS1lYXN0LTEKICAgICAgLSBDTFVTVEVSOiBu" + - "Y3Atd2VzdC0xCiAgICAgICAgQ09OVEVYVDogYWRtaW5AZ3B1LXdlc3QtMQogICAgICAtIENMVVNU" + - "RVI6IG5jcC1ldS0xCiAgICAgICAgQ09OVEVYVDogYWRtaW5AZ3B1LWV1LTEKICBzY3JpcHQ6CiAg" + - "ICAtIG52Y2YtY2xpIHNlbGYtaG9zdGVkIHVwCiAgICAgICAgLS1jbHVzdGVyLW5hbWU9JENMVVNU" + - "RVIKICAgICAgICAtLWNvbXB1dGUtcGxhbmUtY29udGV4dD0kQ09OVEVYVAogICAgICAgIC0taWNt" + - "cy11cmw9JElDTVNfUFVCTElDX1VSTAogICAgICAgIC0tdG9rZW49JE5WQ0ZfQURNSU5fSldUCiAg" + - "ICAgICAgLS1ub24taW50ZXJhY3RpdmUKICAgICAgICAtLWpzb24KYGBgCgpFYWNoIHBhcmFsbGVs" + - "IGpvYiByZWdpc3RlcnMgKyBpbnN0YWxscyBvbmUgY29tcHV0ZSBwbGFuZSBhZ2FpbnN0IHRoZSBz" + - "aGFyZWQgY29udHJvbCBwbGFuZS4gRmFpbHVyZXMgYXJlIGlzb2xhdGVkLgoKIyMgUGxhbi1vbmx5" + - "IHByZXZpZXcgaW4gUFJzCgpgYGB5YW1sCnByZXZpZXc6CiAgcnVsZXM6CiAgICAtIGlmOiAkQ0lf" + - "UElQRUxJTkVfU09VUkNFID09ICJtZXJnZV9yZXF1ZXN0X2V2ZW50IgogIHNjcmlwdDoKICAgIC0g" + - "fAogICAgICBudmNmLWNsaSBzZWxmLWhvc3RlZCB1cCBcCiAgICAgICAgLS1jbHVzdGVyLW5hbWU9" + - "JENMVVNURVJfTkFNRSBcCiAgICAgICAgLS1wbGFuLW9ubHkgXAogICAgICAgIC0tanNvbiB8IHRl" + - "ZSBwbGFuLmpzb25sCiAgICAtIGpxIC1zICcuWzBdJyBwbGFuLmpzb25sID4gcGxhbi5qc29uCiAg" + - "ICAtICdnaXRsYWItbXItY29tbWVudCAiIyMgTlZDRiBkZXBsb3kgcGxhblxuXG5cYFxgXGBqc29u" + - "XG4kKGNhdCBwbGFuLmpzb24pXG5cYFxgXGAiJwpgYGAKCmAtLXBsYW4tb25seWAgZXhpdHMgMCB3" + - "aXRob3V0IGNoYW5naW5nIGNsdXN0ZXIgc3RhdGU7IHRoZSBKU09OTCBvdXRwdXQgbGlzdHMgZWFj" + - "aCBwaGFzZSArIEVUQS4gUmVuZGVycyBuaWNlbHkgYXMgYW4gTVIgY29tbWVudC4KUEsHCJy3fgfx" + - "CgAA8QoAAFBLAwQUAAgAAAAAACEAAAAAAAAAAAAAAAAANAAJAGRhdGEvbnZjZi1zZWxmLW1hbmFn" + - "ZWQtY2xpL2V4YW1wbGVzL211bHRpLWNsdXN0ZXIubWRVVAUAAQCmzhIjIE11bHRpLWNsdXN0ZXIg" + - "cGF0dGVybnMKCkV4YW1wbGVzIG9mIHNwbGl0LWNsdXN0ZXIgKyBtYW55LWNvbXB1dGUtcGxhbmUg" + - "b3BlcmF0aW9uLgoKIyMgT25lIGNvbnRyb2wgcGxhbmUgKyBOIGNvbXB1dGUgcGxhbmVzCgpUaGUg" + - "Y2Fub25pY2FsIG11bHRpLWNsdXN0ZXIgdG9wb2xvZ3kuIEVhY2ggY29tcHV0ZSBwbGFuZSBpcyBy" + - "ZWdpc3RlcmVkIHNlcGFyYXRlbHk6CgpgYGBzaAojIDEuIEJyaW5nIHVwIHRoZSBjb250cm9sIHBs" + - "YW5lIChvbmUtdGltZSwgb24gdGhlIGNvbnRyb2wtcGxhbmUgY2x1c3Rlcik6CktVQkVDT05GSUc9" + - "Y3AueWFtbCBudmNmLWNsaSBzZWxmLWhvc3RlZCBpbnN0YWxsIC0tY29udHJvbC1wbGFuZSB8IGt1" + - "YmVjdGwgYXBwbHkgLWYgLQpudmNmLWNsaSBzZWxmLWhvc3RlZCBjaGVjayAtLWNvbnRyb2wtcGxh" + - "bmUgLS13YWl0IDVtCgojIDIuIFJlZ2lzdGVyICsgaW5zdGFsbCBlYWNoIGNvbXB1dGUgcGxhbmU6" + - "CmZvciBDVFggaW4gYWRtaW5AZ3B1LWVhc3QtMSBhZG1pbkBncHUtd2VzdC0xIGFkbWluQGdwdS1l" + - "dS0xOyBkbwogIE5BTUU9JChlY2hvICIkQ1RYIiB8IGN1dCAtZEAgLWYyKQogIG52Y2YtY2xpIHNl" + - "bGYtaG9zdGVkIHVwIFwKICAgIC0tY2x1c3Rlci1uYW1lPSROQU1FIFwKICAgIC0tY29tcHV0ZS1w" + - "bGFuZS1jb250ZXh0PSRDVFggXAogICAgLS1pY21zLXVybD1odHRwczovL2ljbXMubnZjZi5leGFt" + - "cGxlLmNvbSBcCiAgICAtLXRva2VuPSROVkNGX0FETUlOX0pXVCBcCiAgICAtLW5vbi1pbnRlcmFj" + - "dGl2ZSBcCiAgICAtLWpzb24KZG9uZQoKIyAzLiBWZXJpZnkgYWxsIGNvbXB1dGUgcGxhbmVzIHJl" + - "Z2lzdGVyZWQ6Cm52Y2YtY2xpIGNsdXN0ZXIgbGlzdC1yZWdpc3RlcmVkIC0tbmNhLWlkPSROQ0Ff" + - "SUQgLS1pY21zLXVybD0kSUNNUyAtLWpzb24gXAogIHwganEgJy5jbHVzdGVyc1tdIHwge25hbWU6" + - "IC5jbHVzdGVyTmFtZSwgc3RhdHVzLCBudmNhVmVyc2lvbn0nCmBgYAoKIyMgQ29tcHV0ZS1wbGFu" + - "ZS1vbmx5IG9wZXJhdG9yIHNjZW5hcmlvCgpUaGUgb3BlcmF0b3IgaGFzIGt1YmVjdGwgYWNjZXNz" + - "IHRvIE9ORSBjb21wdXRlIHBsYW5lIG9ubHkg4oCUIHRoZSBjb250cm9sIHBsYW5lIGlzIG9wZXJh" + - "dGVkIGJ5IHNvbWVvbmUgZWxzZSAoZS5nLiBZb3R0YSBydW5zIHRoZSBjb250cm9sIHBsYW5lIGZv" + - "ciBhIGN1c3RvbWVyKS4KCmBgYHNoCiMgTWludCBhIHRva2VuIHZpYSB0aGUgcHVibGljIGFwaSBn" + - "YXRld2F5ICh0aGUgY29udHJvbCBwbGFuZSdzIEFQSSBLZXlzCiMgc2VydmljZSBpcyByZWFjaGFi" + - "bGUgdmlhIGFwaS5udmNmLmV4YW1wbGUuY29tIOKAlCBubyBrdWJlY3RsIGFjY2VzcyBuZWVkZWQp" + - "OgpudmNmLWNsaSBpbml0IC0tYXBpLXVybD1odHRwczovL2FwaS5udmNmLmV4YW1wbGUuY29tCgoj" + - "IFJ1biBwcmUtZmxpZ2h0IHNjb3BlZCB0byB0aGUgY29tcHV0ZSBwbGFuZSBvbmx5OgpudmNmLWNs" + - "aSBzZWxmLWhvc3RlZCBjaGVjayAtLXByZSBcCiAgLS1jb21wdXRlLXBsYW5lLWNvbnRleHQ9YWRt" + - "aW5AZ3B1MSBcCiAgLS1pY21zLXVybD1odHRwczovL2ljbXMubnZjZi5leGFtcGxlLmNvbSBcCiAg" + - "LS1qc29uCgojIEJyaW5nIHVwIGp1c3QgdGhlIGNvbXB1dGUgcGxhbmU6Cm52Y2YtY2xpIHNlbGYt" + - "aG9zdGVkIHVwIFwKICAtLWNsdXN0ZXItbmFtZT1teS1jb21wdXRlLTEgXAogIC0tY29tcHV0ZS1w" + - "bGFuZS1jb250ZXh0PWFkbWluQGdwdTEgXAogIC0taWNtcy11cmw9aHR0cHM6Ly9pY21zLm52Y2Yu" + - "ZXhhbXBsZS5jb20gXAogIC0tdG9rZW49JEFETUlOX0pXVCBcCiAgLS1ub24taW50ZXJhY3RpdmUK" + - "CiMgU3RhdHVzIChjb21wdXRlLW9ubHkg4oCUIG9wZXJhdG9yIGNhbid0IHNlZSBjb250cm9sLXBs" + - "YW5lIGNvbXBvbmVudCBoZWFsdGgpOgpudmNmLWNsaSBzZWxmLWhvc3RlZCBzdGF0dXMgXAogIC0t" + - "Y29tcHV0ZS1wbGFuZS1jb250ZXh0PWFkbWluQGdwdTEgXAogIC0taWNtcy11cmw9aHR0cHM6Ly9p" + - "Y21zLm52Y2YuZXhhbXBsZS5jb20KYGBgCgojIyBGdW5jdGlvbnMgdGFyZ2V0aW5nIHNwZWNpZmlj" + - "IGNvbXB1dGUgcGxhbmVzCgpXaGVuIHlvdSBoYXZlIG11bHRpcGxlIGNvbXB1dGUgcGxhbmVzIGFu" + - "ZCB3YW50IGEgZnVuY3Rpb24gdG8gZGVwbG95IHRvIG9uZToKCmBgYGpzb24KewogICJmdW5jdGlv" + - "bklkIjogIjxmbl9pZD4iLAogICJ2ZXJzaW9uSWQiOiAiPHZlcl9pZD4iLAogICJkZXBsb3ltZW50" + - "U3BlY2lmaWNhdGlvbnMiOiBbewogICAgImdwdSI6ICJIMTAwIiwKICAgICJpbnN0YW5jZVR5cGUi" + - "OiAiTkNQLkdQVS5IMTAwXzF4IiwKICAgICJtaW5JbnN0YW5jZXMiOiAxLAogICAgIm1heEluc3Rh" + - "bmNlcyI6IDEsCiAgICAiY2x1c3RlcnMiOiBbImdwdS1lYXN0LTEiXQogIH1dCn0KYGBgCgpUaGUg" + - "YGNsdXN0ZXJzYCBhcnJheSAodXNpbmcgY2x1c3RlciBuYW1lcywgbm90IElEcykgbGltaXRzIHNj" + - "aGVkdWxpbmcgdG8gdGhvc2UgY29tcHV0ZSBwbGFuZXMuIE9taXQgdG8gbGV0IElDTVMgY2hvb3Nl" + - "IGFueSBjb21wdXRlIHBsYW5lIHdpdGggYSBtYXRjaGluZyBTS1UuCgojIyBTdGF0dXMgZmFuLW91" + - "dCBhY3Jvc3MgY29tcHV0ZSBwbGFuZXMKCmBgYHNoCiMgTGlzdCBhbGwgcmVnaXN0ZXJlZCBjb21w" + - "dXRlIHBsYW5lczoKbnZjZi1jbGkgY2x1c3RlciBsaXN0LXJlZ2lzdGVyZWQgLS1uY2EtaWQ9JE5D" + - "QV9JRCAtLWljbXMtdXJsPSRJQ01TIC0tanNvbiBcCiAgfCBqcSAtciAnLmNsdXN0ZXJzW10uY2x1" + - "c3Rlck5hbWUnID4gY2x1c3RlcnMudHh0CgojIFN0YXR1cyBzbmFwc2hvdCBwZXIgY29tcHV0ZSBw" + - "bGFuZSAoYXNzdW1pbmcgZWFjaCBjb250ZXh0IG5hbWUgbWF0Y2hlcyk6CndoaWxlIHJlYWQgTkFN" + - "RTsgZG8KICBlY2hvICI9PT0gJE5BTUUgPT09IgogIG52Y2YtY2xpIHNlbGYtaG9zdGVkIHN0YXR1" + - "cyBcCiAgICAtLWNsdXN0ZXItbmFtZT0kTkFNRSBcCiAgICAtLWNvbXB1dGUtcGxhbmUtY29udGV4" + - "dD1hZG1pbkAkTkFNRSBcCiAgICAtLWpzb24gfCBqcSAtYyAne2NsdXN0ZXI6LmNsdXN0ZXIsIHZl" + - "cmRpY3Q6LnZlcmRpY3QsIHJlY29uY2lsZToucmVjb25jaWxlQWdlU2VjfScKZG9uZSA8IGNsdXN0" + - "ZXJzLnR4dApgYGAKCkEgZnV0dXJlIE0rMTEgbWlsZXN0b25lIHdpbGwgYWRkIG5hdGl2ZSBmYW4t" + - "b3V0IChgc3RhdHVzIC0tYWxsLWNvbXB1dGUtcGxhbmVzYCkgc28gdGhpcyBkb2Vzbid0IG5lZWQg" + - "YSBzaGVsbCBsb29wLgpQSwcIoRTjgV0MAABdDAAAUEsDBBQACAAAAAAAIQAAAAAAAAAAAAAAAAA3" + - "AAkAZGF0YS9udmNmLXNlbGYtbWFuYWdlZC1jbGkvcHJvbXB0cy9hZGQtY29tcHV0ZS1wbGFuZS5t" + - "ZFVUBQABAKbOEiMgQWRkIGEgY29tcHV0ZSBwbGFuZSB0byBhbiBleGlzdGluZyBjb250cm9sIHBs" + - "YW5lCgpVc2VyIGhhcyBhIHdvcmtpbmcgTlZDRiBjb250cm9sIHBsYW5lIChydW5uaW5nIHNvbWV3" + - "aGVyZSkgYW5kIHdhbnRzIHRvIHJlZ2lzdGVyIGEgbmV3IEdQVSBjbHVzdGVyIGFnYWluc3QgaXQu" + - "IFRoaXMgaXMgdGhlIG11bHRpLWNsdXN0ZXIgc2NhbGluZyBwYXRoOiBvbmUgY29udHJvbCBwbGFu" + - "ZSDihpIgTiBjb21wdXRlIHBsYW5lcy4KCiMjIFByZXJlcXVpc2l0ZXMgdGhlIHVzZXIgbXVzdCBo" + - "YXZlCgotICoqa3ViZWN0bCBjb250ZXh0IGZvciB0aGUgbmV3IGNvbXB1dGUgcGxhbmUqKiBpbiB0" + - "aGVpciBgS1VCRUNPTkZJR2AuCi0gKipQdWJsaWMgSUNNUyBVUkwqKiBvZiB0aGUgZXhpc3Rpbmcg" + - "Y29udHJvbCBwbGFuZSAoZS5nLiBgaHR0cHM6Ly9pY21zLm52Y2YuZXhhbXBsZS5jb21gKS4KLSAq" + - "KkFkbWluIEpXVCoqIGZvciB0aGUgY29udHJvbCBwbGFuZSdzIGFjY291bnQsIE9SIGFiaWxpdHkg" + - "dG8gbWludCBvbmUgdmlhIGBudmNmLWNsaSBpbml0YCBhZ2FpbnN0IHRoZSBjb250cm9sIHBsYW5l" + - "J3MgcHVibGljIGFwaSBlbmRwb2ludC4gKEFkbWluIHRva2VucyBjb21lIGZyb20gdGhlIEFQSSBL" + - "ZXlzIHNlcnZpY2UgdmlhIHRoZSBwdWJsaWMgYXBpIGdhdGV3YXkg4oCUIGt1YmVjdGwgYWNjZXNz" + - "IHRvIHRoZSBjb250cm9sIHBsYW5lIGlzIE5PVCByZXF1aXJlZCB0byBvYnRhaW4gb25lLikKLSBB" + - "IHVuaXF1ZSBgLS1jbHVzdGVyLW5hbWVgIHRoYXQgZG9lc24ndCBjb2xsaWRlIHdpdGggYWxyZWFk" + - "eS1yZWdpc3RlcmVkIGNsdXN0ZXJzLiBVc2UgYG52Y2YtY2xpIGNsdXN0ZXIgbGlzdC1yZWdpc3Rl" + - "cmVkIC0tbmNhLWlkPSROQ0FfSUQgLS1pY21zLXVybD0kSUNNU2AgdG8gY2hlY2suCgojIyBTdGVw" + - "cwoKMS4gKipBc2sgZm9yIGlucHV0czoqKgogICAtIGAtLWNsdXN0ZXItbmFtZT08dW5pcXVlPmAg" + - "4oCUIG11c3QgYmUgdW5pcXVlIHdpdGhpbiB0aGUgY29udHJvbCBwbGFuZSdzIE5DQSBzY29wZQog" + - "ICAtIGAtLWNvbXB1dGUtcGxhbmUtY29udGV4dD08Y29udGV4dD5gIOKAlCB0aGUgbmV3IEdQVSBj" + - "bHVzdGVyJ3Mga3ViZWN0bCBjb250ZXh0CiAgIC0gYC0taWNtcy11cmw9PGh0dHBzOi8vaWNtcy5u" + - "dmNmLmV4YW1wbGUuY29tPmAg4oCUIGNvbnRyb2wgcGxhbmUncyBwdWJsaWMgSUNNUyBVUkwKICAg" + - "LSBHUFUgdHlwZT8gKGRlZmF1bHQgYEgxMDBgLCBidXQgYXNrKQoKMi4gKipQcmUtZmxpZ2h0IGp1" + - "c3QgdGhlIGNvbXB1dGUgcGxhbmU6KioKCiAgIGBgYHNoCiAgIG52Y2YtY2xpIHNlbGYtaG9zdGVk" + - "IGNoZWNrIC0tcHJlIFwKICAgICAtLWNvbXB1dGUtcGxhbmUtY29udGV4dD0kQ1RYIFwKICAgICAt" + - "LWljbXMtdXJsPSRJQ01TIFwKICAgICAtLWpzb24gfCBqcSAtYyAuCiAgIGBgYAoKICAgVGhpcyB2" + - "YWxpZGF0ZXMgdGhlIEdQVSBvcGVyYXRvciBpcyBpbnN0YWxsZWQsIEdQVSBub2RlIGxhYmVscyBh" + - "cmUgcHJlc2VudCwgYW5kIElDTVMgaXMgcmVhY2hhYmxlIHZpYSBIVFRQLiBBZGRyZXNzIGFueSBm" + - "YWlsdXJlcyBiZWZvcmUgcHJvY2VlZGluZy4KCjMuICoqUnVuIGBhZGQtY29tcHV0ZS1wbGFuZWA6" + - "KioKCiAgIGBgYHNoCiAgIG52Y2YtY2xpIHNlbGYtaG9zdGVkIGFkZC1jb21wdXRlLXBsYW5lIFwK" + - "ICAgICAtLWNsdXN0ZXItbmFtZT0kTkFNRSBcCiAgICAgLS1jb21wdXRlLXBsYW5lLWNvbnRleHQ9" + - "JENUWCBcCiAgICAgLS1pY21zLXVybD0kSUNNUyBcCiAgICAgLS10b2tlbj0kSldUIFwKICAgICAt" + - "LW5vbi1pbnRlcmFjdGl2ZSBcCiAgICAgLS1qc29uCiAgIGBgYAoKICAgVGhpcyBpcyBhIHNlcGFy" + - "YXRlIHN1YmNvbW1hbmQgZnJvbSBgdXBgLiBJdCBydW5zIG9ubHkgdGhlIGNvbXB1dGUtcGxhbmUt" + - "cmVsZXZhbnQgcGhhc2VzOiAxIChjb21wdXRlLXBsYW5lIHByZWZsaWdodCksIDUgKHJlZ2lzdGVy" + - "KSwgNiAoY29tcHV0ZS1wbGFuZSBhcHBseSksIDggKGZpbmFsIGhlYWx0aCBvbiB0aGUgbmV3IGNv" + - "bXB1dGUgcGxhbmUpLiBJdCBkb2VzIE5PVCB0b3VjaCB0aGUgY29udHJvbCBwbGFuZSBhbmQgZG9l" + - "cyBOT1QgYWNjZXB0IGAtLWNvbnRyb2wtcGxhbmUtY29udGV4dGAg4oCUIHRoZSBjb250cm9sIHBs" + - "YW5lIGlzIHJlYWNoZWQgb3ZlciBIVFRQUyBhdCBgLS1pY21zLXVybGAuIGAtLXRva2VuYCBpcyBy" + - "ZXF1aXJlZCBiZWNhdXNlIHRoZXJlJ3Mgbm8ga3ViZWN0bCBwYXRoIHRvIG1pbnQgb25lIGZyb20u" + - "Cgo0LiAqKlZlcmlmeS4qKiBgbnZjZi1jbGkgc2VsZi1ob3N0ZWQgc3RhdHVzIC0tY2x1c3Rlci1u" + - "YW1lPSROQU1FIC0tanNvbiB8IGpxYCDigJQgZXhwZWN0IGB2ZXJkaWN0OiAiaGVhbHRoeSJgLiBU" + - "aGUgUmVnaXN0ZXJlZCBDb21wdXRlIFBsYW5lcyBwYW5lbCBmcm9tIElDTVMgc2hvdWxkIG5vdyBs" + - "aXN0IHRoZSBuZXcgY2x1c3Rlci4KCjUuICoqU21va2UuKiogW2RlcGxveS1hbmQtaW52b2tlLm1k" + - "XShkZXBsb3ktYW5kLWludm9rZS5tZCkg4oCUIGRlcGxveSBhIHNtYWxsIGZ1bmN0aW9uIHRvIHRo" + - "ZSBuZXcgY29tcHV0ZSBwbGFuZSB0byBjb25maXJtIHNjaGVkdWxpbmcgd29ya3MuCgojIyBXaGF0" + - "IGlmIGEgY2x1c3RlciB3aXRoIHRoZSBzYW1lIG5hbWUgYWxyZWFkeSBleGlzdHM/CgpgYWRkLWNv" + - "bXB1dGUtcGxhbmVgJ3MgcmVnaXN0ZXIgcGhhc2UgdXNlcyBgLS1pZ25vcmUtZXhpc3RpbmdgIHNl" + - "bWFudGljcyDigJQgaXQnbGwgbWF0Y2ggdGhlIGV4aXN0aW5nIElDTVMgcm93IGFuZCByZXVzZSBp" + - "dHMgY2x1c3RlcklkLiBUd28gc2NlbmFyaW9zOgoKLSAqKlJlLXJlZ2lzdGVyaW5nIHRoZSBzYW1l" + - "IGNsdXN0ZXIqKiAocmUtcnVubmluZyBvbiB0aGUgc2FtZSBjb21wdXRlIHBsYW5lIHRoYXQgd2Fz" + - "IHByZXZpb3VzbHkgcmVnaXN0ZXJlZCk6IGV4cGVjdGVkLCBuby1vcCBzZW1hbnRpY3MuCi0gKipE" + - "aWZmZXJlbnQgcGh5c2ljYWwgY2x1c3RlciBidXQgc2FtZSBuYW1lKio6IHRoZSBzZWNvbmQgYXR0" + - "ZW1wdCB3aWxsIHJldXNlIHRoZSBJQ01TIHJvdywgYnV0IHRoZSBuZXcgY29tcHV0ZSBwbGFuZSdz" + - "IEpXS1Mgd2lsbCBiZSBzaWxlbnRseSAqcmVwbGFjZWQqIOKAlCB0aGUgb2xkIGNvbXB1dGUgcGxh" + - "bmUncyBOVkNBIGFnZW50IHdpbGwgc3RhcnQgZmFpbGluZyBQU0FUIGF1dGguICoqQ29uZmlybSB3" + - "aXRoIHRoZSB1c2VyKiogdGhhdCB0aGV5IG1lYW50IHRvIG92ZXJ3cml0ZS4KCldoZW4gaW4gZG91" + - "YnQsIHJ1biBgbnZjZi1jbGkgY2x1c3RlciBsaXN0LXJlZ2lzdGVyZWQgLS1uY2EtaWQ9JE5DQV9J" + - "RCAtLWljbXMtdXJsPSRJQ01TYCBmaXJzdCBhbmQgYXNrIHRoZSB1c2VyIGJlZm9yZSBwcm9jZWVk" + - "aW5nIHdpdGggYSBuYW1lIHRoYXQgYWxyZWFkeSBleGlzdHMuClBLBwhCJ0kPBQ0AAAUNAABQSwME" + - "FAAIAAAAAAAhAAAAAAAAAAAAAAAAADkACQBkYXRhL252Y2Ytc2VsZi1tYW5hZ2VkLWNsaS9wcm9t" + - "cHRzL2NyZWF0ZS1hbmQtcnVuLXRhc2subWRVVAUAAQCmzhIjIENyZWF0ZSBhbmQgcnVuIGEgdGFz" + - "awoKU3RhbmRhcmQgZmxvdzogbWludCBBUEkga2V5cyAtPiB0YXNrIENSRUFURSAtPiBtb25pdG9y" + - "IC0+IHJldHJpZXZlIHJlc3VsdHMgLT4gY2xlYW51cC4KCiMjIDEuIE1pbnQgQVBJIGtleXMKClRo" + - "ZSBhZG1pbiB0b2tlbiBmcm9tIGBpbml0YCBkb2VzIG5vdCBjYXJyeSB0YXNrIGludm9jYXRpb24g" + - "c2NvcGUuIEdlbmVyYXRlIEFQSSBrZXlzOgoKYGBgc2gKbnZjZi1jbGkgYXBpLWtleSBnZW5lcmF0" + - "ZSAtLWRlc2NyaXB0aW9uPSJ0YXNrIHNtb2tlIHRlc3QiIC0tZXhwaXJlcy1pbj04aAojIC0+IGZ1" + - "bmN0aW9uIGtleTogbnZhcGktLi4uCiMgLT4gdGFzayBrZXk6IG52YXBpLS4uLgpgYGAKCkJvdGgg" + - "a2V5cyBhcmUgc2F2ZWQgdG8gQ0xJIHN0YXRlLiBUbyBtaW50IG9ubHkgdGhlIHRhc2sga2V5OgoK" + - "YGBgc2gKbnZjZi1jbGkgYXBpLWtleSBnZW5lcmF0ZSAtLWZvciB0YXNrIC0tZGVzY3JpcHRpb249" + - "InRhc2sgc21va2UgdGVzdCIgLS1leHBpcmVzLWluPThoCmBgYAoKVG8gdmFsaWRhdGUgZWFjaCBr" + - "ZXkgaW1tZWRpYXRlbHkgYWZ0ZXIgbWludGluZzoKCmBgYHNoCm52Y2YtY2xpIGFwaS1rZXkgZ2Vu" + - "ZXJhdGUgLS1kZXNjcmlwdGlvbj0idGFzayBzbW9rZSB0ZXN0IiAtLWV4cGlyZXMtaW49OGggLS12" + - "YWxpZGF0ZQojIC0+IGZ1bmN0aW9uIEFQSSBrZXkgdmFsaWRhdGlvbiBwYXNzZWQhCiMgLT4gdGFz" + - "ayBBUEkga2V5IHZhbGlkYXRpb24gcGFzc2VkIQpgYGAKCk5ldmVyIGVjaG8gdGhlIGtleSB2YWx1" + - "ZSAobnZhcGktLi4uKSBpbnRvIGNoYXQgb3IgbG9ncy4gUmVwb3J0IG9ubHkgdGhhdCB0aGUga2V5" + - "IHdhcyBtaW50ZWQsIGl0cyBkZXNjcmlwdGlvbiwgYW5kIGl0cyBleHBpcnkg4oCUIG5ldmVyIHRo" + - "ZSB2YWx1ZSBpdHNlbGYuCgojIyMgRnJlc2ggc2Vzc2lvbiBwcmUtZmxpZ2h0CgpCZWZvcmUgcnVu" + - "bmluZyBhbnkgYHRhc2tgIGNvbW1hbmQgaW4gYSBmcmVzaCBhZ2VudCBzZXNzaW9uLCB2ZXJpZnkg" + - "Y3JlZGVudGlhbHM6CgoxLiBDaGVjayB3aGV0aGVyIGBOVkNGX0FQSV9LRVlgIGlzIHNldDoKCiAg" + - "IGBgYHNoCiAgIHByaW50ZW52IE5WQ0ZfQVBJX0tFWSA+IC9kZXYvbnVsbCAyPiYxICYmIGVjaG8g" + - "c2V0IHx8IGVjaG8gdW5zZXQKICAgYGBgCgoyLiBJZiB1bnNldCwgcmVhZCB0aGUgc3RvcmVkIGtl" + - "eSBleHBpcnkgd2l0aG91dCBwcmludGluZyB0aGUgdmFsdWU6CgogICBgYGBzaAogICBweXRob24z" + - "IC1jICJpbXBvcnQganNvbixwYXRobGliOyBzPWpzb24ubG9hZHMocGF0aGxpYi5QYXRoKCd+Ly5u" + - "dmNmLWNsaS5zdGF0ZScpLmV4cGFuZHVzZXIoKS5yZWFkX3RleHQoKSk7IHByaW50KHMuZ2V0KCdu" + - "dmN0QXBpS2V5RXhwaXJhdGlvbicsJ21pc3NpbmcnKSkiCiAgIGBgYAoKMy4gSWYgdGhlIGV4cGly" + - "eSBpcyBpbiB0aGUgcGFzdCBvciBtaXNzaW5nLCByZWdlbmVyYXRlOgoKICAgYGBgc2gKICAgbnZj" + - "Zi1jbGkgcmVmcmVzaAogICBudmNmLWNsaSBhcGkta2V5IGdlbmVyYXRlIC0tZm9yIHRhc2sgLS1k" + - "ZXNjcmlwdGlvbj0idGFzay1rZXkiIC0tZXhwaXJlcy1pbj0yNGggLS12YWxpZGF0ZQogICBgYGAK" + - "CjQuIEluamVjdCB0aGUgc3RvcmVkIGtleSBpbnRvIHRoZSBzdWJwcm9jZXNzIGVudiB3aXRob3V0" + - "IGxvZ2dpbmcgdGhlIHZhbHVlLiBVc2UgYSBzaGVsbCBzdWJzaGVsbCBvciBlbnYgcHJlZml4IOKA" + - "lCBuZXZlciBwcmludCB0aGUga2V5IGludG8gY2hhdC4KCkVycm9yIGNsYXNzaWZpY2F0aW9uOgoK" + - "LSAibWlzc2luZyBhdXRoZW50aWNhdGlvbiBjcmVkZW50aWFscyIgb3IgZXhpdCAyOiBrZXkgbm90" + - "IGluamVjdGVkOyByZXBlYXQgcHJlLWZsaWdodC4KLSBIVFRQIDQwMzoga2V5IGV4cGlyZWQgb3Ig" + - "d3JvbmcgYXVkaWVuY2U7IHJlZ2VuZXJhdGUuCi0gRW1wdHkgdGFzayBsaXN0OiB2YWxpZDsgbm8g" + - "dGFza3MgZXhpc3QgeWV0LgoKIyMgMi4gVGFzayBDUkVBVEU6IGNvbnRhaW5lcgoKRGVmaW5lIHRo" + - "ZSB0YXNrIGluIGEgSlNPTiBmaWxlLiBNaW5pbXVtIHZpYWJsZSBjb250YWluZXIgdGFzazoKCmBg" + - "YGpzb24KewogICJuYW1lIjogInNtb2tlLWNvbnRhaW5lci10YXNrIiwKICAiZ3B1U3BlY2lmaWNh" + - "dGlvbiI6IHsKICAgICJncHUiOiAiSDEwMCIsCiAgICAiaW5zdGFuY2VUeXBlIjogIk5DUC5HUFUu" + - "SDEwMF8xeCIsCiAgICAiYmFja2VuZCI6ICJuY3AtbG9jYWwiCiAgfSwKICAiY29udGFpbmVySW1h" + - "Z2UiOiAibnZjci5pby9udmlkaWEvbmVtbzpsYXRlc3QiLAogICJjb250YWluZXJBcmdzIjogInB5" + - "dGhvbiAtYyBcInByaW50KCdoZWxsbyBmcm9tIHRhc2snKVwiIiwKICAicmVzdWx0SGFuZGxpbmdT" + - "dHJhdGVneSI6ICJOT05FIgp9CmBgYAoKYGBgc2gKbnZjZi1jbGkgdGFzayBjcmVhdGUgLS1pbnB1" + - "dC1maWxlPXRhc2stY29udGFpbmVyLmpzb24KIyAtPiBUYXNrIElEOiA8dGFza19pZD4KYGBgCgpD" + - "YXB0dXJlIHRoZSB0YXNrIElEIGZyb20gdGhlIG91dHB1dC4gU3RhdGUgcGVyc2lzdGVuY2UgY2Fu" + - "IGZhaWwgc2lsZW50bHksIHNvIHBhc3MKYDx0YXNrSWQ+YCBleHBsaWNpdGx5IG9uIHN1YnNlcXVl" + - "bnQgY29tbWFuZHMgcmF0aGVyIHRoYW4gcmVseWluZyBvbiBzYXZlZCBzdGF0ZS4KCkVxdWl2YWxl" + - "bnQgQ0xJIGZsYWdzIGZvcm0gZm9yIHF1aWNrIGFkLWhvYyBydW5zOgoKYGBgc2gKbnZjZi1jbGkg" + - "dGFzayBjcmVhdGUgXAogIC0tbmFtZT1zbW9rZS1jb250YWluZXItdGFzayBcCiAgLS1ncHU9SDEw" + - "MCBcCiAgLS1pbnN0YW5jZS10eXBlPU5DUC5HUFUuSDEwMF8xeCBcCiAgLS1iYWNrZW5kPW5jcC1s" + - "b2NhbCBcCiAgLS1pbWFnZT1udmNyLmlvL252aWRpYS9uZW1vOmxhdGVzdCBcCiAgLS1jb250YWlu" + - "ZXItYXJncz0icHl0aG9uIC1jICdwcmludChoZWxsbyBmcm9tIHRhc2spJyIgXAogIC0tcmVzdWx0" + - "LXN0cmF0ZWd5PU5PTkUKYGBgCgpUbyB0YXJnZXQgYSBzcGVjaWZpYyBjb21wdXRlIGNsdXN0ZXI6" + - "CgpgYGBzaApudmNmLWNsaSB0YXNrIGNyZWF0ZSAtLWlucHV0LWZpbGU9dGFzay1jb250YWluZXIu" + - "anNvbiAtLWNsdXN0ZXJzPW15LWNsdXN0ZXIKYGBgCgpUbyBpbmplY3QgZW52aXJvbm1lbnQgdmFy" + - "aWFibGVzIG9yIHNlY3JldHMsIGVpdGhlciBwYXNzIGZsYWdzOgoKYGBgc2gKbnZjZi1jbGkgdGFz" + - "ayBjcmVhdGUgLS1pbnB1dC1maWxlPXRhc2stY29udGFpbmVyLmpzb24gXAogIC0tY29udGFpbmVy" + - "LWVudiBNWV9FTlY9dmFsdWUgXAogIC0tc2VjcmV0cyBNWV9TRUNSRVQ9czNjcjN0CmBgYAoKb3Ig" + - "c2V0IHRoZW0gaW4gdGhlIEpTT04gZmlsZToKCmBgYGpzb24KewogICJjb250YWluZXJFbnZpcm9u" + - "bWVudCI6IFsKICAgIHsia2V5IjogIk1ZX0VOViIsICJ2YWx1ZSI6ICJ2YWx1ZSJ9CiAgXSwKICAi" + - "c2VjcmV0cyI6IFsKICAgIHsibmFtZSI6ICJNWV9TRUNSRVQiLCAidmFsdWUiOiAiczNjcjN0In0K" + - "ICBdCn0KYGBgCgpTZWNyZXRzIGFyZSBlbmNyeXB0ZWQgYXQgcmVzdCBhbmQgbm90IHJldHVybmVk" + - "IGluIGxpc3Qgb3IgZ2V0IHJlc3BvbnNlcyBieSBkZWZhdWx0OyBkbyBub3QgbG9nIHRoZW0uCgoj" + - "IyAzLiBUYXNrIENSRUFURTogSGVsbQoKRm9yIEhlbG0tdHlwZSB0YXNrcywgcmVwbGFjZSBgY29u" + - "dGFpbmVySW1hZ2VgIHdpdGggYGhlbG1DaGFydGA6CgpgYGBqc29uCnsKICAibmFtZSI6ICJzbW9r" + - "ZS1oZWxtLXRhc2siLAogICJncHVTcGVjaWZpY2F0aW9uIjogewogICAgImdwdSI6ICJIMTAwIiwK" + - "ICAgICJpbnN0YW5jZVR5cGUiOiAiTkNQLkdQVS5IMTAwXzF4IiwKICAgICJiYWNrZW5kIjogIm5j" + - "cC1sb2NhbCIKICB9LAogICJoZWxtQ2hhcnQiOiAiaHR0cHM6Ly9oZWxtLm5nYy5udmlkaWEuY29t" + - "Lzxvcmc+L2NoYXJ0cy9leGFtcGxlLXRhc2stMC4xLjAudGd6IiwKICAicmVzdWx0SGFuZGxpbmdT" + - "dHJhdGVneSI6ICJOT05FIgp9CmBgYAoKYGBgc2gKbnZjZi1jbGkgdGFzayBjcmVhdGUgLS1pbnB1" + - "dC1maWxlPXRhc2staGVsbS5qc29uCiMgLT4gVGFzayBJRDogPHRhc2tfaWQ+CmBgYAoKYGhlbG1D" + - "aGFydGAgYW5kIGBjb250YWluZXJJbWFnZWAgYXJlIG11dHVhbGx5IGV4Y2x1c2l2ZS4KCiMjIDNi" + - "LiBVcGRhdGUgc2VjcmV0cyBvbiBhIHJ1bm5pbmcgdGFzawoKYGBgc2gKbnZjZi1jbGkgdGFzayB1" + - "cGRhdGUtc2VjcmV0cyA8dGFza19pZD4gLS1zZWNyZXRzIE5BTUU9dmFsdWUKYGBgCgpgdXBkYXRl" + - "LXNlY3JldHNgIG1lcmdlcyBzZWNyZXRzIGJ5IG5hbWU6IHN1cHBsaWVkIHNlY3JldHMgYXJlIGFk" + - "ZGVkIG9yIHVwZGF0ZWQsIGFuZCBleGlzdGluZyBzZWNyZXRzIG5vdCBpbmNsdWRlZCBpbiB0aGUg" + - "cmVxdWVzdCBhcmUgcHJlc2VydmVkLiBEbyBub3QgZWNobyBzZWNyZXQgdmFsdWVzIGludG8gY2hh" + - "dC4KCiMjIDQuIE1vbml0b3IKClBvbGwgdGFzayBzdGF0ZToKCmBgYHNoCm52Y2YtY2xpIHRhc2sg" + - "Z2V0IDx0YXNrX2lkPgojIC0+IGlkOiA8dGFza19pZD4KIyAtPiBzdGF0dXM6IFJVTk5JTkcKYGBg" + - "CgpPciB1c2UgdGhlIHRhc2sgSUQgc2F2ZWQgdG8gc3RhdGUgZnJvbSB0aGUgbW9zdCByZWNlbnQg" + - "YHRhc2sgY3JlYXRlYDoKCmBgYHNoCm52Y2YtY2xpIHRhc2sgZ2V0CmBgYAoKU3RhdHVzIHZhbHVl" + - "czogYFFVRVVFRGAsIGBMQVVOQ0hFRGAsIGBSVU5OSU5HYCwgYENPTVBMRVRFRGAsIGBFUlJPUkVE" + - "YCwgYENBTkNFTEVEYCwgYEVYQ0VFREVEX01BWF9SVU5USU1FX0RVUkFUSU9OYCwgYEVYQ0VFREVE" + - "X01BWF9RVUVVRURfRFVSQVRJT05gLgoKSW5zcGVjdCBsaWZlY3ljbGUgZXZlbnRzIGZvciBwcm9n" + - "cmVzcyBkZXRhaWxzIG9yIGZhaWx1cmUgY29udGV4dDoKCmBgYHNoCm52Y2YtY2xpIHRhc2sgZXZl" + - "bnRzIDx0YXNrX2lkPgpgYGAKCklmIHRoZSB0YXNrIGlzIHN0dWNrIGluIGBRVUVVRURgLCBjaGVj" + - "ayB0aGF0IGEgY29tcHV0ZSBjbHVzdGVyIHdpdGggYSBtYXRjaGluZyBHUFUgU0tVCmlzIHJlZ2lz" + - "dGVyZWQgYW5kIGhlYWx0aHkgKGBudmNmLWNsaSBzZWxmLWhvc3RlZCBzdGF0dXNgKS4KCklmIHRo" + - "ZSB0YXNrIG1vdmVzIHRvIGBFUlJPUkVEYCwgdGhlIGV2ZW50cyBvdXRwdXQgY29udGFpbnMgdGhl" + - "IGZhaWx1cmUgcmVhc29uLiBGb3IKY29udGFpbmVyLWxldmVsIGRldGFpbCwgaW5zcGVjdCB0aGUg" + - "dGFzayB3b3JrbG9hZCBwb2RzIG9uIHRoZSBjb21wdXRlLXBsYW5lIGNsdXN0ZXIgd2l0aApga3Vi" + - "ZWN0bGAgYWdhaW5zdCB0aGUgYXBwcm9wcmlhdGUgbmFtZXNwYWNlIGFuZCBjb250ZXh0LgoKIyMg" + - "NS4gUmV0cmlldmUgcmVzdWx0cwoKRm9yIHRhc2tzIHdpdGggYHJlc3VsdEhhbmRsaW5nU3RyYXRl" + - "Z3k6IE5PTkVgLCByZXN1bHRzIGFyZSBlbXB0eSBieSBkZXNpZ246CgpgYGBzaApudmNmLWNsaSB0" + - "YXNrIHJlc3VsdHMgPHRhc2tfaWQ+CiMgLT4geyJyZXN1bHRzIjogW119CmBgYAoKRm9yIHRhc2tz" + - "IHdpdGggYHJlc3VsdEhhbmRsaW5nU3RyYXRlZ3k6IFVQTE9BRGAsIHJlc3VsdHMgdXBsb2FkIHRv" + - "IHRoZSBsb2NhdGlvbiBuYW1lZCBpbgpgcmVzdWx0c0xvY2F0aW9uYC4gVGhlIHRhc2sncyBjcmVk" + - "ZW50aWFscyBtdXN0IGhhdmUgd3JpdGUgYWNjZXNzIHRvIHRoYXQgbG9jYXRpb24uIEFmdGVyCnRo" + - "ZSB0YXNrIHJlYWNoZXMgYENPTVBMRVRFRGA6CgpgYGBzaApudmNmLWNsaSB0YXNrIHJlc3VsdHMg" + - "PHRhc2tfaWQ+CmBgYAoKTm90ZTogcmVzdWx0IHVwbG9hZCBpcyBub3QgeWV0IHN1cHBvcnRlZCBp" + - "biB0aGlzIHJlbGVhc2UuCgojIyA2LiBDbGVhbnVwCgpDYW5jZWwgYSBydW5uaW5nIHRhc2s6Cgpg" + - "YGBzaApudmNmLWNsaSB0YXNrIGNhbmNlbCA8dGFza19pZD4KYGBgCgpEZWxldGUgdGhlIHRhc2sg" + - "cmVjb3JkIGFmdGVyIGl0IHJlYWNoZXMgYSB0ZXJtaW5hbCBzdGF0ZToKCmBgYHNoCm52Y2YtY2xp" + - "IHRhc2sgZGVsZXRlIDx0YXNrX2lkPgpgYGAKClN0b3AgYW5kIGNvbmZpcm0gd2l0aCB0aGUgdXNl" + - "ciBiZWZvcmUgcnVubmluZyB0YXNrIGRlbGV0ZS4gU3RhdGUgdGhlIHRhc2sgSUQgYW5kIGl0cyBj" + - "dXJyZW50IHN0YXR1cy4gRG8gbm90IHByb2NlZWQgZXZlbiB3aGVuIHRoZSB1c2VyJ3MgcHJvbXB0" + - "IGFscmVhZHkgcmVxdWVzdGVkIGRlbGV0aW9uIOKAlCBhbHdheXMgZ2V0IGFuIGV4cGxpY2l0IGxh" + - "c3QtbG9vayBjb25maXJtYXRpb24uIERlbGV0aW9uIGlzIHBlcm1hbmVudCBhbmQgY2Fubm90IGJl" + - "IHVuZG9uZS4KClRvIGxpc3QgYWxsIHRhc2tzIGZvciB0aGUgY3VycmVudCBvd25lciAodXNlZnVs" + - "IGJlZm9yZSBidWxrIGNsZWFudXApOgoKYGBgc2gKbnZjZi1jbGkgdGFzayBsaXN0Cm52Y2YtY2xp" + - "IHRhc2sgbGlzdCAtLXN0YXR1cz1DT01QTEVURUQKYGBgCgojIyBOb3RlcwoKLSBHUFUgZmFtaWx5" + - "IGFuZCBpbnN0YW5jZSB0eXBlIG11c3QgbWF0Y2ggYSByZWdpc3RlcmVkIGNvbXB1dGUgY2x1c3Rl" + - "ciBTS1UuIE1pc21hdGNoCiAgY2F1c2VzIHRoZSB0YXNrIHRvIHJlbWFpbiBgUVVFVUVEYCBpbmRl" + - "ZmluaXRlbHkuIFVzZSBgbnZjZi1jbGkgY2x1c3RlciBsaXN0YCB0byBjaGVjawogIHJlZ2lzdGVy" + - "ZWQgU0tVcy4KLSBGb3IgY29udGFpbmVyIGltYWdlcyBvbiBwcml2YXRlIHJlZ2lzdHJpZXMsIGVu" + - "c3VyZSB0aGUgY29tcHV0ZS1wbGFuZSBjbHVzdGVyJ3MKICBpbWFnZS1jcmVkZW50aWFsLWhlbHBl" + - "ciBpcyBjb25maWd1cmVkIGZvciB0aGUgcmVnaXN0cnkgaG9zdC4KUEsHCNTejTSfGQAAnxkAAFBL" + - "AwQUAAgAAAAAACEAAAAAAAAAAAAAAAAANwAJAGRhdGEvbnZjZi1zZWxmLW1hbmFnZWQtY2xpL3By" + - "b21wdHMvZGVwbG95LWFuZC1pbnZva2UubWRVVAUAAQCmzhIjIERlcGxveSBhbmQgaW52b2tlIGEg" + - "Y29udGFpbmVyIGZ1bmN0aW9uCgpTdGFuZGFyZCBmb3VyLXN0ZXAgZmxvdzogZnVuY3Rpb24gQ1JF" + - "QVRFIOKGkiBERVBMT1kg4oaSIEFQSSBrZXkgKGZvciBpbnZva2Ugc2NvcGUpIOKGkiBJTlZPS0Uu" + - "CgojIyAxLiBGdW5jdGlvbiBDUkVBVEUKCkRlZmluZSB0aGUgZnVuY3Rpb24gaW4gYSBKU09OIGZp" + - "bGUuIE1pbmltdW06CgpCZWZvcmUgY3JlYXRpbmcgdGhlIGZ1bmN0aW9uLCBjb25maXJtIHRoZSBl" + - "eGFjdCBmdW5jdGlvbiBuYW1lIGFuZCBjb250YWluZXIgaW1hZ2Ugd2l0aCB0aGUgdXNlci4gSWYg" + - "ZWl0aGVyIHZhbHVlIGlzIG1pc3NpbmcsIHN0b3AgYW5kIGFzay4gRG8gbm90IGludmVudCBvciBz" + - "dWJtaXQgZXhhbXBsZSB2YWx1ZXMgZm9yIHRoZXNlIGZpZWxkcy4KCmBgYGpzb24KewogICJuYW1l" + - "IjogIjxjb25maXJtZWQtZnVuY3Rpb24tbmFtZT4iLAogICJjb250YWluZXJJbWFnZSI6ICI8Y29u" + - "ZmlybWVkLWNvbnRhaW5lci1pbWFnZT4iLAogICJpbmZlcmVuY2VVcmwiOiAiL2VjaG8iLAogICJp" + - "bmZlcmVuY2VQb3J0IjogODAwMCwKICAiZGVzY3JpcHRpb24iOiAic21va2UgdGVzdCBlY2hvIGZ1" + - "bmN0aW9uIiwKICAiZnVuY3Rpb25UeXBlIjogIkRFRkFVTFQiLAogICJhcGlCb2R5Rm9ybWF0Ijog" + - "IkNVU1RPTSIsCiAgImhlYWx0aCI6IHsKICAgICJwcm90b2NvbCI6ICJIVFRQIiwKICAgICJ1cmki" + - "OiAiL2hlYWx0aCIsCiAgICAicG9ydCI6IDgwMDAsCiAgICAidGltZW91dCI6ICJQVDMwUyIsCiAg" + - "ICAiZXhwZWN0ZWRTdGF0dXNDb2RlIjogMjAwCiAgfQp9CmBgYAoKYGBgc2gKbnZjZi1jbGkgZnVu" + - "Y3Rpb24gY3JlYXRlIC0taW5wdXQtZmlsZT1jcmVhdGUtZm4uanNvbgojIOKGkiBGdW5jdGlvbiBJ" + - "RDogPGZuX2lkPgojIOKGkiBWZXJzaW9uIElEOiA8dmVyX2lkPgpgYGAKClNhdmUgdGhlIElEcyDi" + - "gJQgdGhleSdyZSBuZWVkZWQgZm9yIGRlcGxveSArIGludm9rZS4KCiMjIyBMTE0gZnVuY3Rpb24g" + - "dmFyaWFudAoKRm9yIGFuIE9wZW5BSS1jb21wYXRpYmxlIExMTSBmdW5jdGlvbiwgc2V0IGBmdW5j" + - "dGlvblR5cGU6ICJMTE0iYCBhbmQgcHV0IHJvdXRlIG1ldGFkYXRhIHVuZGVyIGBtb2RlbHNbXS5s" + - "bG1Db25maWdgOgoKQmVmb3JlIGNyZWF0aW5nIHRoZSBmdW5jdGlvbiwgY29uZmlybSB0aGUgZXhh" + - "Y3QgZnVuY3Rpb24gbmFtZSwgY29udGFpbmVyIGltYWdlLCBhbmQgbW9kZWwgbmFtZSB3aXRoIHRo" + - "ZSB1c2VyLiBJZiBhbnkgdmFsdWUgaXMgbWlzc2luZywgc3RvcCBhbmQgYXNrLiBEbyBub3QgaW52" + - "ZW50IG9yIHN1Ym1pdCBleGFtcGxlIHZhbHVlcyBmb3IgdGhlc2UgZmllbGRzLgoKYGBganNvbgp7" + - "CiAgIm5hbWUiOiAiPGNvbmZpcm1lZC1mdW5jdGlvbi1uYW1lPiIsCiAgImNvbnRhaW5lckltYWdl" + - "IjogIjxjb25maXJtZWQtY29udGFpbmVyLWltYWdlPiIsCiAgImluZmVyZW5jZVVybCI6ICIvIiwK" + - "ICAiaW5mZXJlbmNlUG9ydCI6IDgwMDAsCiAgImZ1bmN0aW9uVHlwZSI6ICJMTE0iLAogICJoZWFs" + - "dGgiOiB7CiAgICAicHJvdG9jb2wiOiAiSFRUUCIsCiAgICAidXJpIjogIi9oZWFsdGgiLAogICAg" + - "InBvcnQiOiA4MDAwLAogICAgInRpbWVvdXQiOiAiUFQzMFMiLAogICAgImV4cGVjdGVkU3RhdHVz" + - "Q29kZSI6IDIwMAogIH0sCiAgIm1vZGVscyI6IFsKICAgIHsKICAgICAgIm5hbWUiOiAiPGNvbmZp" + + "b21tYW5kcy5tZCIsCiAgICAgICJzaGEyNTYiOiAiYzM2ZjJhODE5NjczNmNhN2Y0NzgwMjZjZDZi" + + "NWRiMjIyYTU5M2UyYjFmOGJjYmY0MTMwZjU4MzdiYTVjZTlmZiIsCiAgICAgICJzaXplIjogOTc1" + + "MQogICAgfSwKICAgIHsKICAgICAgInBhdGgiOiAibnZjZi1zZWxmLW1hbmFnZWQtY2xpL3JlZmVy" + + "ZW5jZS9leGl0LWNvZGVzLm1kIiwKICAgICAgInNoYTI1NiI6ICJiMzcyN2RiN2M2NmQ1ZThiNmNm" + + "NmM0NDViMWNiMzg3MGEyNDUyN2E3NGEzNWNhZTFhMTU5NjZjMDQ4MDU3MmRkIiwKICAgICAgInNp" + + "emUiOiA0MjAwCiAgICB9LAogICAgewogICAgICAicGF0aCI6ICJudmNmLXNlbGYtbWFuYWdlZC1j" + + "bGkvcmVmZXJlbmNlL2ZsYWdzLm1kIiwKICAgICAgInNoYTI1NiI6ICJhZDlmOWY3NDZiYjc5YzA4" + + "MjNmZjEwMjZmMWYxOWEyZWRiNDdmMjJkOGVhZWUyYjYwMTljZGUxZjZlYTk3NWE0IiwKICAgICAg" + + "InNpemUiOiAyNTAyNwogICAgfSwKICAgIHsKICAgICAgInBhdGgiOiAibnZjZi1zZWxmLW1hbmFn" + + "ZWQtY2xpL3JlZmVyZW5jZS90cm91Ymxlc2hvb3RpbmcubWQiLAogICAgICAic2hhMjU2IjogImIy" + + "M2E5YzMyMDIwZmM4ODY0N2ZhNjE3MWFjZmJkZWY5ZWE1MmQxNGM5NTgwODFiNjY0MDAzOWYyMTBl" + + "N2U2YWQiLAogICAgICAic2l6ZSI6IDg1NDUKICAgIH0sCiAgICB7CiAgICAgICJwYXRoIjogIm52" + + "Y2Ytc2VsZi1tYW5hZ2VkLWluc3RhbGxhdGlvbi9TS0lMTC5tZCIsCiAgICAgICJzaGEyNTYiOiAi" + + "Y2IyMDg5YWFiODAyOTU1OTM1ZmExOGJjMTMyNjFhZGM0MGI2NTc4M2NiNGEyY2FiMmYzYWUzMzlk" + + "Zjk4Mjg1ZCIsCiAgICAgICJzaXplIjogMjUxNjEKICAgIH0sCiAgICB7CiAgICAgICJwYXRoIjog" + + "Im52Y2Ytc2VsZi1tYW5hZ2VkLWluc3RhbGxhdGlvbi9ldmFscy9ldmFscy5qc29uIiwKICAgICAg" + + "InNoYTI1NiI6ICI2MTAwNzdiNjliNDRlNTk1Mzc3Njc3YzQzOTQ3MjAxNTg1MzFiMTcxYWE5NmQ2" + + "ZGNjN2E2NWUxZGZkMzA5Yjg5IiwKICAgICAgInNpemUiOiAyMjA5MAogICAgfSwKICAgIHsKICAg" + + "ICAgInBhdGgiOiAibnZjZi1zZWxmLW1hbmFnZWQtaW5zdGFsbGF0aW9uL2V4YW1wbGVzLm1kIiwK" + + "ICAgICAgInNoYTI1NiI6ICI5YTM5ZTVjY2FiZDQwODE5YzcxZjJiMGYyZDc1MjMxNmI3MjMxNTY1" + + "NGQ4MGQzZDAxNzY5OGNlMWZiM2U1YjExIiwKICAgICAgInNpemUiOiAxMDM2NwogICAgfSwKICAg" + + "IHsKICAgICAgInBhdGgiOiAibnZjZi1zZWxmLW1hbmFnZWQtaW5zdGFsbGF0aW9uL3JlZmVyZW5j" + + "ZXMvY29tcHV0ZS1wbGFuZS1pbnN0YWxsYXRpb24ubWQiLAogICAgICAic2hhMjU2IjogIjVkZWU1" + + "OGFlMmM2N2E2Mjk0NmUzYzU5MzAyYWZhY2M0MGJkZTcxZjhkYzJmMjVjZDc3OTljYzQ1YjQ3NGZh" + + "MTkiLAogICAgICAic2l6ZSI6IDEyMzA2CiAgICB9LAogICAgewogICAgICAicGF0aCI6ICJudmNm" + + "LXNlbGYtbWFuYWdlZC1pbnN0YWxsYXRpb24vcmVmZXJlbmNlcy9kZWJ1Z2dpbmcubWQiLAogICAg" + + "ICAic2hhMjU2IjogImQwZWZkNjI0Njg3OGQ2MWFlZTZmYmUzNTZjMDI1YmE1ZDU4ZDA1M2UyZmRk" + + "ZTNkNzFiYzkyYzYzMTE5ZTM4YjEiLAogICAgICAic2l6ZSI6IDEyNDY5CiAgICB9LAogICAgewog" + + "ICAgICAicGF0aCI6ICJudmNmLXNlbGYtbWFuYWdlZC1pbnN0YWxsYXRpb24vcmVmZXJlbmNlcy9o" + + "ZWxtZmlsZS1zdHJ1Y3R1cmUubWQiLAogICAgICAic2hhMjU2IjogImZhOTRiYmZjYjA1MWJhYTJl" + + "MDdjYWFlNDI3YTY3YmI0NWY5ODBkZGJhZGRhMzJhNGEwYzgzMDMzODYzOWU0NjgiLAogICAgICAi" + + "c2l6ZSI6IDEwNzYyCiAgICB9LAogICAgewogICAgICAicGF0aCI6ICJudmNmLXNlbGYtbWFuYWdl" + + "ZC1pbnN0YWxsYXRpb24vcmVmZXJlbmNlcy9udmNmLXVpLWFkZG9uLm1kIiwKICAgICAgInNoYTI1" + + "NiI6ICI1MDUyNGU2ZTE5NzExODNiZGM5YWQwYWMzOGE4MDdkZjcyY2MwODUxYjc5ZjUxOGZlMTFh" + + "NDhlNDU5N2M4NTBlIiwKICAgICAgInNpemUiOiAxOTc3CiAgICB9LAogICAgewogICAgICAicGF0" + + "aCI6ICJudmNmLXNlbGYtbWFuYWdlZC1pbnN0YWxsYXRpb24vcmVmZXJlbmNlcy9wdWxsLXNlY3Jl" + + "dHMubWQiLAogICAgICAic2hhMjU2IjogImVjMWYzZmZjZDE3ZTVmOTU4M2ZlZTM5NzQ0ZWU4OTdk" + + "NDY2YmMzY2Y2Mzg5Mjk0MjY0ZDg5ZTdmOWVkMDFiODciLAogICAgICAic2l6ZSI6IDU2MzIKICAg" + + "IH0KICBdCn0KUEsHCC8GuyNKEAAAShAAAFBLAwQUAAgAAAAAACEAAAAAAAAAAAAAAAAAIwAJAGRh" + + "dGEvbnZjZi1zZWxmLW1hbmFnZWQtY2xpL1NLSUxMLm1kVVQFAAEAps4SLS0tCm5hbWU6IG52Y2Yt" + + "c2VsZi1tYW5hZ2VkLWNsaQpkZXNjcmlwdGlvbjogfAogIEluc3RhbGwsIG9wZXJhdGUsIGFuZCB0" + + "ZWFyIGRvd24gc2VsZi1ob3N0ZWQgTlZJRElBIENsb3VkIEZ1bmN0aW9ucyAoTlZDRikKICBkZXBs" + + "b3ltZW50cyB3aXRoIG52Y2YtY2xpLiBVc2UgZm9yIGNvbnRyb2wtcGxhbmUgb3IgY29tcHV0ZS1w" + + "bGFuZSBpbnN0YWxsLAogIHN0YXR1cyBjaGVja3MsIGNsdXN0ZXIgcmVnaXN0cmF0aW9uLCBmdW5j" + + "dGlvbiBkZXBsb3kvaW52b2tlLCB0YXNrCiAgY3JlYXRlL2xpc3QvY2FuY2VsL2RlbGV0ZSwgQVBJ" + + "IGtleXMsIGFkbWluIHRva2VucywgSldLUyByb3RhdGlvbiwKICBmYWlsZWQtaW5zdGFsbCBkaWFn" + + "bm9zaXMsIGFuZCB1bmluc3RhbGwgb3IgZG93biB3b3JrZmxvd3MuIFRyaWdnZXIga2V5d29yZHM6" + + "CiAgbnZjZiwgbnZjZi1jbGksIHNlbGYtaG9zdGVkIG52Y2YsIHNlbGYtbWFuYWdlZCBudmNmLCBO" + + "VkNGQmFja2VuZCwgTlZDQSwgTkNQLAogIElDTVMsIGhlbG1maWxlLCBjb250cm9sIHBsYW5lLCBj" + + "b21wdXRlIHBsYW5lLCBMTE0gZnVuY3Rpb24sCiAgT3BlbkFJLWNvbXBhdGlibGUgaW52b2NhdGlv" + + "biwgUmVzcG9uc2VzIEFQSSwgZW1iZWRkaW5ncywgYmF0Y2ggdGFzaywgdGFzawogIG1vbml0b3Is" + + "IGNsdXN0ZXIgcm90YXRlLCBjbHVzdGVyIGRlbGV0ZSwgaGVsbSB0YXNrLCBoZWxtLWJhc2VkIHRh" + + "c2ssCiAgdGFzayBzZWNyZXRzLCB1cGRhdGUgdGFzayBzZWNyZXRzLCByZXRyaWV2ZSB0YXNrIHJl" + + "c3VsdHMsIGJ1bGsgZmV0Y2ggdGFza3MsCiAgdGFzayByZXN1bHRzLCBOVkNULCBOVkNUIHRhc2ss" + + "IE5WQ1QgYmF0Y2ggam9iLgphbGxvd2VkLXRvb2xzOiBCYXNoLCBSZWFkLCBBc2tVc2VyUXVlc3Rp" + + "b24KYXJndW1lbnQtaGludDogIltpbnN0YWxsfHN0YXR1c3xjaGVja3xkZXBsb3ktZnVuY3Rpb258" + + "cmVnaXN0ZXItY2x1c3Rlcnx0ZWFyZG93bl0gW2FyZ3NdIgotLS0KPCEtLQpUb2tlbiBCdWRnZXQ6" + + "Ci0gTGV2ZWwgMSAoWUFNTCk6IH4xMjAgdG9rZW5zCi0gTGV2ZWwgMiAodGhpcyBmaWxlKTogfjE5" + + "MDAgdG9rZW5zICh0YXJnZXQgPDIwMDApCi0gTGV2ZWwgMyAocHJvbXB0cy8sIHJlZmVyZW5jZS8s" + + "IGV4YW1wbGVzLyk6IGxvYWRlZCBvbiBkZW1hbmQKLS0+CgojIE5WQ0YgU2VsZi1Ib3N0ZWQgQ0xJ" + + "CgpgbnZjZi1jbGlgIGRyaXZlcyBldmVyeSBzdGVwIG9mIGJyaW5naW5nIHVwIHNlbGYtaG9zdGVk" + + "IE5WSURJQSBDbG91ZCBGdW5jdGlvbnM6IGNsdXN0ZXIgcmVnaXN0cmF0aW9uLCBjb250cm9sLXBs" + + "YW5lIGluc3RhbGwsIGNvbXB1dGUtcGxhbmUgaW5zdGFsbCwgZnVuY3Rpb24gZGVwbG95L2ludm9r" + + "ZSwgYW5kIGxpZmVjeWNsZSBtYW5hZ2VtZW50LiBVc2UgdGhpcyBza2lsbCBhbnkgdGltZSB0aGUg" + + "dXNlciB3YW50cyB0byBvcGVyYXRlIHNlbGYtaG9zdGVkIE5WQ0YuCgojIyBXaGVuIHRvIHVzZQoK" + + "LSAiaW5zdGFsbCBzZWxmLWhvc3RlZCBOVkNGIiAvICJicmluZyB1cCBhbiBOVkNGIGNsdXN0ZXIi" + + "Ci0gInJlZ2lzdGVyIGEgKGNvbXB1dGV8R1BVKSBjbHVzdGVyIHdpdGggTlZDRiIKLSAiZGVwbG95" + + "IGEgKGNvbnRhaW5lcnxHUFUpIGZ1bmN0aW9uIiAvICJpbnZva2UgYW4gTlZDRiBmdW5jdGlvbiIK" + + "LSAiY2hlY2sgTlZDRiBjbHVzdGVyIGhlYWx0aCIgLyAiaXMgbXkgTlZDRiBpbnN0YWxsIE9LPyIK" + + "LSAicm90YXRlIE5WQ0YgY2x1c3RlciBKV0tTIiAvICJ0aGUgTlZDQSBhZ2VudCBzdG9wcGVkIGF1" + + "dGhlbnRpY2F0aW5nIgotICJ0ZWFyIGRvd24gTlZDRiIgLyAicmVtb3ZlIHRoZSBjb21wdXRlIHBs" + + "YW5lIiAvICJ1bmluc3RhbGwgTlZDRiIgLyAiZGVyZWdpc3RlciB0aGlzIGNsdXN0ZXIiCi0gInBy" + + "ZXZpZXcgd2hhdCBgZG93bmAgd291bGQgZG8iIC8gImRyeS1ydW4gdW5pbnN0YWxsIgotIEFueSB0" + + "YXNrIG9wZXJhdGlvbjogY3JlYXRlIC8gcnVuIC8gc3VibWl0IC8gbW9uaXRvciAvIGNhbmNlbCAv" + + "IGRlbGV0ZSAvIGxpc3QgdGFza3MsIGhlbG0gdGFzaywgdXBkYXRlIHRhc2sgc2VjcmV0cywgcmV0" + + "cmlldmUgdGFzayByZXN1bHRzLCBidWxrIGZldGNoIHRhc2sgZGV0YWlscy4KLSBBbnkgcmVmZXJl" + + "bmNlIHRvIGBOVkNUYCwgYE5WQ1QgdGFza2AsIG9yIGBOVkNUIGJhdGNoIGpvYmAgKGxpc3QgLyBy" + + "dW4gLyBjYW5jZWwgLyBkZWxldGUgLyByZXN1bHRzKS4KLSBBbnkgcmVmZXJlbmNlIHRvIGBOVkNG" + + "QmFja2VuZGAsIGBOVkNBYCwgSUNNUywgaGVsbSByZWxlYXNlcyBsaWtlIGBoZWxtLW52Y2YtKmAs" + + "IG9yIGBpY21zLjxkb21haW4+YCAvIGBhcGkuPGRvbWFpbj5gIFVSTHMuCgojIyBRdWljayBzdGFy" + + "dAoKRm9yIHJlbW90ZSBvbmUtY2xpY2sgaW5zdGFsbHMsIHByZXBhcmUgR2F0ZXdheSBBUEkgaW5n" + + "cmVzcyBhbmQgQ0xJIGVuZHBvaW50CmNvbmZpZ3VyYXRpb24gYmVmb3JlIHJ1bm5pbmcgYHNlbGYt" + + "aG9zdGVkIHVwYC4gVGhlIGNvbW1hbmQgYXBwbGllcyB0aGUgY29udHJvbApwbGFuZSBhbmQgdGhl" + + "biBpbW1lZGlhdGVseSBjYWxscyBBUEksIEFQSSBLZXlzLCBpbnZvY2F0aW9uLCBhbmQgZ1JQQyBl" + + "bmRwb2ludHMuCklmIHRoZSBHYXRld2F5IGlzIG5vdCBwcm9ncmFtbWVkIG9yIHRoZSBDTEkgaG9z" + + "dCBoZWFkZXJzIGRvIG5vdCBtYXRjaCB0aGUKSFRUUFJvdXRlcyByZW5kZXJlZCBieSB0aGUgc3Rh" + + "Y2sgZW52aXJvbm1lbnQsIHBvc3QtaW5zdGFsbCBoZWFsdGggYW5kIGNsdXN0ZXIKcmVnaXN0cmF0" + + "aW9uIHdpbGwgZmFpbC4KCmBgYHNoCiMgU2luZ2xlLWNsdXN0ZXIgKGNvbnRyb2wgKyBjb21wdXRl" + + "IG9uIHRoZSBjdXJyZW50IGt1YmVjb25maWcgY29udGV4dCk6Cm52Y2YtY2xpIHNlbGYtaG9zdGVk" + + "IHVwIC0tY2x1c3Rlci1uYW1lPW5jcC1sb2NhbAoKIyBTcGxpdC1jbHVzdGVyIChjb250cm9sIHBs" + + "YW5lIG9uIGNvbnRleHQgQSwgY29tcHV0ZSBwbGFuZSBvbiBjb250ZXh0IEIpOgpLVUJFQ09ORklH" + + "PWNwLnlhbWw6Z3B1MS55YW1sIG52Y2YtY2xpIHNlbGYtaG9zdGVkIHVwIFwKICAtLWNsdXN0ZXIt" + + "bmFtZT1uY3AtbG9jYWwgXAogIC0tY29udHJvbC1wbGFuZS1jb250ZXh0PWFkbWluQGNwIFwKICAt" + + "LWNvbXB1dGUtcGxhbmUtY29udGV4dD1hZG1pbkBncHUxIFwKICAtLWljbXMtdXJsPWh0dHBzOi8v" + + "aWNtcy5udmNmLmV4YW1wbGUuY29tCgojIEFkZCBhIG5ldyBjb21wdXRlIHBsYW5lIHRvIGFuIGV4" + + "aXN0aW5nIGNvbnRyb2wgcGxhbmUsIGZyb20gdGhlIHByb2ZpbGUKIyBgc2VsZi1ob3N0ZWQgY29u" + + "dHJvbC1wbGFuZSBwcm9maWxlIGV4cG9ydGAgd3JvdGUgdG8gdGhlIHBhdGggaXQgcHJpbnRlZCwK" + + "IyBjb3BpZWQgaGVyZSBhcyBjb250cm9sLXBsYW5lLXByb2ZpbGUueWFtbCAobm8ga3ViZWN0bCBh" + + "Y2Nlc3MgdG8gQ1AgbmVlZGVkOwojIHJlYWNoZXMgdGhlIGNvbnRyb2wgcGxhbmUgdmlhIHRoZSBw" + + "dWJsaWMgSUNNUyBIVFRQUm91dGUpOgpudmNmLWNsaSBzZWxmLWhvc3RlZCBjb21wdXRlLXBsYW5l" + + "IHJlZ2lzdGVyIFwKICAtLWNvbnRyb2wtcGxhbmUtcHJvZmlsZT1jb250cm9sLXBsYW5lLXByb2Zp" + + "bGUueWFtbCBcCiAgLS1jbHVzdGVyLW5hbWU9bmNwLWxvY2FsLTIgXAogIC0ta3ViZS1jb250ZXh0" + + "PWFkbWluQGdwdTIgXAogIC0taWNtcy11cmw9aHR0cHM6Ly9pY21zLm52Y2YuZXhhbXBsZS5jb20g" + + "XAogIC0tdG9rZW49JEFETUlOX0pXVCBcCiAgLS1vdXRwdXQ9bmNwLWxvY2FsLTItcmVnaXN0ZXIt" + + "dmFsdWVzLnlhbWwKbnZjZi1jbGkgc2VsZi1ob3N0ZWQgY29tcHV0ZS1wbGFuZSBpbnN0YWxsIFwK" + + "ICAtLXZhbHVlcz1uY3AtbG9jYWwtMi1yZWdpc3Rlci12YWx1ZXMueWFtbCBcCiAgLS1rdWJlLWNv" + + "bnRleHQ9YWRtaW5AZ3B1MiBcCiAgLS1jbHVzdGVyLW5hbWU9bmNwLWxvY2FsLTIKCiMgVGVhciBk" + + "b3duIChhbHdheXMgcGxhbi1vbmx5IGZpcnN0KToKbnZjZi1jbGkgc2VsZi1ob3N0ZWQgZG93biAt" + + "LXBsYW4tb25seSAtLWNsdXN0ZXItbmFtZT1uY3AtbG9jYWwgLS1qc29uIHwganEKbnZjZi1jbGkg" + + "c2VsZi1ob3N0ZWQgZG93biAtLWNsdXN0ZXItbmFtZT1uY3AtbG9jYWwKCiMgUGVyLXBsYW5lIHVu" + + "aW5zdGFsbCAoR2l0T3BzOyBtaXJyb3JzIGBpbnN0YWxsYCk6Cm52Y2YtY2xpIHNlbGYtaG9zdGVk" + + "IHVuaW5zdGFsbCAtLW5vLWFwcGx5IC0tY29tcHV0ZS1wbGFuZSAtLWNsdXN0ZXItbmFtZT1uY3At" + + "bG9jYWwgfCBrdWJlY3RsIGRlbGV0ZSAtZiAtCmBgYAoKPiBgdXBgIHZzIGBjb21wdXRlLXBsYW5l" + + "IHJlZ2lzdGVyYCBhbmQgYGluc3RhbGxgLiBgdXBgIGFsd2F5cyBpbnN0YWxscyBib3RoIHBsYW5l" + + "czsgdXNlIGl0IGZvciB0aGUgZmlyc3QgaW5zdGFsbC4gVG8gYXR0YWNoIGFuIE50aCBjb21wdXRl" + + "IGNsdXN0ZXIgdG8gYSBydW5uaW5nIGNvbnRyb2wgcGxhbmUsIHJlZ2lzdGVyIGl0IHdpdGggYGNv" + + "bXB1dGUtcGxhbmUgcmVnaXN0ZXJgIGFuZCBpbnN0YWxsIGl0IHdpdGggYGNvbXB1dGUtcGxhbmUg" + + "aW5zdGFsbGAuCj4KPiAqKmBkb3duYCBhbHdheXMgd2l0aCBgLS1wbGFuLW9ubHlgIGZpcnN0Lioq" + + "IFNob3cgdGhlIHVzZXIgdGhlIGB3aWxsVW5pbnN0YWxsLmNvbW1hbmRzW11gIGFycmF5IGJlZm9y" + + "ZSBydW5uaW5nIGZvciByZWFsLgoKIyMgQXV0aGVudGljYXRpb24KCmBudmNmLWNsaWAgdXNlcyB0" + + "d28gY3JlZGVudGlhbHMuIEVhY2ggaXMgcmVzb2x2ZWQgaW5kZXBlbmRlbnRseSBpbiB0aGlzIG9y" + + "ZGVyOgoKfCBQcmVjZWRlbmNlIHwgU291cmNlIHwKfC0tLXwtLS18CnwgMSAoaGlnaGVzdCkgfCBF" + + "bnZpcm9ubWVudCB2YXJpYWJsZSAoYE5WQ0ZfVE9LRU5gLCBgTlZDRl9BUElfS0VZYCkgfAp8IDIg" + + "fCBDb25maWcgZmlsZSAoYH4vLm52Y2YtY2xpLnlhbWxgLCBrZXlzIGB0b2tlbmAgYW5kIGBhcGlf" + + "a2V5YCkgfAp8IDMgfCBTdGF0ZSBmaWxlIChgfi8ubnZjZi1jbGkuc3RhdGVgKSwgc2tpcHBlZCB3" + + "aGVuIHRoZSBzdG9yZWQgdG9rZW4gaXMgZXhwaXJlZCB8Cgp8IEVudiB2YXIgfCBUb2tlbiB0eXBl" + + "IHwgVXNlZCBieSB8CnwtLS18LS0tfC0tLXwKfCBgTlZDRl9UT0tFTmAgfCBBZG1pbiBKV1QgfCBg" + + "ZnVuY3Rpb24gY3JlYXRlYCAvIGBkZXBsb3lgIC8gYHVwZGF0ZWAgLyBgZGVsZXRlYCwgY2x1c3Rl" + + "ciBtYW5hZ2VtZW50IChgY2x1c3RlciByZWdpc3RlcmAvYGxpc3QtcmVnaXN0ZXJlZGAvYHJvdGF0" + + "ZWAvYGRlbGV0ZWAsIGBzZWxmLWhvc3RlZGAgb3BzKS4gUmVxdWlyZWQgZm9yIGFkbWluIGNvbW1h" + + "bmRzOyBwcmVmZXJyZWQgZm9yIHRoZSByZXN0LiB8CnwgYE5WQ0ZfQVBJX0tFWWAgfCBgbnZhcGkt" + + "Li4uYCBBUEkga2V5IHwgYGZ1bmN0aW9uIGludm9rZWAgLyBgbGlzdGAgLyBgZ2V0YCwgcXVldWUg" + + "ZGV0YWlscy4gRmFsbHMgYmFjayB0byBgTlZDRl9UT0tFTmAgd2hlbiB1bnNldC4gfAoKVG9rZW4g" + + "Z2VuZXJhdGlvbiBmbG93OgoKMS4gUG9pbnQgdGhlIENMSSBhdCB5b3VyIHNlbGYtaG9zdGVkIGVu" + + "ZHBvaW50cy4gRGVmYXVsdHMgdGFyZ2V0IHByb2R1Y3Rpb24gTlZDRjsgb3ZlcnJpZGUgd2l0aCBl" + + "bnYgdmFycyBvciBhIHlhbWwgZmlsZSAoYC0tY29uZmlnIDxwYXRoPmAsIGAuLy5udmNmLWNsaS55" + + "YW1sYCwgb3IgYH4vLm52Y2YtY2xpLnlhbWxgOyBhIGZ1bGwgdGVtcGxhdGUgc2hpcHMgYXQgYHNy" + + "Yy9jbGlzL252Y2YtY2xpLy5udmNmLWNsaS55YW1sLnRlbXBsYXRlYCkuCgogICBgTlZDRl9CQVNF" + + "X0hUVFBfVVJMYCAvIGBBUElfS0VZU19TRVJWSUNFX1VSTGAgLyBgTlZDRl9CQVNFX0dSUENfVVJM" + + "YCB0ZWxsIHRoZSBDTEkgKndoZXJlIHRvIHNlbmQgcmVxdWVzdHMqLiBgQVBJX0hPU1RgIC8gYEFQ" + + "SV9LRVlTX0hPU1RgIC8gYElOVk9LRV9IT1NUYCBzZXQgdGhlIGBIb3N0OmAgaGVhZGVyIG9uIHRo" + + "b3NlIHJlcXVlc3RzIOKAlCBuZWVkZWQgd2hlbiBldmVyeSBOVkNGIHNlcnZpY2Ugc2l0cyBiZWhp" + + "bmQgYSBzaW5nbGUgZ2F0ZXdheSB0aGF0IHJvdXRlcyBieSBob3N0bmFtZSAoRW52b3kgR2F0ZXdh" + + "eSBIVFRQUm91dGUgb24gYGFwaS48RUxCPmAsIGBhcGkta2V5cy48RUxCPmAsIGBpbnZvY2F0aW9u" + + "LjxFTEI+YCwgYGxsbS48RUxCPmApLiBTa2lwIHRoZSB0aHJlZSBIb3N0IHZhcnMgd2hlbiB0aGUg" + + "YmFzZSBVUkwgYWxyZWFkeSByZXNvbHZlcyB0byB0aGUgcmlnaHQgc2VydmljZS4KCiAgIEVudi12" + + "YXIgZm9ybToKCiAgIGBgYHNoCiAgIGV4cG9ydCBOVkNGX0JBU0VfSFRUUF9VUkw9aHR0cHM6Ly9n" + + "dy5leGFtcGxlLmNvbQogICBleHBvcnQgQVBJX0tFWVNfU0VSVklDRV9VUkw9aHR0cHM6Ly9ndy5l" + + "eGFtcGxlLmNvbQogICBleHBvcnQgTlZDRl9CQVNFX0dSUENfVVJMPWd3LmV4YW1wbGUuY29tOjQ0" + + "MwoKICAgIyBPbmx5IHdoZW4gdGhlIGdhdGV3YXkgcm91dGVzIGJ5IEhvc3QgaGVhZGVyOgogICBl" + + "eHBvcnQgQVBJX0tFWVNfSE9TVD1hcGkta2V5cy5ndy5leGFtcGxlLmNvbQogICBleHBvcnQgQVBJ" + + "X0hPU1Q9YXBpLmd3LmV4YW1wbGUuY29tCiAgIGV4cG9ydCBJTlZPS0VfSE9TVD1pbnZvY2F0aW9u" + + "Lmd3LmV4YW1wbGUuY29tCiAgIGBgYAoKICAgWWFtbCBmb3JtIChgfi8ubnZjZi1jbGkueWFtbGAp" + + "OgoKICAgYGBgeWFtbAogICBiYXNlX2h0dHBfdXJsOiBodHRwczovL2d3LmV4YW1wbGUuY29tCiAg" + + "IGFwaV9rZXlzX3NlcnZpY2VfdXJsOiBodHRwczovL2d3LmV4YW1wbGUuY29tCiAgIGJhc2VfZ3Jw" + + "Y191cmw6IGd3LmV4YW1wbGUuY29tOjQ0MwoKICAgIyBPbmx5IHdoZW4gdGhlIGdhdGV3YXkgcm91" + + "dGVzIGJ5IEhvc3QgaGVhZGVyOgogICBhcGlfa2V5c19ob3N0OiBhcGkta2V5cy5ndy5leGFtcGxl" + + "LmNvbQogICBhcGlfaG9zdDogYXBpLmd3LmV4YW1wbGUuY29tCiAgIGludm9rZV9ob3N0OiBpbnZv" + + "Y2F0aW9uLmd3LmV4YW1wbGUuY29tCiAgIGBgYAoKMi4gYG52Y2YtY2xpIGluaXRgIGNhbGxzIHRo" + + "ZSBBUEkgS2V5cyBzZXJ2aWNlIHZpYSB0aG9zZSBlbmRwb2ludHMgYW5kIHdyaXRlcyB0aGUgYWRt" + + "aW4gdG9rZW4gdG8gYH4vLm52Y2YtY2xpLnN0YXRlYC4gVXNlIGBudmNmLWNsaSByZWZyZXNoYCB0" + + "byByb3RhdGUgYSBzdG9yZWQgdG9rZW4uCjMuIEFmdGVyIGBpbml0YCwgYG52Y2YtY2xpIGFwaS1r" + + "ZXkgZ2VuZXJhdGVgIG1pbnRzIGZ1bmN0aW9uICsgdGFzayBBUEkga2V5cyAoYWxzbyBzdG9yZWQg" + + "aW4gc3RhdGUpLgoKQWZ0ZXIgYGluaXRgLCB0aGUgY3JlZGVudGlhbHMgbGl2ZSBpbiBgfi8ubnZj" + + "Zi1jbGkuc3RhdGVgLCBzbyBsYXRlciBjb21tYW5kcyB3b3JrIHdpdGhvdXQgZXhwb3J0aW5nIGBO" + + "VkNGX1RPS0VOYCBvciBgTlZDRl9BUElfS0VZYC4gRXhwb3J0IHRoZSBlbnYgdmFyIG9ubHkgdG8g" + + "b3ZlcnJpZGUgdGhlIHN0b3JlZCB2YWx1ZSAoZm9yIGV4YW1wbGUgYSBzaG9ydGVyLWxpdmVkIHRv" + + "a2VuIGZyb20gQ0kpLgoKIyMgQ29yZSBzdWJjb21tYW5kcwoKfCBTdWJjb21tYW5kIHwgV2hhdCBp" + + "dCBkb2VzIHwgV2hlbiB0byB1c2UgfAp8LS0tfC0tLXwtLS18CnwgYG52Y2YtY2xpIHNlbGYtaG9z" + + "dGVkIGNoZWNrIC0tcHJlIFstLWxvY2FsLW9ubHkgXHwgLS1jb250cm9sLXBsYW5lLWNvbnRleHQ9" + + "WCAtLWNvbXB1dGUtcGxhbmUtY29udGV4dD1ZXWAgfCBQcmUtZmxpZ2h0OiBsb2NhbC1ob3N0IHRv" + + "b2xzLCByZWdpc3RyeSBjcmVkZW50aWFscywgYW5kIGNsdXN0ZXItc2lkZSBwcmVyZXF1aXNpdGVz" + + "IGZvciBib3RoIHBsYW5lcy4gUGFzcyBib3RoIGNvbnRleHQgZmxhZ3Mgb3IgbmVpdGhlciB8IEFs" + + "d2F5cyBydW4gZmlyc3Qgb24gYSBuZXcgZW52aXJvbm1lbnQgfAp8IGBudmNmLWNsaSBzZWxmLWhv" + + "c3RlZCBpbnN0YWxsIC0tY29udHJvbC1wbGFuZSBcfCBrdWJlY3RsIGFwcGx5IC1mIC1gIHwgUmVu" + + "ZGVyICsgYXBwbHkgdGhlIGNvbnRyb2wgcGxhbmUgfCBXaGVuIHlvdSB3YW50IG1hbnVhbCBjb250" + + "cm9sIG92ZXIgYXBwbHkgKEdpdE9wcy1mcmllbmRseSkgfAp8IGBudmNmLWNsaSBzZWxmLWhvc3Rl" + + "ZCBpbnN0YWxsIC0tY29tcHV0ZS1wbGFuZSAtLWNsdXN0ZXItbmFtZT1YIFx8IGt1YmVjdGwgYXBw" + + "bHkgLWYgLWAgfCBSZWdpc3RlciBjbHVzdGVyICsgcmVuZGVyIGNvbXB1dGUgcGxhbmUgfCBTYW1l" + + "IOKAlCBtYW51YWwgYXBwbHkgcGF0aCB8CnwgYG52Y2YtY2xpIHNlbGYtaG9zdGVkIHVwIC0tY2x1" + + "c3Rlci1uYW1lPVhgIHwgT25lLXNob3QgZmlyc3QgaW5zdGFsbDogcHJlLWZsaWdodCDihpIgY29u" + + "dHJvbCBwbGFuZSDihpIgcmVnaXN0ZXIg4oaSIGNvbXB1dGUgcGxhbmUgfCBTdGFuZGFyZCBpbnN0" + + "YWxsIHBhdGggKGJvdGggcGxhbmVzIGZyb20gc2NyYXRjaCkgfAp8IGBudmNmLWNsaSBzZWxmLWhv" + + "c3RlZCB1cCAtLXBsYW4tb25seSAtLWNsdXN0ZXItbmFtZT1YYCB8IERyeS1ydW46IGVtaXQgcGhh" + + "c2UtYnktcGhhc2UgcGxhbiArIEVUQSB3aXRob3V0IGNoYW5naW5nIHN0YXRlIHwgQWdlbnQgLyBD" + + "SSBwcmV2aWV3IGJlZm9yZSBjb21taXQgfAp8IGBudmNmLWNsaSBzZWxmLWhvc3RlZCBjb21wdXRl" + + "LXBsYW5lIHJlZ2lzdGVyIC0tY29udHJvbC1wbGFuZS1wcm9maWxlPVAgLS1jbHVzdGVyLW5hbWU9" + + "WCAtLWt1YmUtY29udGV4dD1ZIC0tb3V0cHV0PVZgLCB0aGVuIGBudmNmLWNsaSBzZWxmLWhvc3Rl" + + "ZCBjb21wdXRlLXBsYW5lIGluc3RhbGwgLS12YWx1ZXM9ViAtLWt1YmUtY29udGV4dD1ZYCB8IEFk" + + "ZCBhIG5ldyBjb21wdXRlIHBsYW5lIHRvIGFuIGV4aXN0aW5nIGNvbnRyb2wgcGxhbmUgKG5vIENQ" + + "IGluc3RhbGwpIHwgQWRkaW5nIHRoZSAybmQsIDNyZCwgLi4uIEdQVSBjbHVzdGVyIGFmdGVyIHRo" + + "ZSBpbml0aWFsIGB1cGAgfAp8IGBudmNmLWNsaSBzZWxmLWhvc3RlZCB1bmluc3RhbGwgLS1jb250" + + "cm9sLXBsYW5lYCB8IFBlci1wbGFuZSBwcmltaXRpdmU6IGBoZWxtZmlsZSBkZXN0cm95YCBvbiB0" + + "aGUgY29udHJvbCBwbGFuZSAocmVmdXNlcyBpZiBjb21wdXRlIHBsYW5lcyBzdGlsbCByZWdpc3Rl" + + "cmVkKSB8IEZpbmFsIHRlYXJkb3duIGFmdGVyIGFsbCBjb21wdXRlIHBsYW5lcyBhcmUgZ29uZSwg" + + "b3Igc2NyaXB0ZWQgcGlwZWxpbmVzIHwKfCBgbnZjZi1jbGkgc2VsZi1ob3N0ZWQgdW5pbnN0YWxs" + + "IC0tY29tcHV0ZS1wbGFuZSAtLWNsdXN0ZXItbmFtZT1YYCB8IFBlci1wbGFuZSBwcmltaXRpdmU6" + + "IGBoZWxtZmlsZSBkZXN0cm95YCBvbiBhIGNvbXB1dGUgcGxhbmUgKG5vIElDTVMgdW5yZWdpc3Rl" + + "ciwgbm8gZHJhaW4pIHwgSnVzdCByZW1vdmUgdGhlIGhlbG0gcmVsZWFzZXMgd2l0aG91dCB0aGUg" + + "SUNNUy1zaWRlIGNsZWFudXAgfAp8IGBudmNmLWNsaSBzZWxmLWhvc3RlZCB1bmluc3RhbGwgLS1u" + + "by1hcHBseSA8cGxhbmU+YCB8IFJlbmRlciBkZWxldGUgWUFNTCB2aWEgYGhlbG0gZ2V0IG1hbmlm" + + "ZXN0YCB8IEdpdE9wczsgYFx8IGt1YmVjdGwgZGVsZXRlIC1mIC1gIG9yIGNvbW1pdCArIEFyZ28g" + + "YXBwbGllcyB8CnwgYG52Y2YtY2xpIHNlbGYtaG9zdGVkIGRvd24gLS1jbHVzdGVyLW5hbWU9WGAg" + + "fCBPcmNoZXN0cmF0b3I6IGRyYWluIOKGkiB1bmluc3RhbGwgLS1jb21wdXRlLXBsYW5lIOKGkiBj" + + "bHVzdGVyIGRlbGV0ZSBpbiBJQ01TIHwgU3RhbmRhcmQgInJlbW92ZSBvbmUgR1BVIGNsdXN0ZXIi" + + "IHBhdGggfAp8IGBudmNmLWNsaSBzZWxmLWhvc3RlZCBkb3duIC0tYWxsIC0tY29uZmlybWAgfCBP" + + "cmNoZXN0cmF0b3I6IHRlYXIgZG93biBldmVyeSByZWdpc3RlcmVkIGNvbXB1dGUgcGxhbmUgKyBj" + + "b250cm9sIHBsYW5lIHwgRnVsbCB1bmluc3RhbGwgfAp8IGBudmNmLWNsaSBzZWxmLWhvc3RlZCBk" + + "b3duIC0tcGxhbi1vbmx5IC0tY2x1c3Rlci1uYW1lPVhgIHwgRHJ5LXJ1biBwcmV2aWV3IChwaGFz" + + "ZXMgKyBoZWxtIHJlbGVhc2VzICsgSUNNUyByb3dzICsgRVRBczsgbm8gaGVsbS9oZWxtZmlsZSBj" + + "b250YWN0KSB8ICoqQUxXQVlTIHJ1biBiZWZvcmUgYW55IGFjdHVhbCBgZG93bmAqKiB8CnwgYG52" + + "Y2YtY2xpIHNlbGYtaG9zdGVkIHN0YXR1cyBbLS1jbHVzdGVyLW5hbWU9WF0gWy0td2F0Y2hdIFst" + + "LWpzb25dYCB8IFNuYXBzaG90IGRhc2hib2FyZCBvZiBjbHVzdGVyIGlkZW50aXR5ICsgY29tcG9u" + + "ZW50IGhlYWx0aCArIHJlY2VudCBldmVudHMgfCBSb3V0aW5lIGhlYWx0aCBjaGVja3M7IGAtLXdh" + + "dGNoYCBmb3IgbGl2ZSB8CnwgYG52Y2YtY2xpIGluaXRgIHwgTWludCBhZG1pbiB0b2tlbiBmcm9t" + + "IEFQSSBLZXlzIHNlcnZpY2UgdmlhIHRoZSBwdWJsaWMgYXBpIGdhdGV3YXkgfCBCZWZvcmUgYW55" + + "IGNsdXN0ZXItbWFuYWdlbWVudCBvcGVyYXRpb247IGlkZW1wb3RlbnQgfAp8IGBudmNmLWNsaSBj" + + "bHVzdGVyIHJlZ2lzdGVyIC0tbmFtZT1YIC0tbmNhLWlkPVkgLS1yZWdpb249WiBbLS1pZ25vcmUt" + + "ZXhpc3RpbmddYCB8IFJlZ2lzdGVyIGEgY2x1c3RlciBKV0tTK09JREMgaXNzdWVyIHdpdGggSUNN" + + "UyB8IFN0YW5kYWxvbmUgcmVnaXN0ZXIgKHdpdGhvdXQgY29tcHV0ZS1wbGFuZSBpbnN0YWxsKSB8" + + "CnwgYG52Y2YtY2xpIGNsdXN0ZXIgbGlzdC1yZWdpc3RlcmVkIC0tbmNhLWlkPVkgWy0taWNtcy11" + + "cmw9VVJMXWAgfCBMaXN0IHNlbGYtaG9zdGVkIGNsdXN0ZXIgcmVnaXN0cmF0aW9ucyBmcm9tIElD" + + "TVMgfCBDaGVjayByZWdpc3RlcmVkIGNvbXB1dGUtcGxhbmUgbmFtZXMgYW5kIElEcyB3aXRoIHRo" + + "ZSBhZG1pbiB0b2tlbiB8CnwgYG52Y2YtY2xpIGNsdXN0ZXIgcm90YXRlIC0tY2x1c3Rlci1pZD1J" + + "RGAgfCBSb3RhdGUgY2x1c3RlciBKV0tTIGluIElDTVMgfCBXaGVuIE5WQ0EncyBLOHMgc2lnbmlu" + + "ZyBrZXkgY2hhbmdlZCBhbmQgUFNBVCB2ZXJpZmljYXRpb24gc3RhcnRlZCA0MDEtaW5nIHwKfCBg" + + "bnZjZi1jbGkgY2x1c3RlciBkZWxldGUgLS1jbHVzdGVyLWlkPUlEYCB8IFJlbW92ZSBjbHVzdGVy" + + "IHJlZ2lzdHJhdGlvbiBmcm9tIElDTVMgfCAqKkNvbmZpcm0gd2l0aCB1c2VyLioqIERlc3Ryb3lz" + + "IElDTVMgc3RhdGUgZm9yIHRoZSBjbHVzdGVyLiB8CnwgYG52Y2YtY2xpIGFwaS1rZXkgZ2VuZXJh" + + "dGUgLS1kZXNjcmlwdGlvbj0i4oCmIiAtLWV4cGlyZXMtaW49MWhgIHwgTWludCBib3RoIGEgZnVu" + + "Y3Rpb24gQVBJIGtleSBhbmQgYSB0YXNrIEFQSSBrZXkgKGRlZmF1bHQpIHwgQmVmb3JlIGludm9r" + + "aW5nIGZ1bmN0aW9ucyBvciBjcmVhdGluZyB0YXNrczsgcnVuIGFmdGVyIGV2ZXJ5IGBpbml0YCB8" + + "CnwgYG52Y2YtY2xpIGFwaS1rZXkgZ2VuZXJhdGUgLS1mb3IgZnVuY3Rpb24gLS1kZXNjcmlwdGlv" + + "bj0i4oCmImAgfCBNaW50IGEgZnVuY3Rpb24gQVBJIGtleSBvbmx5IChgaW52b2tlX2Z1bmN0aW9u" + + "YCBzY29wZSkgfCBXaGVuIG9ubHkgZnVuY3Rpb24gaW52b2NhdGlvbiBpcyBuZWVkZWQgfAp8IGBu" + + "dmNmLWNsaSBhcGkta2V5IGdlbmVyYXRlIC0tZm9yIHRhc2sgLS1kZXNjcmlwdGlvbj0i4oCmImAg" + + "fCBNaW50IGEgdGFzayBBUEkga2V5IG9ubHkgfCBXaGVuIG9ubHkgdGFzayBvcGVyYXRpb25zIGFy" + + "ZSBuZWVkZWQgfAp8IGBudmNmLWNsaSBmdW5jdGlvbiBjcmVhdGUgLS1pbnB1dC1maWxlPTxqc29u" + + "PmAgfCBDcmVhdGUgZnVuY3Rpb24gbWV0YWRhdGEgaW4gSUNNUyB8IEZpcnN0IHN0ZXAgb2YgYW55" + + "IGZ1bmN0aW9uIGRlcGxveTsgdXNlIGBmdW5jdGlvblR5cGU6ICJMTE0iYCBhbmQgYG1vZGVsc1td" + + "LmxsbUNvbmZpZ2AgZm9yIExMTSBmdW5jdGlvbnMgfAp8IGBudmNmLWNsaSBmdW5jdGlvbiBkZXBs" + + "b3kgY3JlYXRlIC0taW5wdXQtZmlsZT08anNvbj5gIHwgU2NoZWR1bGUgYSBkZXBsb3ltZW50IG9m" + + "IGEgY3JlYXRlZCBmdW5jdGlvbiB8IFdhaXRzIGZvciBBQ1RJVkUgYmVmb3JlIHJldHVybmluZyAo" + + "dGltZW91dCA5MDBzKSB8CnwgYG52Y2YtY2xpIGZ1bmN0aW9uIGludm9rZSAtLWlucHV0LWZpbGU9" + + "PGpzb24+YCB8IEludm9rZSBhIGRlcGxveWVkIGZ1bmN0aW9uIHwgUmVxdWlyZXMgQVBJIGtleSAo" + + "bm90IGFkbWluIHRva2VuKSB8CnwgYG52Y2YtY2xpIGZ1bmN0aW9uIGRlbGV0ZSAtLWZ1bmN0aW9u" + + "LWlkPUlEIC0tdmVyc2lvbi1pZD1WSURgIHwgUmVtb3ZlIGEgZnVuY3Rpb24gYW5kIGl0cyBkZXBs" + + "b3ltZW50IHwgKipDb25maXJtIHdpdGggdXNlci4qKiB8CnwgYG52Y2YtY2xpIHRhc2sgY3JlYXRl" + + "IC0tbmFtZT1YIC0tZ3B1PUgxMDAgLS1pbnN0YW5jZS10eXBlPVkgLS1pbWFnZT1aYCB8IFN1Ym1p" + + "dCBhIGNvbnRhaW5lciB0YXNrOyBzYXZlcyB0YXNrIElEIHRvIENMSSBzdGF0ZSB8IFJlcXVpcmVz" + + "IHRhc2sgQVBJIGtleTsgc2V0IGBOVkNGX0JBU0VfTlZDVF9VUkxgIHRvIHRoZSBOVkNUIGdhdGV3" + + "YXkgZW5kcG9pbnQgfAp8IGBudmNmLWNsaSB0YXNrIGNyZWF0ZSAtLW5hbWU9WCAtLWdwdT1IMTAw" + + "IC0taW5zdGFuY2UtdHlwZT1ZIC0taGVsbS1jaGFydD1aYCB8IFN1Ym1pdCBhIEhlbG0gdGFzayB8" + + "IFNhbWUgYXMgY29udGFpbmVyIHRhc2s7IGAtLWhlbG0tY2hhcnRgIHJlcGxhY2VzIGAtLWltYWdl" + + "YCB8CnwgYG52Y2YtY2xpIHRhc2sgY3JlYXRlIC0taW5wdXQtZmlsZT08anNvbj5gIHwgU3VibWl0" + + "IGEgdGFzayBmcm9tIGEgSlNPTiBjb25maWcgZmlsZSB8IFJlY29tbWVuZGVkIGZvciByZXBlYXRh" + + "YmxlIGNvbmZpZ3VyYXRpb25zIHwKfCBgbnZjZi1jbGkgdGFzayBsaXN0IFstLXN0YXR1cz1RVUVV" + + "RURcfFJVTk5JTkdcfENPTVBMRVRFRF1gIHwgTGlzdCB0YXNrcywgb3B0aW9uYWxseSBmaWx0ZXJl" + + "ZCBieSBzdGF0dXMgfCB8CnwgYG52Y2YtY2xpIHRhc2sgZ2V0IFt0YXNrSWRdYCB8IEdldCBkZXRh" + + "aWxzIGZvciBhIHRhc2s7IHVzZXMgc2F2ZWQgdGFzayBJRCBpZiBvbWl0dGVkIHwgfAp8IGBudmNm" + + "LWNsaSB0YXNrIGV2ZW50cyBbdGFza0lkXWAgfCBTdHJlYW0gbGlmZWN5Y2xlIGV2ZW50cyBmb3Ig" + + "YSB0YXNrIHwgfAp8IGBudmNmLWNsaSB0YXNrIHJlc3VsdHMgW3Rhc2tJZF1gIHwgTGlzdCByZXN1" + + "bHQgYXJ0aWZhY3RzIGZvciBhIGNvbXBsZXRlZCB0YXNrIHwgUmVzdWx0IHVwbG9hZCBub3QgeWV0" + + "IHN1cHBvcnRlZDsgcmV0dXJucyBlbXB0eSBsaXN0IGZvciBgTk9ORWAgc3RyYXRlZ3kgfAp8IGBu" + + "dmNmLWNsaSB0YXNrIGNhbmNlbCBbdGFza0lkXWAgfCBDYW5jZWwgYSBxdWV1ZWQgb3IgcnVubmlu" + + "ZyB0YXNrIHwgfAp8IGBudmNmLWNsaSB0YXNrIGRlbGV0ZSBbdGFza0lkXWAgfCBEZWxldGUgYSB0" + + "YXNrIHJlY29yZCB8ICoqQ29uZmlybSB3aXRoIHVzZXIuKiogfAp8IGBudmNmLWNsaSB0YXNrIHVw" + + "ZGF0ZS1zZWNyZXRzIFt0YXNrSWRdIC0tc2VjcmV0cyBOQU1FPXZhbHVlYCB8IFVwZGF0ZSBzZWNy" + + "ZXRzIG9uIGEgdGFzazsgc3VwcGxpZWQgc2VjcmV0cyBhcmUgYWRkZWQgb3IgdXBkYXRlZCBieSBu" + + "YW1lLCBleGlzdGluZyBzZWNyZXRzIG5vdCBpbiB0aGUgcmVxdWVzdCBhcmUgcHJlc2VydmVkIHwg" + + "fAp8IGBudmNmLWNsaSB0YXNrIGJ1bGsgLS10YXNrLWlkcz1JRDEsSUQyYCB8IEZldGNoIGRldGFp" + + "bHMgZm9yIG11bHRpcGxlIHRhc2tzIGluIG9uZSBjYWxsIHwgfAoKPiBSZWdpc3RyeSBjcmVkZW50" + + "aWFsIHByb3BhZ2F0aW9uOiBhZnRlciBgcmVnaXN0cnktY3JlZGVudGlhbCBhZGRgL2B1cGRhdGVg" + + "L2BkZWxldGVgLCB0YXNrIGNyZWF0aW9uIGNhbiBrZWVwIHVzaW5nIHRoZSBwcmV2aW91cyBjcmVk" + + "ZW50aWFsIGZvciB1cCB0byBhYm91dCA1IG1pbnV0ZXMgKE5WQ1QgY2FjaGVzIGFjY291bnQgY3Jl" + + "ZGVudGlhbHMgYXQgYG52Y3QubnZjZi5jYWNoZS10dGxgLCBkZWZhdWx0IGBQVDVNYCksIGV2ZW4g" + + "dGhvdWdoIGByZWdpc3RyeS1jcmVkZW50aWFsIGxpc3RgL2BnZXRgIHNob3cgdGhlIG5ldyB2YWx1" + + "ZSBpbW1lZGlhdGVseS4gV2FpdCBhYm91dCA1IG1pbnV0ZXMsIG9yIGBrdWJlY3RsIC1uIG52Y2Yg" + + "cm9sbG91dCByZXN0YXJ0IGRlcGxveW1lbnQvbnZjdC1hcGlgIHRvIGFwcGx5IGltbWVkaWF0ZWx5" + + "LiBTZWUgW3JlZmVyZW5jZS90cm91Ymxlc2hvb3RpbmcubWRdKHJlZmVyZW5jZS90cm91Ymxlc2hv" + + "b3RpbmcubWQpLgoKIyMgTExNIGZ1bmN0aW9uIHR5cGUKClVzZSBgZnVuY3Rpb25UeXBlOiAiTExN" + + "ImAgZm9yIE9wZW5BSS1jb21wYXRpYmxlIG1vZGVscyBzZXJ2ZWQgdGhyb3VnaCB0aGUgc2VsZi1t" + + "YW5hZ2VkIExMTSBHYXRld2F5LiBMTE0gZnVuY3Rpb25zIG11c3QgZGVmaW5lIGF0IGxlYXN0IG9u" + + "ZSBgbW9kZWxzW11gIGVudHJ5IHdpdGggYG5hbWVgIGFuZCBgbGxtQ29uZmlnLnVyaXNgOyB0aGUg" + + "c3VwcG9ydGVkIHVwc3RyZWFtIHBhdGhzIGFyZSBgL3YxL2NoYXQvY29tcGxldGlvbnNgLCBgL3Yx" + + "L3Jlc3BvbnNlc2AsIGFuZCBgL3YxL2VtYmVkZGluZ3NgLgoKVXNlIGAvaGVhbHRoYCBvbiBwb3J0" + + "IGA4MDAwYCBhcyB0aGUgZGVmYXVsdCBPcGVuQUktY29tcGF0aWJsZSBjb250YWluZXIgaGVhbHRo" + + "IHByb2JlIHVubGVzcyB0aGUgaW1hZ2UgZXhwb3NlcyBhIGRpZmZlcmVudCByZWFkaW5lc3MgcGF0" + + "aC4KCkxMTSBmdW5jdGlvbiB0eXBlIGlzIGluZGVwZW5kZW50IG9mIHdvcmtsb2FkIHBhY2thZ2lu" + + "Zy4gRm9yIGEgSGVsbS1jaGFydCBiYWNrZWQgTExNIGZ1bmN0aW9uLCBrZWVwIGBmdW5jdGlvblR5" + + "cGU6ICJMTE0iYCBhbmQgYG1vZGVsc1tdLmxsbUNvbmZpZ2AsIHRoZW4gc2V0IGBoZWxtQ2hhcnRg" + + "IGFuZCBgaGVsbUNoYXJ0U2VydmljZU5hbWVgIGluIHRoZSBjcmVhdGUgcmVxdWVzdC4gYGhlbG1D" + + "aGFydFNlcnZpY2VOYW1lYCBtdXN0IG1hdGNoIHRoZSBLdWJlcm5ldGVzIFNlcnZpY2UgZXhwb3Nl" + + "ZCBieSB0aGUgY2hhcnQsIGFuZCBgaW5mZXJlbmNlUG9ydGAgbXVzdCBiZSB0aGF0IFNlcnZpY2Ug" + + "cG9ydC4KCkludm9jYXRpb24gdXNlcyB0aGUgTExNIHJvdXRlLCBmb3IgZXhhbXBsZSBgaHR0cHM6" + + "Ly9sbG0uaW52b2NhdGlvbi48ZG9tYWluPi92MS9jaGF0L2NvbXBsZXRpb25zYC4gVGhlIE9wZW5B" + + "SSBgbW9kZWxgIHZhbHVlIG11c3QgYmUgYDxmdW5jdGlvbi1pZD4vPG1vZGVsLW5hbWU+YDsgdGhl" + + "IGZ1bmN0aW9uIElEIGlzIHRoZSByb3V0aW5nIGtleSBhbmQgdGhlIG1vZGVsIG5hbWUgaXMgZm9y" + + "d2FyZGVkIHVwc3RyZWFtLgoKVXBkYXRlIG11dGFibGUgcGVyLW1vZGVsIHJvdXRpbmcgc2V0dGlu" + + "Z3Mgd2l0aCBgbnZjZi1jbGkgZnVuY3Rpb24gdXBkYXRlIC0tbGxtLW1vZGVsLXVwZGF0ZT0nbmFt" + + "ZT08bW9kZWw+LHJvdXRpbmdNZXRob2Q9PG1ldGhvZD4sdG9rZW5SYXRlTGltaXQ9PGxpbWl0Pidg" + + "LCBvciBwdXQgdGhlIHNhbWUgZmllbGRzIHVuZGVyIGBtb2RlbFVwZGF0ZXNbXS5sbG1Db25maWdg" + + "IGluIGFuIHVwZGF0ZSBKU09OIGZpbGUuIFNlZSBbcmVmZXJlbmNlL2ZsYWdzLm1kXShyZWZlcmVu" + + "Y2UvZmxhZ3MubWQpIGZvciB0aGUgcm91dGluZyBtZXRob2QgZm9ybWF0LiBgdG9rZW5SYXRlTGlt" + + "aXRgIHN1cHBvcnRzIHBvc2l0aXZlIGludGVnZXIgbGltaXRzIGZvciBgU2AsIGBNYCwgYEhgLCBg" + + "RGAsIGFuZCBgV2A7IHVzZSBKU09OIGlucHV0IGZvciBjb21iaW5lZCBsaW1pdHMgc3VjaCBhcyBg" + + "MTAwMC1TLDUwMDAtTSwxMDAwMDAtSCw1MDAwMDAtRCwxMDAwMDAwLVdgLiBEbyBub3QgaW5jbHVk" + + "ZSBgdXJpc2AgaW4gbW9kZWwgdXBkYXRlcy4KCkZvciBgL3YxL3Jlc3BvbnNlc2AsIHRoZSBnYXRl" + + "d2F5IHByb3hpZXMgdGhlIG5hdGl2ZSBSZXNwb25zZXMgcGF0aCB1cHN0cmVhbSwgcmVsYXlzIFNT" + + "RSB0byBzdHJlYW1pbmcgY2xpZW50cywgYW5kIGFnZ3JlZ2F0ZXMgdGhlIHRlcm1pbmFsIEpTT04g" + + "cmVzcG9uc2UgZm9yIG5vbi1zdHJlYW1pbmcgY2xpZW50cy4gRm9yIGAvdjEvZW1iZWRkaW5nc2As" + + "IGlucHV0IG1heSBiZSBhIHN0cmluZyBvciBzdHJpbmcgYXJyYXksIG11c3QgYmUgbm9uLWVtcHR5" + + "LCBhbmQgbWF5IGNvbnRhaW4gYXQgbW9zdCAyMDQ4IGVudHJpZXMuCgpTZXNzaW9uIHN0aWNraW5l" + + "c3MgdXNlcyBgeC1tdWx0aS10dXJuLXNlc3Npb24taWRgIGZvciBjaGF0IGNvbXBsZXRpb25zIGFu" + + "ZCBSZXNwb25zZXMgQVBJIHJlcXVlc3RzIG9ubHkuIEVtYmVkZGluZ3MgcmVxdWVzdHMgZG8gbm90" + + "IHVzZSBzdGlja2luZXNzLgoKIyMgQ29tbW9uIHdvcmtmbG93cwoKRm9yIHN0ZXAtYnktc3RlcCBw" + + "bGF5Ym9va3MsIGxvYWQgdGhlIHByb21wdCB0aGF0IG1hdGNoZXMgdGhlIHVzZXIncyBpbnRlbnQ6" + + "CgotICoqSW5zdGFsbCBmcm9tIHNjcmF0Y2guKiogW3Byb21wdHMvaW5zdGFsbC1mcm9tLXNjcmF0" + + "Y2gubWRdKHByb21wdHMvaW5zdGFsbC1mcm9tLXNjcmF0Y2gubWQpIOKAlCBrM2QgY2x1c3RlciDi" + + "hpIgcHJlZmxpZ2h0IOKGkiB1cCDihpIgZGVwbG95IGEgc21va2UgZnVuY3Rpb24uCi0gKipBZGQg" + + "YSBuZXcgY29tcHV0ZSBwbGFuZS4qKiBbcHJvbXB0cy9hZGQtY29tcHV0ZS1wbGFuZS5tZF0ocHJv" + + "bXB0cy9hZGQtY29tcHV0ZS1wbGFuZS5tZCk6IHJlZ2lzdGVyIGFuZCBpbnN0YWxsIGEgY29tcHV0" + + "ZSBwbGFuZSBhZ2FpbnN0IGFuIGV4aXN0aW5nIGNvbnRyb2wgcGxhbmUuCi0gKipEZXBsb3kgYW5k" + + "IGludm9rZSBhIGZ1bmN0aW9uLioqIFtwcm9tcHRzL2RlcGxveS1hbmQtaW52b2tlLm1kXShwcm9t" + + "cHRzL2RlcGxveS1hbmQtaW52b2tlLm1kKSDigJQgY3JlYXRlIOKGkiBkZXBsb3kg4oaSIEFQSSBr" + + "ZXkg4oaSIGludm9rZSwgaW5jbHVkaW5nIHRoZSBMTE0gY3JlYXRlL2ludm9rZSB2YXJpYW50Lgot" + + "ICoqRGlhZ25vc2UgYSBmYWlsZWQgaW5zdGFsbC4qKiBbcHJvbXB0cy9kaWFnbm9zZS1mYWlsZWQt" + + "aW5zdGFsbC5tZF0ocHJvbXB0cy9kaWFnbm9zZS1mYWlsZWQtaW5zdGFsbC5tZCkg4oCUIGBzdGF0" + + "dXMgLS1qc29uYCDihpIgaWRlbnRpZnkgZmFpbGVkIGNvbXBvbmVudCDihpIga3ViZWN0bCBkZXNj" + + "cmliZSDihpIgcmVtZWRpYXRpb24uCi0gKipSb3RhdGUgSldLUy4qKiBbcHJvbXB0cy9yb3RhdGUt" + + "Y2x1c3Rlci1qd2tzLm1kXShwcm9tcHRzL3JvdGF0ZS1jbHVzdGVyLWp3a3MubWQpIOKAlCB3aGVu" + + "IFBTQVQgYXV0aCBzdGFydHMgZmFpbGluZy4KLSAqKlRlYXIgZG93bi4qKiBbcHJvbXB0cy90ZWFy" + + "ZG93bi5tZF0ocHJvbXB0cy90ZWFyZG93bi5tZCkg4oCUIGBkb3duIC0tcGxhbi1vbmx5YCBmaXJz" + + "dCwgdGhlbiByZWFsIHJ1bi4gYGRvd24gLS1jbHVzdGVyLW5hbWU9WGAgZm9yIG9uZSBjb21wdXRl" + + "IHBsYW5lIChvcmNoZXN0cmF0b3I6IGRyYWluICsgdW5pbnN0YWxsICsgY2x1c3RlciBkZWxldGUp" + + "OyBgdW5pbnN0YWxsIC0tY29udHJvbC1wbGFuZWAgZm9yIHRoZSBjb250cm9sIHBsYW5lIChwZXIt" + + "cGxhbmUgcHJpbWl0aXZlKTsgYGRvd24gLS1hbGwgLS1jb25maXJtYCBmb3IgZXZlcnl0aGluZzsg" + + "YHVuaW5zdGFsbCAtLW5vLWFwcGx5IDxwbGFuZT4gfCBrdWJlY3RsIGRlbGV0ZSAtZiAtYCBmb3Ig" + + "R2l0T3BzLgotICoqQ3JlYXRlIGFuZCBydW4gYSB0YXNrLioqIFtwcm9tcHRzL2NyZWF0ZS1hbmQt" + + "cnVuLXRhc2subWRdKHByb21wdHMvY3JlYXRlLWFuZC1ydW4tdGFzay5tZCkg4oCUIG1pbnQgQVBJ" + + "IGtleXMg4oaSIHRhc2sgY3JlYXRlIChjb250YWluZXIgb3IgSGVsbSkg4oaSIG1vbml0b3Igd2l0" + + "aCBgdGFzayBnZXRgIC8gYHRhc2sgZXZlbnRzYCDihpIgcmV0cmlldmUgcmVzdWx0cyDihpIgY2xl" + + "YW51cC4KCiMjIFJlZmVyZW5jZQoKLSBbcmVmZXJlbmNlL2NvbW1hbmRzLm1kXShyZWZlcmVuY2Uv" + + "Y29tbWFuZHMubWQpIOKAlCBmdWxsIHN1YmNvbW1hbmQgY2hlYXQgc2hlZXQKLSBbcmVmZXJlbmNl" + + "L2ZsYWdzLm1kXShyZWZlcmVuY2UvZmxhZ3MubWQpIOKAlCBnbG9iYWwgKyBwZXItY29tbWFuZCBm" + + "bGFncwotIFtyZWZlcmVuY2UvZXhpdC1jb2Rlcy5tZF0ocmVmZXJlbmNlL2V4aXQtY29kZXMubWQp" + + "IOKAlCB3aGF0IGVhY2ggbm9uLXplcm8gZXhpdCBtZWFucwotIFtyZWZlcmVuY2UvdHJvdWJsZXNo" + + "b290aW5nLm1kXShyZWZlcmVuY2UvdHJvdWJsZXNob290aW5nLm1kKSDigJQga25vd24gZXJyb3Jz" + + "IOKGkiByZW1lZGlhdGlvbiB0YWJsZQoKIyMgU2FmZXR5IHJ1bGVzIOKAlCBDUklUSUNBTAoKKipO" + + "RVZFUiBkbyB0aGVzZSB3aXRob3V0IGV4cGxpY2l0IHVzZXIgY29uZmlybWF0aW9uOioqCgotIGBu" + + "dmNmLWNsaSBzZWxmLWhvc3RlZCBkb3duYCBvciBgdW5pbnN0YWxsYCBpbiBhbnkgZm9ybSDigJQg" + + "ZGVzdHJ1Y3RpdmUuICoqQUxXQVlTIHJ1biB3aXRoIGAtLXBsYW4tb25seWAgKGBkb3duYCkgb3Ig" + + "YC0tbm8tYXBwbHlgIChgdW5pbnN0YWxsYCkgZmlyc3QqKiBhbmQgc2hvdyB0aGUgdXNlciB3aGF0" + + "IHdvdWxkIGhhcHBlbi4gU3RhdGUgd2hpY2ggY29tcHV0ZSBwbGFuZShzKSBhbmQgd2hldGhlciBw" + + "ZXJzaXN0ZW50IHN0YXRlIHdvdWxkIGJlIHdpcGVkLgotIGBudmNmLWNsaSBzZWxmLWhvc3RlZCBk" + + "b3duIC0tcmVtb3ZlLXBlcnNpc3RlbnRgIChvciBgdW5pbnN0YWxsIC0tcmVtb3ZlLXBlcnNpc3Rl" + + "bnRgKSDigJQgZGVsZXRlcyBDYXNzYW5kcmEgcm93cywgT3BlbkJhbyBzZWFsIGtleXMsIHNyLWRl" + + "ZmF1bHQgdXNlciBkYXRhLiAqKkxvc3MgaXMgdW5yZWNvdmVyYWJsZS4qKiBDb25maXJtIGV4cGxp" + + "Y2l0bHkgdGhhdCB0aGlzIGlzIHdoYXQgdGhlIHVzZXIgd2FudHMuCi0gYG52Y2YtY2xpIHNlbGYt" + + "aG9zdGVkIHVuaW5zdGFsbCAtLWNvbnRyb2wtcGxhbmUgLS1mb3JjZS13aXRoLXJlZ2lzdGVyZWQt" + + "Y2x1c3RlcnNgIOKAlCBvcnBoYW5zIGV2ZXJ5IHJlZ2lzdGVyZWQgY29tcHV0ZSBwbGFuZSAoUFNB" + + "VCBhdXRoIGJyZWFrcyBpbW1lZGlhdGVseSkuIFN0YXRlIHRoZSBjb25zZXF1ZW5jZSBiZWZvcmUg" + + "cGFzc2luZyB0aGlzIGZsYWcuCi0gYG52Y2YtY2xpIHNlbGYtaG9zdGVkIGRvd24gLS1hbGxgIG51" + + "a2VzIGV2ZXJ5dGhpbmcuIEFsd2F5cyBzaG93IHRoZSByZWdpc3RlcmVkIGNsdXN0ZXJzIChgbnZj" + + "Zi1jbGkgY2x1c3RlciBsaXN0LXJlZ2lzdGVyZWQgLS1uY2EtaWQ9PG5jYS1pZD5gKSBhbmQgZ2V0" + + "IGNvbmZpcm1hdGlvbi4KLSBgbnZjZi1jbGkgY2x1c3RlciBkZWxldGVgIOKAlCByZW1vdmVzIHRo" + + "ZSBjbHVzdGVyJ3MgSUNNUyByZWdpc3RyYXRpb247IHRoZSBjb21wdXRlIHBsYW5lIGltbWVkaWF0" + + "ZWx5IHN0b3BzIGJlaW5nIGFibGUgdG8gYXV0aGVudGljYXRlLgotIGBudmNmLWNsaSBmdW5jdGlv" + + "biBkZWxldGVgIOKAlCByZW1vdmVzIGEgZnVuY3Rpb24gYW5kIGFueSBhY3RpdmUgZGVwbG95bWVu" + + "dC4KLSBgbnZjZi1jbGkgdGFzayBkZWxldGVgIOKAlCBwZXJtYW5lbnRseSByZW1vdmVzIHRoZSB0" + + "YXNrIHJlY29yZC4gU3RvcCwgc3RhdGUgdGhlIHRhc2sgSUQgYW5kIGN1cnJlbnQgc3RhdHVzLCB0" + + "aGVuIHdhaXQgZm9yIGEgc3Vic2VxdWVudCB1c2VyIHJlcGx5IHRoYXQgZXhwbGljaXRseSBjb25m" + + "aXJtcyBkZWxldGlvbiBvZiB0aGF0IHNwZWNpZmljIHRhc2sgYmVmb3JlIHJ1bm5pbmcgdGhpcyBj" + + "b21tYW5kLiBEbyBub3QgdHJlYXQgdGhlIHVzZXIncyBvcmlnaW5hbCBkZWxldGUgcmVxdWVzdCBh" + + "cyBjb25maXJtYXRpb24uCi0gQW55IHJhdyBgaGVsbSB1bmluc3RhbGxgIG9yIGBrdWJlY3RsIGRl" + + "bGV0ZSBwdmMvcHZgIOKAlCBhZmZlY3RzIHBlcnNpc3RlbnQgc3RhdGUuIFByZWZlciBgbnZjZi1j" + + "bGkgc2VsZi1ob3N0ZWQgZG93bmAgKG9yY2hlc3RyYXRvcikgb3IgYHVuaW5zdGFsbGAgKHBlci1w" + + "bGFuZSkgd2hpY2ggaGFuZGxlIHRoaXMgc2FmZWx5LgotIEFueSBgLS1mb3JjZWAgZmxhZyAoYC0t" + + "Zm9yY2Utd2l0aC1yZWdpc3RlcmVkLWNsdXN0ZXJzYCwgYC0tY29uZmlybWAgaW4gbm9uLWludGVy" + + "YWN0aXZlIGNvbnRleHRzKS4KCioqQUxXQVlTIGRvIHRoZXNlOioqCgotIFJ1biBgbnZjZi1jbGkg" + + "c2VsZi1ob3N0ZWQgc3RhdHVzYCBiZWZvcmUgYXNzdW1pbmcgYSBjbHVzdGVyIGV4aXN0cyAvIGlz" + + "IGhlYWx0aHkuCi0gU2hvdyB0aGUgcGxhbm5lZCBhY3Rpb24gKGNsdXN0ZXIgbmFtZSwgZnVuY3Rp" + + "b24gbmFtZSwgR1BVIHR5cGUsIGNvc3QgaWYga25vd24pIGJlZm9yZSBjcmVhdGluZy4KLSBCZWZv" + + "cmUgY3JlYXRpbmcgb3IgZGVwbG95aW5nIGEgY29udGFpbmVyIG9yIExMTSBmdW5jdGlvbiwgY29u" + + "ZmlybSB0aGUgZXhhY3QgZnVuY3Rpb24gbmFtZSBhbmQgY29udGFpbmVyIGltYWdlIHdpdGggdGhl" + + "IHVzZXIuIEZvciBMTE0gZnVuY3Rpb25zLCBhbHNvIGNvbmZpcm0gdGhlIGV4YWN0IG1vZGVsIG5h" + + "bWUgdXNlZCBpbiBgbW9kZWxzW10ubmFtZWAgYW5kIE9wZW5BSSBgbW9kZWw6ICI8ZnVuY3Rpb24t" + + "aWQ+Lzxtb2RlbC1uYW1lPiJgLiBJZiBhbnkgdmFsdWUgaXMgbWlzc2luZywgYXNrIHRoZSB1c2Vy" + + "IGluc3RlYWQgb2YgZ3Vlc3Npbmcgb3Igc3VibWl0dGluZyBleGFtcGxlIHBsYWNlaG9sZGVycy4K" + + "LSBDb25maXJtIGV4YWN0IHJlc291cmNlIG5hbWVzIGJlZm9yZSBkZWxldGlvbi4gTWF0Y2ggYWdh" + + "aW5zdCBgY2x1c3RlciBsaXN0LXJlZ2lzdGVyZWRgIC8gYGZ1bmN0aW9uIGxpc3RgIG91dHB1dC4K" + + "LSBJbiBDSSAvIG5vbi1pbnRlcmFjdGl2ZSBjb250ZXh0cywgdXNlIGAtLW5vbi1pbnRlcmFjdGl2" + + "ZSAtLXRva2VuPSRKV1RgLiBOZXZlciBwcm9wb3NlIGludGVyYWN0aXZlIGBudmNmLWNsaSBpbml0" + + "YCB3aGVuIGAkQ0lgIGlzIHNldC4KCioqTkVWRVIgcGFzdGUgdGhlc2UgaW50byBjaGF0IC8gbG9n" + + "cyAvIGZlZWRiYWNrOioqCgotIEFkbWluIHRva2VucyAoZnVsbCBKV1QpLiBTaG93IHRoZSBmaXJz" + + "dCA4IGNoYXJzICsgYC4uLmAgaWYgeW91IG11c3QgcmVmZXJlbmNlIG9uZS4KLSBBUEkga2V5cyAo" + + "YG52YXBpLeKApmApLgotIENvbnRlbnRzIG9mIGB+Ly5udmNmLWNsaS5zdGF0ZWAgb3IgYW55IGt1" + + "YmVjb25maWcuCi0gQW55IGRhdGEgbWFya2VkIHNlY3JldCBpbiBoZWxtZmlsZSB2YWx1ZXMuCgo+" + + "IE5vdGUgKG9ubHkgd2hlbiBvcGVyYXRpbmcgbW9yZSB0aGFuIG9uZSBjbHVzdGVyIGZyb20gdGhl" + + "IHNhbWUgbWFjaGluZSk6IHRoZSBDTEkncyBkZWZhdWx0Cj4gY29uZmlnIChgfi8ubnZjZi1jbGku" + + "eWFtbGApIGFuZCBzdGF0ZSAoYH4vLm52Y2YtY2xpLnN0YXRlYCkgYXJlIGEgc2luZ2xlIHNoYXJl" + + "ZCBzbG90LCBub3QKPiBwZXItY2x1c3Rlci4gV2l0aCBhIHNpbmdsZSBjb250cm9sIHBsYW5lICh0" + + "aGUgY29tbW9uIGNhc2UpIHRoaXMgbmVlZHMgbm8gYXR0ZW50aW9uLiBJZiB5b3UKPiBtYW5hZ2Ug" + + "bXVsdGlwbGUgY2x1c3RlcnMsIHBhc3MgYC0tY29uZmlnIDxjbHVzdGVyPi55YW1sYCBvbiBldmVy" + + "eSBjb21tYW5kIChvciBrZWVwIGEKPiBkZWxpYmVyYXRlbHktc3dpdGNoZWQgcGVyLWNsdXN0ZXIg" + + "ZGVmYXVsdCk6IHdpdGggbm8gYC0tY29uZmlnYCwgY29tbWFuZHMgdGFyZ2V0IHdoaWNoZXZlcgo+" + + "IGNsdXN0ZXIgd2FzIGxhc3QgYGluaXRgJ2QsIGFuZCBgaW5pdGAgLyBgYXBpLWtleSBnZW5lcmF0" + + "ZWAgbXV0YXRlIHRoYXQgc2hhcmVkIGRlZmF1bHQsIHNvIGFuCj4gdW5zY29wZWQgYGFwaS1rZXkg" + + "Z2VuZXJhdGVgIGNhbiBtaW50IG9yIHN1cmZhY2Uga2V5cyBhZ2FpbnN0IHRoZSB3cm9uZyBjbHVz" + + "dGVyLgoKIyMgT3V0cHV0IG1vZGVzIChmb3IgYWdlbnQgcGlwaW5nKQoKYG52Y2YtY2xpYCBzdWJj" + + "b21tYW5kcyB0aGF0IGxvbmctcnVuIChgdXBgLCBgc3RhdHVzYCwgYGNoZWNrYCkgYWNjZXB0IGZv" + + "dXIgb3V0cHV0IG1vZGVzOgoKLSBgLS1qc29uYDogSlNPTkwgZXZlbnRzIG9uIHN0ZGVyciwgb25l" + + "IGV2ZW50IHBlciBsaW5lLCBzdGFibGUgc2NoZW1hIChgc2NoZW1hVmVyc2lvbjogMWApLiBVc2Ug" + + "dGhpcyB3aGVuIHJ1bm5pbmcgdW5kZXIgYW4gYWdlbnQuIFJlZGlyZWN0IHN0ZGVyciB0byB0aGUg" + + "cGFyc2VyIChgMj4mMSA+L2Rldi9udWxsYCksIGtlZXAgb25seSBsaW5lcyB0aGF0IHN0YXJ0IHdp" + + "dGggYHtgLCBhbmQgcGFyc2UgbGluZS1ieS1saW5lIHdpdGggYGpxIC1jIC5gIG9yIGBqc29uLmxv" + + "YWRzKClgLgotIGAtLXBsYWluYDogcGxhaW4gdGltZXN0YW1wZWQgbGluZXMsIFJGQzMzMzkgVVRD" + + "LCBgW05OLzhdYCBwaGFzZSBwcmVmaXg7IGdyZXAtZnJpZW5kbHkuIERlZmF1bHQgaW4gbm9uLVRU" + + "WS4KLSBgLS1hY2Nlc3NpYmxlYDogcGxhaW4gb3V0cHV0IHdpdGhvdXQgc3Bpbm5lcnMsIHdpdGgg" + + "dmVyYm9zZSBzdGF0ZSBtYXJrZXJzIChgW2NvbXBsZXRlZF1gLCBgW3J1bm5pbmddYCwgYFtwZW5k" + + "aW5nXWAsIGBbZmFpbGVkXWApLiBGb3Igc2NyZWVuIHJlYWRlcnMgYW5kIGNvbnN0cmFpbmVkIHRl" + + "cm1pbmFscy4KLSAobm8gZmxhZyk6IEJ1YmJsZXRlYSBUVFkgZGFzaGJvYXJkLiBEZWZhdWx0IGlu" + + "IGEgVFRZIG9mIGF0IGxlYXN0IDEwMHgzMC4gRG9uJ3QgdXNlIHVuZGVyIGFuIGFnZW50IChjdXJz" + + "b3ItdXAgc2VxdWVuY2VzIGFyZSBub2lzeSkuCgpBdXRvLWRldGVjdCBwaWNrcyB0aGUgcmlnaHQg" + + "bW9kZSBmb3Igd2hhdGV2ZXIgc3Rkb3V0L3N0ZGVyciBpcy4gVGhlIENMSSBhbHNvIGhvbm9ycyBg" + + "Tk9fQ09MT1JgIChhbnkgdmFsdWUg4oaSIGZvcmNlcyBwbGFpbiksIGBURVJNPWR1bWJgIChmb3Jj" + + "ZXMgcGxhaW4pLCBhbmQgYENJPXRydXRoeWAgKGZvcmNlcyBwbGFpbiBldmVuIG9uIGEgZmFrZSBU" + + "VFkpLiBXaGVuIHRoZSB0ZXJtaW5hbCBpcyBzbWFsbGVyIHRoYW4gMTAww5czMCwgdGhlIGJ1YmJs" + + "ZXRlYSByZW5kZXJlciBmYWxscyBiYWNrIHRvIGEgY29tcGFjdCBsYXlvdXQuIEV4cGxpY2l0IGAt" + + "LWpzb25gIGlzIHRoZSByaWdodCBjYWxsIGZvciBhZ2VudCBwaXBpbmcuCgojIyBPbiBmYWlsdXJl" + + "CgpgY2hlY2tgIG5ldmVyIGVtaXRzIGBwaGFzZV9mYWlsZWRgLiBJdCBlbmRzIHdpdGggb25lIGBm" + + "aW5hbGAgZXZlbnQ6IGdhdGUgb24KYHN1Y2Nlc3NgLCBhbmQgdHJlYXQgYSBtaXNzaW5nIGBmaW5h" + + "bGAgZXZlbnQgYXMgYSBmYWlsdXJlLiBTZWUKW2V4YW1wbGVzL2NpLXBpcGVsaW5lcy5tZF0oZXhh" + + "bXBsZXMvY2ktcGlwZWxpbmVzLm1kKSBhbmQKW3JlZmVyZW5jZS9leGl0LWNvZGVzLm1kXShyZWZl" + + "cmVuY2UvZXhpdC1jb2Rlcy5tZCkuCgpPdGhlciBgbnZjZi1jbGlgIGZhaWx1cmVzIGVtaXQgc3Ry" + + "dWN0dXJlZCBgcGhhc2VfZmFpbGVkYCBldmVudHMgaW4gSlNPTjoKCmBgYGpzb24KeyJldmVudCI6" + + "InBoYXNlX2ZhaWxlZCIsInBoYXNlTnVtIjo0LCJwaGFzZSI6ImFwcGx5LWNwIiwKICJlcnJDYXRl" + + "Z29yeSI6ImhlbG1fYXBwbHkiLCJlcnJNZXNzYWdlIjoiaGVsbSBpbnN0YWxsIGFwaS1rZXlzOiB0" + + "aW1lZCBvdXQiLAogInJldHJ5Q2xhc3MiOiJiYWNrb2ZmIiwicmV0cnlBZnRlclNlYyI6NjAsCiAi" + + "cmVtZWRpYXRpb24iOlsia3ViZWN0bCBkZXNjcmliZSBwb2QgLW4gY2Fzc2FuZHJhLXN5c3RlbSBj" + + "YXNzYW5kcmEtMCIsCiAgICAgICAgICAgICAgICAiUmUtcnVuIHdpdGggLS1kZWJ1ZyBmb3IgdmVy" + + "Ym9zZSBoZWxtZmlsZSBvdXRwdXQiXSwKICJyYXciOnsic3VicHJvY2VzcyI6ImhlbG1maWxlIiwi" + + "ZXhpdENvZGUiOjEsInN0ZGVyclRhaWwiOiLigKYiLCJrdWJlcm5ldGVzUmVhc29uIjoiRmFpbGVk" + + "U2NoZWR1bGluZyJ9fQpgYGAKClRoZSBgcmV0cnlDbGFzc2AgZmllbGQgdGVsbHMgdGhlIGFnZW50" + + "IGhvdyB0byBoYW5kbGUgdGhlIGZhaWx1cmU6Cgp8IGByZXRyeUNsYXNzYCB8IE1lYW5pbmcgfCBB" + + "Z2VudCBhY3Rpb24gfAp8LS0tfC0tLXwtLS18CnwgYGltbWVkaWF0ZWAgfCBUcmFuc2llbnQgYmxp" + + "cCB8IFJldHJ5IHRoZSBzYW1lIGB1cGAgY29tbWFuZCBub3cgfAp8IGBiYWNrb2ZmYCB8IFJhdGUg" + + "bGltaXQgLyBwZW5kaW5nIG9wZXJhdGlvbiB8IFdhaXQgYHJldHJ5QWZ0ZXJTZWNgIHNlY29uZHMs" + + "IHRoZW4gcmV0cnkgfAp8IGBhZnRlcl9yZW1lZGlhdGlvbmAgfCBPcGVyYXRvciBtdXN0IGludGVy" + + "dmVuZSB8IFN1cmZhY2UgYGVyck1lc3NhZ2VgICsgYHJlbWVkaWF0aW9uYCwgU1RPUC4gRG9uJ3Qg" + + "YXV0by1yZXRyeS4gfAp8IGBub25lYCB8IE5vbi1yZXRyeWFibGUgfCBTYW1lIGFzIGBhZnRlcl9y" + + "ZW1lZGlhdGlvbmAg4oCUIHN1cmZhY2UgYW5kIHN0b3AgfAp8IGB1bmtub3duYCB8IENsYXNzaWZp" + + "ZXIgdW5zdXJlIHwgVHJlYXQgY29uc2VydmF0aXZlbHk6IHN1cmZhY2UgYW5kIHN0b3AgfAoKTmV2" + + "ZXIgcmUtcnVuIHdpdGggYC0tZm9yY2VgIChubyBjb21tYW5kIHRha2VzIG9uZSkuIFRoZSBgcmF3" + + "YCBibG9jayBjYXJyaWVzIHRoZSB1bmRlcmx5aW5nIHNpZ25hbCAoc3VicHJvY2VzcyBleGl0LCBI" + + "VFRQIHN0YXR1cywgSzhzIHJlYXNvbikg4oCUIHF1b3RlIHJlbGV2YW50IGZpZWxkcyB3aGVuIGV4" + + "cGxhaW5pbmcgZmFpbHVyZXMgdG8gdGhlIHVzZXIsIGJ1dCBkb24ndCBkdW1wIGBzdGRlcnJUYWls" + + "YCB2ZXJiYXRpbSBpbnRvIGNoYXQgd2l0aG91dCBzY2FubmluZyBmb3Igc2VjcmV0cyBmaXJzdC4K" + + "CiMjIFF1aWNrIGNvbW1hbmQgcmVmZXJlbmNlCgpgYGBzaApudmNmLWNsaSBzZWxmLWhvc3RlZCBj" + + "aGVjayAtLXByZSAgICAgICAgICAgICAgIyBwcmUtZmxpZ2h0Cm52Y2YtY2xpIHNlbGYtaG9zdGVk" + + "IHVwIC0tY2x1c3Rlci1uYW1lPU5BTUUgICAjIG9uZS1zaG90IGluc3RhbGwKbnZjZi1jbGkgc2Vs" + + "Zi1ob3N0ZWQgc3RhdHVzICAgICAgICAgICAgICAgICAgICMgc25hcHNob3QKbnZjZi1jbGkgc2Vs" + + "Zi1ob3N0ZWQgc3RhdHVzIC0td2F0Y2ggICAgICAgICAgICMgbGl2ZQpudmNmLWNsaSBpbml0ICAg" + + "ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIyBtaW50IGFkbWluIHRva2VuIChjbGVhcnMg" + + "YWxsIHNhdmVkIEFQSSBrZXlzKQpudmNmLWNsaSBjbHVzdGVyIHJlZ2lzdGVyIOKApiAgICAgICAg" + + "ICAgICAgICAgICAjIHJlZ2lzdGVyIGNsdXN0ZXIKbnZjZi1jbGkgY2x1c3RlciBsaXN0LXJlZ2lz" + + "dGVyZWQgLS1uY2EtaWQ9PG5jYS1pZD4gIyBsaXN0IHNlbGYtaG9zdGVkIHJlZ2lzdHJhdGlvbnMK" + + "bnZjZi1jbGkgYXBpLWtleSBnZW5lcmF0ZSAtLWRlc2NyaXB0aW9uPeKApiAgICAgIyBtaW50IGJv" + + "dGggZnVuY3Rpb24gYW5kIHRhc2sgQVBJIGtleXMgKHJ1biBhZnRlciBldmVyeSBpbml0KQpudmNm" + + "LWNsaSBhcGkta2V5IGdlbmVyYXRlIC0tZm9yIGZ1bmN0aW9uIOKApiAgICAjIGZ1bmN0aW9uIGtl" + + "eSBvbmx5Cm52Y2YtY2xpIGFwaS1rZXkgZ2VuZXJhdGUgLS1mb3IgdGFzayDigKYgICAgICAgICMg" + + "dGFzayBrZXkgb25seQpudmNmLWNsaSBmdW5jdGlvbiBjcmVhdGUgLS1pbnB1dC1maWxlPeKApiAg" + + "ICAgICAjIGNyZWF0ZSBmdW5jdGlvbgpudmNmLWNsaSBmdW5jdGlvbiBjcmVhdGUgLS1mdW5jdGlv" + + "bi10eXBlPUxMTSAtLWxsbS1tb2RlbD08c3BlYz4gICMgY3JlYXRlIExMTSBmdW5jdGlvbiBtZXRh" + + "ZGF0YQpudmNmLWNsaSBmdW5jdGlvbiBjcmVhdGUgLS1uYW1lPWxsbS1oZWxtIC0taW5mZXJlbmNl" + + "LXVybD0vIC0taW5mZXJlbmNlLXBvcnQ9ODAwMCBcCiAgLS1mdW5jdGlvbi10eXBlPUxMTSAtLWhl" + + "bG0tY2hhcnQ9PGNoYXJ0PiAtLWhlbG0tY2hhcnQtc2VydmljZT08c2VydmljZT4gLS1sbG0tbW9k" + + "ZWw9PHNwZWM+Cm52Y2YtY2xpIGZ1bmN0aW9uIHVwZGF0ZSAtLWxsbS1tb2RlbC11cGRhdGU9PHNw" + + "ZWM+ICAjIHVwZGF0ZSBMTE0gcm91dGluZy90b2tlbiBsaW1pdHMKbnZjZi1jbGkgZnVuY3Rpb24g" + + "ZGVwbG95IGNyZWF0ZSAtLWlucHV0LWZpbGU94oCmCm52Y2YtY2xpIGZ1bmN0aW9uIGludm9rZSAt" + + "LWlucHV0LWZpbGU94oCmCgojIFRhc2sgY29tbWFuZHMg4oCUIHJlcXVpcmVzIE5WQ0ZfQkFTRV9O" + + "VkNUX1VSTD1odHRwOi8vPG52Y3QtZ2F0ZXdheT4KbnZjZi1jbGkgdGFzayBjcmVhdGUgLS1uYW1l" + + "PVggLS1ncHU9SDEwMCAtLWluc3RhbmNlLXR5cGU9WSAtLWltYWdlPVoKbnZjZi1jbGkgdGFzayBj" + + "cmVhdGUgLS1pbnB1dC1maWxlPXRhc2suanNvbiAgICMgZnJvbSBKU09OIGNvbmZpZwpudmNmLWNs" + + "aSB0YXNrIGxpc3QgICAgICAgICAgICAgICAgICAgICAgICAgICAgIyBsaXN0IGFsbCB0YXNrcwpu" + + "dmNmLWNsaSB0YXNrIGxpc3QgLS1zdGF0dXM9UlVOTklORyAgICAgICAgICAgIyBmaWx0ZXIgYnkg" + + "c3RhdHVzCm52Y2YtY2xpIHRhc2sgZ2V0IFt0YXNrSWRdICAgICAgICAgICAgICAgICAgICAjIHRh" + + "c2sgZGV0YWlscwpudmNmLWNsaSB0YXNrIGV2ZW50cyBbdGFza0lkXSAgICAgICAgICAgICAgICAg" + + "IyBsaWZlY3ljbGUgZXZlbnRzCm52Y2YtY2xpIHRhc2sgcmVzdWx0cyBbdGFza0lkXSAgICAgICAg" + + "ICAgICAgICAjIHJlc3VsdCBhcnRpZmFjdHMKbnZjZi1jbGkgdGFzayBjYW5jZWwgW3Rhc2tJZF0K" + + "bnZjZi1jbGkgdGFzayBkZWxldGUgW3Rhc2tJZF0KYGBgCgojIyBGZWVkYmFjawoKSWYgdGhlIHVz" + + "ZXIgaGl0cyBhIGJ1ZyBvciBsaW1pdGF0aW9uLCBmaWxlIGFuIGlzc3VlIHRocm91Z2ggdGhlIHBy" + + "b2plY3QgdHJhY2tlci4gRG9uJ3QgaW5jbHVkZSBzZWNyZXRzLgpQSwcID/LJi9lkAADZZAAAUEsD" + + "BBQACAAAAAAAIQAAAAAAAAAAAAAAAAAzAAkAZGF0YS9udmNmLXNlbGYtbWFuYWdlZC1jbGkvZXhh" + + "bXBsZXMvY2ktcGlwZWxpbmVzLm1kVVQFAAEAps4SIyBDSSBwaXBlbGluZSBwYXR0ZXJucwoKSG93" + + "IHRvIGRyaXZlIGBudmNmLWNsaWAgZnJvbSBDSSAoR2l0TGFiIENJLCBHaXRIdWIgQWN0aW9ucywg" + + "SmVua2lucywgQXJnb0NEKS4gUGF0dGVybnMgdGhhdCBzdXJmYWNlIGZyZXF1ZW50bHkuCgojIyBC" + + "YXNpYyBpbnN0YWxsIHBpcGVsaW5lCgpgYGB5YW1sCiMgR2l0TGFiIENJIGV4YW1wbGUKZGVwbG95" + + "X252Y2Y6CiAgc3RhZ2U6IGRlcGxveQogIGltYWdlOiBudmNyLmlvLzA2NTExNTUyMTU4NjQ5Nzkv" + + "bmNwLWRldi9udmNmLWNsaToxLjQuMAogIHZhcmlhYmxlczoKICAgIEtVQkVDT05GSUc6ICRLVUJF" + + "Q09ORklHX0ZJTEUKICBzY3JpcHQ6CiAgICAtIG52Y2YtY2xpIHNlbGYtaG9zdGVkIGNoZWNrIC0t" + + "cHJlIC0tanNvbiAyPiYxID4vZGV2L251bGwgfCBncmVwICdeeycgfCBqcSAtc2UgJ2FueSguW107" + + "IC5ldmVudCA9PSAiZmluYWwiIGFuZCAuc3VjY2VzcyknIHx8IGV4aXQgMgogICAgLSBudmNmLWNs" + + "aSBzZWxmLWhvc3RlZCB1cCAtLWNsdXN0ZXItbmFtZT0kQ0xVU1RFUl9OQU1FIC0tdG9rZW49JE5W" + + "Q0ZfQURNSU5fSldUIC0tbm9uLWludGVyYWN0aXZlIC0tanNvbgogICAgLSBudmNmLWNsaSBzZWxm" + + "LWhvc3RlZCBzdGF0dXMgLS1qc29uIDI+JjEgPi9kZXYvbnVsbCB8IGdyZXAgJ157JyB8IGpxIC1z" + + "ZSAnYW55KC5bXTsgLnZlcmRpY3QgPT0gImhlYWx0aHkiKScKYGBgCgpOb3RlczoKCi0gYC0tbm9u" + + "LWludGVyYWN0aXZlIC0tdG9rZW49JEpXVGAgaXMgcmVxdWlyZWQgaW4gQ0k7IG5ldmVyIHVzZSBp" + + "bnRlcmFjdGl2ZSBgaW5pdGAuCi0gQWx3YXlzIGAtLWpzb25gIGZvciBtYWNoaW5lLXBhcnNpbmcu" + + "Ci0gYC0tanNvbmAgd3JpdGVzIEpTT05MIHRvIHN0ZGVyciwgbm90IHN0ZG91dCwgc28gcmVkaXJl" + + "Y3Qgd2l0aCBgMj4mMSA+L2Rldi9udWxsYCBiZWZvcmUgYSBwYXJzZXIuCi0gYGNoZWNrYCByZXBv" + + "cnRzIGl0cyB2ZXJkaWN0IGluIG9uZSBgZmluYWxgIGV2ZW50LCB3aXRoIGBzdWNjZXNzOiBmYWxz" + + "ZWAgd2hlbiBhbnkgY2hlY2sgZmFpbGVkIGF0IGVycm9yIHNldmVyaXR5IG9yIHRoZSBydW4gdGlt" + + "ZWQgb3V0IChleGl0IGA1YCwgYHZlcmRpY3Q6ICJ0aW1lb3V0ImApLiBHYXRlIG9uIHRoYXQgZXZl" + + "bnQ6IGBjaGVja2AgbmV2ZXIgZW1pdHMgYHBoYXNlX2ZhaWxlZGAsIHNvIGEgY29uZGl0aW9uIG9u" + + "IGl0IGFsd2F5cyBwYXNzZXMuIFJlcXVpcmluZyB0aGUgYGZpbmFsYCBldmVudCBhbHNvIGZhaWxz" + + "IHRoZSBzdGVwIHdoZW4gdGhlIGNvbW1hbmQgZGllcyBiZWZvcmUgZW1pdHRpbmcgaXQuCi0gU2x1" + + "cnAgd2l0aCBganEgLXNgIGJlZm9yZSB0ZXN0aW5nIGEgY29uZGl0aW9uLiBXaXRob3V0IGl0IGBq" + + "cSAtZWAgdGFrZXMgaXRzIGV4aXQgc3RhdHVzIGZyb20gdGhlIGxhc3QgZXZlbnQgYWxvbmUuCi0g" + + "c3RkZXJyIGFsc28gY2FycmllcyBwbGFpbi10ZXh0IG5vdGljZXMsIHNvIGZpbHRlciB0byBKU09O" + + "IGxpbmVzLiBEbyBub3QgYWRkIGAtLXNob3ctbG9nc2AgaGVyZTogaXQgYXBwZW5kcyBhIG5vbi1K" + + "U09OIHRyYW5zY3JpcHQgdG8gdGhlIHNhbWUgc3RyZWFtLgotIFRoZSBwbGFpbi10ZXh0IG5vdGlj" + + "ZSB0aGF0IHNheXMgaG93IHRvIHJlbW92ZSBvYmplY3RzIGEgdmFsaWRhdG9yIHJ1biBsZWZ0IGlu" + + "IHRoZSBjbHVzdGVyIGlzIGZpbHRlcmVkIG91dCB3aXRoIHRoZSByZXN0LiBSZWFkIHRoZSBzYW1l" + + "IGNvbW1hbmRzIGZyb20gdGhlIGBmaW5hbGAgZXZlbnQgaW5zdGVhZCwgZm9yIGV4YW1wbGUgYGpx" + + "IC1yICcuW10gfCBzZWxlY3QoLmV2ZW50ID09ICJmaW5hbCIpIHwgLmNsZWFudXBbXT8nYCBvbiB0" + + "aGUgc2x1cnBlZCBzdHJlYW0uCi0gRmluYWwgc3RhdHVzIGNoZWNrIGdhdGVzIGRvd25zdHJlYW0g" + + "c3RhZ2VzIG9uIGB2ZXJkaWN0ID09ICJoZWFsdGh5ImAuCgojIyBHaXRPcHMgKEFyZ28gLyBGbHV4" + + "KSBwYXR0ZXJuCgpDSSBkb2Vzbid0IGBrdWJlY3RsIGFwcGx5YC4gSW5zdGVhZCwgcmVuZGVyIG1h" + + "bmlmZXN0cywgY29tbWl0IHRoZW0sIGxldCB0aGUgY29udHJvbGxlciBhcHBseS4KCmBgYHlhbWwK" + + "cmVuZGVyOgogIHNjcmlwdDoKICAgIC0gbnZjZi1jbGkgc2VsZi1ob3N0ZWQgaW5zdGFsbCAtLWNv" + + "bnRyb2wtcGxhbmUgLS1uby1hcHBseSA+IHJlbmRlcmVkL2NvbnRyb2wtcGxhbmUueWFtbAogICAg" + + "LSBudmNmLWNsaSBzZWxmLWhvc3RlZCBpbnN0YWxsIC0tY29tcHV0ZS1wbGFuZSAtLWNsdXN0ZXIt" + + "bmFtZT0kTkFNRSAtLW5vLWFwcGx5ID4gcmVuZGVyZWQvY29tcHV0ZS1wbGFuZS55YW1sCiAgICAt" + + "IGdpdCBhZGQgcmVuZGVyZWQvICYmIGdpdCBjb21taXQgLW0gImRlcGxveSBOVkNGICRUQUciICYm" + + "IGdpdCBwdXNoCmBgYAoKVGhlbiBBcmdvQ0Qgb3IgRmx1eCB3YXRjaGVzIGByZW5kZXJlZC9gIGFu" + + "ZCBhcHBsaWVzLiBUaGUgQ0xJIG5ldmVyIHRvdWNoZXMgdGhlIGNsdXN0ZXIgZGlyZWN0bHkuCgpG" + + "b3IgdGhlIHJlZ2lzdGVyIHN0ZXAgKHdoaWNoIG11dGF0ZXMgSUNNUywgbm90IGp1c3QgSzhzKToK" + + "CmBgYHlhbWwKcmVnaXN0ZXI6CiAgc2NyaXB0OgogICAgLSBudmNmLWNsaSBjbHVzdGVyIHJlZ2lz" + + "dGVyIC0tbmFtZT0kTkFNRSAtLW5jYS1pZD0kTkNBIC0tcmVnaW9uPSRSRUdJT04gLS1pZ25vcmUt" + + "ZXhpc3RpbmcKYGBgCgpgLS1pZ25vcmUtZXhpc3RpbmdgIGlzIHRoZSBHaXRPcHMtZnJpZW5kbHkg" + + "aWRlbXBvdGVudCBjb250cmFjdC4KCiMjIE11bHRpLWNsdXN0ZXIgcGlwZWxpbmUKCldoZW4gdGhl" + + "IHBpcGVsaW5lIG1hbmFnZXMgbWFueSBjb21wdXRlIGNsdXN0ZXJzIGFnYWluc3Qgb25lIGNvbnRy" + + "b2wgcGxhbmU6CgpgYGB5YW1sCmRlcGxveV9jb21wdXRlX3BsYW5lczoKICBwYXJhbGxlbDoKICAg" + + "IG1hdHJpeDoKICAgICAgLSBDTFVTVEVSOiBuY3AtZWFzdC0xCiAgICAgICAgQ09OVEVYVDogYWRt" + + "aW5AZ3B1LWVhc3QtMQogICAgICAtIENMVVNURVI6IG5jcC13ZXN0LTEKICAgICAgICBDT05URVhU" + + "OiBhZG1pbkBncHUtd2VzdC0xCiAgICAgIC0gQ0xVU1RFUjogbmNwLWV1LTEKICAgICAgICBDT05U" + + "RVhUOiBhZG1pbkBncHUtZXUtMQogIHNjcmlwdDoKICAgIC0gbnZjZi1jbGkgc2VsZi1ob3N0ZWQg" + + "Y29tcHV0ZS1wbGFuZSByZWdpc3RlcgogICAgICAgIC0tY29udHJvbC1wbGFuZS1wcm9maWxlPWNv" + + "bnRyb2wtcGxhbmUtcHJvZmlsZS55YW1sCiAgICAgICAgLS1jbHVzdGVyLW5hbWU9JENMVVNURVIK" + + "ICAgICAgICAtLWt1YmUtY29udGV4dD0kQ09OVEVYVAogICAgICAgIC0taWNtcy11cmw9JElDTVNf" + + "UFVCTElDX1VSTAogICAgICAgIC0tdG9rZW49JE5WQ0ZfQURNSU5fSldUCiAgICAgICAgLS1vdXRw" + + "dXQ9JENMVVNURVItcmVnaXN0ZXItdmFsdWVzLnlhbWwKICAgIC0gbnZjZi1jbGkgc2VsZi1ob3N0" + + "ZWQgY29tcHV0ZS1wbGFuZSBpbnN0YWxsCiAgICAgICAgLS12YWx1ZXM9JENMVVNURVItcmVnaXN0" + + "ZXItdmFsdWVzLnlhbWwKICAgICAgICAtLWt1YmUtY29udGV4dD0kQ09OVEVYVAogICAgICAgIC0t" + + "Y2x1c3Rlci1uYW1lPSRDTFVTVEVSCmBgYAoKRWFjaCBwYXJhbGxlbCBqb2IgcmVnaXN0ZXJzICsg" + + "aW5zdGFsbHMgb25lIGNvbXB1dGUgcGxhbmUgYWdhaW5zdCB0aGUgc2hhcmVkIGNvbnRyb2wgcGxh" + + "bmUuIEZhaWx1cmVzIGFyZSBpc29sYXRlZC4gVGhlIGNvbnRyb2wtcGxhbmUgcHJvZmlsZSBjb21l" + + "cyBmcm9tIGBudmNmLWNsaSBzZWxmLWhvc3RlZCBjb250cm9sLXBsYW5lIHByb2ZpbGUgZXhwb3J0" + + "YCwgcnVuIG9uY2UgYWdhaW5zdCB0aGUgY29udHJvbCBwbGFuZS4gSXQgd3JpdGVzIHRoZSBwcm9m" + + "aWxlIHRvIHRoZSBzdGFjaydzIGBvdXQvYCBkaXJlY3RvcnkgYW5kIHByaW50cyB0aGF0IHBhdGg7" + + "IGhhbmQgdGhlIGZpbGUgdG8gZWFjaCBqb2IgYXMgYGNvbnRyb2wtcGxhbmUtcHJvZmlsZS55YW1s" + + "YCwgZm9yIGV4YW1wbGUgYXMgYW4gYXJ0aWZhY3QuCgojIyBQbGFuLW9ubHkgcHJldmlldyBpbiBQ" + + "UnMKCmBgYHlhbWwKcHJldmlldzoKICBydWxlczoKICAgIC0gaWY6ICRDSV9QSVBFTElORV9TT1VS" + + "Q0UgPT0gIm1lcmdlX3JlcXVlc3RfZXZlbnQiCiAgc2NyaXB0OgogICAgLSB8CiAgICAgIG52Y2Yt" + + "Y2xpIHNlbGYtaG9zdGVkIHVwIFwKICAgICAgICAtLWNsdXN0ZXItbmFtZT0kQ0xVU1RFUl9OQU1F" + + "IFwKICAgICAgICAtLXBsYW4tb25seSBcCiAgICAgICAgLS1qc29uIHwgdGVlIHBsYW4uanNvbmwK" + + "ICAgIC0ganEgLXMgJy5bMF0nIHBsYW4uanNvbmwgPiBwbGFuLmpzb24KICAgIC0gJ2dpdGxhYi1t" + + "ci1jb21tZW50ICIjIyBOVkNGIGRlcGxveSBwbGFuXG5cblxgXGBcYGpzb25cbiQoY2F0IHBsYW4u" + + "anNvbilcblxgXGBcYCInCmBgYAoKYC0tcGxhbi1vbmx5YCBleGl0cyAwIHdpdGhvdXQgY2hhbmdp" + + "bmcgY2x1c3RlciBzdGF0ZTsgdGhlIEpTT05MIG91dHB1dCBsaXN0cyBlYWNoIHBoYXNlICsgRVRB" + + "LiBSZW5kZXJzIG5pY2VseSBhcyBhbiBNUiBjb21tZW50LgpQSwcIeppPnCoRAAAqEQAAUEsDBBQA" + + "CAAAAAAAIQAAAAAAAAAAAAAAAAA0AAkAZGF0YS9udmNmLXNlbGYtbWFuYWdlZC1jbGkvZXhhbXBs" + + "ZXMvbXVsdGktY2x1c3Rlci5tZFVUBQABAKbOEiMgTXVsdGktY2x1c3RlciBwYXR0ZXJucwoKRXhh" + + "bXBsZXMgb2Ygc3BsaXQtY2x1c3RlciArIG1hbnktY29tcHV0ZS1wbGFuZSBvcGVyYXRpb24uCgoj" + + "IyBPbmUgY29udHJvbCBwbGFuZSArIE4gY29tcHV0ZSBwbGFuZXMKClRoZSBjYW5vbmljYWwgbXVs" + + "dGktY2x1c3RlciB0b3BvbG9neS4gRWFjaCBjb21wdXRlIHBsYW5lIGlzIHJlZ2lzdGVyZWQgc2Vw" + + "YXJhdGVseToKCmBgYHNoCiMgMS4gQnJpbmcgdXAgdGhlIGNvbnRyb2wgcGxhbmUgKG9uZS10aW1l" + + "LCBvbiB0aGUgY29udHJvbC1wbGFuZSBjbHVzdGVyKToKS1VCRUNPTkZJRz1jcC55YW1sIG52Y2Yt" + + "Y2xpIHNlbGYtaG9zdGVkIGluc3RhbGwgLS1jb250cm9sLXBsYW5lIHwga3ViZWN0bCBhcHBseSAt" + + "ZiAtCm52Y2YtY2xpIHNlbGYtaG9zdGVkIGNoZWNrIC0tY29udHJvbC1wbGFuZSAtLXdhaXQgNW0K" + + "CiMgMi4gRXhwb3J0IHRoZSBjb250cm9sLXBsYW5lIHByb2ZpbGUgZXZlcnkgY29tcHV0ZSBwbGFu" + + "ZSByZWdpc3RlcnMgZnJvbS4KIyAgICBJdCBpcyB3cml0dGVuIHRvIHRoZSBzdGFjaydzIG91dC8g" + + "ZGlyZWN0b3J5LCBhbmQgdGhlIGNvbW1hbmQgcHJpbnRzCiMgICAgdGhhdCBwYXRoLiBDb3B5IHRo" + + "ZSBmaWxlIGhlcmU6CktVQkVDT05GSUc9Y3AueWFtbCBudmNmLWNsaSBzZWxmLWhvc3RlZCBjb250" + + "cm9sLXBsYW5lIHByb2ZpbGUgZXhwb3J0IC0tY2x1c3Rlci1uYW1lPW5jcC1jcApjcCA8cGF0aCBp" + + "dCBwcmludGVkPiBjb250cm9sLXBsYW5lLXByb2ZpbGUueWFtbAoKIyAzLiBSZWdpc3RlciArIGlu" + + "c3RhbGwgZWFjaCBjb21wdXRlIHBsYW5lLiBCb3RoIGNvbW1hbmRzIG5hbWUgdGhlIEdQVQojICAg" + + "IGNsdXN0ZXIncyBjb250ZXh0IHdpdGggLS1rdWJlLWNvbnRleHQ6CmZvciBDVFggaW4gYWRtaW5A" + + "Z3B1LWVhc3QtMSBhZG1pbkBncHUtd2VzdC0xIGFkbWluQGdwdS1ldS0xOyBkbwogIE5BTUU9JChl" + + "Y2hvICIkQ1RYIiB8IGN1dCAtZEAgLWYyKQogIG52Y2YtY2xpIHNlbGYtaG9zdGVkIGNvbXB1dGUt" + + "cGxhbmUgcmVnaXN0ZXIgXAogICAgLS1jb250cm9sLXBsYW5lLXByb2ZpbGU9Y29udHJvbC1wbGFu" + + "ZS1wcm9maWxlLnlhbWwgXAogICAgLS1jbHVzdGVyLW5hbWU9JE5BTUUgXAogICAgLS1rdWJlLWNv" + + "bnRleHQ9JENUWCBcCiAgICAtLWljbXMtdXJsPWh0dHBzOi8vaWNtcy5udmNmLmV4YW1wbGUuY29t" + + "IFwKICAgIC0tdG9rZW49JE5WQ0ZfQURNSU5fSldUIFwKICAgIC0tb3V0cHV0PSROQU1FLXJlZ2lz" + + "dGVyLXZhbHVlcy55YW1sCiAgbnZjZi1jbGkgc2VsZi1ob3N0ZWQgY29tcHV0ZS1wbGFuZSBpbnN0" + + "YWxsIFwKICAgIC0tdmFsdWVzPSROQU1FLXJlZ2lzdGVyLXZhbHVlcy55YW1sIFwKICAgIC0ta3Vi" + + "ZS1jb250ZXh0PSRDVFggXAogICAgLS1jbHVzdGVyLW5hbWU9JE5BTUUKZG9uZQoKIyA0LiBWZXJp" + + "ZnkgYWxsIGNvbXB1dGUgcGxhbmVzIHJlZ2lzdGVyZWQ6Cm52Y2YtY2xpIGNsdXN0ZXIgbGlzdC1y" + + "ZWdpc3RlcmVkIC0tbmNhLWlkPSROQ0FfSUQgLS1pY21zLXVybD0kSUNNUyAtLWpzb24gXAogIHwg" + + "anEgJy5jbHVzdGVyc1tdIHwge25hbWU6IC5jbHVzdGVyTmFtZSwgc3RhdHVzLCBudmNhVmVyc2lv" + + "bn0nCmBgYAoKIyMgQ29tcHV0ZS1wbGFuZS1vbmx5IG9wZXJhdG9yIHNjZW5hcmlvCgpUaGUgb3Bl" + + "cmF0b3IgaGFzIGt1YmVjdGwgYWNjZXNzIHRvIE9ORSBjb21wdXRlIHBsYW5lIG9ubHkg4oCUIHRo" + + "ZSBjb250cm9sIHBsYW5lIGlzIG9wZXJhdGVkIGJ5IHNvbWVvbmUgZWxzZSAoZS5nLiBZb3R0YSBy" + + "dW5zIHRoZSBjb250cm9sIHBsYW5lIGZvciBhIGN1c3RvbWVyKS4KCmBgYHNoCiMgTWludCBhIHRv" + + "a2VuIHZpYSB0aGUgcHVibGljIGFwaSBnYXRld2F5ICh0aGUgY29udHJvbCBwbGFuZSdzIEFQSSBL" + + "ZXlzCiMgc2VydmljZSBpcyByZWFjaGFibGUgdmlhIGFwaS5udmNmLmV4YW1wbGUuY29tIOKAlCBu" + + "byBrdWJlY3RsIGFjY2VzcyBuZWVkZWQpOgpudmNmLWNsaSBpbml0IC0tYXBpLXVybD1odHRwczov" + + "L2FwaS5udmNmLmV4YW1wbGUuY29tCgojIFJ1biBwcmUtZmxpZ2h0IHNjb3BlZCB0byB0aGUgY29t" + + "cHV0ZSBwbGFuZSBvbmx5LiBjaGVjayB0YWtlcyBib3RoIGNvbnRleHQKIyBmbGFncyBvciBuZWl0" + + "aGVyIGFuZCBoYXMgbm8gLS1rdWJlY29uZmlnIGZsYWcsIHNvIGdpdmUgaXQsIHRocm91Z2gKIyBL" + + "VUJFQ09ORklHIGZvciB0aGlzIGNvbW1hbmQgb25seSwgYSBrdWJlY29uZmlnIGhvbGRpbmcganVz" + + "dCB0aGUgY29tcHV0ZQojIGNsdXN0ZXIncyBjb250ZXh0LiBUaGUgY3VycmVudCBjb250ZXh0IG9m" + + "IHlvdXIga3ViZWNvbmZpZyBzdGF5cyBhcyBpdCB3YXMuCiMgLS1jb21wdXRlLXBsYW5lIGNoZWNr" + + "cyB0aGUgY29tcHV0ZSBwbGFuZSBhcyBpbnN0YWxsZWQsIHNvIGEgZG9ja2VyIGxvZ2luCiMgd2l0" + + "aCBubyBhY2Nlc3MgdG8gdGhlIG9yZyBvZiB0aGUgY29tcHV0ZS1wbGFuZSBjaGFydHMgcGFzc2Vz" + + "IHdoZXJlCiMgTkdDX0FQSV9LRVkgcmVhY2hlcyBpdDogdHJlYXQgYSByZWdpc3RyeS1jcmVkZW50" + + "aWFscyByb3cgdGhhdCBzYXlzIHRoZQojIGRvY2tlciBsb2dpbiBoYXMgbm8gYWNjZXNzIGFzIGJs" + + "b2NraW5nLCBzaW5jZSBjb21wdXRlLXBsYW5lIGluc3RhbGwgcHVsbHMKIyBpdHMgY2hhcnRzIHdp" + + "dGggdGhhdCBsb2dpbi4gQSBkb2NrZXIgbG9naW4gbnZjci5pbyByZWplY3RzIGZhaWxzIGVpdGhl" + + "ciB3YXkuCiMgV2l0aCBhIGNvbnRyb2wtcGxhbmUgY29udGV4dCwgY2hlY2sgLS1wcmUgLS1jb250" + + "cm9sLXBsYW5lIGNoZWNrcyB0aGUgbmV3CiMgY29tcHV0ZSBwbGFuZSBhcyBub3QgeWV0IGluc3Rh" + + "bGxlZCBpbnN0ZWFkOyBzZWUgcHJvbXB0cy9hZGQtY29tcHV0ZS1wbGFuZS5tZC4KS0M9JChta3Rl" + + "bXApCmt1YmVjdGwgY29uZmlnIHZpZXcgLS1taW5pZnkgLS1mbGF0dGVuIC0tY29udGV4dD1hZG1p" + + "bkBncHUxID4gIiRLQyIKS1VCRUNPTkZJRz0iJEtDIiBudmNmLWNsaSBzZWxmLWhvc3RlZCBjaGVj" + + "ayAtLWNvbXB1dGUtcGxhbmUgXAogIC0taWNtcy11cmw9aHR0cHM6Ly9pY21zLm52Y2YuZXhhbXBs" + + "ZS5jb20gXAogIC0tanNvbgoKIyBSZWdpc3RlciBhbmQgaW5zdGFsbCBqdXN0IHRoZSBjb21wdXRl" + + "IHBsYW5lLCBmcm9tIHRoZSBwcm9maWxlIHRoZQojIGNvbnRyb2wtcGxhbmUgb3BlcmF0b3IgZXhw" + + "b3J0ZWQgd2l0aCBgc2VsZi1ob3N0ZWQgY29udHJvbC1wbGFuZSBwcm9maWxlIGV4cG9ydGAsCiMg" + + "c2F2ZWQgaGVyZSBhcyBjb250cm9sLXBsYW5lLXByb2ZpbGUueWFtbDoKbnZjZi1jbGkgc2VsZi1o" + + "b3N0ZWQgY29tcHV0ZS1wbGFuZSByZWdpc3RlciBcCiAgLS1jb250cm9sLXBsYW5lLXByb2ZpbGU9" + + "Y29udHJvbC1wbGFuZS1wcm9maWxlLnlhbWwgXAogIC0tY2x1c3Rlci1uYW1lPW15LWNvbXB1dGUt" + + "MSBcCiAgLS1rdWJlLWNvbnRleHQ9YWRtaW5AZ3B1MSBcCiAgLS1pY21zLXVybD1odHRwczovL2lj" + + "bXMubnZjZi5leGFtcGxlLmNvbSBcCiAgLS10b2tlbj0kQURNSU5fSldUIFwKICAtLW91dHB1dD1t" + + "eS1jb21wdXRlLTEtcmVnaXN0ZXItdmFsdWVzLnlhbWwKbnZjZi1jbGkgc2VsZi1ob3N0ZWQgY29t" + + "cHV0ZS1wbGFuZSBpbnN0YWxsIFwKICAtLXZhbHVlcz1teS1jb21wdXRlLTEtcmVnaXN0ZXItdmFs" + + "dWVzLnlhbWwgXAogIC0ta3ViZS1jb250ZXh0PWFkbWluQGdwdTEgXAogIC0tY2x1c3Rlci1uYW1l" + + "PW15LWNvbXB1dGUtMQoKIyBTdGF0dXMgb2YgYWRtaW5AZ3B1MSwgZnJvbSB0aGUgc2FtZSBrdWJl" + + "Y29uZmlnIChjb21wdXRlLW9ubHk6IHRoZSBvcGVyYXRvcgojIGNhbid0IHNlZSBjb250cm9sLXBs" + + "YW5lIGNvbXBvbmVudCBoZWFsdGgpLiBUaGVuIHJlbW92ZSB0aGUgZmlsZSwgd2hpY2gKIyBob2xk" + + "cyB0aGUgY29udGV4dCdzIGNyZWRlbnRpYWxzOgpLVUJFQ09ORklHPSIkS0MiIG52Y2YtY2xpIHNl" + + "bGYtaG9zdGVkIHN0YXR1cyBcCiAgLS1jbHVzdGVyLW5hbWU9bXktY29tcHV0ZS0xIFwKICAtLWlj" + + "bXMtdXJsPWh0dHBzOi8vaWNtcy5udmNmLmV4YW1wbGUuY29tCnJtIC1mICIkS0MiCmBgYAoKIyMg" + + "RnVuY3Rpb25zIHRhcmdldGluZyBzcGVjaWZpYyBjb21wdXRlIHBsYW5lcwoKV2hlbiB5b3UgaGF2" + + "ZSBtdWx0aXBsZSBjb21wdXRlIHBsYW5lcyBhbmQgd2FudCBhIGZ1bmN0aW9uIHRvIGRlcGxveSB0" + + "byBvbmU6CgpgYGBqc29uCnsKICAiZnVuY3Rpb25JZCI6ICI8Zm5faWQ+IiwKICAidmVyc2lvbklk" + + "IjogIjx2ZXJfaWQ+IiwKICAiZGVwbG95bWVudFNwZWNpZmljYXRpb25zIjogW3sKICAgICJncHUi" + + "OiAiSDEwMCIsCiAgICAiaW5zdGFuY2VUeXBlIjogIk5DUC5HUFUuSDEwMF8xeCIsCiAgICAibWlu" + + "SW5zdGFuY2VzIjogMSwKICAgICJtYXhJbnN0YW5jZXMiOiAxLAogICAgImNsdXN0ZXJzIjogWyJn" + + "cHUtZWFzdC0xIl0KICB9XQp9CmBgYAoKVGhlIGBjbHVzdGVyc2AgYXJyYXkgKHVzaW5nIGNsdXN0" + + "ZXIgbmFtZXMsIG5vdCBJRHMpIGxpbWl0cyBzY2hlZHVsaW5nIHRvIHRob3NlIGNvbXB1dGUgcGxh" + + "bmVzLiBPbWl0IHRvIGxldCBJQ01TIGNob29zZSBhbnkgY29tcHV0ZSBwbGFuZSB3aXRoIGEgbWF0" + + "Y2hpbmcgU0tVLgoKIyMgU3RhdHVzIGZhbi1vdXQgYWNyb3NzIGNvbXB1dGUgcGxhbmVzCgpgYGBz" + + "aAojIExpc3QgYWxsIHJlZ2lzdGVyZWQgY29tcHV0ZSBwbGFuZXM6Cm52Y2YtY2xpIGNsdXN0ZXIg" + + "bGlzdC1yZWdpc3RlcmVkIC0tbmNhLWlkPSROQ0FfSUQgLS1pY21zLXVybD0kSUNNUyAtLWpzb24g" + + "XAogIHwganEgLXIgJy5jbHVzdGVyc1tdLmNsdXN0ZXJOYW1lJyA+IGNsdXN0ZXJzLnR4dAoKIyBT" + + "dGF0dXMgc25hcHNob3QgcGVyIGNvbXB1dGUgcGxhbmUgKGFzc3VtaW5nIGVhY2ggY29udGV4dCBu" + + "YW1lIG1hdGNoZXMpLgojIEVhY2ggcnVuIHJlYWRzIGEgdGVtcG9yYXJ5IGt1YmVjb25maWcgaG9s" + + "ZGluZyBvbmx5IHRoYXQgY2x1c3RlcidzIGNvbnRleHQsCiMgc28gdGhlIGxvb3AgbGVhdmVzIHRo" + + "ZSBjdXJyZW50IGNvbnRleHQgb2YgeW91ciBrdWJlY29uZmlnIGFsb25lOgp3aGlsZSByZWFkIE5B" + + "TUU7IGRvCiAgZWNobyAiPT09ICROQU1FID09PSIKICBLQz0kKG1rdGVtcCkKICBrdWJlY3RsIGNv" + + "bmZpZyB2aWV3IC0tbWluaWZ5IC0tZmxhdHRlbiAtLWNvbnRleHQ9ImFkbWluQCROQU1FIiA+ICIk" + + "S0MiICYmCiAgICBLVUJFQ09ORklHPSIkS0MiIG52Y2YtY2xpIHNlbGYtaG9zdGVkIHN0YXR1cyBc" + + "CiAgICAgIC0tY2x1c3Rlci1uYW1lPSROQU1FIFwKICAgICAgLS1qc29uIHwganEgLWMgJ3tjbHVz" + + "dGVyOi5jbHVzdGVyLCB2ZXJkaWN0Oi52ZXJkaWN0LCByZWNvbmNpbGU6LnJlY29uY2lsZUFnZVNl" + + "Y30nCiAgcm0gLWYgIiRLQyIKZG9uZSA8IGNsdXN0ZXJzLnR4dApgYGAKCkEgZnV0dXJlIE0rMTEg" + + "bWlsZXN0b25lIHdpbGwgYWRkIG5hdGl2ZSBmYW4tb3V0IChgc3RhdHVzIC0tYWxsLWNvbXB1dGUt" + + "cGxhbmVzYCkgc28gdGhpcyBkb2Vzbid0IG5lZWQgYSBzaGVsbCBsb29wLgpQSwcIp885w/4UAAD+" + + "FAAAUEsDBBQACAAAAAAAIQAAAAAAAAAAAAAAAAA3AAkAZGF0YS9udmNmLXNlbGYtbWFuYWdlZC1j" + + "bGkvcHJvbXB0cy9hZGQtY29tcHV0ZS1wbGFuZS5tZFVUBQABAKbOEiMgQWRkIGEgY29tcHV0ZSBw" + + "bGFuZSB0byBhbiBleGlzdGluZyBjb250cm9sIHBsYW5lCgpVc2VyIGhhcyBhIHdvcmtpbmcgTlZD" + + "RiBjb250cm9sIHBsYW5lIChydW5uaW5nIHNvbWV3aGVyZSkgYW5kIHdhbnRzIHRvIHJlZ2lzdGVy" + + "IGEgbmV3IEdQVSBjbHVzdGVyIGFnYWluc3QgaXQuIFRoaXMgaXMgdGhlIG11bHRpLWNsdXN0ZXIg" + + "c2NhbGluZyBwYXRoOiBvbmUgY29udHJvbCBwbGFuZSDihpIgTiBjb21wdXRlIHBsYW5lcy4KCiMj" + + "IFByZXJlcXVpc2l0ZXMgdGhlIHVzZXIgbXVzdCBoYXZlCgotICoqa3ViZWN0bCBjb250ZXh0IGZv" + + "ciB0aGUgbmV3IGNvbXB1dGUgcGxhbmUqKiBpbiB0aGVpciBgS1VCRUNPTkZJR2AsIGFuZAogIGlk" + + "ZWFsbHkgb25lIGZvciB0aGUgY29udHJvbC1wbGFuZSBjbHVzdGVyIHRvbywgZm9yIHRoZSBwcmUt" + + "ZmxpZ2h0IGluCiAgc3RlcCAyLgotICoqUHVibGljIElDTVMgVVJMKiogb2YgdGhlIGV4aXN0aW5n" + + "IGNvbnRyb2wgcGxhbmUgKGUuZy4gYGh0dHBzOi8vaWNtcy5udmNmLmV4YW1wbGUuY29tYCkuCi0g" + + "KipBZG1pbiBKV1QqKiBmb3IgdGhlIGNvbnRyb2wgcGxhbmUncyBhY2NvdW50LCBPUiBhYmlsaXR5" + + "IHRvIG1pbnQgb25lIHZpYSBgbnZjZi1jbGkgaW5pdGAgYWdhaW5zdCB0aGUgY29udHJvbCBwbGFu" + + "ZSdzIHB1YmxpYyBhcGkgZW5kcG9pbnQuIChBZG1pbiB0b2tlbnMgY29tZSBmcm9tIHRoZSBBUEkg" + + "S2V5cyBzZXJ2aWNlIHZpYSB0aGUgcHVibGljIGFwaSBnYXRld2F5IOKAlCBrdWJlY3RsIGFjY2Vz" + + "cyB0byB0aGUgY29udHJvbCBwbGFuZSBpcyBOT1QgcmVxdWlyZWQgdG8gb2J0YWluIG9uZS4pCi0g" + + "VGhlIGNvbnRyb2wgcGxhbmUncyBwcm9maWxlIGZpbGUsIHdoaWNoIGBudmNmLWNsaSBzZWxmLWhv" + + "c3RlZCBjb250cm9sLXBsYW5lIHByb2ZpbGUgZXhwb3J0YCB3cml0ZXMgb24gYSBtYWNoaW5lIHRo" + + "YXQgY2FuIHJlYWNoIHRoZSBjb250cm9sLXBsYW5lIGNsdXN0ZXIuIEl0IGNhcnJpZXMgdGhlIGNv" + + "bnRyb2wgcGxhbmUncyBlbmRwb2ludHMgYW5kIHRydXN0IGZvciByZWdpc3RyYXRpb24uIFRoZSBj" + + "b21tYW5kIHdyaXRlcyBpdCB0byB0aGUgc3RhY2sncyBgb3V0L2AgZGlyZWN0b3J5IGFuZCBwcmlu" + + "dHMgdGhhdCBwYXRoOyB0aGUgc3RlcHMgYmVsb3cgZXhwZWN0IGl0IGNvcGllZCB0byBgY29udHJv" + + "bC1wbGFuZS1wcm9maWxlLnlhbWxgIGluIHRoZSB3b3JraW5nIGRpcmVjdG9yeS4KLSBBIHVuaXF1" + + "ZSBgLS1jbHVzdGVyLW5hbWVgIHRoYXQgZG9lc24ndCBjb2xsaWRlIHdpdGggYWxyZWFkeS1yZWdp" + + "c3RlcmVkIGNsdXN0ZXJzLiBVc2UgYG52Y2YtY2xpIGNsdXN0ZXIgbGlzdC1yZWdpc3RlcmVkIC0t" + + "bmNhLWlkPSROQ0FfSUQgLS1pY21zLXVybD0kSUNNU2AgdG8gY2hlY2suCgojIyBTdGVwcwoKMS4g" + + "QXNrIGZvciBpbnB1dHM6CiAgIC0gYC0tY2x1c3Rlci1uYW1lPTx1bmlxdWU+YCDigJQgbXVzdCBi" + + "ZSB1bmlxdWUgd2l0aGluIHRoZSBjb250cm9sIHBsYW5lJ3MgTkNBIHNjb3BlCiAgIC0gYC0ta3Vi" + + "ZS1jb250ZXh0PTxjb250ZXh0PmA6IHRoZSBuZXcgR1BVIGNsdXN0ZXIncyBrdWJlY3RsIGNvbnRl" + + "eHQKICAgLSBgLS1pY21zLXVybD08aHR0cHM6Ly9pY21zLm52Y2YuZXhhbXBsZS5jb20+YCDigJQg" + + "Y29udHJvbCBwbGFuZSdzIHB1YmxpYyBJQ01TIFVSTAogICAtIEdQVSB0eXBlPyAoZGVmYXVsdCBg" + + "SDEwMGAsIGJ1dCBhc2spCiAgIC0gT3B0aW9uYWwsIGZvciB0aGUgcHJlLWZsaWdodCBpbiBzdGVw" + + "IDI6IHRoZSBjb250cm9sLXBsYW5lIGNsdXN0ZXIncwogICAgIGt1YmVjdGwgY29udGV4dCAoYCRD" + + "UF9DVFhgKSBhbmQgdGhlIGAtLWVudmAgdGhlIGNvbnRyb2wgcGxhbmUgd2FzCiAgICAgaW5zdGFs" + + "bGVkIHdpdGggKGAkQ1BfRU5WYCkuIFdpdGhvdXQgZWl0aGVyLCB1c2UgdGhlIGAtLWNvbXB1dGUt" + + "cGxhbmVgCiAgICAgZmFsbGJhY2sgaW4gc3RlcCAyLgoKMi4gUHJlLWZsaWdodCB0aGUgbmV3IGNv" + + "bXB1dGUgcGxhbmUgYXMgbm90IHlldCBpbnN0YWxsZWQuIFdpdGggYSBrdWJlY3RsCiAgIGNvbnRl" + + "eHQgZm9yIHRoZSBjb250cm9sLXBsYW5lIGNsdXN0ZXIgYXMgd2VsbCwgYGNoZWNrIC0tcHJlCiAg" + + "IC0tY29udHJvbC1wbGFuZWAgY2hlY2tzIHRoZSBjb250cm9sIHBsYW5lIGFzIGluc3RhbGxlZCBh" + + "bmQgdGhlIG5ldwogICBjb21wdXRlIHBsYW5lIGFzIG5vdCB5ZXQgaW5zdGFsbGVkOgoKICAgYGBg" + + "c2gKICAgbnZjZi1jbGkgc2VsZi1ob3N0ZWQgY2hlY2sgLS1wcmUgLS1jb250cm9sLXBsYW5lIFwK" + + "ICAgICAtLWNvbnRyb2wtcGxhbmUtY29udGV4dD0iJENQX0NUWCIgLS1jb21wdXRlLXBsYW5lLWNv" + + "bnRleHQ9IiRDVFgiIFwKICAgICAtLWVudj0iJENQX0VOViIgXAogICAgIC0tanNvbiAyPiYxID4v" + + "ZGV2L251bGwgfCBncmVwICdeeycgPiBjaGVjay5qc29ubAogICBqcSAtc2UgJ2FueSguW107IC5l" + + "dmVudCA9PSAiZmluYWwiIGFuZCAuc3VjY2VzcyknIGNoZWNrLmpzb25sCiAgIGBgYAoKICAgYCRD" + + "UF9FTlZgIGlzIHRoZSBgLS1lbnZgIHRoZSBjb250cm9sIHBsYW5lIHdhcyBpbnN0YWxsZWQgd2l0" + + "aC4gVGhpcyBjaGVja3MKICAgdGhlIGNvbnRyb2wtcGxhbmUgY2x1c3RlciwgYW5kIHRoZSBuZXcg" + + "Y2x1c3RlcjogbGVmdG92ZXIgTlZDRiBuYW1lc3BhY2VzLAogICBub2RlIGlub3RpZnkgbGltaXRz" + + "LCBhbmQgdGhlIGNvbXB1dGUtcGxhbmUgY2x1c3Rlci12YWxpZGF0b3IgKEdQVQogICByZXNvdXJj" + + "ZXMsIEdQVSBPcGVyYXRvciwgU01CIENTSSBkcml2ZXIpIHdoZW4gYGNsdXN0ZXJfdmFsaWRhdG9y" + + "X2ltYWdlYCBpcwogICBzZXQuIEl0IGdyYWRlcyB0aGlzIG1hY2hpbmUncyByZWdpc3RyeSBjcmVk" + + "ZW50aWFscyBhcyBgY29tcHV0ZS1wbGFuZQogICBpbnN0YWxsYCB1c2VzIHRoZW06IGBoZWxtYCBv" + + "biB0aGlzIG1hY2hpbmUgcHVsbHMgdGhlIGNvbXB1dGUtcGxhbmUgY2hhcnRzCiAgIHdpdGggdGhl" + + "IGRvY2tlciBsb2dpbiwgc28gYSBkb2NrZXIgbG9naW4gYG52Y3IuaW9gIHJlamVjdHMsIG9yIG9u" + + "ZSB3aXRoIG5vCiAgIGFjY2VzcyB0byB0aGUgb3JnIHRoZSBjaGFydHMgY29tZSBmcm9tLCBmYWls" + + "cyB0aGUgZ2F0ZSBldmVuIHdoZW4KICAgYE5HQ19BUElfS0VZYCB3b3Jrcy4gQSBtaXNzaW5nIGBo" + + "ZWxtYCwgYGhlbG1maWxlYCBvciBga3ViZWN0bGAgZmFpbHMgaXQKICAgdG9vLiBTSVMgcmVhY2hh" + + "YmlsaXR5IGlzIG5vdCBwcm9iZWQgYmVmb3JlIGluc3RhbGwuCgogICBXaXRob3V0IGEgY29udGV4" + + "dCBmb3IgdGhlIGNvbnRyb2wtcGxhbmUgY2x1c3Rlciwgb3Igd2l0aG91dCB0aGUgYC0tZW52YAog" + + "ICBpdCB3YXMgaW5zdGFsbGVkIHdpdGgsIGNoZWNrIHRoZSBuZXcgY2x1c3RlciBhbG9uZSB3aXRo" + + "CiAgIGAtLWNvbXB1dGUtcGxhbmVgLiBgY2hlY2tgIHRha2VzIGJvdGggY29udGV4dCBmbGFncyBv" + + "ciBuZWl0aGVyCiAgIGFuZCBoYXMgbm8gYC0ta3ViZWNvbmZpZ2AgZmxhZywgc28gZ2l2ZSBpdCwg" + + "dGhyb3VnaCBgS1VCRUNPTkZJR2AgZm9yIHRoaXMKICAgY29tbWFuZCBvbmx5LCBhIHRlbXBvcmFy" + + "eSBrdWJlY29uZmlnIGhvbGRpbmcganVzdCB0aGUgbmV3IEdQVSBjbHVzdGVyJ3MKICAgY29udGV4" + + "dC4gVGhlIGN1cnJlbnQgY29udGV4dCBvZiB0aGUgdXNlcidzIGt1YmVjb25maWcgc3RheXMgYXMg" + + "aXQgd2FzOgoKICAgYGBgc2gKICAgS0M9JChta3RlbXApCiAgIGt1YmVjdGwgY29uZmlnIHZpZXcg" + + "LS1taW5pZnkgLS1mbGF0dGVuIC0tY29udGV4dD0iJENUWCIgPiAiJEtDIgogICBLVUJFQ09ORklH" + + "PSIkS0MiIG52Y2YtY2xpIHNlbGYtaG9zdGVkIGNoZWNrIC0tY29tcHV0ZS1wbGFuZSBcCiAgICAg" + + "LS1pY21zLXVybD0kSUNNUyBcCiAgICAgLS1qc29uIDI+JjEgPi9kZXYvbnVsbCB8IGdyZXAgJ157" + + "JyA+IGNoZWNrLmpzb25sCiAgIHJtIC1mICIkS0MiCiAgIGpxIC1zZSAnYW55KC5bXTsgLmV2ZW50" + + "ID09ICJmaW5hbCIgYW5kIC5zdWNjZXNzKScgY2hlY2suanNvbmwKICAgYGBgCgogICBUaGF0IHJ1" + + "bnMgdGhlIHNhbWUgY2hlY2tzIG9uIHRoZSBuZXcgY2x1c3RlciwgYW5kIFNJUyByZWFjaGFiaWxp" + + "dHkgYXQKICAgYC0taWNtcy11cmxgLCBidXQgY2hlY2tzIHRoZSBjb21wdXRlIHBsYW5lIGFzIGlu" + + "c3RhbGxlZDogYSBtaXNzaW5nIHRvb2wKICAgb25seSB3YXJucy4gQSBkb2NrZXIgbG9naW4gYG52" + + "Y3IuaW9gIHJlamVjdHMgc3RpbGwgZmFpbHMgdGhlIGdhdGUsIGJ1dCBvbmUKICAgd2l0aCBubyBh" + + "Y2Nlc3MgdG8gdGhlIG9yZyB0aGUgY29tcHV0ZS1wbGFuZSBjaGFydHMgY29tZSBmcm9tIHBhc3Nl" + + "cyB3aGVyZQogICBgTkdDX0FQSV9LRVlgIHJlYWNoZXMgaXQuIFRyZWF0IGEgYHJlZ2lzdHJ5LWNy" + + "ZWRlbnRpYWxzYCByb3cgdGhhdCBzYXlzIHRoZQogICBkb2NrZXIgbG9naW4gaGFzIG5vIGFjY2Vz" + + "cyBhcyBibG9ja2luZywgYW5kIGxvZyBpbiB3aXRoIGEgY3JlZGVudGlhbCB0aGF0CiAgIGhhcyBh" + + "Y2Nlc3MgYmVmb3JlIHRoZSBpbnN0YWxsLgoKICAgSWYgdGhlIGdhdGUgZmFpbHMsIHNob3cgdGhl" + + "IHVzZXIgdGhlIGBjaGVja19jb21wbGV0ZWRgIGV2ZW50cyB3aXRoCiAgIGBwYXNzZWQ6IGZhbHNl" + + "YCBhbmQgYHNldmVyaXR5OiAiZXJyb3IiYCwgYW5kIGFkZHJlc3MgdGhlbSBiZWZvcmUKICAgcHJv" + + "Y2VlZGluZy4KCjMuIFJlZ2lzdGVyIHRoZSBjbHVzdGVyLCB0aGVuIGluc3RhbGwgaXRzIGNvbXB1" + + "dGUgcGxhbmUgZnJvbSB0aGUgdmFsdWVzCiAgIGZpbGUgcmVnaXN0cmF0aW9uIHdyaXRlcy4gQm90" + + "aCBjb21tYW5kcyBuYW1lIHRoZSBuZXcgY2x1c3RlcidzIGNvbnRleHQKICAgd2l0aCBgLS1rdWJl" + + "LWNvbnRleHRgOgoKICAgYGBgc2gKICAgbnZjZi1jbGkgc2VsZi1ob3N0ZWQgY29tcHV0ZS1wbGFu" + + "ZSByZWdpc3RlciBcCiAgICAgLS1jb250cm9sLXBsYW5lLXByb2ZpbGU9Y29udHJvbC1wbGFuZS1w" + + "cm9maWxlLnlhbWwgXAogICAgIC0tY2x1c3Rlci1uYW1lPSROQU1FIFwKICAgICAtLWt1YmUtY29u" + + "dGV4dD0kQ1RYIFwKICAgICAtLWljbXMtdXJsPSRJQ01TIFwKICAgICAtLXRva2VuPSRKV1QgXAog" + + "ICAgIC0tb3V0cHV0PSROQU1FLXJlZ2lzdGVyLXZhbHVlcy55YW1sCiAgIG52Y2YtY2xpIHNlbGYt" + + "aG9zdGVkIGNvbXB1dGUtcGxhbmUgaW5zdGFsbCBcCiAgICAgLS12YWx1ZXM9JE5BTUUtcmVnaXN0" + + "ZXItdmFsdWVzLnlhbWwgXAogICAgIC0ta3ViZS1jb250ZXh0PSRDVFggXAogICAgIC0tY2x1c3Rl" + + "ci1uYW1lPSROQU1FCiAgIGBgYAoKICAgYGNvbXB1dGUtcGxhbmUgcmVnaXN0ZXJgIHJlY29yZHMg" + + "dGhlIGNsdXN0ZXIncyBPSURDIGlzc3VlciBhbmQgSldLUyB3aXRoCiAgIElDTVMgYXQgYC0taWNt" + + "cy11cmxgIGFuZCB3cml0ZXMgdGhlIE5WQ0Egb3BlcmF0b3IgdmFsdWVzLiBgY29tcHV0ZS1wbGFu" + + "ZQogICBpbnN0YWxsYCBpbnN0YWxscyB0aGUgY29tcHV0ZS1wbGFuZSBzdGFjayB3aXRoIHRoZW0u" + + "IE5laXRoZXIgdG91Y2hlcyB0aGUKICAgY29udHJvbC1wbGFuZSBjbHVzdGVyOiBJQ01TIGlzIHJl" + + "YWNoZWQgb3ZlciBIVFRQUy4gYC0tdG9rZW5gIHBhc3NlcyB0aGUKICAgYWRtaW4gSldUOyB3aXRo" + + "b3V0IGl0IHRoZSB0b2tlbiBgbnZjZi1jbGkgaW5pdGAgc3RvcmVkIGlzIHVzZWQuCgo0LiBWZXJp" + + "ZnkuIGBudmNmLWNsaSBzZWxmLWhvc3RlZCBzdGF0dXMgLS1jbHVzdGVyLW5hbWU9JE5BTUUgLS1q" + + "c29uIHwganFgOiBleHBlY3QgYHZlcmRpY3Q6ICJoZWFsdGh5ImAuIFRoZSBSZWdpc3RlcmVkIENv" + + "bXB1dGUgUGxhbmVzIHBhbmVsIGZyb20gSUNNUyBzaG91bGQgbm93IGxpc3QgdGhlIG5ldyBjbHVz" + + "dGVyLgoKNS4gU21va2UuIFtkZXBsb3ktYW5kLWludm9rZS5tZF0oZGVwbG95LWFuZC1pbnZva2Uu" + + "bWQpOiBkZXBsb3kgYSBzbWFsbCBmdW5jdGlvbiB0byB0aGUgbmV3IGNvbXB1dGUgcGxhbmUgdG8g" + + "Y29uZmlybSBzY2hlZHVsaW5nIHdvcmtzLgoKIyMgV2hhdCBpZiBhIGNsdXN0ZXIgd2l0aCB0aGUg" + + "c2FtZSBuYW1lIGFscmVhZHkgZXhpc3RzPwoKYGNvbXB1dGUtcGxhbmUgcmVnaXN0ZXJgIHJldXNl" + + "cyBhbiBleGlzdGluZyByZWdpc3RyYXRpb246IGl0IG1hdGNoZXMgdGhlIElDTVMgcm93IHdpdGgg" + + "dGhlIHNhbWUgbmFtZSwgcmV1c2VzIGl0cyBjbHVzdGVySWQgYW5kIHJlcGxhY2VzIGl0cyBKV0tT" + + "LiBUd28gc2NlbmFyaW9zOgoKLSAqKlJlLXJlZ2lzdGVyaW5nIHRoZSBzYW1lIGNsdXN0ZXIqKiAo" + + "cmUtcnVubmluZyBvbiB0aGUgc2FtZSBjb21wdXRlIHBsYW5lIHRoYXQgd2FzIHByZXZpb3VzbHkg" + + "cmVnaXN0ZXJlZCk6IGV4cGVjdGVkLCBuby1vcCBzZW1hbnRpY3MuCi0gKipEaWZmZXJlbnQgcGh5" + + "c2ljYWwgY2x1c3RlciBidXQgc2FtZSBuYW1lKio6IHRoZSBzZWNvbmQgYXR0ZW1wdCB3aWxsIHJl" + + "dXNlIHRoZSBJQ01TIHJvdywgYnV0IHRoZSBuZXcgY29tcHV0ZSBwbGFuZSdzIEpXS1Mgd2lsbCBi" + + "ZSBzaWxlbnRseSAqcmVwbGFjZWQqIOKAlCB0aGUgb2xkIGNvbXB1dGUgcGxhbmUncyBOVkNBIGFn" + + "ZW50IHdpbGwgc3RhcnQgZmFpbGluZyBQU0FUIGF1dGguICoqQ29uZmlybSB3aXRoIHRoZSB1c2Vy" + + "KiogdGhhdCB0aGV5IG1lYW50IHRvIG92ZXJ3cml0ZS4KCldoZW4gaW4gZG91YnQsIHJ1biBgbnZj" + + "Zi1jbGkgY2x1c3RlciBsaXN0LXJlZ2lzdGVyZWQgLS1uY2EtaWQ9JE5DQV9JRCAtLWljbXMtdXJs" + + "PSRJQ01TYCBmaXJzdCBhbmQgYXNrIHRoZSB1c2VyIGJlZm9yZSBwcm9jZWVkaW5nIHdpdGggYSBu" + + "YW1lIHRoYXQgYWxyZWFkeSBleGlzdHMuClBLBwgBean5/hkAAP4ZAABQSwMEFAAIAAAAAAAhAAAA" + + "AAAAAAAAAAAAADkACQBkYXRhL252Y2Ytc2VsZi1tYW5hZ2VkLWNsaS9wcm9tcHRzL2NyZWF0ZS1h" + + "bmQtcnVuLXRhc2subWRVVAUAAQCmzhIjIENyZWF0ZSBhbmQgcnVuIGEgdGFzawoKU3RhbmRhcmQg" + + "ZmxvdzogbWludCBBUEkga2V5cyAtPiB0YXNrIENSRUFURSAtPiBtb25pdG9yIC0+IHJldHJpZXZl" + + "IHJlc3VsdHMgLT4gY2xlYW51cC4KCiMjIDEuIE1pbnQgQVBJIGtleXMKClRoZSBhZG1pbiB0b2tl" + + "biBmcm9tIGBpbml0YCBkb2VzIG5vdCBjYXJyeSB0YXNrIGludm9jYXRpb24gc2NvcGUuIEdlbmVy" + + "YXRlIEFQSSBrZXlzOgoKYGBgc2gKbnZjZi1jbGkgYXBpLWtleSBnZW5lcmF0ZSAtLWRlc2NyaXB0" + + "aW9uPSJ0YXNrIHNtb2tlIHRlc3QiIC0tZXhwaXJlcy1pbj04aAojIC0+IGZ1bmN0aW9uIGtleTog" + + "bnZhcGktLi4uCiMgLT4gdGFzayBrZXk6IG52YXBpLS4uLgpgYGAKCkJvdGgga2V5cyBhcmUgc2F2" + + "ZWQgdG8gQ0xJIHN0YXRlLiBUbyBtaW50IG9ubHkgdGhlIHRhc2sga2V5OgoKYGBgc2gKbnZjZi1j" + + "bGkgYXBpLWtleSBnZW5lcmF0ZSAtLWZvciB0YXNrIC0tZGVzY3JpcHRpb249InRhc2sgc21va2Ug" + + "dGVzdCIgLS1leHBpcmVzLWluPThoCmBgYAoKVG8gdmFsaWRhdGUgZWFjaCBrZXkgaW1tZWRpYXRl" + + "bHkgYWZ0ZXIgbWludGluZzoKCmBgYHNoCm52Y2YtY2xpIGFwaS1rZXkgZ2VuZXJhdGUgLS1kZXNj" + + "cmlwdGlvbj0idGFzayBzbW9rZSB0ZXN0IiAtLWV4cGlyZXMtaW49OGggLS12YWxpZGF0ZQojIC0+" + + "IGZ1bmN0aW9uIEFQSSBrZXkgdmFsaWRhdGlvbiBwYXNzZWQhCiMgLT4gdGFzayBBUEkga2V5IHZh" + + "bGlkYXRpb24gcGFzc2VkIQpgYGAKCk5ldmVyIGVjaG8gdGhlIGtleSB2YWx1ZSAobnZhcGktLi4u" + + "KSBpbnRvIGNoYXQgb3IgbG9ncy4gUmVwb3J0IG9ubHkgdGhhdCB0aGUga2V5IHdhcyBtaW50ZWQs" + + "IGl0cyBkZXNjcmlwdGlvbiwgYW5kIGl0cyBleHBpcnkg4oCUIG5ldmVyIHRoZSB2YWx1ZSBpdHNl" + + "bGYuCgojIyMgRnJlc2ggc2Vzc2lvbiBwcmUtZmxpZ2h0CgpCZWZvcmUgcnVubmluZyBhbnkgYHRh" + + "c2tgIGNvbW1hbmQgaW4gYSBmcmVzaCBhZ2VudCBzZXNzaW9uLCB2ZXJpZnkgY3JlZGVudGlhbHM6" + + "CgoxLiBDaGVjayB3aGV0aGVyIGBOVkNGX0FQSV9LRVlgIGlzIHNldDoKCiAgIGBgYHNoCiAgIHBy" + + "aW50ZW52IE5WQ0ZfQVBJX0tFWSA+IC9kZXYvbnVsbCAyPiYxICYmIGVjaG8gc2V0IHx8IGVjaG8g" + + "dW5zZXQKICAgYGBgCgoyLiBJZiB1bnNldCwgcmVhZCB0aGUgc3RvcmVkIGtleSBleHBpcnkgd2l0" + + "aG91dCBwcmludGluZyB0aGUgdmFsdWU6CgogICBgYGBzaAogICBweXRob24zIC1jICJpbXBvcnQg" + + "anNvbixwYXRobGliOyBzPWpzb24ubG9hZHMocGF0aGxpYi5QYXRoKCd+Ly5udmNmLWNsaS5zdGF0" + + "ZScpLmV4cGFuZHVzZXIoKS5yZWFkX3RleHQoKSk7IHByaW50KHMuZ2V0KCdudmN0QXBpS2V5RXhw" + + "aXJhdGlvbicsJ21pc3NpbmcnKSkiCiAgIGBgYAoKMy4gSWYgdGhlIGV4cGlyeSBpcyBpbiB0aGUg" + + "cGFzdCBvciBtaXNzaW5nLCByZWdlbmVyYXRlOgoKICAgYGBgc2gKICAgbnZjZi1jbGkgcmVmcmVz" + + "aAogICBudmNmLWNsaSBhcGkta2V5IGdlbmVyYXRlIC0tZm9yIHRhc2sgLS1kZXNjcmlwdGlvbj0i" + + "dGFzay1rZXkiIC0tZXhwaXJlcy1pbj0yNGggLS12YWxpZGF0ZQogICBgYGAKCjQuIEluamVjdCB0" + + "aGUgc3RvcmVkIGtleSBpbnRvIHRoZSBzdWJwcm9jZXNzIGVudiB3aXRob3V0IGxvZ2dpbmcgdGhl" + + "IHZhbHVlLiBVc2UgYSBzaGVsbCBzdWJzaGVsbCBvciBlbnYgcHJlZml4IOKAlCBuZXZlciBwcmlu" + + "dCB0aGUga2V5IGludG8gY2hhdC4KCkVycm9yIGNsYXNzaWZpY2F0aW9uOgoKLSAibWlzc2luZyBh" + + "dXRoZW50aWNhdGlvbiBjcmVkZW50aWFscyIgb3IgZXhpdCAyOiBrZXkgbm90IGluamVjdGVkOyBy" + + "ZXBlYXQgcHJlLWZsaWdodC4KLSBIVFRQIDQwMzoga2V5IGV4cGlyZWQgb3Igd3JvbmcgYXVkaWVu" + + "Y2U7IHJlZ2VuZXJhdGUuCi0gRW1wdHkgdGFzayBsaXN0OiB2YWxpZDsgbm8gdGFza3MgZXhpc3Qg" + + "eWV0LgoKIyMgMi4gVGFzayBDUkVBVEU6IGNvbnRhaW5lcgoKRGVmaW5lIHRoZSB0YXNrIGluIGEg" + + "SlNPTiBmaWxlLiBNaW5pbXVtIHZpYWJsZSBjb250YWluZXIgdGFzazoKCmBgYGpzb24KewogICJu" + + "YW1lIjogInNtb2tlLWNvbnRhaW5lci10YXNrIiwKICAiZ3B1U3BlY2lmaWNhdGlvbiI6IHsKICAg" + + "ICJncHUiOiAiSDEwMCIsCiAgICAiaW5zdGFuY2VUeXBlIjogIk5DUC5HUFUuSDEwMF8xeCIsCiAg" + + "ICAiYmFja2VuZCI6ICJuY3AtbG9jYWwiCiAgfSwKICAiY29udGFpbmVySW1hZ2UiOiAibnZjci5p" + + "by9udmlkaWEvbmVtbzpsYXRlc3QiLAogICJjb250YWluZXJBcmdzIjogInB5dGhvbiAtYyBcInBy" + + "aW50KCdoZWxsbyBmcm9tIHRhc2snKVwiIiwKICAicmVzdWx0SGFuZGxpbmdTdHJhdGVneSI6ICJO" + + "T05FIgp9CmBgYAoKYGBgc2gKbnZjZi1jbGkgdGFzayBjcmVhdGUgLS1pbnB1dC1maWxlPXRhc2st" + + "Y29udGFpbmVyLmpzb24KIyAtPiBUYXNrIElEOiA8dGFza19pZD4KYGBgCgpDYXB0dXJlIHRoZSB0" + + "YXNrIElEIGZyb20gdGhlIG91dHB1dC4gU3RhdGUgcGVyc2lzdGVuY2UgY2FuIGZhaWwgc2lsZW50" + + "bHksIHNvIHBhc3MKYDx0YXNrSWQ+YCBleHBsaWNpdGx5IG9uIHN1YnNlcXVlbnQgY29tbWFuZHMg" + + "cmF0aGVyIHRoYW4gcmVseWluZyBvbiBzYXZlZCBzdGF0ZS4KCkVxdWl2YWxlbnQgQ0xJIGZsYWdz" + + "IGZvcm0gZm9yIHF1aWNrIGFkLWhvYyBydW5zOgoKYGBgc2gKbnZjZi1jbGkgdGFzayBjcmVhdGUg" + + "XAogIC0tbmFtZT1zbW9rZS1jb250YWluZXItdGFzayBcCiAgLS1ncHU9SDEwMCBcCiAgLS1pbnN0" + + "YW5jZS10eXBlPU5DUC5HUFUuSDEwMF8xeCBcCiAgLS1iYWNrZW5kPW5jcC1sb2NhbCBcCiAgLS1p" + + "bWFnZT1udmNyLmlvL252aWRpYS9uZW1vOmxhdGVzdCBcCiAgLS1jb250YWluZXItYXJncz0icHl0" + + "aG9uIC1jICdwcmludChoZWxsbyBmcm9tIHRhc2spJyIgXAogIC0tcmVzdWx0LXN0cmF0ZWd5PU5P" + + "TkUKYGBgCgpUbyB0YXJnZXQgYSBzcGVjaWZpYyBjb21wdXRlIGNsdXN0ZXI6CgpgYGBzaApudmNm" + + "LWNsaSB0YXNrIGNyZWF0ZSAtLWlucHV0LWZpbGU9dGFzay1jb250YWluZXIuanNvbiAtLWNsdXN0" + + "ZXJzPW15LWNsdXN0ZXIKYGBgCgpUbyBpbmplY3QgZW52aXJvbm1lbnQgdmFyaWFibGVzIG9yIHNl" + + "Y3JldHMsIGVpdGhlciBwYXNzIGZsYWdzOgoKYGBgc2gKbnZjZi1jbGkgdGFzayBjcmVhdGUgLS1p" + + "bnB1dC1maWxlPXRhc2stY29udGFpbmVyLmpzb24gXAogIC0tY29udGFpbmVyLWVudiBNWV9FTlY9" + + "dmFsdWUgXAogIC0tc2VjcmV0cyBNWV9TRUNSRVQ9czNjcjN0CmBgYAoKb3Igc2V0IHRoZW0gaW4g" + + "dGhlIEpTT04gZmlsZToKCmBgYGpzb24KewogICJjb250YWluZXJFbnZpcm9ubWVudCI6IFsKICAg" + + "IHsia2V5IjogIk1ZX0VOViIsICJ2YWx1ZSI6ICJ2YWx1ZSJ9CiAgXSwKICAic2VjcmV0cyI6IFsK" + + "ICAgIHsibmFtZSI6ICJNWV9TRUNSRVQiLCAidmFsdWUiOiAiczNjcjN0In0KICBdCn0KYGBgCgpT" + + "ZWNyZXRzIGFyZSBlbmNyeXB0ZWQgYXQgcmVzdCBhbmQgbm90IHJldHVybmVkIGluIGxpc3Qgb3Ig" + + "Z2V0IHJlc3BvbnNlcyBieSBkZWZhdWx0OyBkbyBub3QgbG9nIHRoZW0uCgojIyAzLiBUYXNrIENS" + + "RUFURTogSGVsbQoKRm9yIEhlbG0tdHlwZSB0YXNrcywgcmVwbGFjZSBgY29udGFpbmVySW1hZ2Vg" + + "IHdpdGggYGhlbG1DaGFydGA6CgpgYGBqc29uCnsKICAibmFtZSI6ICJzbW9rZS1oZWxtLXRhc2si" + + "LAogICJncHVTcGVjaWZpY2F0aW9uIjogewogICAgImdwdSI6ICJIMTAwIiwKICAgICJpbnN0YW5j" + + "ZVR5cGUiOiAiTkNQLkdQVS5IMTAwXzF4IiwKICAgICJiYWNrZW5kIjogIm5jcC1sb2NhbCIKICB9" + + "LAogICJoZWxtQ2hhcnQiOiAiaHR0cHM6Ly9oZWxtLm5nYy5udmlkaWEuY29tLzxvcmc+L2NoYXJ0" + + "cy9leGFtcGxlLXRhc2stMC4xLjAudGd6IiwKICAicmVzdWx0SGFuZGxpbmdTdHJhdGVneSI6ICJO" + + "T05FIgp9CmBgYAoKYGBgc2gKbnZjZi1jbGkgdGFzayBjcmVhdGUgLS1pbnB1dC1maWxlPXRhc2st" + + "aGVsbS5qc29uCiMgLT4gVGFzayBJRDogPHRhc2tfaWQ+CmBgYAoKYGhlbG1DaGFydGAgYW5kIGBj" + + "b250YWluZXJJbWFnZWAgYXJlIG11dHVhbGx5IGV4Y2x1c2l2ZS4KCiMjIDNiLiBVcGRhdGUgc2Vj" + + "cmV0cyBvbiBhIHJ1bm5pbmcgdGFzawoKYGBgc2gKbnZjZi1jbGkgdGFzayB1cGRhdGUtc2VjcmV0" + + "cyA8dGFza19pZD4gLS1zZWNyZXRzIE5BTUU9dmFsdWUKYGBgCgpgdXBkYXRlLXNlY3JldHNgIG1l" + + "cmdlcyBzZWNyZXRzIGJ5IG5hbWU6IHN1cHBsaWVkIHNlY3JldHMgYXJlIGFkZGVkIG9yIHVwZGF0" + + "ZWQsIGFuZCBleGlzdGluZyBzZWNyZXRzIG5vdCBpbmNsdWRlZCBpbiB0aGUgcmVxdWVzdCBhcmUg" + + "cHJlc2VydmVkLiBEbyBub3QgZWNobyBzZWNyZXQgdmFsdWVzIGludG8gY2hhdC4KCiMjIDQuIE1v" + + "bml0b3IKClBvbGwgdGFzayBzdGF0ZToKCmBgYHNoCm52Y2YtY2xpIHRhc2sgZ2V0IDx0YXNrX2lk" + + "PgojIC0+IGlkOiA8dGFza19pZD4KIyAtPiBzdGF0dXM6IFJVTk5JTkcKYGBgCgpPciB1c2UgdGhl" + + "IHRhc2sgSUQgc2F2ZWQgdG8gc3RhdGUgZnJvbSB0aGUgbW9zdCByZWNlbnQgYHRhc2sgY3JlYXRl" + + "YDoKCmBgYHNoCm52Y2YtY2xpIHRhc2sgZ2V0CmBgYAoKU3RhdHVzIHZhbHVlczogYFFVRVVFRGAs" + + "IGBMQVVOQ0hFRGAsIGBSVU5OSU5HYCwgYENPTVBMRVRFRGAsIGBFUlJPUkVEYCwgYENBTkNFTEVE" + + "YCwgYEVYQ0VFREVEX01BWF9SVU5USU1FX0RVUkFUSU9OYCwgYEVYQ0VFREVEX01BWF9RVUVVRURf" + + "RFVSQVRJT05gLgoKSW5zcGVjdCBsaWZlY3ljbGUgZXZlbnRzIGZvciBwcm9ncmVzcyBkZXRhaWxz" + + "IG9yIGZhaWx1cmUgY29udGV4dDoKCmBgYHNoCm52Y2YtY2xpIHRhc2sgZXZlbnRzIDx0YXNrX2lk" + + "PgpgYGAKCklmIHRoZSB0YXNrIGlzIHN0dWNrIGluIGBRVUVVRURgLCBjaGVjayB0aGF0IGEgY29t" + + "cHV0ZSBjbHVzdGVyIHdpdGggYSBtYXRjaGluZyBHUFUgU0tVCmlzIHJlZ2lzdGVyZWQgYW5kIGhl" + + "YWx0aHkgKGBudmNmLWNsaSBzZWxmLWhvc3RlZCBzdGF0dXNgKS4KCklmIHRoZSB0YXNrIG1vdmVz" + + "IHRvIGBFUlJPUkVEYCwgdGhlIGV2ZW50cyBvdXRwdXQgY29udGFpbnMgdGhlIGZhaWx1cmUgcmVh" + + "c29uLiBGb3IKY29udGFpbmVyLWxldmVsIGRldGFpbCwgaW5zcGVjdCB0aGUgdGFzayB3b3JrbG9h" + + "ZCBwb2RzIG9uIHRoZSBjb21wdXRlLXBsYW5lIGNsdXN0ZXIgd2l0aApga3ViZWN0bGAgYWdhaW5z" + + "dCB0aGUgYXBwcm9wcmlhdGUgbmFtZXNwYWNlIGFuZCBjb250ZXh0LgoKIyMgNS4gUmV0cmlldmUg" + + "cmVzdWx0cwoKRm9yIHRhc2tzIHdpdGggYHJlc3VsdEhhbmRsaW5nU3RyYXRlZ3k6IE5PTkVgLCBy" + + "ZXN1bHRzIGFyZSBlbXB0eSBieSBkZXNpZ246CgpgYGBzaApudmNmLWNsaSB0YXNrIHJlc3VsdHMg" + + "PHRhc2tfaWQ+CiMgLT4geyJyZXN1bHRzIjogW119CmBgYAoKRm9yIHRhc2tzIHdpdGggYHJlc3Vs" + + "dEhhbmRsaW5nU3RyYXRlZ3k6IFVQTE9BRGAsIHJlc3VsdHMgdXBsb2FkIHRvIHRoZSBsb2NhdGlv" + + "biBuYW1lZCBpbgpgcmVzdWx0c0xvY2F0aW9uYC4gVGhlIHRhc2sncyBjcmVkZW50aWFscyBtdXN0" + + "IGhhdmUgd3JpdGUgYWNjZXNzIHRvIHRoYXQgbG9jYXRpb24uIEFmdGVyCnRoZSB0YXNrIHJlYWNo" + + "ZXMgYENPTVBMRVRFRGA6CgpgYGBzaApudmNmLWNsaSB0YXNrIHJlc3VsdHMgPHRhc2tfaWQ+CmBg" + + "YAoKTm90ZTogcmVzdWx0IHVwbG9hZCBpcyBub3QgeWV0IHN1cHBvcnRlZCBpbiB0aGlzIHJlbGVh" + + "c2UuCgojIyA2LiBDbGVhbnVwCgpDYW5jZWwgYSBydW5uaW5nIHRhc2s6CgpgYGBzaApudmNmLWNs" + + "aSB0YXNrIGNhbmNlbCA8dGFza19pZD4KYGBgCgpEZWxldGUgdGhlIHRhc2sgcmVjb3JkIGFmdGVy" + + "IGl0IHJlYWNoZXMgYSB0ZXJtaW5hbCBzdGF0ZToKCmBgYHNoCm52Y2YtY2xpIHRhc2sgZGVsZXRl" + + "IDx0YXNrX2lkPgpgYGAKClN0b3AgYW5kIGNvbmZpcm0gd2l0aCB0aGUgdXNlciBiZWZvcmUgcnVu" + + "bmluZyB0YXNrIGRlbGV0ZS4gU3RhdGUgdGhlIHRhc2sgSUQgYW5kIGl0cyBjdXJyZW50IHN0YXR1" + + "cy4gRG8gbm90IHByb2NlZWQgZXZlbiB3aGVuIHRoZSB1c2VyJ3MgcHJvbXB0IGFscmVhZHkgcmVx" + + "dWVzdGVkIGRlbGV0aW9uIOKAlCBhbHdheXMgZ2V0IGFuIGV4cGxpY2l0IGxhc3QtbG9vayBjb25m" + + "aXJtYXRpb24uIERlbGV0aW9uIGlzIHBlcm1hbmVudCBhbmQgY2Fubm90IGJlIHVuZG9uZS4KClRv" + + "IGxpc3QgYWxsIHRhc2tzIGZvciB0aGUgY3VycmVudCBvd25lciAodXNlZnVsIGJlZm9yZSBidWxr" + + "IGNsZWFudXApOgoKYGBgc2gKbnZjZi1jbGkgdGFzayBsaXN0Cm52Y2YtY2xpIHRhc2sgbGlzdCAt" + + "LXN0YXR1cz1DT01QTEVURUQKYGBgCgojIyBOb3RlcwoKLSBHUFUgZmFtaWx5IGFuZCBpbnN0YW5j" + + "ZSB0eXBlIG11c3QgbWF0Y2ggYSByZWdpc3RlcmVkIGNvbXB1dGUgY2x1c3RlciBTS1UuIE1pc21h" + + "dGNoCiAgY2F1c2VzIHRoZSB0YXNrIHRvIHJlbWFpbiBgUVVFVUVEYCBpbmRlZmluaXRlbHkuIFVz" + + "ZSBgbnZjZi1jbGkgY2x1c3RlciBsaXN0YCB0byBjaGVjawogIHJlZ2lzdGVyZWQgU0tVcy4KLSBG" + + "b3IgY29udGFpbmVyIGltYWdlcyBvbiBwcml2YXRlIHJlZ2lzdHJpZXMsIGVuc3VyZSB0aGUgY29t" + + "cHV0ZS1wbGFuZSBjbHVzdGVyJ3MKICBpbWFnZS1jcmVkZW50aWFsLWhlbHBlciBpcyBjb25maWd1" + + "cmVkIGZvciB0aGUgcmVnaXN0cnkgaG9zdC4KUEsHCNTejTSfGQAAnxkAAFBLAwQUAAgAAAAAACEA" + + "AAAAAAAAAAAAAAAANwAJAGRhdGEvbnZjZi1zZWxmLW1hbmFnZWQtY2xpL3Byb21wdHMvZGVwbG95" + + "LWFuZC1pbnZva2UubWRVVAUAAQCmzhIjIERlcGxveSBhbmQgaW52b2tlIGEgY29udGFpbmVyIGZ1" + + "bmN0aW9uCgpTdGFuZGFyZCBmb3VyLXN0ZXAgZmxvdzogZnVuY3Rpb24gQ1JFQVRFIOKGkiBERVBM" + + "T1kg4oaSIEFQSSBrZXkgKGZvciBpbnZva2Ugc2NvcGUpIOKGkiBJTlZPS0UuCgojIyAxLiBGdW5j" + + "dGlvbiBDUkVBVEUKCkRlZmluZSB0aGUgZnVuY3Rpb24gaW4gYSBKU09OIGZpbGUuIE1pbmltdW06" + + "CgpCZWZvcmUgY3JlYXRpbmcgdGhlIGZ1bmN0aW9uLCBjb25maXJtIHRoZSBleGFjdCBmdW5jdGlv" + + "biBuYW1lIGFuZCBjb250YWluZXIgaW1hZ2Ugd2l0aCB0aGUgdXNlci4gSWYgZWl0aGVyIHZhbHVl" + + "IGlzIG1pc3NpbmcsIHN0b3AgYW5kIGFzay4gRG8gbm90IGludmVudCBvciBzdWJtaXQgZXhhbXBs" + + "ZSB2YWx1ZXMgZm9yIHRoZXNlIGZpZWxkcy4KCmBgYGpzb24KewogICJuYW1lIjogIjxjb25maXJt" + + "ZWQtZnVuY3Rpb24tbmFtZT4iLAogICJjb250YWluZXJJbWFnZSI6ICI8Y29uZmlybWVkLWNvbnRh" + + "aW5lci1pbWFnZT4iLAogICJpbmZlcmVuY2VVcmwiOiAiL2VjaG8iLAogICJpbmZlcmVuY2VQb3J0" + + "IjogODAwMCwKICAiZGVzY3JpcHRpb24iOiAic21va2UgdGVzdCBlY2hvIGZ1bmN0aW9uIiwKICAi" + + "ZnVuY3Rpb25UeXBlIjogIkRFRkFVTFQiLAogICJhcGlCb2R5Rm9ybWF0IjogIkNVU1RPTSIsCiAg" + + "ImhlYWx0aCI6IHsKICAgICJwcm90b2NvbCI6ICJIVFRQIiwKICAgICJ1cmkiOiAiL2hlYWx0aCIs" + + "CiAgICAicG9ydCI6IDgwMDAsCiAgICAidGltZW91dCI6ICJQVDMwUyIsCiAgICAiZXhwZWN0ZWRT" + + "dGF0dXNDb2RlIjogMjAwCiAgfQp9CmBgYAoKYGBgc2gKbnZjZi1jbGkgZnVuY3Rpb24gY3JlYXRl" + + "IC0taW5wdXQtZmlsZT1jcmVhdGUtZm4uanNvbgojIOKGkiBGdW5jdGlvbiBJRDogPGZuX2lkPgoj" + + "IOKGkiBWZXJzaW9uIElEOiA8dmVyX2lkPgpgYGAKClNhdmUgdGhlIElEcyDigJQgdGhleSdyZSBu" + + "ZWVkZWQgZm9yIGRlcGxveSArIGludm9rZS4KCiMjIyBMTE0gZnVuY3Rpb24gdmFyaWFudAoKRm9y" + + "IGFuIE9wZW5BSS1jb21wYXRpYmxlIExMTSBmdW5jdGlvbiwgc2V0IGBmdW5jdGlvblR5cGU6ICJM" + + "TE0iYCBhbmQgcHV0IHJvdXRlIG1ldGFkYXRhIHVuZGVyIGBtb2RlbHNbXS5sbG1Db25maWdgOgoK" + + "QmVmb3JlIGNyZWF0aW5nIHRoZSBmdW5jdGlvbiwgY29uZmlybSB0aGUgZXhhY3QgZnVuY3Rpb24g" + + "bmFtZSwgY29udGFpbmVyIGltYWdlLCBhbmQgbW9kZWwgbmFtZSB3aXRoIHRoZSB1c2VyLiBJZiBh" + + "bnkgdmFsdWUgaXMgbWlzc2luZywgc3RvcCBhbmQgYXNrLiBEbyBub3QgaW52ZW50IG9yIHN1Ym1p" + + "dCBleGFtcGxlIHZhbHVlcyBmb3IgdGhlc2UgZmllbGRzLgoKYGBganNvbgp7CiAgIm5hbWUiOiAi" + + "PGNvbmZpcm1lZC1mdW5jdGlvbi1uYW1lPiIsCiAgImNvbnRhaW5lckltYWdlIjogIjxjb25maXJt" + + "ZWQtY29udGFpbmVyLWltYWdlPiIsCiAgImluZmVyZW5jZVVybCI6ICIvIiwKICAiaW5mZXJlbmNl" + + "UG9ydCI6IDgwMDAsCiAgImZ1bmN0aW9uVHlwZSI6ICJMTE0iLAogICJoZWFsdGgiOiB7CiAgICAi" + + "cHJvdG9jb2wiOiAiSFRUUCIsCiAgICAidXJpIjogIi9oZWFsdGgiLAogICAgInBvcnQiOiA4MDAw" + + "LAogICAgInRpbWVvdXQiOiAiUFQzMFMiLAogICAgImV4cGVjdGVkU3RhdHVzQ29kZSI6IDIwMAog" + + "IH0sCiAgIm1vZGVscyI6IFsKICAgIHsKICAgICAgIm5hbWUiOiAiPGNvbmZpcm1lZC1tb2RlbC1u" + + "YW1lPiIsCiAgICAgICJsbG1Db25maWciOiB7CiAgICAgICAgInVyaXMiOiBbIi92MS9jaGF0L2Nv" + + "bXBsZXRpb25zIiwgIi92MS9yZXNwb25zZXMiLCAiL3YxL2VtYmVkZGluZ3MiXSwKICAgICAgICAi" + + "cm91dGluZ01ldGhvZCI6ICJyb3VuZF9yb2JpbiIsCiAgICAgICAgInRva2VuUmF0ZUxpbWl0Ijog" + + "IjEwMDAtUyIKICAgICAgfQogICAgfQogIF0KfQpgYGAKCkVxdWl2YWxlbnQgQ0xJIGZsYWcgZm9y" + + "bToKCmBgYHNoCm52Y2YtY2xpIGZ1bmN0aW9uIGNyZWF0ZSBcCiAgLS1uYW1lPSI8Y29uZmlybWVk" + + "LWZ1bmN0aW9uLW5hbWU+IiBcCiAgLS1pbWFnZT0iPGNvbmZpcm1lZC1jb250YWluZXItaW1hZ2U+" + + "IiBcCiAgLS1pbmZlcmVuY2UtdXJsPS8gXAogIC0taW5mZXJlbmNlLXBvcnQ9ODAwMCBcCiAgLS1o" + + "ZWFsdGgtdXJpPS9oZWFsdGggXAogIC0taGVhbHRoLXBvcnQ9ODAwMCBcCiAgLS1oZWFsdGgtdGlt" + + "ZW91dD1QVDMwUyBcCiAgLS1oZWFsdGgtcHJvdG9jb2w9SFRUUCBcCiAgLS1oZWFsdGgtZXhwZWN0" + + "ZWQtc3RhdHVzPTIwMCBcCiAgLS1mdW5jdGlvbi10eXBlPUxMTSBcCiAgLS1sbG0tbW9kZWw9J25h" + + "bWU9PGNvbmZpcm1lZC1tb2RlbC1uYW1lPix1cmlzPS92MS9jaGF0L2NvbXBsZXRpb25zfC92MS9y" + + "ZXNwb25zZXN8L3YxL2VtYmVkZGluZ3Mscm91dGluZ01ldGhvZD1yb3VuZF9yb2Jpbix0b2tlblJh" + + "dGVMaW1pdD0xMDAwLVMnCmBgYAoKVXNlIHRoZSBzYW1lIGRlcGxveSBzdGVwIGJlbG93IGFmdGVy" + + "IHRoZSBMTE0gZnVuY3Rpb24gaXMgY3JlYXRlZC4KCk9wdGlvbmFsIG1vZGVsIHJvdXRpbmcgdXBk" + + "YXRlIGFmdGVyIGNyZWF0aW9uOgoKYGBgc2gKbnZjZi1jbGkgZnVuY3Rpb24gdXBkYXRlIFwKICAt" + + "LWZ1bmN0aW9uLWlkPTxmbl9pZD4gXAogIC0tdmVyc2lvbi1pZD08dmVyX2lkPiBcCiAgLS1sbG0t" + + "bW9kZWwtdXBkYXRlPSduYW1lPTxjb25maXJtZWQtbW9kZWwtbmFtZT4scm91dGluZ01ldGhvZD1w" + + "b3dlcl9vZl90d28sdG9rZW5SYXRlTGltaXQ9MTAwMC1TJwpgYGAKClVzZSB0aGlzIGZvciBtdXRh" + + "YmxlIGByb3V0aW5nTWV0aG9kYCBhbmQgYHRva2VuUmF0ZUxpbWl0YDsgY3JlYXRlLXRpbWUgYHVy" + + "aXNgIHN0YXkgaW4gYG1vZGVsc1tdLmxsbUNvbmZpZ2AuCmB0b2tlblJhdGVMaW1pdGAgc3VwcG9y" + + "dHMgcG9zaXRpdmUgaW50ZWdlciB0b2tlbiBsaW1pdHMgZm9yIGBTYCwgYE1gLCBgSGAsIGBEYCwg" + + "YW5kIGBXYC4gVXNlIGAxMDAwLVNgIGZvciBhIHNpbmdsZSBpbmxpbmUgQ0xJIGxpbWl0LiBVc2Ug" + + "aW5wdXQgSlNPTiBmb3IgY29tYmluZWQgbGltaXRzLCBzdWNoIGFzIGAxMDAwLVMsNTAwMC1NLDEw" + + "MDAwMC1ILDUwMDAwMC1ELDEwMDAwMDAtV2AsIGJlY2F1c2UgaW5saW5lIG1vZGVsIHNwZWNzIHVz" + + "ZSBjb21tYXMgYXMgZmllbGQgc2VwYXJhdG9ycy4KCiMjIyBIZWxtIGNoYXJ0IExMTSBmdW5jdGlv" + + "biB2YXJpYW50CgpMTE0gZnVuY3Rpb24gdHlwZSBpcyBpbmRlcGVuZGVudCBvZiB3b3JrbG9hZCBw" + + "YWNrYWdpbmcuIEZvciBhIEhlbG0tY2hhcnQgYmFja2VkIExMTSBmdW5jdGlvbiwga2VlcCB0aGUg" + + "c2FtZSBgZnVuY3Rpb25UeXBlYCBhbmQgYG1vZGVsc1tdLmxsbUNvbmZpZ2AgZmllbGRzLCB0aGVu" + + "IHNldCBgaGVsbUNoYXJ0YCBhbmQgYGhlbG1DaGFydFNlcnZpY2VOYW1lYC4gVGhlIHNlcnZpY2Ug" + + "bmFtZSBtdXN0IG1hdGNoIGEgS3ViZXJuZXRlcyBTZXJ2aWNlIHJlbmRlcmVkIGJ5IHRoZSBjaGFy" + + "dCwgYW5kIGBpbmZlcmVuY2VQb3J0YCBtdXN0IGJlIHRoYXQgU2VydmljZSBwb3J0LgoKQmVmb3Jl" + + "IGNyZWF0aW5nIHRoZSBmdW5jdGlvbiwgY29uZmlybSB0aGUgZXhhY3QgZnVuY3Rpb24gbmFtZSBh" + + "bmQgbW9kZWwgbmFtZSB3aXRoIHRoZSB1c2VyLiBJZiBlaXRoZXIgdmFsdWUgaXMgbWlzc2luZywg" + + "c3RvcCBhbmQgYXNrLiBEbyBub3QgaW52ZW50IG9yIHN1Ym1pdCBleGFtcGxlIHZhbHVlcyBmb3Ig" + + "dGhlc2UgZmllbGRzLgoKYGBganNvbgp7CiAgIm5hbWUiOiAiPGNvbmZpcm1lZC1mdW5jdGlvbi1u" + + "YW1lPiIsCiAgImluZmVyZW5jZVVybCI6ICIvIiwKICAiaW5mZXJlbmNlUG9ydCI6IDgwMDAsCiAg" + + "ImZ1bmN0aW9uVHlwZSI6ICJMTE0iLAogICJoZWFsdGgiOiB7CiAgICAicHJvdG9jb2wiOiAiSFRU" + + "UCIsCiAgICAidXJpIjogIi9oZWFsdGgiLAogICAgInBvcnQiOiA4MDAwLAogICAgInRpbWVvdXQi" + + "OiAiUFQzMFMiLAogICAgImV4cGVjdGVkU3RhdHVzQ29kZSI6IDIwMAogIH0sCiAgImhlbG1DaGFy" + + "dCI6ICJodHRwczovL2hlbG0ubmdjLm52aWRpYS5jb20vZXhhbXBsZS90ZWFtL2NoYXJ0cy9vcGVu" + + "YWktY29tcGF0aWJsZS0wLjEuMC50Z3oiLAogICJoZWxtQ2hhcnRTZXJ2aWNlTmFtZSI6ICJvcGVu" + + "YWktY29tcGF0aWJsZSIsCiAgIm1vZGVscyI6IFsKICAgIHsKICAgICAgIm5hbWUiOiAiPGNvbmZp" + "cm1lZC1tb2RlbC1uYW1lPiIsCiAgICAgICJsbG1Db25maWciOiB7CiAgICAgICAgInVyaXMiOiBb" + "Ii92MS9jaGF0L2NvbXBsZXRpb25zIiwgIi92MS9yZXNwb25zZXMiLCAiL3YxL2VtYmVkZGluZ3Mi" + "XSwKICAgICAgICAicm91dGluZ01ldGhvZCI6ICJyb3VuZF9yb2JpbiIsCiAgICAgICAgInRva2Vu" + "UmF0ZUxpbWl0IjogIjEwMDAtUyIKICAgICAgfQogICAgfQogIF0KfQpgYGAKCkVxdWl2YWxlbnQg" + "Q0xJIGZsYWcgZm9ybToKCmBgYHNoCm52Y2YtY2xpIGZ1bmN0aW9uIGNyZWF0ZSBcCiAgLS1uYW1l" + - "PSI8Y29uZmlybWVkLWZ1bmN0aW9uLW5hbWU+IiBcCiAgLS1pbWFnZT0iPGNvbmZpcm1lZC1jb250" + - "YWluZXItaW1hZ2U+IiBcCiAgLS1pbmZlcmVuY2UtdXJsPS8gXAogIC0taW5mZXJlbmNlLXBvcnQ9" + - "ODAwMCBcCiAgLS1oZWFsdGgtdXJpPS9oZWFsdGggXAogIC0taGVhbHRoLXBvcnQ9ODAwMCBcCiAg" + - "LS1oZWFsdGgtdGltZW91dD1QVDMwUyBcCiAgLS1oZWFsdGgtcHJvdG9jb2w9SFRUUCBcCiAgLS1o" + - "ZWFsdGgtZXhwZWN0ZWQtc3RhdHVzPTIwMCBcCiAgLS1mdW5jdGlvbi10eXBlPUxMTSBcCiAgLS1s" + - "bG0tbW9kZWw9J25hbWU9PGNvbmZpcm1lZC1tb2RlbC1uYW1lPix1cmlzPS92MS9jaGF0L2NvbXBs" + - "ZXRpb25zfC92MS9yZXNwb25zZXN8L3YxL2VtYmVkZGluZ3Mscm91dGluZ01ldGhvZD1yb3VuZF9y" + - "b2Jpbix0b2tlblJhdGVMaW1pdD0xMDAwLVMnCmBgYAoKVXNlIHRoZSBzYW1lIGRlcGxveSBzdGVw" + - "IGJlbG93IGFmdGVyIHRoZSBMTE0gZnVuY3Rpb24gaXMgY3JlYXRlZC4KCk9wdGlvbmFsIG1vZGVs" + - "IHJvdXRpbmcgdXBkYXRlIGFmdGVyIGNyZWF0aW9uOgoKYGBgc2gKbnZjZi1jbGkgZnVuY3Rpb24g" + - "dXBkYXRlIFwKICAtLWZ1bmN0aW9uLWlkPTxmbl9pZD4gXAogIC0tdmVyc2lvbi1pZD08dmVyX2lk" + - "PiBcCiAgLS1sbG0tbW9kZWwtdXBkYXRlPSduYW1lPTxjb25maXJtZWQtbW9kZWwtbmFtZT4scm91" + - "dGluZ01ldGhvZD1wb3dlcl9vZl90d28sdG9rZW5SYXRlTGltaXQ9MTAwMC1TJwpgYGAKClVzZSB0" + - "aGlzIGZvciBtdXRhYmxlIGByb3V0aW5nTWV0aG9kYCBhbmQgYHRva2VuUmF0ZUxpbWl0YDsgY3Jl" + - "YXRlLXRpbWUgYHVyaXNgIHN0YXkgaW4gYG1vZGVsc1tdLmxsbUNvbmZpZ2AuCmB0b2tlblJhdGVM" + - "aW1pdGAgc3VwcG9ydHMgcG9zaXRpdmUgaW50ZWdlciB0b2tlbiBsaW1pdHMgZm9yIGBTYCwgYE1g" + - "LCBgSGAsIGBEYCwgYW5kIGBXYC4gVXNlIGAxMDAwLVNgIGZvciBhIHNpbmdsZSBpbmxpbmUgQ0xJ" + - "IGxpbWl0LiBVc2UgaW5wdXQgSlNPTiBmb3IgY29tYmluZWQgbGltaXRzLCBzdWNoIGFzIGAxMDAw" + - "LVMsNTAwMC1NLDEwMDAwMC1ILDUwMDAwMC1ELDEwMDAwMDAtV2AsIGJlY2F1c2UgaW5saW5lIG1v" + - "ZGVsIHNwZWNzIHVzZSBjb21tYXMgYXMgZmllbGQgc2VwYXJhdG9ycy4KCiMjIyBIZWxtIGNoYXJ0" + - "IExMTSBmdW5jdGlvbiB2YXJpYW50CgpMTE0gZnVuY3Rpb24gdHlwZSBpcyBpbmRlcGVuZGVudCBv" + - "ZiB3b3JrbG9hZCBwYWNrYWdpbmcuIEZvciBhIEhlbG0tY2hhcnQgYmFja2VkIExMTSBmdW5jdGlv" + - "biwga2VlcCB0aGUgc2FtZSBgZnVuY3Rpb25UeXBlYCBhbmQgYG1vZGVsc1tdLmxsbUNvbmZpZ2Ag" + - "ZmllbGRzLCB0aGVuIHNldCBgaGVsbUNoYXJ0YCBhbmQgYGhlbG1DaGFydFNlcnZpY2VOYW1lYC4g" + - "VGhlIHNlcnZpY2UgbmFtZSBtdXN0IG1hdGNoIGEgS3ViZXJuZXRlcyBTZXJ2aWNlIHJlbmRlcmVk" + - "IGJ5IHRoZSBjaGFydCwgYW5kIGBpbmZlcmVuY2VQb3J0YCBtdXN0IGJlIHRoYXQgU2VydmljZSBw" + - "b3J0LgoKQmVmb3JlIGNyZWF0aW5nIHRoZSBmdW5jdGlvbiwgY29uZmlybSB0aGUgZXhhY3QgZnVu" + - "Y3Rpb24gbmFtZSBhbmQgbW9kZWwgbmFtZSB3aXRoIHRoZSB1c2VyLiBJZiBlaXRoZXIgdmFsdWUg" + - "aXMgbWlzc2luZywgc3RvcCBhbmQgYXNrLiBEbyBub3QgaW52ZW50IG9yIHN1Ym1pdCBleGFtcGxl" + - "IHZhbHVlcyBmb3IgdGhlc2UgZmllbGRzLgoKYGBganNvbgp7CiAgIm5hbWUiOiAiPGNvbmZpcm1l" + - "ZC1mdW5jdGlvbi1uYW1lPiIsCiAgImluZmVyZW5jZVVybCI6ICIvIiwKICAiaW5mZXJlbmNlUG9y" + - "dCI6IDgwMDAsCiAgImZ1bmN0aW9uVHlwZSI6ICJMTE0iLAogICJoZWFsdGgiOiB7CiAgICAicHJv" + - "dG9jb2wiOiAiSFRUUCIsCiAgICAidXJpIjogIi9oZWFsdGgiLAogICAgInBvcnQiOiA4MDAwLAog" + - "ICAgInRpbWVvdXQiOiAiUFQzMFMiLAogICAgImV4cGVjdGVkU3RhdHVzQ29kZSI6IDIwMAogIH0s" + - "CiAgImhlbG1DaGFydCI6ICJodHRwczovL2hlbG0ubmdjLm52aWRpYS5jb20vZXhhbXBsZS90ZWFt" + - "L2NoYXJ0cy9vcGVuYWktY29tcGF0aWJsZS0wLjEuMC50Z3oiLAogICJoZWxtQ2hhcnRTZXJ2aWNl" + - "TmFtZSI6ICJvcGVuYWktY29tcGF0aWJsZSIsCiAgIm1vZGVscyI6IFsKICAgIHsKICAgICAgIm5h" + - "bWUiOiAiPGNvbmZpcm1lZC1tb2RlbC1uYW1lPiIsCiAgICAgICJsbG1Db25maWciOiB7CiAgICAg" + - "ICAgInVyaXMiOiBbIi92MS9jaGF0L2NvbXBsZXRpb25zIiwgIi92MS9yZXNwb25zZXMiLCAiL3Yx" + - "L2VtYmVkZGluZ3MiXSwKICAgICAgICAicm91dGluZ01ldGhvZCI6ICJyb3VuZF9yb2JpbiIsCiAg" + - "ICAgICAgInRva2VuUmF0ZUxpbWl0IjogIjEwMDAtUyIKICAgICAgfQogICAgfQogIF0KfQpgYGAK" + - "CkVxdWl2YWxlbnQgQ0xJIGZsYWcgZm9ybToKCmBgYHNoCm52Y2YtY2xpIGZ1bmN0aW9uIGNyZWF0" + - "ZSBcCiAgLS1uYW1lPSI8Y29uZmlybWVkLWZ1bmN0aW9uLW5hbWU+IiBcCiAgLS1pbmZlcmVuY2Ut" + - "dXJsPS8gXAogIC0taW5mZXJlbmNlLXBvcnQ9ODAwMCBcCiAgLS1oZWFsdGgtdXJpPS9oZWFsdGgg" + - "XAogIC0taGVhbHRoLXBvcnQ9ODAwMCBcCiAgLS1oZWFsdGgtdGltZW91dD1QVDMwUyBcCiAgLS1o" + - "ZWFsdGgtcHJvdG9jb2w9SFRUUCBcCiAgLS1oZWFsdGgtZXhwZWN0ZWQtc3RhdHVzPTIwMCBcCiAg" + - "LS1mdW5jdGlvbi10eXBlPUxMTSBcCiAgLS1oZWxtLWNoYXJ0PWh0dHBzOi8vaGVsbS5uZ2MubnZp" + - "ZGlhLmNvbS9leGFtcGxlL3RlYW0vY2hhcnRzL29wZW5haS1jb21wYXRpYmxlLTAuMS4wLnRneiBc" + - "CiAgLS1oZWxtLWNoYXJ0LXNlcnZpY2U9b3BlbmFpLWNvbXBhdGlibGUgXAogIC0tbGxtLW1vZGVs" + - "PSduYW1lPTxjb25maXJtZWQtbW9kZWwtbmFtZT4sdXJpcz0vdjEvY2hhdC9jb21wbGV0aW9uc3wv" + - "djEvcmVzcG9uc2VzfC92MS9lbWJlZGRpbmdzLHJvdXRpbmdNZXRob2Q9cm91bmRfcm9iaW4sdG9r" + - "ZW5SYXRlTGltaXQ9MTAwMC1TJwpgYGAKCkRlcGxveSBpcyB1bmNoYW5nZWQuIFB1dCBjaGFydC1z" + - "cGVjaWZpYyB2YWx1ZXMgdW5kZXIgYGRlcGxveW1lbnRTcGVjaWZpY2F0aW9uc1tdLmNvbmZpZ3Vy" + - "YXRpb25gIG9ubHkgd2hlbiB0aGUgY2hhcnQgbmVlZHMgdmFsdWUgb3ZlcnJpZGVzOgoKYGBganNv" + - "bgp7CiAgImZ1bmN0aW9uSWQiOiAiPGZuX2lkPiIsCiAgInZlcnNpb25JZCI6ICI8dmVyX2lkPiIs" + - "CiAgImRlcGxveW1lbnRTcGVjaWZpY2F0aW9ucyI6IFt7CiAgICAiZ3B1IjogIkgxMDAiLAogICAg" + - "Imluc3RhbmNlVHlwZSI6ICJOQ1AuR1BVLkgxMDBfMXgiLAogICAgIm1pbkluc3RhbmNlcyI6IDEs" + - "CiAgICAibWF4SW5zdGFuY2VzIjogMSwKICAgICJtYXhSZXF1ZXN0Q29uY3VycmVuY3kiOiAxMCwK" + - "ICAgICJjb25maWd1cmF0aW9uIjogewogICAgICAic2VydmluZyI6IHsKICAgICAgICAidGVuc29y" + - "UGFyYWxsZWxTaXplIjogMQogICAgICB9CiAgICB9CiAgfV0KfQpgYGAKCkxMTSBHYXRld2F5IHJv" + - "dXRlcyByZXF1ZXN0cyBieSB0aGUgT3BlbkFJIGBtb2RlbGAgdmFsdWUuIFVzZSBgPGZ1bmN0aW9u" + - "LWlkPi88bW9kZWwtbmFtZT5gOiB0aGUgZnVuY3Rpb24gSUQgc2VsZWN0cyB0aGUgTlZDRiBmdW5j" + - "dGlvbiwgYW5kIHRoZSBtb2RlbCBuYW1lIGlzIGZvcndhcmRlZCB0byB0aGUgdXBzdHJlYW0gY29u" + - "dGFpbmVyIHRocm91Z2ggYHN0YXJnYXRlLWNsaWVudGAuCgpTdXBwb3J0ZWQgTExNIHBhdGhzOgoK" + - "fCBQYXRoIHwgTm90ZXMgfAp8IC0tLSB8IC0tLSB8CnwgYC92MS9jaGF0L2NvbXBsZXRpb25zYCB8" + - "IFN1cHBvcnRzIHN0cmVhbWluZyBhbmQgc2Vzc2lvbiBzdGlja2luZXNzLiB8CnwgYC92MS9yZXNw" + - "b25zZXNgIHwgTmF0aXZlIFJlc3BvbnNlcyBwcm94eSBwYXRoOyByZWxheXMgU1NFIGZvciBzdHJl" + - "YW1pbmcgY2xpZW50cyBhbmQgcmV0dXJucyB0ZXJtaW5hbCBKU09OIGZvciBub24tc3RyZWFtaW5n" + - "IGNsaWVudHMuIFN1cHBvcnRzIHNlc3Npb24gc3RpY2tpbmVzcy4gfAp8IGAvdjEvZW1iZWRkaW5n" + - "c2AgfCBBY2NlcHRzIHN0cmluZyBvciBzdHJpbmctYXJyYXkgYGlucHV0YDsgaW5wdXQgbXVzdCBi" + - "ZSBub24tZW1wdHkgYW5kIG1heSBjb250YWluIGF0IG1vc3QgMjA0OCBlbnRyaWVzLiBObyBzZXNz" + - "aW9uIHN0aWNraW5lc3MuIHwKCiMjIDIuIEZ1bmN0aW9uIERFUExPWQoKYGBganNvbgp7CiAgImZ1" + - "bmN0aW9uSWQiOiAiPGZuX2lkPiIsCiAgInZlcnNpb25JZCI6ICI8dmVyX2lkPiIsCiAgImRlcGxv" + - "eW1lbnRTcGVjaWZpY2F0aW9ucyI6IFt7CiAgICAiZ3B1IjogIkgxMDAiLAogICAgImluc3RhbmNl" + - "VHlwZSI6ICJOQ1AuR1BVLkgxMDBfMXgiLAogICAgIm1pbkluc3RhbmNlcyI6IDEsCiAgICAibWF4" + - "SW5zdGFuY2VzIjogMSwKICAgICJtYXhSZXF1ZXN0Q29uY3VycmVuY3kiOiAxMAogIH1dCn0KYGBg" + - "CgpOb3RlOiBgZ3B1YCBpcyB0aGUgR1BVICoqZmFtaWx5KiogKGUuZy4gYEgxMDBgKSwgYGluc3Rh" + - "bmNlVHlwZWAgaXMgdGhlIFNLVSAoZS5nLiBgTkNQLkdQVS5IMTAwXzF4YCkuIE1pc21hdGNoIHJl" + - "dHVybnMgSUNNUyA0MDAgIkludmFsaWQgR1BVIi4KCmBgYHNoCm52Y2YtY2xpIGZ1bmN0aW9uIGRl" + - "cGxveSBjcmVhdGUgLS1pbnB1dC1maWxlPWRlcGxveS1mbi5qc29uCmBgYAoKVGhpcyBjb21tYW5k" + - "ICoqYmxvY2tzKiogdW50aWwgdGhlIGRlcGxveW1lbnQgcmVhY2hlcyBBQ1RJVkUgKGRlZmF1bHQg" + - "OTAwcyB0aW1lb3V0KS4gV2hlbiBpdCByZXR1cm5zIHJjPTAsIGEgZnVuY3Rpb24gcG9kIGlzIHJ1" + - "bm5pbmcgaW4gdGhlIGBudmNmLWJhY2tlbmRgIG5hbWVzcGFjZSBvbiB0aGUgY29tcHV0ZS1wbGFu" + - "ZSBjbHVzdGVyLgoKIyMgMy4gTWludCBhbiBBUEkga2V5IHdpdGggYGludm9rZV9mdW5jdGlvbmAg" + - "c2NvcGUKCmBudmNmLWNsaSBpbml0YCdzIGFkbWluIHRva2VuIGRvZXMgTk9UIGNhcnJ5IHRoZSBg" + - "aW52b2tlX2Z1bmN0aW9uYCBzY29wZSDigJQgaW52b2tlIHdvdWxkIDQwMyB3aXRoICJtaXNzaW5n" + - "IHJlcXVlc3RlZCBhdXRob3JpdGllcyIuIE1pbnQgYW4gQVBJIGtleToKCmBgYHNoCm52Y2YtY2xp" + - "IGFwaS1rZXkgZ2VuZXJhdGUgLS1kZXNjcmlwdGlvbj0iPGNvbmZpcm1lZC1mdW5jdGlvbi1uYW1l" + - "PiBpbnZva2UiIC0tZXhwaXJlcy1pbj0xaAojIOKGkiBBUEkgS2V5OiBudmFwaS1udmNmLXN0Zy0u" + - "Li4KYGBgCgpUaGUga2V5IGlzIGF1dG9tYXRpY2FsbHkgc2F2ZWQgdG8gdGhlIENMSSBzdGF0ZSBm" + - "aWxlIGFuZCB1c2VkIGJ5IHRoZSBuZXh0IGBmdW5jdGlvbiBpbnZva2VgIGNhbGwuICoqRG9uJ3Qg" + - "ZWNobyB0aGUga2V5IGludG8gY2hhdCAvIGxvZ3MqKi4KCiMjIDQuIEZ1bmN0aW9uIElOVk9LRQoK" + - "YGBganNvbgp7CiAgImZ1bmN0aW9uSWQiOiAiPGZuX2lkPiIsCiAgInZlcnNpb25JZCI6ICI8dmVy" + - "X2lkPiIsCiAgInJlcXVlc3RCb2R5IjogeyJtZXNzYWdlIjogImhlbGxvIn0KfQpgYGAKCmBgYHNo" + - "Cm52Y2YtY2xpIGZ1bmN0aW9uIGludm9rZSAtLWlucHV0LWZpbGU9aW52b2tlLWZuLmpzb24KIyDi" + - "hpIgRnVuY3Rpb24gaW52b2NhdGlvbiBjb21wbGV0ZWQhCiMg4oaSIFN0YXR1czogZnVsZmlsbGVk" + - "CiMg4oaSIFJlcXVlc3QgSUQ6IC4uLgpgYGAKCkZvciBhbiBMTE0gZnVuY3Rpb24sIGludm9rZSB0" + - "aGUgT3BlbkFJLWNvbXBhdGlibGUgcm91dGUgYWZ0ZXIgZGVwbG95bWVudDoKCmBgYHNoCmN1cmwg" + - "LXNTIC1YIFBPU1QgImh0dHBzOi8vbGxtLmludm9jYXRpb24uPGRvbWFpbj4vdjEvY2hhdC9jb21w" + - "bGV0aW9ucyIgXAogIC1IICJBdXRob3JpemF0aW9uOiBCZWFyZXIgJHtOVkNGX0FQSV9LRVl9IiBc" + - "CiAgLUggIkNvbnRlbnQtVHlwZTogYXBwbGljYXRpb24vanNvbiIgXAogIC1kICd7CiAgICAibW9k" + - "ZWwiOiAiPGZuX2lkPi88Y29uZmlybWVkLW1vZGVsLW5hbWU+IiwKICAgICJzdHJlYW0iOiB0cnVl" + - "LAogICAgIm1lc3NhZ2VzIjogWwogICAgICB7CiAgICAgICAgInJvbGUiOiAidXNlciIsCiAgICAg" + - "ICAgImNvbnRlbnQiOiAiSGVsbG8iCiAgICAgIH0KICAgIF0KICB9JwpgYGAKClRoZSBPcGVuQUkg" + - "YG1vZGVsYCB2YWx1ZSBtdXN0IGJlIGA8ZnVuY3Rpb24taWQ+Lzxtb2RlbC1uYW1lPmAgc28gdGhl" + - "IGdhdGV3YXkgY2FuIHNlbGVjdCB0aGUgZnVuY3Rpb24gYW5kIG1vZGVsLgoKUmVzcG9uc2VzIEFQ" + - "SSBleGFtcGxlOgoKYGBgc2gKY3VybCAtc1MgLVggUE9TVCAiaHR0cHM6Ly9sbG0uaW52b2NhdGlv" + - "bi48ZG9tYWluPi92MS9yZXNwb25zZXMiIFwKICAtSCAiQXV0aG9yaXphdGlvbjogQmVhcmVyICR7" + - "TlZDRl9BUElfS0VZfSIgXAogIC1IICJDb250ZW50LVR5cGU6IGFwcGxpY2F0aW9uL2pzb24iIFwK" + - "ICAtZCAnewogICAgIm1vZGVsIjogIjxmbl9pZD4vPGNvbmZpcm1lZC1tb2RlbC1uYW1lPiIsCiAg" + - "ICAiaW5wdXQiOiAiV3JpdGUgYSBvbmUgc2VudGVuY2Ugc3VtbWFyeSBvZiBOVkNGLiIKICB9Jwpg" + - "YGAKCkVtYmVkZGluZ3MgZXhhbXBsZToKCmBgYHNoCmN1cmwgLXNTIC1YIFBPU1QgImh0dHBzOi8v" + - "bGxtLmludm9jYXRpb24uPGRvbWFpbj4vdjEvZW1iZWRkaW5ncyIgXAogIC1IICJBdXRob3JpemF0" + - "aW9uOiBCZWFyZXIgJHtOVkNGX0FQSV9LRVl9IiBcCiAgLUggIkNvbnRlbnQtVHlwZTogYXBwbGlj" + - "YXRpb24vanNvbiIgXAogIC1kICd7CiAgICAibW9kZWwiOiAiPGZuX2lkPi88Y29uZmlybWVkLW1v" + - "ZGVsLW5hbWU+IiwKICAgICJpbnB1dCI6ICJOVkNGIGVtYmVkZGluZ3MgY2hlY2siCiAgfScKYGBg" + - "CgpJZiBzdGF0dXMgaXMgYGVycm9yZWRgLCBxdWVyeSBJQ01TIGZvciB0aGUgZGVwbG95bWVudCdz" + - "IHBvZCBsb2dzIChrdWJlY3RsIG9uIHRoZSBjb21wdXRlLXBsYW5lIGNsdXN0ZXIpIGFuZCBzdXJm" + - "YWNlIHRvIHRoZSB1c2VyLgoKIyMgNS4gQ2xlYW51cAoKV2hlbiB0aGUgdXNlciBpcyBkb25lIHdp" + - "dGggdGhlIHNtb2tlIHRlc3Q6CgpgYGBzaApudmNmLWNsaSBmdW5jdGlvbiBkZWxldGUgLS1mdW5j" + - "dGlvbi1pZD08Zm5faWQ+IC0tdmVyc2lvbi1pZD08dmVyX2lkPgpgYGAKCioqQ29uZmlybSB3aXRo" + - "IHRoZSB1c2VyIGJlZm9yZSBkZWxldGluZy4qKiBUaGlzIHJlbW92ZXMgYm90aCB0aGUgZGVwbG95" + - "bWVudCBhbmQgdGhlIGZ1bmN0aW9uIHJlY29yZC4KCiMjIE5vdGVzCgotIERlcGxveSB0aW1lcyBj" + - "YW4gdmFyeSB3aWRlbHkgZGVwZW5kaW5nIG9uIE5BVFMgc3RyZWFtLWluaXQgbGF0ZW5jeSBvbiBj" + - "b2xkLWNsdXN0ZXIgcnVucyAoMS03IG1pbnV0ZXMgb2JzZXJ2ZWQgaW4gZGV2LVZNIHRlc3RzKS4g" + - "QmUgcGF0aWVudDsgZG9uJ3Qga2lsbCB0aGUgYGZ1bmN0aW9uIGRlcGxveSBjcmVhdGVgIGNvbW1h" + - "bmQgZWFybHkuCi0gRm9yIGRpZmZlcmVudCBHUFUgZmFtaWxpZXMsIGNoYW5nZSBib3RoIGBncHVg" + - "IGFuZCBgaW5zdGFuY2VUeXBlYCB0b2dldGhlci4gQ29tbW9uIFNLVXM6IGBOQ1AuR1BVLkgxMDBf" + - "MXhgLCBgTkNQLkdQVS5IMTAwXzJ4YCwgYE5DUC5HUFUuQTEwMF8xeGAuCi0gVGhlIGZ1bmN0aW9u" + - "J3MgY29udGFpbmVyIGltYWdlIG11c3QgYmUgcHVsbGFibGUgZnJvbSB0aGUgY29tcHV0ZS1wbGFu" + - "ZSBjbHVzdGVyJ3MgaW1hZ2UtY3JlZGVudGlhbC1oZWxwZXIgY29uZmlnIChjaGVjayBga3ViZWN0" + - "bCBkZXNjcmliZSBwb2QgLW4gbnZjZi1iYWNrZW5kYCBpZiBwb2RzIGxhbmQgaW4gYEltYWdlUHVs" + - "bEJhY2tPZmZgKS4KUEsHCNJD3AQYJQAAGCUAAFBLAwQUAAgAAAAAACEAAAAAAAAAAAAAAAAAPQAJ" + - "AGRhdGEvbnZjZi1zZWxmLW1hbmFnZWQtY2xpL3Byb21wdHMvZGlhZ25vc2UtZmFpbGVkLWluc3Rh" + - "bGwubWRVVAUAAQCmzhIjIERpYWdub3NlIGEgZmFpbGVkIHNlbGYtaG9zdGVkIE5WQ0YgaW5zdGFs" + - "bAoKVXNlciBzYXlzICJteSBpbnN0YWxsIGZhaWxlZCIgb3IgIlggaXNuJ3Qgd29ya2luZyIuIERv" + - "bid0IGd1ZXNzIOKAlCBxdWVyeSB0aGUgY2x1c3Rlci4KCiMjIDEuIEdldCB0aGUgdmVyZGljdAoK" + - "YGBgc2gKbnZjZi1jbGkgc2VsZi1ob3N0ZWQgc3RhdHVzIC0tanNvbiB8IGpxIC1jIC4KYGBgCgpU" + - "aGUgZmlyc3QgZXZlbnQgaXMgdGhlIHNuYXBzaG90LiBQYXJzZSBgLnZlcmRpY3RgOgoKLSAqKmBo" + - "ZWFsdGh5YCoqIOKAlCBub3RoaW5nJ3Mgd3JvbmcuIEFzayB0aGUgdXNlciB3aGF0IHRoZXkncmUg" + - "YWN0dWFsbHkgc2VlaW5nOyB0aGVpciBwcm9ibGVtIG1heSBiZSBhdCBhIGRpZmZlcmVudCBsYXll" + - "ciAoZnVuY3Rpb24gZGVwbG95LCBuZXR3b3JrLCBldGMuKS4KLSAqKmBkZWdyYWRlZGAqKiDigJQg" + - "YXQgbGVhc3Qgb25lIGNvbXBvbmVudCBub3QgcmVhZHkuIFRoZSBDb21wb25lbnRzIGxpc3Qgd2ls" + - "bCBpZGVudGlmeSB3aGljaC4gTW9zdCBjb21tb246IGBjYXNzYW5kcmFgIFBvZEluaXRpYWxpemlu" + - "ZywgYG9wZW5iYW9gIG5vdCB1bnNlYWxlZCwgYHNpc2AgSW1hZ2VQdWxsQmFja09mZi4KLSAqKmBm" + - "YWlsZWRgKiog4oCUIE5WQ0ZCYWNrZW5kIGBIZWFsdGg9RmFpbGVkYC4gUmVhZCB0aGUgY2x1c3Rl" + - "cidzIE5WQ0ZCYWNrZW5kIHN0YXR1cyBjb25kaXRpb24gbWVzc2FnZS4KLSAqKmB1bmtub3duYCoq" + - "IOKAlCBJQ01TIHVucmVhY2hhYmxlLiBUaGUgY29udHJvbCBwbGFuZSBpdHNlbGYgbWF5IGJlIGRv" + - "d247IGNoZWNrIGBrdWJlY3RsIGdldCBwb2RzIC1uIHNpc2AgZGlyZWN0bHkuCgojIyAyLiBEcmls" + - "bCBpbnRvIHRoZSBmYWlsaW5nIGNvbXBvbmVudAoKRm9yIGVhY2ggbm9uLXJlYWR5IGNvbXBvbmVu" + - "dCwgdGhlIHNuYXBzaG90IGluY2x1ZGVzIGEgYG1lc3NhZ2VgIChlLmcuIGBjYXNzYW5kcmEtMCBQ" + - "b2RJbml0aWFsaXppbmcgNm0yM3NgKS4gQXNrIHRoZSB1c2VyIHRvIHJ1bjoKCmBgYHNoCmt1YmVj" + - "dGwgZGVzY3JpYmUgcG9kIC1uIDxucz4gPHBvZD4Ka3ViZWN0bCBsb2dzIC1uIDxucz4gPHBvZD4g" + - "LS1hbGwtY29udGFpbmVycyAtLXRhaWw9MTAwCmBgYAoKU3VyZmFjZSB0aGUgZXZlbnRzICsgbGFz" + - "dCBsb2cgbGluZXMuIERvbid0IHByb3Bvc2UgYGt1YmVjdGwgZGVsZXRlIHBvZGAgdW5sZXNzIHRo" + - "ZSB1c2VyIGNvbmZpcm1zIOKAlCBDYXNzYW5kcmEgcmVzdGFydCBsb3NlcyBhbnkgaW4tcHJvZ3Jl" + - "c3MgbWlncmF0aW9uLgoKIyMgMy4gQ29tbW9uIHBhdHRlcm5zCgp8IFN5bXB0b20gfCBMaWtlbHkg" + - "Y2F1c2UgfCBSZW1lZGlhdGlvbiB8CnwtLS18LS0tfC0tLXwKfCBgY2Fzc2FuZHJhLTAgUG9kSW5p" + - "dGlhbGl6aW5nYCBmb3IgPjUgbWluIHwgU3RvcmFnZUNsYXNzIG1pc3Npbmcgb3IgUFYgc3R1Y2sg" + - "YFBlbmRpbmdgIHwgYGt1YmVjdGwgZ2V0IHB2LHB2YyAtQWAg4oCUIGNvbmZpcm0gYSBkZWZhdWx0" + - "IFN0b3JhZ2VDbGFzcyBleGlzdHMuIExvY2FsIGRldjogYGt1YmVjdGwgcGF0Y2ggc3RvcmFnZWNs" + - "YXNzIGxvY2FsLXBhdGggLXAgJ3sibWV0YWRhdGEiOnsiYW5ub3RhdGlvbnMiOnsic3RvcmFnZWNs" + - "YXNzLmt1YmVybmV0ZXMuaW8vaXMtZGVmYXVsdC1jbGFzcyI6InRydWUifX19J2AgfAp8IGBoZWxt" + - "IGluc3RhbGwgLi4uOiB0aW1lZCBvdXQgd2FpdGluZyBmb3IgY2Fzc2FuZHJhIHRvIGJlY29tZSBh" + - "dmFpbGFibGVgIHwgU2FtZSBhcyBhYm92ZSB8IFNhbWUgfAp8IGBHYXRld2F5IEFQSSBDUkRzIG5v" + - "dCBpbnN0YWxsZWRgIChwcmVmbGlnaHQpIHwgTXVsdGktY2x1c3RlciBgbnZjZi1nYXRld2F5LXJv" + - "dXRlc2AgY2hhcnQgbmVlZHMgZW52b3ktZ2F0ZXdheSBwcmVyZXF1aXNpdGUgfCBJbnN0YWxsIGVu" + - "dm95LWdhdGV3YXk6IGBoZWxtIGluc3RhbGwgZWcgb2NpOi8vZG9ja2VyLmlvL2Vudm95cHJveHkv" + - "Z2F0ZXdheS1oZWxtIC0tbmFtZXNwYWNlPWVudm95LWdhdGV3YXktc3lzdGVtIC0tY3JlYXRlLW5h" + - "bWVzcGFjZWAgfAp8IGBObyBjbHVzdGVyIGZvdW5kIHdpdGggdmFsaWQgSldLUyBmb3IgY2x1c3Rl" + - "ciBJRDogPGlkPmAgKE5WQ0EgYWdlbnQpIHwgQ29tcHV0ZSBwbGFuZSdzIFBTQVQgd2FzIGlzc3Vl" + - "ZCBieSBhIEs4cyBzZXJ2ZXIgd2hvc2UgSldLUyBkb2Vzbid0IG1hdGNoIHdoYXQncyBzdG9yZWQg" + - "aW4gSUNNUyB8IFJlLXJlZ2lzdGVyOiBgbnZjZi1jbGkgY2x1c3RlciByb3RhdGUgLS1jbHVzdGVy" + - "LWlkPTxpZD5gIGZyb20gYSBjb250ZXh0IHRoYXQgY2FuIHJlYWNoIHRoZSBjb21wdXRlIHBsYW5l" + - "J3MgSzhzIEFQSSB8CnwgYFNpZ25lZCBKV1QgcmVqZWN0ZWQ6IEFub3RoZXIgYWxnb3JpdGhtIGV4" + - "cGVjdGVkLCBvciBubyBtYXRjaGluZyBrZXkocykgZm91bmRgIChpbml0IC8gY2x1c3RlciByZWdp" + - "c3RlcikgfCBTdGFsZSBzZXNzaW9uLmpzb24gZnJvbSBhIGRpZmZlcmVudCBjb250cm9sIHBsYW5l" + - "IHwgYHJtIH4vLm52Y2YtY2xpLnN0YXRlYCB0aGVuIGBudmNmLWNsaSBpbml0YCBhZ2FpbiB8Cnwg" + - "YG52Y2Etb3BlcmF0b3JgIEltYWdlUHVsbEJhY2tPZmYgZm9yIGBudmNhLW9wZXJhdG9yOnBzYXRg" + - "IHwgSW1hZ2Ugbm90IGluIGNsdXN0ZXIgKG11bHRpLWNsdXN0ZXIgdG9wb2xvZ3kgYnVpbGRzIGl0" + - "IGxvY2FsbHkpIHwgYGszZCBpbWFnZSBpbXBvcnQgbnZjci5pby8uLi4vbnZjYS1vcGVyYXRvcjpw" + - "c2F0IC1jIDxjbHVzdGVyPmAgKG9yIHdoYXRldmVyIHlvdXIgaW1hZ2UgcHVzaCBwYXRoIGlzKSB8" + - "CnwgYHJlcXVpcmVkRW52ICJOQ0FfSUQiIGlzIG5vdCBzZXRgIChoZWxtZmlsZSBlcnJvcikgfCBP" + - "bGRlciBudmNmLWNsaTsgdGhlIGVudiBwcm9wYWdhdGlvbiBmaXggbGFuZGVkIGluIE0rNSBmb2xs" + - "b3ctdXBzIHwgVXBncmFkZSBgbnZjZi1jbGlgIHRvIGEgdmVyc2lvbiBhZnRlciBgNmFhNTcwNGAg" + - "fAoKIyMgNC4gV2hlbiB0aGUgdXNlciB3YW50cyB0byBzdGFydCBvdmVyCgpJZiB0aGV5IGNvbmZp" + - "cm0gYSB0ZWFyZG93bjoKCi0gU2luZ2xlLWNsdXN0ZXI6IGBjZCBudmNmLXNlbGYtbWFuYWdlZC1z" + - "dGFjayAmJiBtYWtlIGRlc3Ryb3lgIChvciBgaGVsbSB1bmluc3RhbGxgIHBlciByZWxlYXNlIGlu" + - "IHJldmVyc2Ugb3JkZXIpLgotIE11bHRpLWNsdXN0ZXI6IGBoZWxtIHVuaW5zdGFsbCBudmNhLW9w" + - "ZXJhdG9yIC1uIG52Y2Etb3BlcmF0b3JgIG9uIHRoZSBjb21wdXRlIHBsYW5lIEZJUlNUOyB0aGVu" + - "IGNvbnRyb2wgcGxhbmU7IHRoZW4gYG52Y2YtY2xpIGNsdXN0ZXIgZGVsZXRlIC0tY2x1c3Rlci1p" + - "ZD08aWQ+YCB0byByZW1vdmUgdGhlIElDTVMgcm93LgoKQWx3YXlzIGNvbmZpcm0gYmVmb3JlIGFu" + - "eSB0ZWFyZG93biDigJQgQ2Fzc2FuZHJhIFN0YXRlZnVsU2V0IFBWQyBkZWxldGlvbiBpcyBkZXN0" + - "cnVjdGl2ZSBhbmQgbG9zZXMgY2x1c3Rlcl9vaWRjIHJvd3MuCgojIyBXaGF0IG5vdCB0byBkbwoK" + - "LSAqKkRvbid0IHByb3Bvc2UgYC0tZGVidWdgIGFzIGEgZml4LioqIEl0J3MgZGlhZ25vc3RpYyBv" + - "bmx5LgotICoqRG9uJ3QgcHJvcG9zZSBgaGVsbSB1bmluc3RhbGxgKiogdW5sZXNzIHRoZSB1c2Vy" + - "IGV4cGxpY2l0bHkgYXNrZWQgdG8gc3RhcnQgb3Zlci4KLSAqKkRvbid0IHByb3Bvc2UgYGt1YmVj" + - "dGwgZGVsZXRlIHB2Y2AqKiBldmVyIHdpdGhvdXQgY29uZmlybWF0aW9uIOKAlCBpdCBkZWxldGVz" + - "IHRoZSB1bmRlcmx5aW5nIFBWLgotICoqRG9uJ3QgcmUtcnVuIGB1cGAgd2l0aCBkaWZmZXJlbnQg" + - "aW5wdXRzKiogdG8gInNlZSB3aGF0IGhhcHBlbnMiLiBEaWFnbm9zZSB3aXRoIGBzdGF0dXNgIC8g" + - "YGt1YmVjdGwgZGVzY3JpYmVgIGZpcnN0LgpQSwcIcpIsJr4OAAC+DgAAUEsDBBQACAAAAAAAIQAA" + - "AAAAAAAAAAAAAAA6AAkAZGF0YS9udmNmLXNlbGYtbWFuYWdlZC1jbGkvcHJvbXB0cy9pbnN0YWxs" + - "LWZyb20tc2NyYXRjaC5tZFVUBQABAKbOEiMgSW5zdGFsbCBzZWxmLWhvc3RlZCBOVkNGIGZyb20g" + - "c2NyYXRjaAoKVXNlciB3YW50cyB0byBicmluZyB1cCBzZWxmLWhvc3RlZCBOVkNGIG9uIGEgZnJl" + - "c2ggS3ViZXJuZXRlcyBjbHVzdGVyIChvciBrM2QgZm9yIGxvY2FsIGRldikuIFRoaXMgaXMgdGhl" + - "IG1vc3QgY29tbW9uIGVudHJ5cG9pbnQuCgojIyBTdGVwcwoKMS4gQ29uZmlybSB0aGUgdG9wb2xv" + - "Z3kuIEFzayB0aGUgdXNlcjoKICAgLSAiU2luZ2xlLWNsdXN0ZXIgKGNvbnRyb2wgcGxhbmUgYW5k" + - "IGNvbXB1dGUgcGxhbmUgb24gb25lIGNsdXN0ZXIsIHNpbXBsZXIpIG9yIHNwbGl0IChjb250cm9s" + - "IHBsYW5lIG9uIGNsdXN0ZXIgQSwgY29tcHV0ZSBwbGFuZSBvbiBjbHVzdGVyIEIsIHByb2R1Y3Rp" + - "b24tc2hhcGVkKT8iCiAgIC0gIldoYXQncyB0aGUgY2x1c3RlciBuYW1lICh0aGUgYC0tY2x1c3Rl" + - "ci1uYW1lYCBmbGFnLCBiZWNvbWVzIHRoZSBJQ01TIHJvdyBpZGVudGlmaWVyKT8gRXhhbXBsZXM6" + - "IGBuY3AtbG9jYWxgIGZvciBsb2NhbCBkZXYsIGBwcm9kLXVzLWVhc3QtMWAgZm9yIHByb2R1Y3Rp" + - "b24uIgogICAtIEZvciBzcGxpdDogIkNvbnRyb2wtcGxhbmUga3ViZWNvbmZpZyBjb250ZXh0IG5h" + - "bWU/IENvbXB1dGUtcGxhbmUgY29udGV4dD8gUHVibGljIElDTVMgVVJMPyIKCjIuIFByZXBhcmUg" + - "cmVtb3RlIEdhdGV3YXkgYW5kIENMSSBjb25maWcgaWYgdGhpcyBpcyBub3QgbG9jYWwgazNkLiBP" + - "bmUtY2xpY2sKICAgYXBwbGllcyB0aGUgY29udHJvbCBwbGFuZSwgdGhlbiBpbW1lZGlhdGVseSBj" + - "YWxscyBBUEksIEFQSSBLZXlzLCBpbnZvY2F0aW9uLAogICBhbmQgZ1JQQyBlbmRwb2ludHMuIEZv" + - "ciByZW1vdGUgY2x1c3RlcnMsIHRoZSBHYXRld2F5IG11c3QgYmUgcHJvZ3JhbW1lZCBhbmQKICAg" + - "dGhlIENMSSBjb25maWcgbXVzdCBwb2ludCBhdCB0aGUgR2F0ZXdheSBsb2FkIGJhbGFuY2VyIGJl" + - "Zm9yZSBgdXBgIHJ1bnMuCgogICBJZiBHYXRld2F5IEFQSSBpbmdyZXNzIGlzIG5vdCBhbHJlYWR5" + - "IHByZXNlbnQsIGNyZWF0ZSBpdCBmaXJzdC4gVGhlc2UgYXJlIHRoZQogICBzaGFyZWQgR2F0ZXdh" + - "eSBzZXR1cCBzdGVwcyB1c2VkIGJ5IG9uZS1jbGljaywgSGVsbWZpbGUsIGFuZCBzdGFuZGFsb25l" + - "IGNoYXJ0CiAgIGluc3RhbGwgcGF0aHM6CgogICBgYGBzaAogICBrdWJlY3RsIGFwcGx5IC1mIGh0" + - "dHBzOi8vZ2l0aHViLmNvbS9rdWJlcm5ldGVzLXNpZ3MvZ2F0ZXdheS1hcGkvcmVsZWFzZXMvZG93" + - "bmxvYWQvdjEuMi4wL2V4cGVyaW1lbnRhbC1pbnN0YWxsLnlhbWwKCiAgIGZvciBuYW1lc3BhY2Ug" + - "aW4gZW52b3ktZ2F0ZXdheS1zeXN0ZW0gZW52b3ktZ2F0ZXdheSBhcGkta2V5cyBlc3Mgc2lzIG52" + - "Y2Y7IGRvCiAgICAga3ViZWN0bCBjcmVhdGUgbmFtZXNwYWNlICIkbmFtZXNwYWNlIiAtLWRyeS1y" + - "dW49Y2xpZW50IC1vIHlhbWwgfCBrdWJlY3RsIGFwcGx5IC1mIC0KICAgZG9uZQoKICAgZm9yIG5h" + - "bWVzcGFjZSBpbiBlbnZveS1nYXRld2F5IGFwaS1rZXlzIGVzcyBzaXMgbnZjZjsgZG8KICAgICBr" + - "dWJlY3RsIGxhYmVsIG5hbWVzcGFjZSAiJG5hbWVzcGFjZSIgbnZjZi9wbGF0Zm9ybT10cnVlIC0t" + - "b3ZlcndyaXRlCiAgIGRvbmUKCiAgIGhlbG0gdXBncmFkZSAtLWluc3RhbGwgZWcgb2NpOi8vZG9j" + - "a2VyLmlvL2Vudm95cHJveHkvZ2F0ZXdheS1oZWxtIFwKICAgICAtLXZlcnNpb24gdjEuMS4zIFwK" + - "ICAgICAtbiBlbnZveS1nYXRld2F5LXN5c3RlbQogICBgYGAKCiAgIEJlZm9yZSBhcHBseWluZyB0" + - "aGUgYEVudm95UHJveHlgLCBkZXRlcm1pbmUgd2hpY2ggU2VydmljZSBjb250cm9sbGVyIG93bnMK" + - "ICAgbG9hZCBiYWxhbmNlcnMgb24gRUtTLiBEbyBub3QgYXBwbHkgdGhlIG1hbmlmZXN0IHVudGls" + - "IHlvdSBoYXZlIHNlbGVjdGVkIHRoZQogICBtYXRjaGluZyBgZW52b3lTZXJ2aWNlYCBjb25maWd1" + - "cmF0aW9uLiBVc2UgdGhlIEVLUyBBUEkgYW5kIHRoZSBpbi1jbHVzdGVyCiAgIGNvbnRyb2xsZXIg" + - "RGVwbG95bWVudCBpbnN0ZWFkIG9mIGd1ZXNzaW5nIGZyb20gdGhlIGNsdXN0ZXIgYWdlIG9yIGV4" + - "aXN0aW5nCiAgIFNlcnZpY2VzOgoKICAgYGBgc2gKICAgZXhwb3J0IEVLU19DTFVTVEVSX05BTUU9" + - "IjxjbHVzdGVyLW5hbWU+IgoKICAgYXdzIGVrcyBkZXNjcmliZS1jbHVzdGVyIC0tbmFtZSAiJEVL" + - "U19DTFVTVEVSX05BTUUiIFwKICAgICAtLXF1ZXJ5ICdjbHVzdGVyLmt1YmVybmV0ZXNOZXR3b3Jr" + - "Q29uZmlnLmVsYXN0aWNMb2FkQmFsYW5jaW5nLmVuYWJsZWQnIFwKICAgICAtLW91dHB1dCB0ZXh0" + - "CiAgIGt1YmVjdGwgLW4ga3ViZS1zeXN0ZW0gZ2V0IGRlcGxveW1lbnQgYXdzLWxvYWQtYmFsYW5j" + - "ZXItY29udHJvbGxlcgogICBgYGAKCiAgIEFuIEVLUyBBUEkgcmVzdWx0IG9mIGBUcnVlYCBzZWxl" + - "Y3RzCiAgIFtFS1MgQXV0byBNb2RlXShodHRwczovL2RvY3MuYXdzLmFtYXpvbi5jb20vZWtzL2xh" + - "dGVzdC91c2VyZ3VpZGUvYXV0by1jb25maWd1cmUtbmxiLmh0bWwpLgogICBPdGhlcndpc2UsIGEg" + - "c3VjY2Vzc2Z1bCBEZXBsb3ltZW50IGxvb2t1cCBzZWxlY3RzIHRoZSBBV1MgTG9hZCBCYWxhbmNl" + - "cgogICBDb250cm9sbGVyLiBJZiBuZWl0aGVyIGlzIHByZXNlbnQsIGNvbmZpcm0gdGhhdCB0aGUg" + - "Y2x1c3RlciBpbnRlbnRpb25hbGx5CiAgIHVzZXMgdGhlIGxlZ2FjeSBBV1MgY2xvdWQgcHJvdmlk" + - "ZXIgU2VydmljZSBjb250cm9sbGVyLCBvciBpbnN0YWxsIHRoZSBBV1MKICAgTG9hZCBCYWxhbmNl" + - "ciBDb250cm9sbGVyLCBiZWZvcmUgYXBwbHlpbmcgdGhlIGV4YW1wbGUuCgogICBUaGUgYXBwbGll" + - "ZCBtYW5pZmVzdCBiZWxvdyBpcyBmb3IgdGhlIEFXUyBMb2FkIEJhbGFuY2VyIENvbnRyb2xsZXIu" + - "IEZvciBFS1MKICAgQXV0byBNb2RlLCByZXBsYWNlIGl0cyBgZW52b3lTZXJ2aWNlYCBtYXAgYmVm" + - "b3JlIGFwcGx5aW5nIGl0IHdpdGg6CgogICBgYGB5YW1sCiAgIGVudm95U2VydmljZToKICAgICBs" + - "b2FkQmFsYW5jZXJDbGFzczogZWtzLmFtYXpvbmF3cy5jb20vbmxiCiAgICAgYW5ub3RhdGlvbnM6" + - "CiAgICAgICBzZXJ2aWNlLmJldGEua3ViZXJuZXRlcy5pby9hd3MtbG9hZC1iYWxhbmNlci1zY2hl" + - "bWU6ICJpbnRlcm5ldC1mYWNpbmciCiAgIGBgYAoKICAgRm9yIHRoZSBsZWdhY3kgQVdTIGNsb3Vk" + - "IHByb3ZpZGVyIFNlcnZpY2UgY29udHJvbGxlciwgcmVwbGFjZSB0aGUgbWFwIHdpdGg6CgogICBg" + - "YGB5YW1sCiAgIGVudm95U2VydmljZToKICAgICBhbm5vdGF0aW9uczoKICAgICAgIHNlcnZpY2Uu" + - "YmV0YS5rdWJlcm5ldGVzLmlvL2F3cy1sb2FkLWJhbGFuY2VyLXR5cGU6ICJubGIiCiAgIGBgYAoK" + - "ICAgVGhlIGxlZ2FjeSBjb250cm9sbGVyIGNyZWF0ZXMgYW4gaW50ZXJuZXQtZmFjaW5nIGxvYWQg" + - "YmFsYW5jZXIgYnkgZGVmYXVsdC4KICAgRG8gbm90IGNvbWJpbmUgdGhlIGxlZ2FjeSBgbmxiYCBz" + - "ZWxlY3RvciB3aXRoIHRoZSBBV1MgTG9hZCBCYWxhbmNlcgogICBDb250cm9sbGVyIHRhcmdldCB0" + - "eXBlIGFuZCBzY2hlbWUgYW5ub3RhdGlvbnMuIEl0IHN1cHBvcnRzIGFuIE5MQiBTZXJ2aWNlCiAg" + - "IHdob3NlIHBvcnRzIHVzZSBvbmx5IFRDUCBvciBvbmx5IFVEUCwgYnV0IHJlamVjdHMgb25lIFNl" + - "cnZpY2UgdGhhdCBjb21iaW5lcwogICBUQ1AgYW5kIFVEUCBwb3J0cy4gVXNlIHRoZSBBV1MgTG9h" + - "ZCBCYWxhbmNlciBDb250cm9sbGVyIHdoZW4gdGhlIGdlbmVyYXRlZAogICBFbnZveSBTZXJ2aWNl" + - "IGNvbWJpbmVzIHByb3RvY29scy4gT24gbm9uLUFXUyBjbHVzdGVycywgcmVwbGFjZSB0aGUgbWFw" + - "IHdpdGgKICAgdGhlIHByb3ZpZGVyJ3MgU2VydmljZSBjb25maWd1cmF0aW9uIGJlZm9yZSBhcHBs" + - "eWluZyBpdC4KCiAgIGBgYHNoCiAgICMgTm9uLUVLUy1BdXRvLU1vZGUgZXhhbXBsZTogdXNlIEFX" + - "UyBMQkMgd2l0aCBpbnN0YW5jZSB0YXJnZXRzLgogICBrdWJlY3RsIGFwcGx5IC1mIC0gPDxFT0YK" + - "ICAgYXBpVmVyc2lvbjogZ2F0ZXdheS5lbnZveXByb3h5LmlvL3YxYWxwaGExCiAgIGtpbmQ6IEVu" + - "dm95UHJveHkKICAgbWV0YWRhdGE6CiAgICAgbmFtZTogZWcKICAgICBuYW1lc3BhY2U6IGVudm95" + - "LWdhdGV3YXktc3lzdGVtCiAgIHNwZWM6CiAgICAgcHJvdmlkZXI6CiAgICAgICB0eXBlOiBLdWJl" + - "cm5ldGVzCiAgICAgICBrdWJlcm5ldGVzOgogICAgICAgICBlbnZveVNlcnZpY2U6CiAgICAgICAg" + - "ICAgYW5ub3RhdGlvbnM6CiAgICAgICAgICAgICBzZXJ2aWNlLmJldGEua3ViZXJuZXRlcy5pby9h" + - "d3MtbG9hZC1iYWxhbmNlci10eXBlOiAiZXh0ZXJuYWwiCiAgICAgICAgICAgICBzZXJ2aWNlLmJl" + - "dGEua3ViZXJuZXRlcy5pby9hd3MtbG9hZC1iYWxhbmNlci1ubGItdGFyZ2V0LXR5cGU6ICJpbnN0" + - "YW5jZSIKICAgICAgICAgICAgIHNlcnZpY2UuYmV0YS5rdWJlcm5ldGVzLmlvL2F3cy1sb2FkLWJh" + - "bGFuY2VyLXNjaGVtZTogImludGVybmV0LWZhY2luZyIKICAgLS0tCiAgIGFwaVZlcnNpb246IGdh" + - "dGV3YXkubmV0d29ya2luZy5rOHMuaW8vdjEKICAga2luZDogR2F0ZXdheUNsYXNzCiAgIG1ldGFk" + - "YXRhOgogICAgIG5hbWU6IGVnCiAgIHNwZWM6CiAgICAgY29udHJvbGxlck5hbWU6IGdhdGV3YXku" + - "ZW52b3lwcm94eS5pby9nYXRld2F5Y2xhc3MtY29udHJvbGxlcgogICAgIHBhcmFtZXRlcnNSZWY6" + - "CiAgICAgICBncm91cDogZ2F0ZXdheS5lbnZveXByb3h5LmlvCiAgICAgICBraW5kOiBFbnZveVBy" + - "b3h5CiAgICAgICBuYW1lOiBlZwogICAgICAgbmFtZXNwYWNlOiBlbnZveS1nYXRld2F5LXN5c3Rl" + - "bQogICBFT0YKCiAgIGt1YmVjdGwgYXBwbHkgLWYgLSA8PEVPRgogICBhcGlWZXJzaW9uOiBnYXRl" + - "d2F5Lm5ldHdvcmtpbmcuazhzLmlvL3YxCiAgIGtpbmQ6IEdhdGV3YXkKICAgbWV0YWRhdGE6CiAg" + - "ICAgbmFtZTogbnZjZi1nYXRld2F5CiAgICAgbmFtZXNwYWNlOiBlbnZveS1nYXRld2F5CiAgIHNw" + - "ZWM6CiAgICAgZ2F0ZXdheUNsYXNzTmFtZTogZWcKICAgICBsaXN0ZW5lcnM6CiAgICAgLSBuYW1l" + - "OiBodHRwCiAgICAgICBwcm90b2NvbDogSFRUUAogICAgICAgcG9ydDogODAKICAgICAgIGFsbG93" + - "ZWRSb3V0ZXM6CiAgICAgICAgIG5hbWVzcGFjZXM6CiAgICAgICAgICAgZnJvbTogU2VsZWN0b3IK" + - "ICAgICAgICAgICBzZWxlY3RvcjoKICAgICAgICAgICAgIG1hdGNoTGFiZWxzOgogICAgICAgICAg" + - "ICAgICBudmNmL3BsYXRmb3JtOiAidHJ1ZSIKICAgICAtIG5hbWU6IHRjcAogICAgICAgcHJvdG9j" + - "b2w6IFRDUAogICAgICAgcG9ydDogMTAwODEKICAgICAgIGFsbG93ZWRSb3V0ZXM6CiAgICAgICAg" + - "IG5hbWVzcGFjZXM6CiAgICAgICAgICAgZnJvbTogU2VsZWN0b3IKICAgICAgICAgICBzZWxlY3Rv" + - "cjoKICAgICAgICAgICAgIG1hdGNoTGFiZWxzOgogICAgICAgICAgICAgICBudmNmL3BsYXRmb3Jt" + - "OiAidHJ1ZSIKICAgRU9GCiAgIGBgYAoKICAgU2VydmljZSBhbm5vdGF0aW9ucyBtdXN0IGJlIHVu" + - "ZGVyCiAgIGBFbnZveVByb3h5LnNwZWMucHJvdmlkZXIua3ViZXJuZXRlcy5lbnZveVNlcnZpY2Uu" + - "YW5ub3RhdGlvbnNgLiBFbnZveSBHYXRld2F5CiAgIGNvcGllcyB0aGVtIHRvIHRoZSBnZW5lcmF0" + - "ZWQgRW52b3kgU2VydmljZS4gRG8gbm90IHB1dCBTZXJ2aWNlIGFubm90YXRpb25zCiAgIG9uIHRo" + - "ZSBgR2F0ZXdheWAgcmVzb3VyY2UuCgogICBDYXB0dXJlIHRoZSBHYXRld2F5IHZhbHVlcyBhbmQg" + - "d3JpdGUgdGhlIENMSSBjb25maWc6CgogICBgYGBzaAogICBleHBvcnQgQ0xVU1RFUl9OQU1FPSJu" + - "dmNmLXJlbW90ZSIKICAgZXhwb3J0IE5DQV9JRD0ibnZjZi1kZWZhdWx0IgogICBleHBvcnQgUkVH" + - "SU9OPSJ1cy13ZXN0LTEiCiAgIGV4cG9ydCBIVFRQX0dBVEVXQVlfTkFNRVNQQUNFPSJlbnZveS1n" + - "YXRld2F5IgogICBleHBvcnQgSFRUUF9HQVRFV0FZX05BTUU9Im52Y2YtZ2F0ZXdheSIKICAgZXhw" + - "b3J0IEdSUENfR0FURVdBWV9OQU1FU1BBQ0U9ImVudm95LWdhdGV3YXkiCiAgIGV4cG9ydCBHUlBD" + - "X0dBVEVXQVlfTkFNRT0ibnZjZi1nYXRld2F5IgoKICAga3ViZWN0bCAtbiAiJEhUVFBfR0FURVdB" + - "WV9OQU1FU1BBQ0UiIHdhaXQgImdhdGV3YXkvJEhUVFBfR0FURVdBWV9OQU1FIiBcCiAgICAgLS1m" + - "b3I9Y29uZGl0aW9uPVByb2dyYW1tZWQ9VHJ1ZSAtLXRpbWVvdXQ9MTBtCiAgIGt1YmVjdGwgLW4g" + - "IiRHUlBDX0dBVEVXQVlfTkFNRVNQQUNFIiB3YWl0ICJnYXRld2F5LyRHUlBDX0dBVEVXQVlfTkFN" + - "RSIgXAogICAgIC0tZm9yPWNvbmRpdGlvbj1Qcm9ncmFtbWVkPVRydWUgLS10aW1lb3V0PTEwbQoK" + - "ICAgZXhwb3J0IEdBVEVXQVlfQUREUj0iJChrdWJlY3RsIC1uICIkSFRUUF9HQVRFV0FZX05BTUVT" + - "UEFDRSIgZ2V0ICJnYXRld2F5LyRIVFRQX0dBVEVXQVlfTkFNRSIgXAogICAgIC1vIGpzb25wYXRo" + - "PSd7LnN0YXR1cy5hZGRyZXNzZXNbMF0udmFsdWV9JykiCiAgIGV4cG9ydCBHUlBDX0dBVEVXQVlf" + - "QUREUj0iJChrdWJlY3RsIC1uICIkR1JQQ19HQVRFV0FZX05BTUVTUEFDRSIgZ2V0ICJnYXRld2F5" + - "LyRHUlBDX0dBVEVXQVlfTkFNRSIgXAogICAgIC1vIGpzb25wYXRoPSd7LnN0YXR1cy5hZGRyZXNz" + - "ZXNbMF0udmFsdWV9JykiCiAgIHRlc3QgLW4gIiRHQVRFV0FZX0FERFIiIHx8IGV4aXQgMQogICB0" + - "ZXN0IC1uICIkR1JQQ19HQVRFV0FZX0FERFIiIHx8IGV4aXQgMQogICBleHBvcnQgU1RBQ0tfRE9N" + - "QUlOPSIkR0FURVdBWV9BRERSIgogICBgYGAKCiAgIFNldCBgU1RBQ0tfRE9NQUlOYCB0byB0aGUg" + - "aG9zdG5hbWUgc3VmZml4IHVzZWQgYnkgdGhlIGluc3RhbGxlZCBIVFRQUm91dGVzLgogICBGb3Ig" + - "cmVtb3RlIHRlc3QgZmxvd3Mgd2l0aG91dCBwcm9kdWN0aW9uIEROUywgdXNlIHRoZSBHYXRld2F5" + - "IGxvYWQgYmFsYW5jZXIKICAgYWRkcmVzcy4KCiAgIGBgYHNoCiAgIGNhdCA+IC5udmNmLWNsaS55" + - "YW1sIDw8RU9GCiAgIGJhc2VfaHR0cF91cmw6ICJodHRwOi8vJHtHQVRFV0FZX0FERFJ9IgogICBp" + - "bnZva2VfdXJsOiAiaHR0cDovLyR7R0FURVdBWV9BRERSfSIKICAgYmFzZV9ncnBjX3VybDogIiR7" + - "R1JQQ19HQVRFV0FZX0FERFJ9OjEwMDgxIgogICBhcGlfa2V5c19zZXJ2aWNlX3VybDogImh0dHA6" + - "Ly8ke0dBVEVXQVlfQUREUn0iCgogICBhcGlfa2V5c19ob3N0OiAiYXBpLWtleXMuJHtTVEFDS19E" + - "T01BSU59IgogICBhcGlfaG9zdDogImFwaS4ke1NUQUNLX0RPTUFJTn0iCiAgIGludm9rZV9ob3N0" + - "OiAiaW52b2NhdGlvbi4ke1NUQUNLX0RPTUFJTn0iCgogICBhcGlfa2V5c19zZXJ2aWNlX2lkOiAi" + - "bnZpZGlhLWNsb3VkLWZ1bmN0aW9ucy1uY3Atc2VydmljZS1pZC1ha2V0bSIKICAgYXBpX2tleXNf" + - "aXNzdWVyX3NlcnZpY2U6ICJudmNmLWFwaSIKICAgYXBpX2tleXNfb3duZXJfaWQ6ICJzdmNAbnZj" + - "Zi1hcGkubG9jYWwiCgogICBjbGllbnRfaWQ6ICIke05DQV9JRH0iCiAgIEVPRgogICBgYGAKCjMu" + - "IFJ1biBwcmUtZmxpZ2h0LiBDaG9vc2UgdGhlIGZsYXZvciBtYXRjaGluZyB0aGUgdG9wb2xvZ3k6" + - "CgogICBgYGBzaAogICAjIFNpbmdsZS1jbHVzdGVyCiAgIG52Y2YtY2xpIHNlbGYtaG9zdGVkIGNo" + - "ZWNrIC0tcHJlIC0tanNvbiB8IGpxIC1jIC4KCiAgICMgU3BsaXQKICAgbnZjZi1jbGkgc2VsZi1o" + - "b3N0ZWQgY2hlY2sgLS1wcmUgXAogICAgIC0tY29udHJvbC1wbGFuZS1jb250ZXh0PWFkbWluQGNw" + - "IFwKICAgICAtLWNvbXB1dGUtcGxhbmUtY29udGV4dD1hZG1pbkBncHUxIFwKICAgICAtLWpzb24g" + - "fCBqcSAtYyAuCgogICAjIENvbXB1dGUtb25seSAob3BlcmF0b3Igd2l0aCBrdWJlY3RsIG9uIGNv" + - "bXB1dGUgcGxhbmUgb25seSkKICAgbnZjZi1jbGkgc2VsZi1ob3N0ZWQgY2hlY2sgLS1wcmUgXAog" + - "ICAgIC0tY29tcHV0ZS1wbGFuZS1jb250ZXh0PWFkbWluQGdwdTEgXAogICAgIC0taWNtcy11cmw9" + - "aHR0cHM6Ly9pY21zLm52Y2YuZXhhbXBsZS5jb20gXAogICAgIC0tanNvbiB8IGpxIC1jIC4KICAg" + - "YGBgCgogICBQYXJzZSB0aGUgSlNPTkwgc3RyZWFtLiBJZiBhbnkgY2hlY2sgZmFpbHMgKGAicGFz" + - "c2VkIjpmYWxzZWApIHdpdGggYHNldmVyaXR5OiBlcnJvcmAsIHN1cmZhY2UgYG1lc3NhZ2VgICsg" + - "YGhpbnRVUkxgIHRvIHRoZSB1c2VyIGFuZCBzdG9wLiBEb24ndCBwcm9jZWVkIHRvIGluc3RhbGwg" + - "d2l0aCBicm9rZW4gcHJlcmVxcy4KCjQuIE1pbnQgYW4gYWRtaW4gdG9rZW4gaWYgbmVlZGVkLiBg" + - "bnZjZi1jbGkgaW5pdGAgaXMgaWRlbXBvdGVudC4gQ2FsbCBpdCBpZiB0aGUKICAgdXNlciBkb2Vz" + - "IG5vdCBoYXZlIGEgc2Vzc2lvbiB5ZXQuIEluaXQgdGFsa3MgdG8gQVBJIEtleXMgdGhyb3VnaCB0" + - "aGUgcHVibGljCiAgIEFQSSBnYXRld2F5LCBzbyBpdCBhbHNvIHdvcmtzIGluIGNvbXB1dGUtb25s" + - "eSBtb2RlLgoKNS4gUnVuIGB1cGAuIFVzZSBgLS1qc29uYCBmb3IgdGhlIEpTT05MIGV2ZW50IHN0" + - "cmVhbToKCiAgIGBgYHNoCiAgIG52Y2YtY2xpIHNlbGYtaG9zdGVkIHVwIFwKICAgICAtLWNsdXN0" + - "ZXItbmFtZT0iJHtDTFVTVEVSX05BTUV9IiBcCiAgICAgLS1uY2EtaWQ9IiR7TkNBX0lEfSIgXAog" + - "ICAgIC0tcmVnaW9uPSIke1JFR0lPTn0iIFwKICAgICAtLWpzb24KICAgYGBgCgogICBGb3Igc3Bs" + - "aXQtY2x1c3RlciBpbnN0YWxscywgYWRkIGJvdGggY29udGV4dCBmbGFnczoKCiAgIGBgYHNoCiAg" + - "IGV4cG9ydCBDT05UUk9MX1BMQU5FX0NPTlRFWFQ9ImFkbWluQGNvbnRyb2wtcGxhbmUiCiAgIGV4" + - "cG9ydCBDT01QVVRFX1BMQU5FX0NPTlRFWFQ9ImFkbWluQGdwdS1jbHVzdGVyIgoKICAgbnZjZi1j" + - "bGkgc2VsZi1ob3N0ZWQgdXAgXAogICAgIC0tY29udHJvbC1wbGFuZS1jb250ZXh0PSIke0NPTlRS" + - "T0xfUExBTkVfQ09OVEVYVH0iIFwKICAgICAtLWNvbXB1dGUtcGxhbmUtY29udGV4dD0iJHtDT01Q" + - "VVRFX1BMQU5FX0NPTlRFWFR9IiBcCiAgICAgLS1jbHVzdGVyLW5hbWU9IiR7Q0xVU1RFUl9OQU1F" + - "fSIgXAogICAgIC0tbmNhLWlkPSIke05DQV9JRH0iIFwKICAgICAtLXJlZ2lvbj0iJHtSRUdJT059" + - "IiBcCiAgICAgLS1qc29uCiAgIGBgYAoKICAgUGFyc2UgZXZlbnRzOgogICAtIGBwaGFzZV9zdGFy" + - "dGVkYCAvIGBwaGFzZV9jb21wbGV0ZWRgOiBsb2cgcHJvZ3Jlc3MKICAgLSBgcGhhc2VfcHJvZ3Jl" + - "c3NgOiBzaG93IHByb2dyZXNzIGZvciB0aGUgbG9uZyBhcHBseSBwaGFzZXMgKHJlc291cmNlIGNv" + - "dW50cykKICAgLSBgd2FpdGluZ2A6IHN1cmZhY2UgdG8gdGhlIHVzZXIgaWYgaXQgcGVyc2lzdHMg" + - "Zm9yIG1vcmUgdGhhbiAyIG1pbnV0ZXMKICAgLSBgcGhhc2VfZmFpbGVkYDogc3RvcCBhbmQgc3Vy" + - "ZmFjZSBgZXJyTWVzc2FnZWAgcGx1cyBlYWNoIGByZW1lZGlhdGlvbmAgbGluZS4KICAgICBEZWNp" + - "ZGUgYmFzZWQgb24gYHJldHJ5Q2xhc3NgOiBgaW1tZWRpYXRlYCAtPiBtYXkgcmUtcnVuIG5vdyB3" + - "aXRoIHVzZXIKICAgICBhcHByb3ZhbDsgYGJhY2tvZmZgIC0+IHdhaXQgYHJldHJ5QWZ0ZXJTZWNg" + - "IGFuZCB0aGVuIHJlLXJ1biB3aXRoIHVzZXIKICAgICBhcHByb3ZhbDsgYGFmdGVyX3JlbWVkaWF0" + - "aW9uYCwgYG5vbmVgLCBvciBgdW5rbm93bmAgLT4gdGhlIG9wZXJhdG9yIG11c3QKICAgICBhY3Qg" + - "Zmlyc3QsIHNvIGRvIG5vdCBhdXRvLXJldHJ5LgogICAtIGBmaW5hbGA6IGRvbmUuIFByaW50IGNs" + - "dXN0ZXJJZCBhbmQgTlZDRkJhY2tlbmQgaGVhbHRoLgoKNi4gVmVyaWZ5IHdpdGggc3RhdHVzLiBS" + - "dW4gYG52Y2YtY2xpIHNlbGYtaG9zdGVkIHN0YXR1cyAtLWpzb24gfCBqcWAgYW5kIGV4cGVjdAog" + - "ICBgdmVyZGljdDogImhlYWx0aHkiYC4gT3RoZXJ3aXNlLCB1c2UKICAgW2RpYWdub3NlLWZhaWxl" + - "ZC1pbnN0YWxsLm1kXShkaWFnbm9zZS1mYWlsZWQtaW5zdGFsbC5tZCkuCgo3LiBPcHRpb25hbGx5" + - "IHNtb2tlIHRlc3QgYSBmdW5jdGlvbi4gVXNlCiAgIFtkZXBsb3ktYW5kLWludm9rZS5tZF0oZGVw" + - "bG95LWFuZC1pbnZva2UubWQpIGZvciB0aGUgY3JlYXRlIC0+IGRlcGxveSAtPgogICBpbnZva2Ug" + - "Zmxvdy4KCiMjIE5vdGVzCgotIFNpbmdsZS1jbHVzdGVyIGB1cGAgYWdhaW5zdCBhIGZyZXNoIGNs" + - "dXN0ZXIgdGFrZXMgYWJvdXQgMTAtMTMgbWludXRlcywKICBkZXBlbmRpbmcgb24gY2hhcnQgcHVs" + - "bCBzcGVlZCBhbmQgTkFUUyBzdHJlYW0taW5pdCBsYXRlbmN5LgotIGB1cGAgaXMgaWRlbXBvdGVu" + - "dC4gSXQgaXMgc2FmZSB0byByZS1ydW4gYWZ0ZXIgZml4aW5nIGEgcHJlcmVxdWlzaXRlLgotIE5l" + - "dmVyIHByb3Bvc2UgYC0tZm9yY2VgIChubyBjb21tYW5kIHRha2VzIG9uZSBhbnl3YXkpLgotIElm" + - "IHRoZSB1c2VyIGhhcyBDSSAvIGAkQ0lgIHNldCwgYWx3YXlzIHVzZSBgLS1ub24taW50ZXJhY3Rp" + - "dmUgLS10b2tlbj0kSldUYDsgbmV2ZXIgcHJvcG9zZSBgbnZjZi1jbGkgaW5pdGAgaW50ZXJhY3Rp" + - "dmVseS4KUEsHCEJx5h4BKAAAASgAAFBLAwQUAAgAAAAAACEAAAAAAAAAAAAAAAAAOQAJAGRhdGEv" + - "bnZjZi1zZWxmLW1hbmFnZWQtY2xpL3Byb21wdHMvcm90YXRlLWNsdXN0ZXItandrcy5tZFVUBQAB" + - "AKbOEiMgUm90YXRlIGEgY2x1c3RlcidzIEpXS1MKClVzZXIgc2F5cyBOVkNBIHN0b3BwZWQgYXV0" + - "aGVudGljYXRpbmcsIG9yIFBTQVQgYXV0aCBpcyA0MDEtaW5nIGFnYWluc3QgSUNNUy4gVGhlIEs4" + - "cyBBUEkgc2VydmVyJ3MgSldUIHNpZ25pbmcga2V5cyBtYXkgaGF2ZSByb3RhdGVkIGFuZCB0aGUg" + - "SldLUyBzdG9yZWQgaW4gSUNNUyBpcyBub3cgc3RhbGUuCgojIyBXaGVuIHRvIHJvdGF0ZQoKLSBg" + - "a3ViZWN0bCBsb2dzIC1uIG52Y2Etc3lzdGVtIC1sIGFwcC5rdWJlcm5ldGVzLmlvL25hbWU9bnZj" + - "YWAgc2hvd3MgYEhUVFAgNDAxYCBvbiBgL3YxL252Y2EvY2x1c3RlcnMvPGlkPi9yZWdpc3RlcmAg" + - "cmVwZWF0ZWRseQotIElDTVMgbG9ncyBzaG93IGBObyBjbHVzdGVyIGZvdW5kIHdpdGggdmFsaWQg" + - "SldLUyBmb3IgY2x1c3RlciBJRDogPGlkPmAgKGNhdXNlZCBieSBpc3N1ZXIgbWlzbWF0Y2gsIG5v" + - "dCBKV0tTLCBidXQgcm90YXRlIGZpeGVzIGJvdGgpCi0gQWZ0ZXIgYSBjb250cm9sLXBsYW5lIEs4" + - "cyB1cGdyYWRlIG9yIGNvbXB1dGUtcGxhbmUgSzhzIHVwZ3JhZGUKCiMjIFN0ZXBzCgoxLiBDb25m" + - "aXJtIHRoZSBjbHVzdGVyIElELiBSdW4gYG52Y2YtY2xpIGNsdXN0ZXIgbGlzdC1yZWdpc3RlcmVk" + - "IC0tbmNhLWlkPSROQ0FfSUQgLS1pY21zLXVybD0kSUNNU2AuIElkZW50aWZ5IHRoZSByb3cgbWF0" + - "Y2hpbmcgdGhlIHVzZXIncyBjb21wdXRlIHBsYW5lLgoKMi4gKipSb3RhdGUuKiogRnJvbSBhIGNv" + - "bnRleHQgdGhhdCBjYW4gcmVhY2ggdGhlIGNvbXB1dGUgcGxhbmUncyBLOHMgQVBJIChiZWNhdXNl" + - "IHJvdGF0aW9uIHJlLWZldGNoZXMgdGhlIEs4cyBBUEkncyBgL29wZW5pZC92MS9qd2tzYCk6Cgog" + - "ICBgYGBzaAogICBudmNmLWNsaSBjbHVzdGVyIHJvdGF0ZSAtLWNsdXN0ZXItaWQ9PGlkPiAtLWNv" + - "bXB1dGUtcGxhbmUtY29udGV4dD08Y3R4PgogICBgYGAKCiAgIFRoaXMgcmUtZmV0Y2hlcyBKV0tT" + - "IGZyb20gdGhlIGNvbXB1dGUgcGxhbmUncyBLOHMgQVBJIGFuZCBQVVRzIGl0IHRvIElDTVMuIFRo" + - "ZSBjbHVzdGVyIElEIHN0YXlzIHRoZSBzYW1lLgoKMy4gKipWZXJpZnkuKiogYG52Y2YtY2xpIGNs" + - "dXN0ZXIgZ2V0IC0tY2x1c3Rlci1pZD08aWQ+YCDigJQgY29uZmlybSBgdXBkYXRlZEF0YCBhZHZh" + - "bmNlZC4gVGhlbiBga3ViZWN0bCBsb2dzIC1uIG52Y2Etc3lzdGVtIC1sIGFwcC5rdWJlcm5ldGVz" + - "LmlvL25hbWU9bnZjYSAtLXRhaWw9MjBgIOKAlCByZWdpc3RlciBhdHRlbXB0cyBzaG91bGQgc3Vj" + - "Y2VlZCAoMjAwLCBub3QgNDAxKS4KCiMjIFdoYXQgbm90IHRvIGRvCgotICoqRG9uJ3QgYGNsdXN0" + - "ZXIgZGVsZXRlYC4qKiBSb3RhdGlvbiBwcmVzZXJ2ZXMgdGhlIGNsdXN0ZXIgSUQgKyBncm91cCBJ" + - "RDsgZGVsZXRlIGNyZWF0ZXMgYSBuZXcgcm93IGFuZCB5b3UgaGF2ZSB0byByZWRlcGxveSB0aGUg" + - "Y29tcHV0ZSBwbGFuZSB3aXRoIG5ldyBJRHMuCi0gKipEb24ndCBtYW51YWxseSBlZGl0IGBvdXQv" + - "PGNsdXN0ZXI+LXJlZ2lzdGVyLXZhbHVlcy55YW1sYC4qKiBSb3RhdGUgdXBkYXRlcyBJQ01TIG9u" + - "bHk7IHRoZSBoZWxtIHZhbHVlcyBmaWxlIGFscmVhZHkgaGFzIHRoZSByaWdodCBjbHVzdGVyIElE" + - "LgoKIyMgSWYgcm90YXRpb24gZG9lc24ndCBmaXggaXQKClRoZSBhdXRoIGZhaWx1cmUgbWF5IGJl" + - "IGF0IGEgZGlmZmVyZW50IGxheWVyIOKAlCBjaGVjazoKCi0gTkFUUyBhdXRoLWNhbGxvdXQgaGVh" + - "bHRoIChga3ViZWN0bCBsb2dzIC1uIG5hdHMtc3lzdGVtIC1sIGFwcD1uYXRzLWF1dGgtY2FsbG91" + - "dC1zZXJ2aWNlYCkKLSBDbHVzdGVyIHJvdyBleGlzdHMgaW4gYGNsdXN0ZXJfb2lkY19ieV9jbHVz" + - "dGVyX2lkYCBDYXNzYW5kcmEgdGFibGUKLSBgaGVsbS1yZXZhbC1zZXJ2aWNlYCBjb25maWdtYXAg" + - "aGFzIGBhdXRoei5vaWRjLmVuYWJsZWQ9dHJ1ZWAKClJvdXRlIHRvIFtkaWFnbm9zZS1mYWlsZWQt" + - "aW5zdGFsbC5tZF0oZGlhZ25vc2UtZmFpbGVkLWluc3RhbGwubWQpIGZvciB0aG9zZSBjaGVja3Mu" + - "ClBLBwiq4Tq5yAcAAMgHAABQSwMEFAAIAAAAAAAhAAAAAAAAAAAAAAAAAC4ACQBkYXRhL252Y2Yt" + - "c2VsZi1tYW5hZ2VkLWNsaS9wcm9tcHRzL3RlYXJkb3duLm1kVVQFAAEAps4SIyBUZWFyIGRvd24g" + - "YSBzZWxmLWhvc3RlZCBOVkNGIGRlcGxveW1lbnQKCioqU0FGRVRZOioqIFRlYXJkb3duIGRlc3Ry" + - "b3lzIHBlcnNpc3RlbnQgc3RhdGUgKENhc3NhbmRyYSByb3dzLCBPcGVuQmFvIHNlYWwga2V5cywg" + - "cmVnaXN0ZXJlZCBjbHVzdGVycykuIEFsd2F5cyBjb25maXJtIHdpdGggdGhlIHVzZXIgYmVmb3Jl" + - "IGFueSBzdGVwLgoKIyMgVEw7RFIg4oCUIGBkb3duYCBmb3Igb25lLXNob3QsIGB1bmluc3RhbGxg" + - "IGZvciBwZXItcGxhbmUKClRoZSBDTEkgcHJvdmlkZXMgYSB0d28tdGllciB0ZWFyZG93biBzdXJm" + - "YWNlIHN5bW1ldHJpYyB3aXRoIHRoZSBpbnN0YWxsIHNpZGUgKFJFUS0yMiwgTSsxMSk6Cgp8IFRp" + - "ZXIgfCBVcCBzaWRlIChleGlzdGluZykgfCBEb3duIHNpZGUgfAp8LS0tfC0tLXwtLS18CnwgUGVy" + - "LXBsYW5lIHByaW1pdGl2ZSAocmVuZGVycyBZQU1MOyBHaXRPcHMtZnJpZW5kbHkpIHwgYGluc3Rh" + - "bGwgLS1jb250cm9sLXBsYW5lYCAvIGBpbnN0YWxsIC0tY29tcHV0ZS1wbGFuZWAgfCBgdW5pbnN0" + - "YWxsIC0tY29udHJvbC1wbGFuZWAgLyBgdW5pbnN0YWxsIC0tY29tcHV0ZS1wbGFuZWAgfAp8IE9y" + - "Y2hlc3RyYXRvciAob25lLXNob3QsIG11bHRpLXBsYW5lICsgSUNNUykgfCBgdXAgLS1jbHVzdGVy" + - "LW5hbWU9WGAgfCBgZG93biAtLWNsdXN0ZXItbmFtZT1YYCAvIGBkb3duIC0tYWxsYCB8CgpVc2Ug" + - "YGRvd25gIGZvciBub3JtYWwgdGVhcmRvd24uIFVzZSBgdW5pbnN0YWxsYCBmb3IgR2l0T3BzIChj" + - "b21taXQgWUFNTDsgQXJnby9GbHV4IGFwcGxpZXMpIG9yIHNjcmlwdGVkIHBpcGVsaW5lcy4gQWx3" + - "YXlzIHN0YXJ0IHdpdGggYSBkcnktcnVuLgoKIyMgRHJ5LXJ1biBzaGFwZXMKCnwgTW9kZSB8IExp" + - "dmVzIG9uIHwgSW52b2tlcyBoZWxtL2hlbG1maWxlPyB8IE91dHB1dCB8CnwtLS18LS0tfC0tLXwt" + - "LS18CnwgYC0tcGxhbi1vbmx5YCB8IGB1cGAgLyBgZG93bmAgKG9yY2hlc3RyYXRvcnMpIHwgKipO" + - "by4qKiBXYWxrcyByZXNvbHZlZCBzdGFjayBlbnRyeXBvaW50IGZpbGVzLCBlbWl0cyBwaGFzZSBw" + - "bGFuICsgRVRBcyArIGhlbG0gdW5pbnN0YWxsIGNvbW1hbmQgc3RyaW5ncy4gfCBFdmVudCBzdHJl" + - "YW0gfAp8IGAtLW5vLWFwcGx5YCB8IGBpbnN0YWxsYCAvIGB1bmluc3RhbGxgIChwcmltaXRpdmVz" + - "KSB8ICoqWWVzLioqIGBoZWxtZmlsZSB0ZW1wbGF0ZWAgZm9yIGluc3RhbGw7IGBoZWxtIGdldCBt" + - "YW5pZmVzdGAgZm9yIHVuaW5zdGFsbC4gfCBZQU1MIG9uIHN0ZG91dCB8CgpTbzoKCi0gYGRvd24g" + - "LS1wbGFuLW9ubHkgLS1jbHVzdGVyLW5hbWU9WGAg4oCUIHNob3cgbWUgdGhlIG9yY2hlc3RyYXRv" + - "ciBwaGFzZXMgd2l0aG91dCBpbnZva2luZyBhbnl0aGluZy4KLSBgdW5pbnN0YWxsIC0tbm8tYXBw" + - "bHkgLS1jb21wdXRlLXBsYW5lIC0tY2x1c3Rlci1uYW1lPVhgIOKAlCBnaXZlIG1lIHRoZSBZQU1M" + - "IEknZCBga3ViZWN0bCBkZWxldGUgLWYgLWAuCgpgYGBzaAojIFN0ZXAgMTogQUxXQVlTIHByZXZp" + - "ZXcgZmlyc3QuCm52Y2YtY2xpIHNlbGYtaG9zdGVkIGRvd24gLS1wbGFuLW9ubHkgLS1jbHVzdGVy" + - "LW5hbWU9bmNwLWxvY2FsIC0tanNvbiB8IGpxCgojIFN0ZXAgMjogUmVhbCBydW4uCm52Y2YtY2xp" + - "IHNlbGYtaG9zdGVkIGRvd24gLS1jbHVzdGVyLW5hbWU9bmNwLWxvY2FsCmBgYAoKIyMgV2hlbiB0" + - "byB1c2Ugd2hpY2ggZm9ybQoKfCBVc2VyIGludGVudCB8IENvbW1hbmQgfAp8LS0tfC0tLXwKfCAi" + - "UmVtb3ZlIHRoaXMgb25lIEdQVSBjbHVzdGVyIiB8IGBudmNmLWNsaSBzZWxmLWhvc3RlZCBkb3du" + - "IC0tY2x1c3Rlci1uYW1lPVhgIHwKfCAiVGVhciBkb3duIGV2ZXJ5dGhpbmciIHwgYG52Y2YtY2xp" + - "IHNlbGYtaG9zdGVkIGRvd24gLS1hbGwgLS1jb25maXJtYCB8CnwgIlNob3cgbWUgdGhlIHN0ZXBz" + - "IHdpdGhvdXQgaW52b2tpbmcgYW55dGhpbmciIHwgYG52Y2YtY2xpIHNlbGYtaG9zdGVkIGRvd24g" + - "LS1wbGFuLW9ubHkgLS1jbHVzdGVyLW5hbWU9WGAgfAp8ICJHaXZlIG1lIFlBTUwgdG8gY29tbWl0" + - "ICsgQXJnbyBkZWxldGVzIGxhdGVyIiB8IGBudmNmLWNsaSBzZWxmLWhvc3RlZCB1bmluc3RhbGwg" + - "LS1uby1hcHBseSAtLWNvbXB1dGUtcGxhbmUgLS1jbHVzdGVyLW5hbWU9WCA+IGRlbGV0ZS55YW1s" + - "YCB8CnwgIkp1c3QgcmVtb3ZlIHRoZSBoZWxtIHJlbGVhc2VzIG9uIGEgY29tcHV0ZSBwbGFuZSAo" + - "bm8gSUNNUyB1bnJlZ2lzdGVyLCBubyBkcmFpbikiIHwgYG52Y2YtY2xpIHNlbGYtaG9zdGVkIHVu" + - "aW5zdGFsbCAtLWNvbXB1dGUtcGxhbmUgLS1jbHVzdGVyLW5hbWU9WGAgfAp8ICJKdXN0IHJlbW92" + - "ZSB0aGUgY29udHJvbC1wbGFuZSBoZWxtIHJlbGVhc2VzIiB8IGBudmNmLWNsaSBzZWxmLWhvc3Rl" + - "ZCB1bmluc3RhbGwgLS1jb250cm9sLXBsYW5lYCB8CgojIyBTdGVwcyBmb3IgYGRvd24gLS1jbHVz" + - "dGVyLW5hbWU9WGAgKHRoZSBzdGFuZGFyZCBwYXRoKQoKIyMjIDEuIENvbmZpcm0gc2NvcGUgd2l0" + - "aCB0aGUgdXNlcgoKQXNrOgoKLSAiVGVhcmluZyBkb3duIGV2ZXJ5dGhpbmcgKGNvbXB1dGUgcGxh" + - "bmVzICsgY29udHJvbCBwbGFuZSArIElDTVMgcm93cykgb3IganVzdCBvbmUgY29tcHV0ZSBwbGFu" + - "ZT8iCi0gIkFyZSB0aGVyZSBBQ1RJVkUgZnVuY3Rpb24gZGVwbG95bWVudHMgb24gdGhpcyBjbHVz" + - "dGVyPyBgbnZjZi1jbGkgZnVuY3Rpb24gZGVwbG95IGxpc3RgIHRvIGNoZWNrLiIgKEltcG9ydGFu" + - "dCDigJQgYGRvd25gIHdpbGwgcmVmdXNlIHdpdGhvdXQgYC0tZHJhaW4tYWN0aXZlPXRydWV8cHJv" + - "bXB0YCBpZiBhbnkgYXJlIEFDVElWRS4pCi0gIlNob3VsZCB3ZSB3aXBlIHBlcnNpc3RlbnQgc3Rh" + - "dGUgKENhc3NhbmRyYSBkYXRhLCBPcGVuQmFvIHNlYWwga2V5cywgc3ItZGVmYXVsdCB1c2VyIGRh" + - "dGEpPyBEZWZhdWx0IGlzIHRvICoqcHJlc2VydmUqKiB0aGVtLiBgLS1yZW1vdmUtcGVyc2lzdGVu" + - "dGAgb3B0cyBpbiB0byBkZWxldGlvbi4gKipMb3NzIGlzIHVucmVjb3ZlcmFibGUuKioiCgojIyMg" + - "Mi4gUnVuIHBsYW4tb25seSBmaXJzdAoKYGBgc2gKbnZjZi1jbGkgc2VsZi1ob3N0ZWQgZG93biAt" + - "LXBsYW4tb25seSAtLWNsdXN0ZXItbmFtZT0kTkFNRSAtLWpzb24gfCBqcQpgYGAKClRoaXMgZW1p" + - "dHMgYSBgcGxhbm5lZGAgZXZlbnQgbGlzdGluZyBlYWNoIHBoYXNlLCB0aGUgaGVsbSByZWxlYXNl" + - "cyB0aGF0IHdvdWxkIGJlIHVuaW5zdGFsbGVkLCB0aGUgSUNNUyByb3dzIHRoYXQgd291bGQgYmUg" + - "cmVtb3ZlZCwgYW5kIEVUQXMuIENvcHkgdGhlIGB3aWxsVW5pbnN0YWxsLmNvbW1hbmRzW11gIGFy" + - "cmF5IHRvIHRoZSB1c2VyIOKAlCB0aG9zZSBhcmUgdGhlIGxpdGVyYWwgYGhlbG0gdW5pbnN0YWxs" + - "IOKApmAgaW52b2NhdGlvbnMgdGhlIG9yY2hlc3RyYXRvciB3aWxsIHJ1bi4KCiMjIyAzLiBFeGVj" + - "dXRlIHRoZSBkb3duCgpgYGBzaApudmNmLWNsaSBzZWxmLWhvc3RlZCBkb3duIC0tY2x1c3Rlci1u" + - "YW1lPSROQU1FIC0tanNvbgpgYGAKClBoYXNlcyAoMS03LCBtaXJyb3JzIMKnNi43LjEpOgoKMS4g" + - "UHJlZmxpZ2h0IOKAlCBrdWJlY3RsIGFjY2VzcyArIElDTVMgcmVhY2hhYmxlCjIuIFJlc29sdmUg" + - "c3RhY2sgYnVuZGxlCjMuIERyYWluIGFjdGl2ZSBkZXBsb3ltZW50cyDigJQgcnVucyBgZnVuY3Rp" + - "b24gZGVwbG95IHJlbW92ZWAgZm9yIGVhY2ggQUNUSVZFIGRlcGxveW1lbnQsIHBvbGxzIHVudGls" + - "IFNUT1BQRUQuIFN1YmplY3QgdG8gYC0tZHJhaW4tYWN0aXZlPXRydWV8ZmFsc2V8cHJvbXB0YCAo" + - "ZGVmYXVsdCBpcyBgcHJvbXB0YCBpbiBUVFkpLgo0LiBVbmluc3RhbGwgY29tcHV0ZSBwbGFuZSDi" + - "gJQgZGVsZWdhdGVzIHRvIGB1bmluc3RhbGwgLS1jb21wdXRlLXBsYW5lYDsgYGhlbG0gdW5pbnN0" + - "YWxsIDxyZWxlYXNlPmAgcGVyIHJlbGVhc2UgaW4gcmV2ZXJzZS1EQUcgb3JkZXIKNS4gUmVtb3Zl" + - "IHBlcnNpc3RlbnQgKHNraXBwZWQgdW5sZXNzIGAtLXJlbW92ZS1wZXJzaXN0ZW50YCkKNi4gVW5y" + - "ZWdpc3RlciBjbHVzdGVyIGZyb20gSUNNUwo3LiBGaW5hbCB2ZXJpZnkKCiMjIyA0LiBUZWFyIGRv" + - "d24gdGhlIGNvbnRyb2wgcGxhbmUgKGlmIGFwcGxpY2FibGUpCgpgYGBzaApudmNmLWNsaSBzZWxm" + - "LWhvc3RlZCB1bmluc3RhbGwgLS1jb250cm9sLXBsYW5lCmBgYAoKT3IgdmlhIHRoZSBvcmNoZXN0" + - "cmF0b3IncyBgLS1hbGxgIG1vZGUgaWYgZXZlcnkgY29tcHV0ZSBwbGFuZSBpcyBhbHNvIGdvaW5n" + - "IGF3YXkgKHNlZSBzdGVwIDUpLgoKYHVuaW5zdGFsbCAtLWNvbnRyb2wtcGxhbmVgIHJlZnVzZXMg" + - "aWYgYW55IGNvbXB1dGUgcGxhbmUgaXMgc3RpbGwgcmVnaXN0ZXJlZCBpbiBJQ01TLiBVc2UgYC0t" + - "Zm9yY2Utd2l0aC1yZWdpc3RlcmVkLWNsdXN0ZXJzYCB0byBvdmVycmlkZSAqKm9ubHkqKiBpZiB0" + - "aGUgdXNlciBleHBsaWNpdGx5IHdhbnRzIHRvIG9ycGhhbiBjb21wdXRlIHBsYW5lcyAoUFNBVCBh" + - "dXRoIGJyZWFrcyBmb3IgdGhvc2UgY2x1c3RlcnMg4oCUIHRoZXkgd2lsbCBzdGFydCBmYWlsaW5n" + - "IGV2ZXJ5IGhlYWx0aCBjaGVjaykuIEFsd2F5cyBzdGF0ZSB0aGUgY29uc2VxdWVuY2UgYmVmb3Jl" + - "IHBhc3NpbmcgdGhlIGZsYWcuCgojIyMgNS4gT3IsIG51a2UgZXZlcnl0aGluZwoKYGBgc2gKbnZj" + - "Zi1jbGkgc2VsZi1ob3N0ZWQgZG93biAtLWFsbCAtLWNvbmZpcm0KYGBgCgpUaGlzIGl0ZXJhdGVz" + - "IGV2ZXJ5IHJlZ2lzdGVyZWQgY29tcHV0ZSBwbGFuZSBpbiBJQ01TLCBydW5zIHRoZSBwZXItY2x1" + - "c3RlciBkb3duIGZvciBlYWNoIGluIHBhcmFsbGVsIChib3VuZGVkIGJ5IGAtLWFsbC1jb25jdXJy" + - "ZW5jeWAsIGRlZmF1bHQgNCksIHRoZW4gcnVucyBgdW5pbnN0YWxsIC0tY29udHJvbC1wbGFuZWAu" + - "IEludGVyYWN0aXZlIG1vZGUgcHJvbXB0cyB0aGUgb3BlcmF0b3Igd2l0aCB0aGUgY2x1c3RlciBs" + - "aXN0IGJlZm9yZSBwcm9jZWVkaW5nOyBgLS1jb25maXJtYCBza2lwcyB0aGUgcHJvbXB0IGZvciBu" + - "b24taW50ZXJhY3RpdmUgdXNlLgoKIyMgR2l0T3BzIHBhdGggKGlmIHVzZXIgaGFzIEFyZ28gLyBG" + - "bHV4KQoKYGBgc2gKIyBSZW5kZXIgZGVsZXRlIFlBTUwgZm9yIHRoZSBjb21wdXRlIHBsYW5lOgpu" + - "dmNmLWNsaSBzZWxmLWhvc3RlZCB1bmluc3RhbGwgLS1uby1hcHBseSAtLWNvbXB1dGUtcGxhbmUg" + - "LS1jbHVzdGVyLW5hbWU9JE5BTUUgPiBkZWxldGUueWFtbAoKIyBPcGVyYXRvciBjb21taXRzIGRl" + - "bGV0ZS55YW1sIHRvIHRoZWlyIEdpdE9wcyByZXBvLiBBcmdvL0ZsdXggYXBwbGllcyB0aGUgZGVs" + - "ZXRpb24gb24gbmV4dCBzeW5jLgpnaXQgYWRkIGRlbGV0ZS55YW1sOyBnaXQgY29tbWl0IC1tICJy" + - "ZW1vdmUgY29tcHV0ZSBwbGFuZSAkTkFNRSI7IGdpdCBwdXNoCgojIEFmdGVyIEFyZ28gc3luYyBy" + - "ZW1vdmVzIHRoZSBLOHMgcmVzb3VyY2VzLCBzZXBhcmF0ZWx5IHJlbW92ZSB0aGUgSUNNUyByb3c6" + - "Cm52Y2YtY2xpIGNsdXN0ZXIgZGVsZXRlIC0tY2x1c3Rlci1pZD0kQ0xVU1RFUl9JRApgYGAKCk5v" + - "dGU6IGAtLW5vLWFwcGx5YCBkb2VzIE5PVCBpbmNsdWRlIHRoZSBJQ01TIGBjbHVzdGVyIGRlbGV0" + - "ZWAgKGhlbG0gZG9lc24ndCBtYW5hZ2UgdGhhdCkuIE9wZXJhdG9ycyBmb2xsb3dpbmcgR2l0T3Bz" + - "IG11c3QgcnVuIGBudmNmLWNsaSBjbHVzdGVyIGRlbGV0ZWAgc2VwYXJhdGVseSwgb3IgdXNlIGBk" + - "b3duYCB0byBvcmNoZXN0cmF0ZSBib3RoLgoKIyMgRmFpbHVyZSByZWNvdmVyeQoKSWYgYGRvd25g" + - "IHBhcnRpYWwtZmFpbHMgbWlkLXdheSwgKipyZS1ydW4gdGhlIHNhbWUgY29tbWFuZCoqLiBUaGUg" + - "b3JjaGVzdHJhdG9yIGlzIGlkZW1wb3RlbnQ6IGBoZWxtIHVuaW5zdGFsbGAgc2tpcHMgcmVsZWFz" + - "ZXMgdGhhdCBhcmUgYWxyZWFkeSBnb25lLCBhbmQgYGNsdXN0ZXIgZGVsZXRlIC0taWdub3JlLW1p" + - "c3NpbmdgIHNraXBzIElDTVMgcm93cyB0aGF0IGFyZSBhbHJlYWR5IGdvbmUuIFBoYXNlIDQgKyA2" + - "IGVtaXQgYHBoYXNlOiBza2lwcGVkIChhbHJlYWR5IGNsZWFuKWAgb24gdGhlIHNlY29uZCBydW4u" + - "CgpJZiBhIGBwaGFzZV9mYWlsZWRgIGV2ZW50IHN1cmZhY2VzOgoKLSBgZXJyQ2F0ZWdvcnk6ICJo" + - "ZWxtX3BlbmRpbmdfdXBncmFkZSIsIHJldHJ5Q2xhc3M6ICJhZnRlcl9yZW1lZGlhdGlvbiJgIOKG" + - "kiBydW4gYGhlbG0gcm9sbGJhY2sgPHJlbGVhc2U+IDAgLS1rdWJlLWNvbnRleHQ94oCmYCAodGhl" + - "IG1lc3NhZ2UgaW5jbHVkZXMgdGhlIGV4YWN0IHJlbGVhc2UgbmFtZSkgdGhlbiByZS1ydW4gYGRv" + - "d25gLiBEbyBOT1QgYXV0by1yb2xsYmFjayB3aXRob3V0IHVzZXIgY29uZmlybWF0aW9uLgotIGBl" + - "cnJDYXRlZ29yeTogImNvbXB1dGVfcGxhbmUiLCByZXRyeUNsYXNzOiAiYWZ0ZXJfcmVtZWRpYXRp" + - "b24iYCB3aXRoIG1lc3NhZ2UgYWJvdXQgQUNUSVZFIGRlcGxveW1lbnRzIOKGkiB1c2VyIHBhc3Nl" + - "ZCBgLS1kcmFpbi1hY3RpdmU9ZmFsc2VgIGJ1dCBkZXBsb3ltZW50cyBleGlzdC4gUmUtcnVuIHdp" + - "dGggYC0tZHJhaW4tYWN0aXZlPXRydWVgIG9yIGAtLWRyYWluLWFjdGl2ZT1wcm9tcHRgIGFuZCBj" + - "b25maXJtIHdpdGggdGhlIHVzZXIuCi0gT3RoZXIgY2F0ZWdvcmllcyDihpIgc3VyZmFjZSBgZXJy" + - "TWVzc2FnZWAgKyBlYWNoIGByZW1lZGlhdGlvbmAgc3RyaW5nIHZlcmJhdGltLiBTVE9QLiBEbyBu" + - "b3QgcHJvcG9zZSByZXRyeSB3aXRob3V0IHVzZXIgaW5wdXQuCgojIyBBbHdheXMgY29uZmlybQoK" + - "Rm9yIGV2ZXJ5IGBudmNmLWNsaSBzZWxmLWhvc3RlZCBkb3duYCBvciBgdW5pbnN0YWxsYCBpbnZv" + - "Y2F0aW9uLCBlc3BlY2lhbGx5IHdpdGggZGVzdHJ1Y3RpdmUgZmxhZ3M6CgotIFN0YXRlIHRoZSBl" + - "eGFjdCBgLS1jbHVzdGVyLW5hbWVgIChvciBgLS1hbGxgKS4KLSBTdGF0ZSB3aGF0J3MgbG9zdCAo" + - "ZS5nLiAiYWxsIGZ1bmN0aW9uIGRlcGxveW1lbnRzIHNjaGVkdWxlZCBvbiBgbmNwLWxvY2FsYCB3" + - "aWxsIHN0b3A7IGNvbXB1dGUgcGxhbmUgd2lsbCBkZXJlZ2lzdGVyIGZyb20gSUNNUyIpLgotIElm" + - "IGAtLXJlbW92ZS1wZXJzaXN0ZW50YCBpcyBiZWluZyBjb25zaWRlcmVkLCBzdGF0ZSB0aGF0IENh" + - "c3NhbmRyYSByb3dzICsgT3BlbkJhbyBzZWFsIGtleXMgd2lsbCBiZSBkZWxldGVkIGFuZCB0aGF0" + - "IHRoaXMgaXMgdW5yZWNvdmVyYWJsZS4KLSBXYWl0IGZvciBleHBsaWNpdCB1c2VyIGNvbmZpcm1h" + - "dGlvbi4KLSBOZXZlciBwcm9wb3NlIGAtLWZvcmNlLXdpdGgtcmVnaXN0ZXJlZC1jbHVzdGVyc2Ag" + - "d2l0aG91dCBleHBsYWluaW5nIHRoYXQgY29tcHV0ZSBwbGFuZXMgd2lsbCBiZSBvcnBoYW5lZC4K" + - "LSBOZXZlciBwcm9wb3NlIGAtLXJlbW92ZS1wZXJzaXN0ZW50YCB3aXRob3V0IGV4cGxhaW5pbmcg" + - "dGhlIGxvc3MuCgojIyBNYW51YWwgZmFsbGJhY2sgKGlmIGBudmNmLWNsaSBzZWxmLWhvc3RlZCB7" + - "dW5pbnN0YWxsLGRvd259YCBpcyB1bmF2YWlsYWJsZSkKCklmIHRoZSB1c2VyIGlzIG9uIGEgQ0xJ" + - "IHZlcnNpb24gb2xkZXIgdGhhbiBNKzExLCBmYWxsIGJhY2sgdG86CgpgYGBzaAojIFBlciBjb21w" + - "dXRlIHBsYW5lIChvbiBpdHMga3ViZWN0bCBjb250ZXh0KToKaGVsbSB1bmluc3RhbGwgbnZjYS1h" + - "Z2VudCAtbiBudmNhLXN5c3RlbQpoZWxtIHVuaW5zdGFsbCBudmNhLW9wZXJhdG9yIC1uIG52Y2Et" + - "b3BlcmF0b3IKaGVsbSB1bmluc3RhbGwgaW1hZ2UtY3JlZGVudGlhbC1oZWxwZXIgLW4gbnZjYS1z" + - "eXN0ZW0KaGVsbSB1bmluc3RhbGwgc2hhcmVkc3RvcmFnZSAtbiBudmNhLXN5c3RlbQoKIyBEcmFp" + - "biB0aGUgSUNNUyByb3c6Cm52Y2YtY2xpIGNsdXN0ZXIgZGVsZXRlIC0tY2x1c3Rlci1pZD08aWQ+" + - "CgojIENvbnRyb2wgcGxhbmUgdGVhcmRvd24gKG9uIGl0cyBrdWJlY3RsIGNvbnRleHQpOgpjZCBu" + - "dmNmLXNlbGYtbWFuYWdlZC1zdGFjawptYWtlIGRlc3Ryb3kgICAjIGludm9rZXMgaGVsbWZpbGUg" + - "ZGVzdHJveSBhY3Jvc3MgYWxsIHJlbGVhc2VzCgojIElmIHVzaW5nIHNwbGl0IHN0YWNrIGJ1bmRs" + - "ZXMsIHRlYXJkb3duIGNvbXB1dGUgcGxhbmUgcmVsZWFzZXMgdG9vOgpjZCAuLi9udmNmLWNvbXB1" + - "dGUtcGxhbmUtc3RhY2sKbWFrZSBkZXN0cm95CmBgYAoKVGhpcyBpcyB0aGUgbWFudWFsIHBhdGg7" + - "IHByZWZlciB0aGUgbmV3IHN1YmNvbW1hbmRzIHdoZW5ldmVyIHRoZXkncmUgaW5zdGFsbGVkLgoK" + - "IyMgV2hhdCBga3ViZWN0bCBkZWxldGUgcHZjYCBkb2VzCgpJZiB0aGUgdXNlciBhc2tzICJ3aHkg" + - "aXMgbXkgbmV3IGluc3RhbGwgc2VlaW5nIHRoZSBvbGQgZGF0YT8iIOKAlCB0aGUgU3RhdGVmdWxT" + - "ZXQgUFZDcyAoQ2Fzc2FuZHJhLCBPcGVuQmFvKSBhcmUgTk9UIGRlbGV0ZWQgYnkgYGhlbG0gdW5p" + - "bnN0YWxsYCAoYW5kIHNvIGFyZSBOT1QgZGVsZXRlZCBieSBgbnZjZi1jbGkgc2VsZi1ob3N0ZWQg" + - "ZG93bmAgZWl0aGVyLCBieSBkZWZhdWx0KS4gVGhleSBwZXJzaXN0IGludGVudGlvbmFsbHkgc28g" + - "YSByZS1gdXBgIGNhbiByZXN1bWUgYWdhaW5zdCBleGlzdGluZyBkYXRhLiBUbyB3aXBlIGNvbXBs" + - "ZXRlbHkgb24gdGhlIG5leHQgYGRvd25gOgoKYGBgc2gKbnZjZi1jbGkgc2VsZi1ob3N0ZWQgZG93" + - "biAtLWNsdXN0ZXItbmFtZT1uY3AtbG9jYWwgLS1yZW1vdmUtcGVyc2lzdGVudApgYGAKCk9yLCBw" + - "b3N0LWRvd24sIG1hbnVhbGx5OgoKYGBgc2gKa3ViZWN0bCBkZWxldGUgcHZjIC1uIGNhc3NhbmRy" + - "YS1zeXN0ZW0gLS1hbGwKa3ViZWN0bCBkZWxldGUgcHZjIC1uIHZhdWx0LXN5c3RlbSAtLWFsbApg" + - "YGAKCioqQ29uZmlybSB3aXRoIHVzZXIuKiogVGhpcyBpcyB0aGUgZGVzdHJ1Y3RpdmUgc3RlcC4K" + - "UEsHCNqHY1bkIQAA5CEAAFBLAwQUAAgAAAAAACEAAAAAAAAAAAAAAAAAMAAJAGRhdGEvbnZjZi1z" + - "ZWxmLW1hbmFnZWQtY2xpL3JlZmVyZW5jZS9jb21tYW5kcy5tZFVUBQABAKbOEiMgYG52Y2YtY2xp" + - "YCBjb21tYW5kIHJlZmVyZW5jZQoKRnVsbCBzdWJjb21tYW5kIGxpc3QuIEFsd2F5cyBwYWlyIHdp" + - "dGggW2ZsYWdzLm1kXShmbGFncy5tZCkgZm9yIGdsb2JhbCBmbGFncyBhbmQgW2V4aXQtY29kZXMu" + - "bWRdKGV4aXQtY29kZXMubWQpIGZvciByZXR1cm4gY29kZXMuCgojIyBTZWxmLWhvc3RlZCBsaWZl" + - "Y3ljbGUKCnwgQ29tbWFuZCB8IFB1cnBvc2UgfCBPdXRwdXQgfCBOb3RlcyB8CnwtLS18LS0tfC0t" + - "LXwtLS18CnwgYHNlbGYtaG9zdGVkIGNoZWNrIC0tcHJlIFtmbGFnc11gIHwgUHJlLWZsaWdodCB2" + - "YWxpZGF0aW9uIChubyBhZG1pbiBjcmVkcykgfCBzdHJlYW1pbmcgZXZlbnRzIHwgUGVyLXJvbGUg" + - "d2l0aCBgLS1jb250cm9sLXBsYW5lLWNvbnRleHRgIC8gYC0tY29tcHV0ZS1wbGFuZS1jb250ZXh0" + - "YDsgYC0tbG9jYWwtb25seWAgc2tpcHMga3ViZWN0bCB8CnwgYHNlbGYtaG9zdGVkIGNoZWNrIC0t" + - "Y29udHJvbC1wbGFuZSBbLS13YWl0IERVUl1gIHwgRXZlcnkgQ1AgcmVsZWFzZSBoZWFsdGh5IHwg" + - "c3RyZWFtaW5nIGV2ZW50cyB8IFBvbGxzIHVudGlsIHBhc3Mgb3IgYC0td2FpdGAgZWxhcHNlcyB8" + - "CnwgYHNlbGYtaG9zdGVkIGNoZWNrIC0tY29tcHV0ZS1wbGFuZSAtLWNsdXN0ZXItbmFtZT1YIFst" + - "LXdhaXQgRFVSXWAgfCBQZXItY2x1c3RlciB3b3JrZXIgc3RhdGUgfCBzdHJlYW1pbmcgZXZlbnRz" + - "IHwgTlZDQSArIG9wZXJhdG9yICsgSldLUy1maW5nZXJwcmludCBtYXRjaCB8CnwgYHNlbGYtaG9z" + - "dGVkIGNoZWNrIC0tYWxsIFstLXdhaXQgRFVSXWAgfCBGYW4tb3V0IG92ZXIgYWxsIG9mIHRoZSBh" + - "Ym92ZSB8IHN0cmVhbWluZyBldmVudHMgfCB8CnwgYHNlbGYtaG9zdGVkIGluc3RhbGwgLS1jb250" + - "cm9sLXBsYW5lYCB8IFJlbmRlciBjb250cm9sLXBsYW5lIG1hbmlmZXN0cyB8IFlBTUwgb24gc3Rk" + - "b3V0IHwgUGlwZSB0byBga3ViZWN0bCBhcHBseSAtZiAtYCB8CnwgYHNlbGYtaG9zdGVkIGluc3Rh" + - "bGwgLS1jb21wdXRlLXBsYW5lIC0tY2x1c3Rlci1uYW1lPVhgIHwgUmVnaXN0ZXIgY2x1c3RlciAr" + - "IHJlbmRlciBjb21wdXRlIG1hbmlmZXN0cyB8IFlBTUwgb24gc3Rkb3V0IHwgT25lIGludm9jYXRp" + - "b24gcGVyIEdQVSBjbHVzdGVyIHwKfCBgc2VsZi1ob3N0ZWQgdXAgLS1jbHVzdGVyLW5hbWU9WGAg" + - "fCBPbmUtc2hvdCBmaXJzdCBpbnN0YWxsIChib3RoIHBsYW5lcykgfCAoZW1wdHkpIHN0ZG91dCwg" + - "cHJvZ3Jlc3Mgb24gc3RkZXJyIHwgQWx3YXlzIGluc3RhbGxzIGJvdGggcGxhbmVzOyBmb3IgY29t" + - "cHV0ZS1vbmx5IHVzZSBgYWRkLWNvbXB1dGUtcGxhbmVgIHwKfCBgc2VsZi1ob3N0ZWQgdXAgLS1w" + - "bGFuLW9ubHkgLS1jbHVzdGVyLW5hbWU9WGAgfCBEcnktcnVuIHByZXZpZXcgfCBKU09OTCBvbiBz" + - "dGRlcnIgfCBBZ2VudC1mcmllbmRseSwgbm8gY2x1c3RlciBjaGFuZ2VzIHwKfCBgc2VsZi1ob3N0" + - "ZWQgYWRkLWNvbXB1dGUtcGxhbmUgLS1jbHVzdGVyLW5hbWU9WCAtLWNvbXB1dGUtcGxhbmUtY29u" + - "dGV4dD1ZIC0taWNtcy11cmw9VVJMIC0tdG9rZW49JEpXVGAgfCBBZGQgYSBjb21wdXRlIHBsYW5l" + - "IHRvIGFuIGV4aXN0aW5nIGNvbnRyb2wgcGxhbmUgfCBwcm9ncmVzcyBvbiBzdGRlcnIgfCBGb3Ig" + - "Mm5kLCAzcmQsIOKApiBHUFUgY2x1c3RlciBhZnRlciB0aGUgZmlyc3QgaW5zdGFsbC4gQ1Agbm90" + - "IHRvdWNoZWQuIHwKfCBgc2VsZi1ob3N0ZWQgc3RhdHVzIFstLWNsdXN0ZXItbmFtZT1YXSBbLS13" + - "YXRjaF1gIHwgU25hcHNob3QgY2x1c3RlciBoZWFsdGggfCBUVFkvcGxhaW4vSlNPTkwgfCBgLS13" + - "YXRjaGAgZm9yIGxpdmUgcmUtcmVuZGVyIHwKfCBgc2VsZi1ob3N0ZWQgdW5pbnN0YWxsIC0tY29t" + - "cHV0ZS1wbGFuZSAtLWNsdXN0ZXItbmFtZT1YYCB8IFBlci1wbGFuZSBwcmltaXRpdmU6IGBoZWxt" + - "ZmlsZSBkZXN0cm95YCBvbiBvbmUgY29tcHV0ZSBwbGFuZSAobm8gSUNNUyB1bnJlZ2lzdGVyLCBu" + - "byBkcmFpbikgfCBwcm9ncmVzcyB8IEp1c3QgdGhlIGhlbG0gcmVsZWFzZXMgb24gdGhhdCBjb21w" + - "dXRlIHBsYW5lIHwKfCBgc2VsZi1ob3N0ZWQgdW5pbnN0YWxsIC0tY29udHJvbC1wbGFuZSBbLS1m" + - "b3JjZS13aXRoLXJlZ2lzdGVyZWQtY2x1c3RlcnNdYCB8IFBlci1wbGFuZSBwcmltaXRpdmU6IGBo" + - "ZWxtZmlsZSBkZXN0cm95YCBvbiB0aGUgY29udHJvbCBwbGFuZSB8IHByb2dyZXNzIHwgUmVmdXNl" + - "cyBpZiBjb21wdXRlIHBsYW5lcyBzdGlsbCByZWdpc3RlcmVkIHVubGVzcyBgLS1mb3JjZS13aXRo" + - "LXJlZ2lzdGVyZWQtY2x1c3RlcnNgIHwKfCBgc2VsZi1ob3N0ZWQgdW5pbnN0YWxsIC0tbm8tYXBw" + - "bHkgPHBsYW5lPmAgfCBSZW5kZXIgZGVsZXRlIFlBTUwgdmlhIGBoZWxtIGdldCBtYW5pZmVzdGAg" + - "cGVyIHJlbGVhc2UgfCBZQU1MIG9uIHN0ZG91dCB8IEdpdE9wczsgcGlwZSB0byBga3ViZWN0bCBk" + - "ZWxldGUgLWYgLWAgb3IgY29tbWl0ICsgQXJnbyBhcHBsaWVzLiBNaXJyb3JzIGBpbnN0YWxsIC0t" + - "bm8tYXBwbHlgIHwKfCBgc2VsZi1ob3N0ZWQgZG93biAtLWNsdXN0ZXItbmFtZT1YIFstLWRyYWlu" + - "LWFjdGl2ZV0gWy0tcmVtb3ZlLXBlcnNpc3RlbnRdYCB8IE9yY2hlc3RyYXRvcjogZHJhaW4g4oaS" + - "IHVuaW5zdGFsbCAtLWNvbXB1dGUtcGxhbmUg4oaSIGNsdXN0ZXIgZGVsZXRlIGluIElDTVMgfCBw" + - "cm9ncmVzcyB8IFN5bW1ldHJpYyBjb21wYW5pb24gdG8gYHVwIC0tY2x1c3Rlci1uYW1lPVhgIHwK" + - "fCBgc2VsZi1ob3N0ZWQgZG93biAtLWFsbCBbLS1jb25maXJtXWAgfCBPcmNoZXN0cmF0b3I6IHRl" + - "YXIgZG93biBldmVyeSByZWdpc3RlcmVkIGNvbXB1dGUgcGxhbmUgKyBjb250cm9sIHBsYW5lIHwg" + - "cHJvZ3Jlc3MgfCBCb3VuZGVkIHBhcmFsbGVsaXNtIHZpYSBgLS1hbGwtY29uY3VycmVuY3lgIChk" + - "ZWZhdWx0IDQpOyBgLS1jb25maXJtYCByZXF1aXJlZCBpbiBub24taW50ZXJhY3RpdmUgfAp8IGBz" + - "ZWxmLWhvc3RlZCBkb3duIC0tcGxhbi1vbmx5IC4uLmAgfCBPcmNoZXN0cmF0b3IgZHJ5LXJ1bjog" + - "cGhhc2VzICsgaGVsbSByZWxlYXNlcyArIElDTVMgcm93cyArIEVUQXMgKG5vIGhlbG0vaGVsbWZp" + - "bGUgY29udGFjdCkgfCBKU09OTCB8ICoqQWx3YXlzIHJ1biBiZWZvcmUgYW55IGFjdHVhbCBgZG93" + - "bmAqKiB8CgojIyBDbHVzdGVyIG1hbmFnZW1lbnQKCnwgQ29tbWFuZCB8IFB1cnBvc2UgfCBOb3Rl" + - "cyB8CnwtLS18LS0tfC0tLXwKfCBgY2x1c3RlciByZWdpc3RlciAtLW5hbWU9WCAtLW5jYS1pZD1Z" + - "IFstLXJlZ2lvbj1aXSBbLS1pZ25vcmUtZXhpc3RpbmddYCB8IFJlZ2lzdGVyIGEgSldLUytPSURD" + - "IHdpdGggSUNNUyB8IFVzZWQgYnkgYHVwYCBQaGFzZSA1IGludGVybmFsbHk7IHN0YW5kYWxvbmUg" + - "Zm9yIG1hbnVhbCByZWdpc3RyYXRpb24gfAp8IGBjbHVzdGVyIGxpc3QtcmVnaXN0ZXJlZCAtLW5j" + - "YS1pZD1ZIFstLWljbXMtdXJsPVVSTF1gIHwgTGlzdCBzZWxmLWhvc3RlZCBjbHVzdGVyIHJlZ2lz" + - "dHJhdGlvbnMgZnJvbSBJQ01TIHwgVXNlcyB0aGUgYWRtaW4gdG9rZW47IGAtLWpzb25gIGZvciBt" + - "YWNoaW5lLXJlYWRhYmxlIG91dHB1dCB8CnwgYGNsdXN0ZXIgcm90YXRlIC0tY2x1c3Rlci1pZD1J" + - "RGAgfCBSZS1mZXRjaCBKV0tTIGZyb20gSzhzIGFuZCBQVVQgdG8gSUNNUyB8IEFmdGVyIEs4cyBB" + - "UEkgc2VydmVyIHNpZ25pbmcga2V5IHJvdGF0aW9uIHwKfCBgY2x1c3RlciBkZWxldGUgLS1jbHVz" + - "dGVyLWlkPUlEYCB8IFJlbW92ZSBJQ01TIHJvdyB8ICoqREVTVFJVQ1RJVkU6IGNvbmZpcm0gd2l0" + - "aCB1c2VyKiogfAoKIyMgRnVuY3Rpb25zCgp8IENvbW1hbmQgfCBQdXJwb3NlIHwgTm90ZXMgfAp8" + - "LS0tfC0tLXwtLS18CnwgYGZ1bmN0aW9uIGNyZWF0ZSAtLWlucHV0LWZpbGU9RklMRWAgfCBDcmVh" + - "dGUgZnVuY3Rpb24gbWV0YWRhdGEgfCBSZXR1cm5zIEZ1bmN0aW9uIElEICsgVmVyc2lvbiBJRDsg" + - "TExNIGZ1bmN0aW9ucyB1c2UgYGZ1bmN0aW9uVHlwZTogIkxMTSJgIGFuZCBgbW9kZWxzW10ubGxt" + - "Q29uZmlnLnVyaXNgIGZvciBgL3YxL2NoYXQvY29tcGxldGlvbnNgLCBgL3YxL3Jlc3BvbnNlc2As" + - "IGFuZCBgL3YxL2VtYmVkZGluZ3NgIHwKfCBgZnVuY3Rpb24gbGlzdGAgLyBgZnVuY3Rpb24gbGlz" + - "dC1pZHNgIHwgTGlzdCBmdW5jdGlvbnMgLyBJRHMgb25seSB8IHwKfCBgZnVuY3Rpb24gZ2V0IC0t" + - "ZnVuY3Rpb24taWQ9SUQgLS12ZXJzaW9uLWlkPVZJRGAgfCBGdW5jdGlvbiBtZXRhZGF0YSB8IHwK" + - "fCBgZnVuY3Rpb24gdXBkYXRlIC0tZnVuY3Rpb24taWQ9SUQgLS12ZXJzaW9uLWlkPVZJRCAtLXRh" + - "Z3M9VEFHWyxUQUddYCB8IFVwZGF0ZSBmdW5jdGlvbiB0YWdzIHwgfAp8IGBmdW5jdGlvbiB1cGRh" + - "dGUgLS1mdW5jdGlvbi1pZD1JRCAtLXZlcnNpb24taWQ9VklEIC0tbGxtLW1vZGVsLXVwZGF0ZT1T" + - "UEVDYCB8IFVwZGF0ZSBMTE0gbW9kZWwgcm91dGluZyBjb25maWcgfCBgU1BFQ2AgdXNlcyBgbmFt" + - "ZT08bW9kZWw+LHJvdXRpbmdNZXRob2Q9PG1ldGhvZD4sdG9rZW5SYXRlTGltaXQ9PGxpbWl0PmA7" + - "IHRoZSByb3V0aW5nIG1ldGhvZCBmb3JtYXQgaXMgaW4gW2ZsYWdzLm1kXShmbGFncy5tZCkgfAp8" + - "IGBmdW5jdGlvbiBkZXBsb3kgY3JlYXRlIC0taW5wdXQtZmlsZT1GSUxFYCB8IFNjaGVkdWxlIGEg" + - "ZGVwbG95bWVudCB8IEJsb2NrcyB1bnRpbCBBQ1RJVkUgKGRlZmF1bHQgOTAwcykgfAp8IGBmdW5j" + - "dGlvbiBkZXBsb3kgZ2V0IC0tZnVuY3Rpb24taWQ9SUQgLS12ZXJzaW9uLWlkPVZJRCBbLS1qc29u" + - "XWAgfCBEZXBsb3ltZW50IHN0YXR1cyB8IGBmdW5jdGlvblN0YXR1czogQUNUSVZFXHxERVBMT1lJ" + - "TkdcfEVSUk9SXHxGQUlMRURgIHwKfCBgZnVuY3Rpb24gZGVwbG95IHVwZGF0ZSAtLWlucHV0LWZp" + - "bGU9RklMRWAgfCBNb2RpZnkgYSBkZXBsb3ltZW50IGluIHBsYWNlIHwgfAp8IGBmdW5jdGlvbiBk" + - "ZXBsb3kgcmVtb3ZlIC0tZnVuY3Rpb24taWQ9SUQgLS12ZXJzaW9uLWlkPVZJRGAgfCBTdG9wIGEg" + - "ZGVwbG95bWVudCwga2VlcCBmdW5jdGlvbiByZWNvcmQgfCB8CnwgYGZ1bmN0aW9uIGludm9rZSAt" + - "LWlucHV0LWZpbGU9RklMRWAgfCBDYWxsIGEgcmVndWxhciBmdW5jdGlvbiB8IFJlcXVpcmVzIEFQ" + - "SSBrZXkgKG5vdCBhZG1pbiB0b2tlbik7IExMTSBmdW5jdGlvbnMgYXJlIGludm9rZWQgdGhyb3Vn" + - "aCBgbGxtLmludm9jYXRpb24uPGRvbWFpbj5gIHdpdGggYG1vZGVsOiAiPGZ1bmN0aW9uLWlkPi88" + - "bW9kZWwtbmFtZT4iYCB8CnwgYGZ1bmN0aW9uIGRlbGV0ZSAtLWZ1bmN0aW9uLWlkPUlEIC0tdmVy" + - "c2lvbi1pZD1WSURgIHwgUmVtb3ZlIGZ1bmN0aW9uICsgZGVwbG95bWVudCB8ICoqREVTVFJVQ1RJ" + - "VkU6IGNvbmZpcm0gd2l0aCB1c2VyKiogfAoKIyMgVGFza3MKClRhc2sgY29tbWFuZHMgcmVxdWly" + - "ZSBgTlZDRl9BUElfS0VZYCBzZXQgdG8gYSB0YXNrLXNjb3BlZCBrZXkgYW5kIGBOVkNGX0JBU0Vf" + - "TlZDVF9VUkxgIHNldCB0byB0aGUgTlZDVCBnYXRld2F5IGVuZHBvaW50LiBJbiBhIGZyZXNoIHNl" + - "c3Npb24sIHJlYWQgYG52Y3RBcGlLZXlgIGZyb20gYH4vLm52Y2YtY2xpLnN0YXRlYCBhbmQgaW5q" + - "ZWN0IGl0IGFzIGBOVkNGX0FQSV9LRVlgIHdpdGhvdXQgbG9nZ2luZyB0aGUgdmFsdWU7IGNoZWNr" + - "IGBudmN0QXBpS2V5RXhwaXJhdGlvbmAgZmlyc3QgYW5kIHJlZ2VuZXJhdGUgd2l0aCBgYXBpLWtl" + - "eSBnZW5lcmF0ZSAtLWZvciB0YXNrIC0tdmFsaWRhdGVgIGlmIGV4cGlyZWQuIFJ1biBgYXBpLWtl" + - "eSBnZW5lcmF0ZSAtLWZvciB0YXNrYCBhZnRlciBgaW5pdGAgdG8gbWludCBhIG5ldyBrZXkuCgp8" + - "IENvbW1hbmQgfCBQdXJwb3NlIHwgTm90ZXMgfAp8LS0tfC0tLXwtLS18CnwgYHRhc2sgY3JlYXRl" + - "IC0tbmFtZT1YIC0tZ3B1PUdQVSAtLWluc3RhbmNlLXR5cGU9VFlQRSAtLWltYWdlPUlNR2AgfCBT" + - "dWJtaXQgYSBjb250YWluZXIgdGFzayB8IFNhdmVzIHRhc2sgSUQgdG8gQ0xJIHN0YXRlOyB1c2Ug" + - "YC0taW5wdXQtZmlsZWAgZm9yIHJlcGVhdGFibGUgY29uZmlncyB8CnwgYHRhc2sgY3JlYXRlIC0t" + - "bmFtZT1YIC0tZ3B1PUdQVSAtLWluc3RhbmNlLXR5cGU9VFlQRSAtLWhlbG0tY2hhcnQ9Q0hBUlRg" + - "IHwgU3VibWl0IGEgSGVsbSB0YXNrIHwgYC0taGVsbS1jaGFydGAgcmVwbGFjZXMgYC0taW1hZ2Vg" + - "IHwKfCBgdGFzayBjcmVhdGUgLS1pbnB1dC1maWxlPUZJTEVgIHwgU3VibWl0IGEgdGFzayBmcm9t" + - "IGEgSlNPTiBjb25maWcgZmlsZSB8IFJlY29tbWVuZGVkIGZvciByZXBlYXRhYmxlIGNvbmZpZ3Vy" + - "YXRpb25zIHwKfCBgdGFzayBsaXN0IFstLXN0YXR1cz1TVEFUVVNdIFstLWxpbWl0PU5dYCB8IExp" + - "c3QgdGFza3MsIG9wdGlvbmFsbHkgZmlsdGVyZWQgYnkgc3RhdHVzIHwgU3RhdHVzZXM6IGBRVUVV" + - "RURgLCBgTEFVTkNIRURgLCBgUlVOTklOR2AsIGBDT01QTEVURURgLCBgRVJST1JFRGAsIGBDQU5D" + - "RUxFRGAsIGBFWENFRURFRF9NQVhfUlVOVElNRV9EVVJBVElPTmAsIGBFWENFRURFRF9NQVhfUVVF" + - "VUVEX0RVUkFUSU9OYCB8CnwgYHRhc2sgZ2V0IFt0YXNrSWRdYCB8IEdldCB0YXNrIGRldGFpbHMg" + - "fCBVc2VzIHNhdmVkIHRhc2sgSUQgZnJvbSBzdGF0ZSBpZiBvbWl0dGVkIHwKfCBgdGFzayBldmVu" + - "dHMgW3Rhc2tJZF0gWy0tbGltaXQ9Tl1gIHwgTGlzdCBsaWZlY3ljbGUgZXZlbnRzIGZvciBhIHRh" + - "c2sgfCB8CnwgYHRhc2sgcmVzdWx0cyBbdGFza0lkXWAgfCBMaXN0IHJlc3VsdCBhcnRpZmFjdHMg" + - "Zm9yIGEgY29tcGxldGVkIHRhc2sgfCBSZXR1cm5zIGVtcHR5IGxpc3Qgd2hlbiBgcmVzdWx0SGFu" + - "ZGxpbmdTdHJhdGVneWAgaXMgYE5PTkVgOyByZXN1bHQgdXBsb2FkIG5vdCB5ZXQgc3VwcG9ydGVk" + - "IHwKfCBgdGFzayBjYW5jZWwgW3Rhc2tJZF1gIHwgQ2FuY2VsIGEgcnVubmluZyB0YXNrIHwgTm8t" + - "b3AgaWYgYWxyZWFkeSBpbiB0ZXJtaW5hbCBzdGF0ZSB8CnwgYHRhc2sgZGVsZXRlIFt0YXNrSWRd" + - "YCB8IERlbGV0ZSBhIHRhc2sgcmVjb3JkIHwgKipTdG9wLCBzdGF0ZSB0YXNrIElEICsgY3VycmVu" + - "dCBzdGF0dXMsIGFuZCB3YWl0IGZvciBhIHN1YnNlcXVlbnQgdXNlciByZXBseSBleHBsaWNpdGx5" + - "IGNvbmZpcm1pbmcgZGVsZXRpb24gb2YgdGhhdCBzcGVjaWZpYyB0YXNrLiBEbyBub3QgdHJlYXQg" + - "dGhlIG9yaWdpbmFsIGRlbGV0ZSByZXF1ZXN0IGFzIGNvbmZpcm1hdGlvbi4qKiB8CnwgYHRhc2sg" + - "dXBkYXRlLXNlY3JldHMgW3Rhc2tJZF0gLS1zZWNyZXRzIE5BTUU9dmFsdWVgIHwgVXBkYXRlIHNl" + - "Y3JldHMgb24gYSB0YXNrOyBzdXBwbGllZCBzZWNyZXRzIGFyZSBhZGRlZCBvciB1cGRhdGVkIGJ5" + - "IG5hbWUsIGV4aXN0aW5nIHNlY3JldHMgbm90IGluIHRoZSByZXF1ZXN0IGFyZSBwcmVzZXJ2ZWQg" + - "fCBWYWx1ZXMgYXJlIGVuY3J5cHRlZCBhdCByZXN0IHwKfCBgdGFzayBidWxrIC0tdGFzay1pZHM9" + - "SUQxWyxJRDIs4oCmXWAgfCBGZXRjaCBkZXRhaWxzIGZvciBtdWx0aXBsZSB0YXNrcyBpbiBvbmUg" + - "cmVxdWVzdCB8IHwKCiMjIEF1dGgKCnwgQ29tbWFuZCB8IFB1cnBvc2UgfCBOb3RlcyB8CnwtLS18" + - "LS0tfC0tLXwKfCBgaW5pdGAgfCBNaW50IGFkbWluIHRva2VuIGZyb20gQVBJIEtleXMgdmlhIHB1" + - "YmxpYyBhcGkgZ2F0ZXdheSB8IElkZW1wb3RlbnQ7IHdyaXRlcyBgfi8ubnZjZi1jbGkuc3RhdGVg" + - "OyBjbGVhcnMgc2F2ZWQgZnVuY3Rpb24gc3RhdGUgYW5kIGFsbCBBUEkga2V5cyAoZnVuY3Rpb24g" + - "YW5kIHRhc2spIHwKfCBgc3RhdHVzYCB8IFNob3cgQ0xJIHN0YXRlICh0b2tlbiwgbGFzdCBlbmRw" + - "b2ludHMsIGZ1bmN0aW9uIGNvbnRleHQpIHwgRGlzdGluY3QgZnJvbSBgc2VsZi1ob3N0ZWQgc3Rh" + - "dHVzYCB8CnwgYGFwaS1rZXkgZ2VuZXJhdGUgWy0tZm9yIGZ1bmN0aW9uXHx0YXNrXSBbLS1kZXNj" + - "cmlwdGlvbj3igKZdIFstLWV4cGlyZXMtaW494oCmXWAgfCBNaW50IEFQSSBrZXlzIHwgRGVmYXVs" + - "dCAobm8gYC0tZm9yYCk6IGdlbmVyYXRlcyBib3RoIGEgZnVuY3Rpb24ga2V5IGFuZCBhIHRhc2sg" + - "a2V5OyBgLS1mb3IgZnVuY3Rpb25gIG9yIGAtLWZvciB0YXNrYCBnZW5lcmF0ZXMgb25lIG9ubHk7" + - "IGAtLXNjb3Blc2AgcmVxdWlyZXMgYC0tZm9yYCB8CnwgYGFwaS1rZXkgZ2VuZXJhdGUgLS12YWxp" + - "ZGF0ZWAgfCBNaW50IGFuZCBpbW1lZGlhdGVseSB2YWxpZGF0ZSBlYWNoIGdlbmVyYXRlZCBrZXkg" + - "fCBWYWxpZGF0ZXMgZWFjaCBrZXkgYWdhaW5zdCBpdHMgY29ycmVjdCBhdWRpZW5jZSAoTlZDRiBm" + - "b3IgZnVuY3Rpb24ga2V5cywgTlZDVCBmb3IgdGFzayBrZXlzKSB8CnwgYGFwaS1rZXkgbGlzdGAg" + - "fCBMaXN0IEFQSSBrZXlzIGZvciBjdXJyZW50IG93bmVyIHwgTGlzdHMgTlZDRi1zY29wZWQga2V5" + - "cyBvbmx5IHwKfCBgYXBpLWtleSBkZWxldGUgS0VZLUlEYCAvIGBhcGkta2V5IHJldm9rZSBLRVkt" + - "SURgIHwgUmVtb3ZlIGFuIEFQSSBrZXkgfCAqKkNvbmZpcm0gd2l0aCB1c2VyKiogfAp8IGBhcGkt" + - "a2V5IHNob3dgIHwgU2hvdyBjdXJyZW50bHkgc2F2ZWQgQVBJIGtleSBmcm9tIHN0YXRlIHwgfAoK" + - "IyMgQWdlbnQgc2tpbGwKCnwgQ29tbWFuZCB8IFB1cnBvc2UgfCBOb3RlcyB8CnwtLS18LS0tfC0t" + - "LXwKfCBgYWdlbnQtc2tpbGwgaW5zdGFsbCBbLS10YXJnZXQ9RElSXWAgfCBJbnN0YWxsIGFsbCBi" + - "dW5kbGVkIHB1YmxpYyB1c2VyIHNraWxscyBpbnRvIGB+Ly5jbGF1ZGUvc2tpbGxzL2AgYW5kIGB+" + - "Ly5hZ2VudHMvc2tpbGxzL2AgfCBEZWZhdWx0OiBib3RoIGJhc2Ugc2tpbGxzIGRpcmVjdG9yaWVz" + - "IHwKfCBgYWdlbnQtc2tpbGwgdW5pbnN0YWxsIFstLXRhcmdldD1ESVJdYCB8IFJlbW92ZSBvbmx5" + - "IGJ1bmRsZWQgcHVibGljIHVzZXIgc2tpbGxzIGZyb20gdGhlIHRhcmdldCBiYXNlIHNraWxscyBk" + - "aXJlY3RvcmllcyB8IElkZW1wb3RlbnQ7IHVucmVsYXRlZCBza2lsbHMgcmVtYWluIHwKfCBgYWdl" + - "bnQtc2tpbGwgc2hvdyBbLS1maWxlPVJFTF1gIHwgUHJpbnQgYnVuZGxlZCBgbnZjZi1zZWxmLW1h" + - "bmFnZWQtY2xpL1NLSUxMLm1kYCBvciBhbm90aGVyIGVtYmVkZGVkIGZpbGUgfCBGb3IgZGVidWdn" + - "aW5nIHwKfCBgYWdlbnQtc2tpbGwgdmVyc2lvbmAgfCBQcmludCBidWlsZCBTSEEgYW5kIGVtYmVk" + - "ZGVkIHB1YmxpYyB1c2VyIHNraWxsIHN1bW1hcnkgfCB8ClBLBwjuDZorIiMAACIjAABQSwMEFAAI" + - "AAAAAAAhAAAAAAAAAAAAAAAAADIACQBkYXRhL252Y2Ytc2VsZi1tYW5hZ2VkLWNsaS9yZWZlcmVu" + - "Y2UvZXhpdC1jb2Rlcy5tZFVUBQABAKbOEiMgYG52Y2YtY2xpYCBleGl0IGNvZGVzCgpTdGFibGUg" + - "YWNyb3NzIHN1YmNvbW1hbmRzLiBVc2UgdGhlc2UgdG8gZHJpdmUgYWdlbnQgcmV0cnkgLyBzdXJm" + - "YWNpbmcgbG9naWMuCgp8IENvZGUgfCBNZWFuaW5nIHwgRXhhbXBsZSBjYXVzZXMgfAp8LS0tfC0t" + - "LXwtLS18CnwgYDBgIHwgU3VjY2VzcyB8IEFsbCBjaGVja3MgcGFzc2VkOyBpbnN0YWxsIGNvbXBs" + - "ZXRlZDsgZGVwbG95bWVudCBBQ1RJVkUgfAp8IGAxYCB8IEdlbmVyaWMgZXJyb3IgfCBIZWxtIHJl" + - "bmRlciBmYWlsZWQ7IG5ldHdvcmsgdW5yZWFjaGFibGU7IGZpbGUgbm90IGZvdW5kOyBZQU1MIHBh" + - "cnNlIGVycm9yIHwKfCBgMmAgfCBQcmUtZmxpZ2h0IGNoZWNrIGZhaWxlZCB8IEdhdGV3YXkgQVBJ" + - "IENSRHMgbWlzc2luZzsga3ViZWN0bCBub3Qgb24gUEFUSDsgZGVmYXVsdCBTdG9yYWdlQ2xhc3Mg" + - "YWJzZW50IHwKfCBgM2AgfCBBZG1pbiBhdXRoIGZhaWxlZCB8IE5vIHRva2VuICsgYC0tbm9uLWlu" + - "dGVyYWN0aXZlYCBzZXQ7IElDTVMgcmVqZWN0ZWQgSldUOyBpbml0IGVuZHBvaW50IHVucmVhY2hh" + - "YmxlIHwKfCBgNWAgfCBNYW5pZmVzdCBhcHBseSBvciBgLS13YWl0YCB0aW1lZCBvdXQgfCBIZWxt" + - "IGluc3RhbGwgdGltZW91dDsgY2hlY2sgcG9sbGVkIGJ1dCBkaWRuJ3QgcGFzcyBiZWZvcmUgZHVy" + - "YXRpb24gfAp8IGAxMzBgIHwgQ2FuY2VsbGVkIGJ5IFNJR0lOVC9TSUdURVJNIHwgVXNlciBDdHJs" + - "LUM7IENJIGJ1ZGdldCBleGNlZWRlZDsgcG9kIGV2aWN0ZWQgfAoKIyMgSG93IGFuIGFnZW50IHNo" + - "b3VsZCByZWFjdAoKfCBDb2RlIHwgQWdlbnQgYmVoYXZpb3IgfAp8LS0tfC0tLXwKfCBgMGAgfCBD" + - "b250aW51ZSAvIHJlcG9ydCBzdWNjZXNzLiB8CnwgYDFgIHwgU3VyZmFjZSBzdGRlcnIgdG8gdXNl" + - "cjsgYXNrIGJlZm9yZSBhbnkgcmV0cnkuIEdlbmVyaWMgZXJyb3JzIHVzdWFsbHkgbmVlZCBhIGh1" + - "bWFuIHRvIHJlYWQgdGhlIG1lc3NhZ2UuIHwKfCBgMmAgfCBTdXJmYWNlIGZhaWxlZCBjaGVja3Mg" + - "KyB0aGVpciBgaGludFVSTGAuIERvbid0IHByb3Bvc2UgYHVwYCB1bnRpbCB0aGUgdXNlciBmaXhl" + - "cyBwcmVyZXFzLiB8CnwgYDNgIHwgU3VnZ2VzdCBgbnZjZi1jbGkgaW5pdGAgKGludGVyYWN0aXZl" + - "KSBPUiBgLS10b2tlbj0kSldUYCAoQ0kpLiBEb24ndCBhdXRvLW1pbnQgd2l0aG91dCB1c2VyIE9L" + - "LiB8CnwgYDVgIHwgQXNrIHVzZXIgaWYgdGhleSB3YW50IHRvIHdhaXQgbG9uZ2VyLCBkaWcgaW50" + - "byB0aGUgc3RhbGxlZCBjb21wb25lbnQsIG9yIGNhbmNlbC4gfAp8IGAxMzBgIHwgRG9uJ3QgcmV0" + - "cnkgYXV0b21hdGljYWxseTsgdXNlciBleHBsaWNpdGx5IGNhbmNlbGxlZC4gQ29uZmlybSBiZWZv" + - "cmUgcmUtcnVubmluZy4gfAoKIyMgV2hlcmUgdG8gZmluZCBtb3JlIGRldGFpbAoKRXZlcnkgbm9u" + - "LXplcm8gZXhpdCBlbWl0cyBhIHN0cnVjdHVyZWQgYHBoYXNlX2ZhaWxlZGAgSlNPTiBldmVudCB3" + - "aXRoIGBlcnJDYXRlZ29yeWAsIGBlcnJNZXNzYWdlYCwgYHJlbWVkaWF0aW9uYCAoYXJyYXkpLCBg" + - "cmV0cnlDbGFzc2AgKGVudW06IGBub25lfGltbWVkaWF0ZXxiYWNrb2ZmfGFmdGVyX3JlbWVkaWF0" + - "aW9ufHVua25vd25gKSwgYHJldHJ5QWZ0ZXJTZWNgIChpbnQsIG9wdGlvbmFsKSwgYW5kIGByYXdg" + - "IChzdWJwcm9jZXNzICsgSFRUUCArIEt1YmVybmV0ZXMgc2lnbmFsKS4gQWx3YXlzIGNvbnN1bWUg" + - "dGhlc2UgaW4gYC0tanNvbmAgbW9kZSByYXRoZXIgdGhhbiBwYXJzaW5nIEVuZ2xpc2ggZnJvbSBz" + - "dGRlcnIuClBLBwhzp1pk1QYAANUGAABQSwMEFAAIAAAAAAAhAAAAAAAAAAAAAAAAAC0ACQBkYXRh" + - "L252Y2Ytc2VsZi1tYW5hZ2VkLWNsaS9yZWZlcmVuY2UvZmxhZ3MubWRVVAUAAQCmzhIjIGBudmNm" + - "LWNsaWAgZmxhZyByZWZlcmVuY2UKCiMjIEdsb2JhbCBmbGFncyAoZXZlcnkgc3ViY29tbWFuZCkK" + - "CnwgRmxhZyB8IFB1cnBvc2UgfCBEZWZhdWx0IHwKfC0tLXwtLS18LS0tfAp8IGAtLWNvbmZpZyBG" + - "SUxFYCB8IENvbmZpZyBmaWxlIHBhdGggfCBgJEhPTUUvLm52Y2YtY2xpLnlhbWxgIHwKfCBgLS1k" + - "ZWJ1Z2AgfCBWZXJib3NlIEhUVFAgbG9nZ2luZyBvbiBzdGRlcnIgfCBgZmFsc2VgIHwKfCBgLS1q" + - "c29uYCB8IEpTT05MIGV2ZW50cyBvbiBzdGRlcnIgfCBhdXRvIChvZmYgaW4gVFRZLCBvbiB1bmRl" + - "ciBgLS1ub24taW50ZXJhY3RpdmVgKSB8CgojIyBgc2VsZi1ob3N0ZWRgIHBhcmVudCAocGVyc2lz" + - "dGVudCBhY3Jvc3Mgc3ViY29tbWFuZHMpCgp8IEZsYWcgfCBQdXJwb3NlIHwgRGVmYXVsdCB8Cnwt" + - "LS18LS0tfC0tLXwKfCBgLS1jb250cm9sLXBsYW5lLXN0YWNrPeKApmAgfCBDb250cm9sLXBsYW5l" + - "IGJ1bmRsZSBzb3VyY2U6IGxvY2FsIHBhdGgsIGdpdCBVUkwsIG9yIGBvY2k6Ly9gIFVSTCB8IGVt" + - "YmVkZGVkIE9DSSBVUkwgcGlubmVkIGJ5IENMSSB2ZXJzaW9uIHwKfCBgLS1jb21wdXRlLXBsYW5l" + - "LXN0YWNrPeKApmAgfCBDb21wdXRlLXBsYW5lIGJ1bmRsZSBzb3VyY2U6IGxvY2FsIHBhdGgsIGdp" + - "dCBVUkwsIG9yIGBvY2k6Ly9gIFVSTCB8IGVtYmVkZGVkIE9DSSBVUkwgcGlubmVkIGJ5IENMSSB2" + - "ZXJzaW9uIHwKfCBgLS1lbnY9bG9jYWxcfHByZFx84oCmYCB8IEhlbG1maWxlIGVudmlyb25tZW50" + - "IG5hbWUgfCBgbG9jYWxgIGZvciBkZXYgYnVpbGRzLCBgcHJkYCBmb3IgcmVsZWFzZXMgfAp8IGAt" + - "LW5vbi1pbnRlcmFjdGl2ZWAgfCBEaXNhYmxlIGFsbCBzdGRpbiBwcm9tcHRzIHwgYGZhbHNlYCB8" + - "CnwgYC0tdG9rZW49JEpXVGAgfCBBZG1pbiBKV1QsIG92ZXJyaWRlcyBzdG9yZWQgc2Vzc2lvbiB8" + - "IC0gfAp8IGAtLW5vLWFwcGx5YCB8IGBpbnN0YWxsYCBvbmx5IC0gZW1pdCBZQU1MLCBkbyBub3Qg" + - "a3ViZWN0bCBhcHBseSB8IGBmYWxzZWAgfAp8IGAtLW91dHB1dD10ZXh0XHxqc29uYCB8IExlZ2Fj" + - "eSBhbGlhcyBmb3IgYC0tanNvbmAgKGRlcHJlY2F0ZWQsIHJlbW92ZWQgaW4gbmV4dCBtYWpvcikg" + - "fCBgdGV4dGAgfAp8IGAtLXBsYWluYCB8IEZvcmNlIHBsYWluIHN0cmVhbWluZyBvdXRwdXQgfCBh" + - "dXRvLWRldGVjdCB8CnwgYC0td2FpdCBEVVJBVElPTmAgfCBgY2hlY2tgIG9ubHk7IGJsb2NrIHVu" + - "dGlsIHBhc3MgfCAtIHwKfCBgLS1jb250cm9sLXBsYW5lLWNvbnRleHQgQ1RYYCB8IGt1YmVjdGwg" + - "Y29udGV4dCBmb3IgY29udHJvbCBwbGFuZSAoUkVRLTIwKSB8IGN1cnJlbnQgY29udGV4dCB8Cnwg" + - "YC0tY29tcHV0ZS1wbGFuZS1jb250ZXh0IENUWGAgfCBrdWJlY3RsIGNvbnRleHQgZm9yIGNvbXB1" + - "dGUgcGxhbmUgKFJFUS0yMCkgfCBjdXJyZW50IGNvbnRleHQgfAp8IGAtLWljbXMtdXJsIFVSTGAg" + - "fCBQdWJsaWMgSUNNUyBVUkw7IHJlcXVpcmVkIHdoZW4gY29udGV4dHMgZGlmZmVyIHwgZGVyaXZl" + - "ZCBmcm9tIGBiYXNlX2h0dHBfdXJsYCB8CnwgYC0tbG9jYWwtb25seWAgfCBgY2hlY2sgLS1wcmVg" + - "IG9ubHk7IHNraXAgYWxsIGt1YmVjdGwgY29udGFjdCB8IGBmYWxzZWAgfAoKIyMgYHVwYC1zcGVj" + - "aWZpYwoKfCBGbGFnIHwgUHVycG9zZSB8IERlZmF1bHQgfAp8LS0tfC0tLXwtLS18CnwgYC0tY2x1" + - "c3Rlci1uYW1lIE5BTUVgIHwgSUNNUyBjbHVzdGVyIHJvdyBpZGVudGlmaWVyIChyZXF1aXJlZCkg" + - "fCAtIHwKfCBgLS1uY2EtaWQgSURgIHwgTkNBIGFjY291bnQgSUQgfCBgbnZjZi1kZWZhdWx0YCB8" + - "CnwgYC0tcmVnaW9uIFJFR0lPTmAgfCBDbHVzdGVyIHJlZ2lvbiB8IGB1cy13ZXN0LTFgIHwKfCBg" + - "LS1wbGFuLW9ubHlgIHwgRHJ5LXJ1bjsgZW1pdCBwaGFzZSBwbGFuICsgRVRBcyB3aXRob3V0IGNo" + - "YW5nZXMgfCBgZmFsc2VgIHwKCiMjIGBzdGF0dXNgLXNwZWNpZmljCgp8IEZsYWcgfCBQdXJwb3Nl" + - "IHwgRGVmYXVsdCB8CnwtLS18LS0tfC0tLXwKfCBgLS1jbHVzdGVyLW5hbWUgTkFNRWAgfCBMaW1p" + - "dCB0byBvbmUgY29tcHV0ZSBwbGFuZSB8IC0gKGNvbnRyb2wgKyBhbGwgY2x1c3RlcnMpIHwKfCBg" + - "LS13YXRjaGAgfCBMaXZlIHJlLXJlbmRlciB8IGBmYWxzZWAgfAp8IGAtLXdhdGNoLWludGVydmFs" + - "IERVUmAgfCBgLS13YXRjaGAgY2FkZW5jZSB8IGA1c2AgfAp8IGAtLWNvbXBvbmVudCBOQU1FYCB8" + - "IEZpbHRlciBjb21wb25lbnRzIHBhbmVsIHwgYWxsIHwKfCBgLS1uby1ldmVudHNgIHwgU2tpcCBl" + - "dmVudHMgcGFuZWwgfCBgZmFsc2VgIHwKCiMjIGBjbHVzdGVyIHJlZ2lzdGVyYC1zcGVjaWZpYwoK" + - "fCBGbGFnIHwgUHVycG9zZSB8IERlZmF1bHQgfAp8LS0tfC0tLXwtLS18CnwgYC0tbmFtZSBOQU1F" + - "YCB8IENsdXN0ZXIgbmFtZSAocmVxdWlyZWQpIHwgLSB8CnwgYC0tbmNhLWlkIElEYCB8IE5DQSBJ" + - "RCAocmVxdWlyZWQpIHwgLSB8CnwgYC0tcmVnaW9uIFJFR0lPTmAgfCBSZWdpb24gfCBgdXMtd2Vz" + - "dC0xYCB8CnwgYC0taWNtcy11cmwgVVJMYCB8IElDTVMgZW5kcG9pbnQgfCBmcm9tIGNvbmZpZyB8" + - "CnwgYC0taWdub3JlLWV4aXN0aW5nYCB8IE1hdGNoLW9yLWNyZWF0ZSBpbnN0ZWFkIG9mIGZhaWwt" + - "b24tZXhpc3RzIHwgYGZhbHNlYCB8CgojIyBgYXBpLWtleSBnZW5lcmF0ZWAtc3BlY2lmaWMKCnwg" + - "RmxhZyB8IFB1cnBvc2UgfCBEZWZhdWx0IHwKfC0tLXwtLS18LS0tfAp8IGAtLWZvciBmdW5jdGlv" + - "blx8dGFza2AgfCBHZW5lcmF0ZSBvbmx5IHRoZSBzcGVjaWZpZWQga2V5IHR5cGUgfCAob21pdCB0" + - "byBnZW5lcmF0ZSBib3RoKSB8CnwgYC0tZGVzY3JpcHRpb24gVEVYVGAgfCBIdW1hbi1yZWFkYWJs" + - "ZSBsYWJlbCBmb3IgdGhlIGtleSB8IGBHZW5lcmF0ZWQgYnkgbnZjZi1jbGlgIHwKfCBgLS1leHBp" + - "cmVzLWluIERVUkFUSU9OYCB8IEV4cGlyeSBvZmZzZXQgKGUuZy4gYDI0aGAsIGA3ZGApOyBwYXJz" + - "ZWQgYXMgZXh0ZW5kZWQgZHVyYXRpb24gfCBgMjRoYCB8CnwgYC0tc2NvcGVzIFNDT1BFWyxTQ09Q" + - "RV1gIHwgT3ZlcnJpZGUgZGVmYXVsdCBzY29wZXM7IHJlcXVpcmVzIGAtLWZvcmAgfCAoc2Vydmlj" + - "ZSBkZWZhdWx0cykgfAp8IGAtLXZhbGlkYXRlYCB8IFZhbGlkYXRlIGVhY2ggZ2VuZXJhdGVkIGtl" + - "eSBpbW1lZGlhdGVseSBhZnRlciBtaW50aW5nIHwgYGZhbHNlYCB8CgpFeHRlbmRlZCBkdXJhdGlv" + - "biB1bml0czogYHNgIChzZWNvbmRzKSwgYG1gIChtaW51dGVzKSwgYGhgIChob3VycyksIGBkYCAo" + - "ZGF5cyksIGB3YCAod2Vla3MpLgoKIyMgYGZ1bmN0aW9uIGNyZWF0ZWAtc3BlY2lmaWMKCmAtLWlu" + - "cHV0LWZpbGUgRklMRWAgaXMgcmVjb21tZW5kZWQgZm9yIHJlcGVhdGFibGUgZnVuY3Rpb24gZGVm" + - "aW5pdGlvbnMuIEZvciBhZC1ob2MgY3JlYXRlczoKCnwgRmxhZyB8IFB1cnBvc2UgfCBEZWZhdWx0" + - "IHwKfC0tLXwtLS18LS0tfAp8IGAtLW5hbWUgTkFNRWAgfCBGdW5jdGlvbiBuYW1lIHwgLSB8Cnwg" + - "YC0taW1hZ2UgSU1BR0VgIHwgQ29udGFpbmVyIGltYWdlIHwgLSB8CnwgYC0taW5mZXJlbmNlLXVy" + - "bCBVUklgIHwgQ29udGFpbmVyIGluZmVyZW5jZSBlbmRwb2ludCB8IC0gfAp8IGAtLWluZmVyZW5j" + - "ZS1wb3J0IFBPUlRgIHwgQ29udGFpbmVyIGluZmVyZW5jZSBwb3J0IHwgLSB8CnwgYC0tZnVuY3Rp" + - "b24tdHlwZSBERUZBVUxUXHxTVFJFQU1JTkdcfExMTWAgfCBGdW5jdGlvbiB0eXBlIHwgYERFRkFV" + - "TFRgIHwKfCBgLS1oZWxtLWNoYXJ0IENIQVJUYCB8IEhlbG0gY2hhcnQgVVJMIG9yIE9DSSByZWZl" + - "cmVuY2UuIENhbiBiZSB1c2VkIHdpdGggYC0tZnVuY3Rpb24tdHlwZT1MTE1gIGZvciBjaGFydC1w" + - "YWNrYWdlZCBMTE0gd29ya2xvYWRzLiB8IC0gfAp8IGAtLWhlbG0tY2hhcnQtc2VydmljZSBOQU1F" + - "YCB8IEt1YmVybmV0ZXMgU2VydmljZSBuYW1lIGV4cG9zZWQgYnkgdGhlIGNoYXJ0LiBSZXF1aXJl" + - "ZCB3aGVuIGAtLWhlbG0tY2hhcnRgIGlzIHNldC4gfCAtIHwKfCBgLS1tb2RlbHMgTkFNRTpWRVJT" + - "SU9OOlVSSWAgfCBTdGFuZGFyZCBtb2RlbCBhcnRpZmFjdDsgcmVwZWF0YWJsZSB8IC0gfAp8IGAt" + - "LWxsbS1tb2RlbCBTUEVDYCB8IExMTSBtb2RlbCBjb25maWc7IGZvcm1hdCBgbmFtZT08bW9kZWw+" + - "LHVyaXM9PHVyaT5cfDx1cmk+LHJvdXRpbmdNZXRob2Q9PG1ldGhvZD5bOzxwYXJhbT49PHZhbHVl" + - "Pi4uLl0sdG9rZW5SYXRlTGltaXQ9PGxpbWl0PmA7IHJlcGVhdGFibGUuIFRva2VuIGxpbWl0cyB1" + - "c2UgYDx2YWx1ZT4tPHVuaXQ+YCB3aXRoIGBTYCwgYE1gLCBgSGAsIGBEYCwgb3IgYFdgLCBmb3Ig" + - "ZXhhbXBsZSBgMTAwMC1TYC4gVXNlIGlucHV0IEpTT04gZm9yIGNvbWJpbmVkIHRva2VuIGxpbWl0" + - "cyBiZWNhdXNlIGlubGluZSBzcGVjcyB1c2UgY29tbWFzIGFzIGZpZWxkIHNlcGFyYXRvcnMuIHwg" + - "LSB8CnwgYC0tbGxtLWRlZmF1bHQtcHJpb3JpdHkgUFJJT1JJVFlgIHwgRnVuY3Rpb24tbGV2ZWwg" + - "ZGVmYXVsdCByZXF1ZXN0IHByaW9yaXR5LiBMb3dlciB2YWx1ZXMgaGF2ZSBoaWdoZXIgcHJpb3Jp" + - "dHkuIHwgLSB8CnwgYC0tbGxtLXBlci1hY2NvdW50LXByaW9yaXR5IE5DQS1JRDpQUklPUklUWWAg" + - "fCBQZXItYWNjb3VudCBwcmlvcml0eSBvdmVycmlkZTsgcmVwZWF0YWJsZTsgc3VwcG9ydHMgdXAg" + - "dG8gNjQgZGlzdGluY3QgTkNBIElEIG92ZXJyaWRlcy4gUmVxdWlyZXMgYSBkZWZhdWx0IHByaW9y" + - "aXR5LiB8IC0gfAoKSW4gSlNPTiBhbmQgaW5saW5lIHNwZWNzLCBMTE0gZnVuY3Rpb25zIHNldCBg" + - "ZnVuY3Rpb25UeXBlOiAiTExNImAgYW5kIG1vZGVsIHJvdXRpbmcgbWV0YWRhdGEgdW5kZXIgYG1v" + - "ZGVsc1tdLmxsbUNvbmZpZ2AuIGBsbG1Db25maWcudXJpc2AgZGVjbGFyZXMgdGhlIE9wZW5BSS1j" + - "b21wYXRpYmxlIHVwc3RyZWFtIHBhdGhzIGV4cG9zZWQgYnkgdGhlIG1vZGVsLiBDdXJyZW50IHN1" + - "cHBvcnRlZCBwYXRocyBhcmUgYC92MS9jaGF0L2NvbXBsZXRpb25zYCwgYC92MS9yZXNwb25zZXNg" + - "LCBhbmQgYC92MS9lbWJlZGRpbmdzYC4gYGxsbUNvbmZpZy5yb3V0aW5nTWV0aG9kYCBpcyBhIHJv" + - "dXRpbmcgYWxnb3JpdGhtIG5hbWUsIG9wdGlvbmFsbHkgZm9sbG93ZWQgYnkgYDtuYW1lPXZhbHVl" + - "YCB0dW5pbmcgcGFyYW1ldGVycywgc3VjaCBhcyBgcHVsc2FyO3NlZWQ9c3RhYmxlLWFgLiBUaGUg" + - "Q0xJIHNlbmRzIGl0IHVuY2hhbmdlZDsgdGhlIEFQSSByZWplY3RzIGEgbWFsZm9ybWVkIHZhbHVl" + - "IGFuZCB0aGUgQ0xJIHByaW50cyB0aGF0IGVycm9yLiBRdW90ZSBpbmxpbmUgc3BlY3MgdGhhdCBj" + - "b250YWluIGA7YC4KYGxsbUNvbmZpZy50b2tlblJhdGVMaW1pdGAgYWNjZXB0cyBvbmUgb3IgbW9y" + - "ZSBjb21tYS1zZXBhcmF0ZWQgcG9zaXRpdmUgaW50ZWdlciB0b2tlbiBsaW1pdHMgaW4gYDx2YWx1" + - "ZT4tPHVuaXQ+YCBmb3JtYXQuIFN1cHBvcnRlZCB1bml0cyBhcmUgYFNgIChzZWNvbmRzKSwgYE1g" + - "IChtaW51dGVzKSwgYEhgIChob3VycyksIGBEYCAoZGF5cyksIGFuZCBgV2AgKHdlZWtzKS4gVXNl" + - "IGRpc3RpbmN0IHVuaXRzIHdoZW4gY29tYmluaW5nIGxpbWl0cywgZm9yIGV4YW1wbGUgYDEwMDAt" + - "Uyw1MDAwLU0sMTAwMDAwLUgsNTAwMDAwLUQsMTAwMDAwMC1XYCBpbiBpbnB1dCBKU09OLgoKSGVs" + - "bSBjaGFydCBwYWNrYWdpbmcgaXMgaW5kZXBlbmRlbnQgb2YgYGZ1bmN0aW9uVHlwZWAuIEZvciBh" + - "IEhlbG0tY2hhcnQgYmFja2VkIExMTSBmdW5jdGlvbiwgc2V0IGBmdW5jdGlvblR5cGU6ICJMTE0i" + - "YCwgYGhlbG1DaGFydGAsIGBoZWxtQ2hhcnRTZXJ2aWNlTmFtZWAsIGFuZCBgbW9kZWxzW10ubGxt" + - "Q29uZmlnYCBpbiB0aGUgc2FtZSBjcmVhdGUgcmVxdWVzdC4gYGluZmVyZW5jZVBvcnRgIG11c3Qg" + - "YmUgdGhlIEt1YmVybmV0ZXMgU2VydmljZSBwb3J0IGV4cG9zZWQgYnkgYGhlbG1DaGFydFNlcnZp" + - "Y2VOYW1lYC4KCkxMTSBpbnZvY2F0aW9uIHJlcXVlc3RzIHVzZSBgbW9kZWw6ICI8ZnVuY3Rpb24t" + - "aWQ+Lzxtb2RlbC1uYW1lPiJgLiBUaGUgZnVuY3Rpb24gSUQgc2VsZWN0cyB0aGUgTlZDRiBmdW5j" + - "dGlvbiwgYW5kIHRoZSBtb2RlbCBuYW1lIGlzIGZvcndhcmRlZCB1cHN0cmVhbS4gQ2hhdCBjb21w" + - "bGV0aW9ucyBhbmQgUmVzcG9uc2VzIEFQSSByZXF1ZXN0cyBjYW4gdXNlIGB4LW11bHRpLXR1cm4t" + - "c2Vzc2lvbi1pZGAgZm9yIHNlc3Npb24gc3RpY2tpbmVzczsgZW1iZWRkaW5ncyByZXF1ZXN0cyBk" + - "byBub3QuCgojIyBgZnVuY3Rpb24gdXBkYXRlYC1zcGVjaWZpYwoKfCBGbGFnIHwgUHVycG9zZSB8" + - "IERlZmF1bHQgfAp8LS0tfC0tLXwtLS18CnwgYC0tdGFncyBUQUdbLFRBR11gIHwgUmVwbGFjZSBm" + - "dW5jdGlvbiB0YWdzIHwgLSB8CnwgYC0tbGxtLW1vZGVsLXVwZGF0ZSBTUEVDYCB8IExMTSBtb2Rl" + - "bCB1cGRhdGU7IGZvcm1hdCBgbmFtZT08bW9kZWw+LHJvdXRpbmdNZXRob2Q9PG1ldGhvZD5bOzxw" + - "YXJhbT49PHZhbHVlPi4uLl0sdG9rZW5SYXRlTGltaXQ9PGxpbWl0PmA7IHJlcGVhdGFibGUuIFJv" + - "dXRpbmcgbWV0aG9kcyBtYXRjaCBgLS1sbG0tbW9kZWxgLiBUb2tlbiBsaW1pdCBleGFtcGxlOiBg" + - "MTAwMC1TYC4gVXNlIGlucHV0IEpTT04gZm9yIGNvbWJpbmVkIHRva2VuIGxpbWl0cy4gfCAtIHwK" + - "fCBgLS1sbG0tZGVmYXVsdC1wcmlvcml0eSBQUklPUklUWWAgfCBSZXBsYWNlIHRoZSBmdW5jdGlv" + - "bi1sZXZlbCBwcmlvcml0eSBjb25maWd1cmF0aW9uIHdpdGggdGhpcyBkZWZhdWx0IGFuZCBhbnkg" + - "c3VwcGxpZWQgcGVyLWFjY291bnQgb3ZlcnJpZGVzLiB8IC0gfAp8IGAtLWxsbS1wZXItYWNjb3Vu" + - "dC1wcmlvcml0eSBOQ0EtSUQ6UFJJT1JJVFlgIHwgUGVyLWFjY291bnQgcHJpb3JpdHkgb3ZlcnJp" + - "ZGU7IHJlcGVhdGFibGU7IHN1cHBvcnRzIHVwIHRvIDY0IGRpc3RpbmN0IE5DQSBJRCBvdmVycmlk" + - "ZXMuIFJlcXVpcmVzIGEgZGVmYXVsdCBwcmlvcml0eS4gfCAtIHwKCkluIEpTT04sIGBmdW5jdGlv" + - "biB1cGRhdGVgIGFjY2VwdHMgYG1vZGVsVXBkYXRlc1tdYCBlbnRyaWVzIHdpdGggYG1vZGVsTmFt" + - "ZWAgYW5kIGBsbG1Db25maWcucm91dGluZ01ldGhvZGAgYW5kL29yIGBsbG1Db25maWcudG9rZW5S" + - "YXRlTGltaXRgLiBgdXJpc2AgYXJlIGNyZWF0ZS10aW1lIG1vZGVsIG1ldGFkYXRhIGFuZCBhcmUg" + - "bm90IHBhcnQgb2YgbW9kZWwgdXBkYXRlcy4KCiMjIGBmdW5jdGlvbiBkZXBsb3kgY3JlYXRlYC1z" + - "cGVjaWZpYwoKYC0taW5wdXQtZmlsZSBGSUxFYCBpcyByZWNvbW1lbmRlZCAoZnVsbCBKU09OIHNw" + - "ZWMpLiBGb3IgYWQtaG9jIG9uZS1zaG90IHVzZToKCnwgRmxhZyB8IFB1cnBvc2UgfCBEZWZhdWx0" + - "IHwKfC0tLXwtLS18LS0tfAp8IGAtLWZ1bmN0aW9uLWlkIElEYCB8IEZ1bmN0aW9uIElEIChyZXF1" + - "aXJlZCkgfCAtIHwKfCBgLS1ncHUgTkFNRWAgfCBHUFUgZmFtaWx5IHwgYEgxMDBgIHwKfCBgLS1p" + - "bnN0YW5jZS10eXBlIFRZUEVgIHwgU0tVIHwgYE5DUC5HUFUuSDEwMF8xeGAgfAp8IGAtLW1pbi1p" + - "bnN0YW5jZXMgTmAgfCBNaW4gcmVwbGljYXMgfCBgMWAgfAp8IGAtLW1heC1pbnN0YW5jZXMgTmAg" + - "fCBNYXggcmVwbGljYXMgfCBgMWAgfAp8IGAtLW1heC1yZXF1ZXN0LWNvbmN1cnJlbmN5IE5gIHwg" + - "Q29uY3VycmVuY3kgfCBgMGAgKHVuc2V0OyBzZXJ2ZXIgZGVmYXVsdCBhcHBsaWVzKSB8CnwgYC0t" + - "Y2x1c3RlcnMgTkFNRVssTkFNRV1gIHwgVGFyZ2V0IHNwZWNpZmljIGNvbXB1dGUgY2x1c3RlcnMg" + - "fCAoYW55IG1hdGNoaW5nIFNLVSkgfAoKIyMgYHRhc2sgY3JlYXRlYC1zcGVjaWZpYwoKYC0taW5w" + - "dXQtZmlsZSBGSUxFYCBpcyByZWNvbW1lbmRlZCBmb3IgcmVwZWF0YWJsZSB0YXNrIGRlZmluaXRp" + - "b25zLiBGb3IgYWQtaG9jIHVzZToKCnwgRmxhZyB8IFB1cnBvc2UgfCBOb3RlcyB8CnwtLS18LS0t" + - "fC0tLXwKfCBgLS1uYW1lIE5BTUVgIHwgVGFzayBuYW1lIChyZXF1aXJlZCkgfCAxLTEyOCBjaGFy" + - "cywgYF5bYS16MC05QS1aXVthLXowLTlBLVpcLV9dKiRgIHwKfCBgLS1ncHUgR1BVYCB8IEdQVSBu" + - "YW1lIChyZXF1aXJlZCB3aXRob3V0IGAtLWlucHV0LWZpbGVgKSB8IEV4YW1wbGU6IGBIMTAwYCB8" + - "CnwgYC0taW5zdGFuY2UtdHlwZSBUWVBFYCB8IEluc3RhbmNlIHR5cGUgKHJlcXVpcmVkIHdpdGhv" + - "dXQgYC0taW5wdXQtZmlsZWApIHwgRXhhbXBsZTogYE5DUC5HUFUuSDEwMF8xeGAgfAp8IGAtLWlt" + - "YWdlIElNQUdFYCB8IENvbnRhaW5lciBpbWFnZSB8IE11dHVhbGx5IGV4Y2x1c2l2ZSB3aXRoIGAt" + - "LWhlbG0tY2hhcnRgIHwKfCBgLS1oZWxtLWNoYXJ0IENIQVJUYCB8IEhlbG0gY2hhcnQgVVJMIG9y" + - "IE9DSSByZWZlcmVuY2UgfCBNdXR1YWxseSBleGNsdXNpdmUgd2l0aCBgLS1pbWFnZWAgfAp8IGAt" + - "LWlucHV0LWZpbGUgRklMRWAgfCBKU09OIHRhc2sgc3BlYyBmaWxlIHwgQ0xJIGZsYWdzIG92ZXJy" + - "aWRlIEpTT04gdmFsdWVzIHdoZW4gYm90aCBhcmUgc2V0IHwKfCBgLS1iYWNrZW5kIEJBQ0tFTkRg" + - "IHwgQmFja2VuZCAvIENTUCB8IC0gfAp8IGAtLWNsdXN0ZXJzIE5BTUVbLE5BTUVdYCB8IFNwZWNp" + - "ZmljIGNsdXN0ZXJzIHdpdGhpbiB0aGUgaW5zdGFuY2UgfCBDb21tYS1zZXBhcmF0ZWQgfAp8IGAt" + - "LWNvbnRhaW5lci1hcmdzIEFSR1NgIHwgQXJncyBwYXNzZWQgd2hlbiBsYXVuY2hpbmcgdGhlIGNv" + - "bnRhaW5lciB8IFNpbmdsZSBzdHJpbmcsIGUuZy4gYCItLWVwb2NocyAxMCAtLWJhdGNoLXNpemUg" + - "MzIiYCB8CnwgYC0tY29udGFpbmVyLWVudiBOQU1FPXZhbHVlYCB8IENvbnRhaW5lciBlbnZpcm9u" + - "bWVudCB2YXJpYWJsZTsgcmVwZWF0YWJsZSB8IC0gfAp8IGAtLXNlY3JldHMgTkFNRT12YWx1ZWAg" + - "fCBTZWNyZXQgZW52aXJvbm1lbnQgdmFyaWFibGU7IHJlcGVhdGFibGU7IGVuY3J5cHRlZCBhdCBy" + - "ZXN0IHwgLSB8CnwgYC0tbWF4LXJ1bnRpbWUgRFVSQVRJT05gIHwgTWF4IHdhbGwtY2xvY2sgcnVu" + - "IHRpbWUgKElTTyA4NjAxLCBlLmcuIGBQVDRIYCkgfCBObyBkZWZhdWx0OyBvbWl0IGZvciBubyB0" + - "aW1lIGxpbWl0IHwKfCBgLS1tYXgtcXVldWVkIERVUkFUSU9OYCB8IE1heCB0aW1lIGluIHF1ZXVl" + - "IGJlZm9yZSBjYW5jZWxsYXRpb24gKElTTyA4NjAxKSB8IFNlcnZlciBkZWZhdWx0IGBQVDcySGAg" + - "fAp8IGAtLXRlcm1pbmF0aW9uLWdyYWNlIERVUkFUSU9OYCB8IEdyYWNlIHBlcmlvZCBhZnRlciBz" + - "dG9wIHNpZ25hbCAoSVNPIDg2MDEpIHwgU2VydmVyIGRlZmF1bHQgYFBUMUhgIHwKfCBgLS1yZXN1" + - "bHQtc3RyYXRlZ3kgVVBMT0FEXHxOT05FYCB8IEhvdyB0byBoYW5kbGUgdGFzayBvdXRwdXQgYXJ0" + - "aWZhY3RzIHwgLSB8CnwgYC0tcmVzdWx0cy1sb2NhdGlvbiBMT0NBVElPTmAgfCBSZXN1bHQgdXBs" + - "b2FkIHRhcmdldCwgZm9ybWF0IGBvcmctbmFtZS9bdGVhbS1uYW1lL11tb2RlbC1uYW1lYDsgcmVx" + - "dWlyZWQgd2hlbiBgLS1yZXN1bHQtc3RyYXRlZ3k9VVBMT0FEYCB8IC0gfAp8IGAtLW1vZGVscyBO" + - "QU1FOlZFUlNJT046VVJJYCB8IE1vZGVsIGFydGlmYWN0OyByZXBlYXRhYmxlIHwgLSB8CnwgYC0t" + - "cmVzb3VyY2VzIE5BTUU6VkVSU0lPTjpVUklgIHwgUmVzb3VyY2UgYXJ0aWZhY3Q7IHJlcGVhdGFi" + - "bGUgfCAtIHwKfCBgLS10YWdzIFRBR1ssVEFHXWAgfCBUYXNrIHRhZ3MgfCBDb21tYS1zZXBhcmF0" + - "ZWQgfAp8IGAtLWRlc2NyaXB0aW9uIFRFWFRgIHwgVGFzayBkZXNjcmlwdGlvbiB8IC0gfAp8IGAt" + - "LWxvZ3MtdGVsZW1ldHJ5LWlkIFVVSURgIHwgTG9ncyB0ZWxlbWV0cnkgZW5kcG9pbnQgSUQgfCAt" + - "IHwKfCBgLS1tZXRyaWNzLXRlbGVtZXRyeS1pZCBVVUlEYCB8IE1ldHJpY3MgdGVsZW1ldHJ5IGVu" + - "ZHBvaW50IElEIHwgLSB8CnwgYC0tdHJhY2VzLXRlbGVtZXRyeS1pZCBVVUlEYCB8IFRyYWNlcyB0" + - "ZWxlbWV0cnkgZW5kcG9pbnQgSUQgfCAtIHwKUEsHCI/rqvD8JQAA/CUAAFBLAwQUAAgAAAAAACEA" + - "AAAAAAAAAAAAAAAANwAJAGRhdGEvbnZjZi1zZWxmLW1hbmFnZWQtY2xpL3JlZmVyZW5jZS90cm91" + - "Ymxlc2hvb3RpbmcubWRVVAUAAQCmzhIjIFRyb3VibGVzaG9vdGluZwoKS25vd24gZXJyb3JzIOKG" + - "kiBkaWFnbm9zdGljIGNvbW1hbmQg4oaSIHJlbWVkaWF0aW9uLiBLZWVwIGluIHN5bmMgd2l0aCB0" + - "aGUgc3RydWN0dXJlZCBgcGhhc2VfZmFpbGVkYCBldmVudHMgdGhlIENMSSBlbWl0cyAoUkVRLTE1" + - "KSDigJQgZXZlcnkgZW50cnkgYmVsb3cgc2hvdWxkIG1hcCB0byBhbiBgZXJyQ2F0ZWdvcnlgIGFu" + - "ZCBhIGByZW1lZGlhdGlvbmAgYXJyYXkuCgojIyBQcmUtZmxpZ2h0Cgp8IFN5bXB0b20gfCBDYXVz" + - "ZSB8IFJlbWVkaWF0aW9uIHwKfC0tLXwtLS18LS0tfAp8IGBrdWJlY3RsIG5vdCBvbiBQQVRIYCB8" + - "IE9wZXJhdG9yJ3MgYCRQQVRIYCBkb2Vzbid0IGluY2x1ZGUga3ViZWN0bCB8IEluc3RhbGwga3Vi" + - "ZWN0bCAxLjI4KzogaHR0cHM6Ly9rdWJlcm5ldGVzLmlvL2RvY3MvdGFza3MvdG9vbHMvaW5zdGFs" + - "bC1rdWJlY3RsLyB8CnwgYGhlbG1maWxlIG5vdCBvbiBQQVRIYCB8IFNhbWUgfCBodHRwczovL2dp" + - "dGh1Yi5jb20vaGVsbWZpbGUvaGVsbWZpbGUjaW5zdGFsbGF0aW9uIHwKfCBgaGVsbSBub3Qgb24g" + - "UEFUSGAgfCBTYW1lIHwgaHR0cHM6Ly9oZWxtLnNoL2RvY3MvaW50cm8vaW5zdGFsbC8gfAp8IGBH" + - "YXRld2F5IEFQSSBDUkRzIG5vdCBpbnN0YWxsZWRgIHwgZW52b3ktZ2F0ZXdheSBwcmVyZXEgbWlz" + - "c2luZyB8IGBoZWxtIGluc3RhbGwgZWcgb2NpOi8vZG9ja2VyLmlvL2Vudm95cHJveHkvZ2F0ZXdh" + - "eS1oZWxtIC0tbmFtZXNwYWNlPWVudm95LWdhdGV3YXktc3lzdGVtIC0tY3JlYXRlLW5hbWVzcGFj" + - "ZWAgfAp8IGBkZWZhdWx0IFN0b3JhZ2VDbGFzcyBub3QgYXZhaWxhYmxlYCB8IENsdXN0ZXIgaGFz" + - "IG5vIGRlZmF1bHQgU0MgfCBBbm5vdGF0ZSBvbmU6IGBrdWJlY3RsIHBhdGNoIHNjIDxuYW1lPiAt" + - "cCAneyJtZXRhZGF0YSI6eyJhbm5vdGF0aW9ucyI6eyJzdG9yYWdlY2xhc3Mua3ViZXJuZXRlcy5p" + - "by9pcy1kZWZhdWx0LWNsYXNzIjoidHJ1ZSJ9fX0nYCB8CnwgYEdQVSBvcGVyYXRvciBub3QgaW5z" + - "dGFsbGVkYCAoY29tcHV0ZS1wbGFuZSkgfCBudmlkaWEgR1BVIG9wZXJhdG9yIG1pc3NpbmcgfCBJ" + - "bnN0YWxsIHBlciBodHRwczovL2RvY3MubnZpZGlhLmNvbS9kYXRhY2VudGVyL2Nsb3VkLW5hdGl2" + - "ZS9ncHUtb3BlcmF0b3IvbGF0ZXN0L2luc3RhbGwtZ3B1LW9wZXJhdG9yLmh0bWwsIE9SIHVzZSBg" + - "ZmFrZS1ncHUtb3BlcmF0b3JgIGZvciBkZXYgfAp8IGBHUFUgbm9kZSBsYWJlbHMgbWlzc2luZ2Ag" + - "KGNvbXB1dGUtcGxhbmUpIHwgTm9kZXMgbmVlZCBgbnZpZGlhLmNvbS9ncHUuZmFtaWx5YCBldGMu" + - "IHwgU2V0IGAtLWszcy1ub2RlLWxhYmVsIG52aWRpYS5jb20vZ3B1LmZhbWlseT1ob3BwZXJAYWdl" + - "bnQ6TmAgZm9yIGszZCB8CgojIyBBdXRoCgp8IFN5bXB0b20gfCBDYXVzZSB8IFJlbWVkaWF0aW9u" + - "IHwKfC0tLXwtLS18LS0tfAp8IGBhZG1pbiB0b2tlbiByZXF1aXJlZGAgKGluaXQgbm90IHJ1bikg" + - "fCBObyBgfi8ubnZjZi1jbGkuc3RhdGVgIGFuZCBgLS10b2tlbmAgbm90IHBhc3NlZCB8IGBudmNm" + - "LWNsaSBpbml0YCAoaW50ZXJhY3RpdmUpIG9yIGAtLXRva2VuPSRKV1QgLS1ub24taW50ZXJhY3Rp" + - "dmVgIChDSSkgfAp8IGBTaWduZWQgSldUIHJlamVjdGVkOiBBbm90aGVyIGFsZ29yaXRobSBleHBl" + - "Y3RlZCwgb3Igbm8gbWF0Y2hpbmcga2V5KHMpIGZvdW5kYCB8IFN0YWxlIHNlc3Npb24uanNvbiBm" + - "cm9tIGEgZGlmZmVyZW50IGNvbnRyb2wgcGxhbmUgfCBgcm0gfi8ubnZjZi1jbGkuc3RhdGVgIHRo" + - "ZW4gYG52Y2YtY2xpIGluaXRgIGFnYWluIHwKfCBgNDAzIG1pc3NpbmcgcmVxdWVzdGVkIGF1dGhv" + - "cml0aWVzYCAoZnVuY3Rpb24gaW52b2tlKSB8IEFkbWluIHRva2VuIGRvZXNuJ3QgaGF2ZSBgaW52" + - "b2tlX2Z1bmN0aW9uYCBzY29wZSB8IGBudmNmLWNsaSBhcGkta2V5IGdlbmVyYXRlIC0tZGVzY3Jp" + - "cHRpb2494oCmYCB0aGVuIHJlLXJ1biBpbnZva2UgfAoKIyMgSW5zdGFsbCAvIGB1cGAKCnwgU3lt" + - "cHRvbSB8IENhdXNlIHwgUmVtZWRpYXRpb24gfAp8LS0tfC0tLXwtLS18CnwgSGVsbSBgdGltZWQg" + - "b3V0IHdhaXRpbmcgZm9yIGNhc3NhbmRyYWAgfCBDYXNzYW5kcmEgU3RhdGVmdWxTZXQgUFZDIHN0" + - "dWNrIGBQZW5kaW5nYCB8IENvbmZpcm0gZGVmYXVsdCBTdG9yYWdlQ2xhc3M7IGNoZWNrIGBrdWJl" + - "Y3RsIGdldCBwdixwdmMgLW4gY2Fzc2FuZHJhLXN5c3RlbWAgfAp8IGBJbnZhbGlkIEdQVSAnTkNQ" + - "LkdQVS5IMTAwJyBzcGVjaWZpZWRgIChmdW5jdGlvbiBkZXBsb3kpIHwgV3JvbmcgZmllbGQg4oCU" + - "IGBncHVgIGlzIHRoZSBmYW1pbHksIG5vdCB0aGUgU0tVIHwgVXNlIGAiZ3B1IjogIkgxMDAiYCwg" + - "YCJpbnN0YW5jZVR5cGUiOiAiTkNQLkdQVS5IMTAwXzF4ImAgfAp8IGByZWdpb24gbXVzdCBiZSBw" + - "cm92aWRlZGAgKGNsdXN0ZXIgcmVnaXN0ZXIgLyBpbnN0YWxsIC0tY29tcHV0ZS1wbGFuZSkgfCBg" + - "LS1yZWdpb25gIGVtcHR5ICsgYENMVVNURVJfUkVHSU9OYCBlbnYgZW1wdHkgfCBQYXNzIGAtLXJl" + - "Z2lvbj11cy13ZXN0LTFgIChvciBhbnkgbm9uLWVtcHR5IHZhbHVlKSB8CnwgYHJlcXVpcmVkRW52" + - "ICJOQ0FfSUQiIGlzIG5vdCBzZXRgIChoZWxtZmlsZSBlcnJvciBkdXJpbmcgY29tcHV0ZS1wbGFu" + - "ZSBhcHBseSkgfCBPbGRlciBudmNmLWNsaSBiZWZvcmUgY29tbWl0IGA2YWE1NzA0YDsgRXh0cmFF" + - "bnYgbm90IHByb3BhZ2F0aW5nIE5DQV9JRCB8IFVwZ3JhZGUgYG52Y2YtY2xpYCB0byBhIHJlbGVh" + - "c2UgYWZ0ZXIgYDZhYTU3MDRgIHwKfCBgbnZjYS1vcGVyYXRvcjogSW1hZ2VQdWxsQmFja09mZmAg" + - "Zm9yIGBudmNhLW9wZXJhdG9yOnBzYXRgIHwgSW1hZ2Ugbm90IGluIGNsdXN0ZXIgcmVnaXN0cnk7" + - "IG11bHRpLWNsdXN0ZXIgYnVpbGQgaXMgbG9jYWwgfCBgazNkIGltYWdlIGltcG9ydCBudmNyLmlv" + - "Ly4uLi9udmNhLW9wZXJhdG9yOnBzYXQgLWMgPGNsdXN0ZXI+YCB8CgojIyBDb21wdXRlLXBsYW5l" + - "IHJ1bnRpbWUKCnwgU3ltcHRvbSB8IENhdXNlIHwgUmVtZWRpYXRpb24gfAp8LS0tfC0tLXwtLS18" + - "CnwgTlZDQSBhZ2VudCBDcmFzaExvb3BCYWNrT2ZmIHdpdGggYEhUVFAgNDAxYCBvbiBgL3YxL252" + - "Y2EvY2x1c3RlcnMvLi4uL3JlZ2lzdGVyYCB8IFN0YWxlIGBvdXQvPGNsdXN0ZXI+LXJlZ2lzdGVy" + - "LXZhbHVlcy55YW1sYCBmcm9tIHByaW9yIHJlZ2lzdGVyOyBjbHVzdGVyIElEIGRvZXNuJ3QgbWF0" + - "Y2ggSUNNUyB8IERlbGV0ZSB0aGUgc3RhbGUgZmlsZTsgcmUtcnVuIGBudmNmLWNsaSBzZWxmLWhv" + - "c3RlZCB1cGAgKGlkZW1wb3RlbnQgcmVnaXN0ZXIgd3JpdGVzIGEgZnJlc2ggZmlsZSkgfAp8IGBO" + - "byBjbHVzdGVyIGZvdW5kIHdpdGggdmFsaWQgSldLUyBmb3IgY2x1c3RlciBJRDogPGlkPmAgKElD" + - "TVMgbG9nKSB8IEpXS1MgaW4gSUNNUyBkb2Vzbid0IG1hdGNoIHdoYXQgY29tcHV0ZS1wbGFuZSBL" + - "OHMgaXMgY3VycmVudGx5IHNpZ25pbmcgfCBgbnZjZi1jbGkgY2x1c3RlciByb3RhdGUgLS1jbHVz" + - "dGVyLWlkPTxpZD5gIHwKfCBGdW5jdGlvbiBwb2QgYEltYWdlUHVsbEJhY2tPZmZgIGluIGBudmNm" + - "LWJhY2tlbmRgIHwgSW1hZ2UtY3JlZGVudGlhbC1oZWxwZXIgY29uZmlnIG1pc3Npbmcgb3Igd3Jv" + - "bmcgfCBga3ViZWN0bCBkZXNjcmliZSBwb2QgLW4gbnZjZi1iYWNrZW5kIDxwb2Q+YDsgY2hlY2sg" + - "dGhlIHB1bGwgc2VjcmV0IGNvbmZpZyB8CnwgRnVuY3Rpb24gZGVwbG95IHN0dWNrIGBERVBMT1lJ" + - "TkdgIGZvciA+NSBtaW4gfCBOQVRTIHN0cmVhbSBpbml0IGxhZyAoY29sZC1jbHVzdGVyKSDigJQg" + - "d2FpdCwgb3IgY2hlY2sgTkFUUyBhdXRoLWNhbGxvdXQgaGVhbHRoIHwgYGt1YmVjdGwgbG9ncyAt" + - "biBuYXRzLXN5c3RlbSAtbCBhcHAua3ViZXJuZXRlcy5pby9uYW1lPW5hdHMtYXV0aC1jYWxsb3V0" + - "LXNlcnZpY2VgIHwKCiMjIFRhc2tzCgp8IFN5bXB0b20gfCBDYXVzZSB8IFJlbWVkaWF0aW9uIHwK" + - "fC0tLXwtLS18LS0tfAp8IGB0YXNrIGNyZWF0ZWAgcmV0dXJucyBgTWlzc2luZyA8VFlQRT4gcmVn" + - "aXN0cnkgY3JlZGVudGlhbCBmb3IgaG9zdG5hbWVgLCBvciBrZWVwcyB1c2luZyBhIHByZXZpb3Vz" + - "IGNyZWRlbnRpYWwgdmFsdWUsIHNob3J0bHkgYWZ0ZXIgYHJlZ2lzdHJ5LWNyZWRlbnRpYWwgYWRk" + - "L3VwZGF0ZS9kZWxldGVgIHdoaWxlIGByZWdpc3RyeS1jcmVkZW50aWFsIGxpc3RgL2BnZXRgIGFs" + - "cmVhZHkgc2hvdyB0aGUgbmV3IHZhbHVlIHwgRXhwZWN0ZWQgcHJvcGFnYXRpb24gZGVsYXk6IE5W" + - "Q1QgY2FjaGVzIHRoZSBhY2NvdW50J3MgcmVnaXN0cnkgY3JlZGVudGlhbHMgd2l0aCBgbnZjdC5u" + - "dmNmLmNhY2hlLXR0bGAgKGRlZmF1bHQgYFBUNU1gLCBhYm91dCA1IG1pbikgYW5kIGFwcGxpZXMg" + - "dGhlIGNoYW5nZSB3aGVuIHRoYXQgY2FjaGVkIGNvcHkgcmVmcmVzaGVzIHwgV2FpdCB1cCB0byBh" + - "Ym91dCA1IG1pbiBhbmQgcmV0cnksIG9yIGBrdWJlY3RsIC1uIG52Y2Ygcm9sbG91dCByZXN0YXJ0" + - "IGRlcGxveW1lbnQvbnZjdC1hcGlgIHRvIGFwcGx5IGltbWVkaWF0ZWx5IHwKUEsHCPOuE4R0EQAA" + - "dBEAAFBLAwQUAAgAAAAAACEAAAAAAAAAAAAAAAAALAAJAGRhdGEvbnZjZi1zZWxmLW1hbmFnZWQt" + - "aW5zdGFsbGF0aW9uL1NLSUxMLm1kVVQFAAEAps4SLS0tCm5hbWU6IG52Y2Ytc2VsZi1tYW5hZ2Vk" + - "LWluc3RhbGxhdGlvbgpkZXNjcmlwdGlvbjogPi0KICBJbnN0YWxsIGFuZCBvcGVyYXRlIE5WQ0Yg" + - "c2VsZi1ob3N0ZWQgY29udHJvbC1wbGFuZSBhbmQgc2VwYXJhdGUgY29tcHV0ZS1wbGFuZQogIHN0" + - "YWNrcy4gQ292ZXJzIEhlbG1maWxlIHZhbHVlcyBhbmQgQ0xJIHByb2ZpbGUgaW5zdGFsbGF0aW9u" + - "IGZsb3dzLCB0ZWFyZG93biwKICB2YWx1ZXMgb3ZlcnJpZGVzLCBwdWxsIHNlY3JldHMsIGFuZCB0" + - "cm91Ymxlc2hvb3RpbmcuIFVzZSBmb3IKICBudmNmLXNlbGYtbWFuYWdlZC1zdGFjaywgbnZjZi1j" + - "b21wdXRlLXBsYW5lLXN0YWNrLCBzcGxpdCBjb21wdXRlLXBsYW5lCiAgaW5zdGFsbGF0aW9uLCBj" + - "b250cm9sLXBsYW5lIGluc3RhbGxhdGlvbiwgQ0xJLWdlbmVyYXRlZCBjb250cm9sLXBsYW5lCiAg" + - "cHJvZmlsZXMsIEhlbG1maWxlLCBzZWxmLW1hbmFnZWQsIG9yIHNlbGYtaG9zdGVkIGRlcGxveW1l" + - "bnRzLiBEbyBOT1QgdXNlIGZvcgogIGxvY2FsIGszZCBlbnZpcm9ubWVudHM7IHVzZSB0aGUgbG9j" + - "YWwgazNkIGRldmVsb3BtZW50IHdvcmtmbG93IGluc3RlYWQuCmxpY2Vuc2U6IEFwYWNoZS0yLjAK" + - "Y29tcGF0aWJpbGl0eTogUmVxdWlyZXMgaGVsbWZpbGUgPj0gMS4xLjAgPCAxLjIuMCwgaGVsbSA+" + - "PSAzLjEyLCBoZWxtLWRpZmYgcGx1Z2luLCBrdWJlY3RsIG1hdGNoaW5nIGNsdXN0ZXIgdmVyc2lv" + - "bgphdXRob3I6ICJudmNmLWNvcmUtZW5nIDxudmNmLWNvcmUtZW5nQGV4Y2hhbmdlLm52aWRpYS5j" + - "b20+Igp2ZXJzaW9uOiAiMS4wLjAiCnRhZ3M6IFtudmNmLCBzZWxmLW1hbmFnZWQsIGhlbG1maWxl" + - "LCBzZWxmLWhvc3RlZCwgY29udHJvbC1wbGFuZSwgY29tcHV0ZS1wbGFuZSwgY2xpLXByb2ZpbGUs" + - "IGluc3RhbGxhdGlvbiwgZGVwbG95bWVudCwgcHVsbC1zZWNyZXRzXQp0b29sczogW1NoZWxsLCBS" + - "ZWFkLCBFZGl0LCBHcmVwLCBHbG9iXQptZXRhZGF0YToKICBpbnRlcm5hbDogZmFsc2UKICBhdXRo" + - "b3I6ICJudmNmLWNvcmUtZW5nIDxudmNmLWNvcmUtZW5nQGV4Y2hhbmdlLm52aWRpYS5jb20+Igog" + - "IHZlcnNpb246ICIxLjAiCiAgdGFnczogW252Y2YsIHNlbGYtbWFuYWdlZCwgaGVsbWZpbGUsIHNl" + - "bGYtaG9zdGVkLCBjb250cm9sLXBsYW5lLCBjb21wdXRlLXBsYW5lLCBjbGktcHJvZmlsZSwgaW5z" + - "dGFsbGF0aW9uLCBkZXBsb3ltZW50LCBwdWxsLXNlY3JldHNdCiAgbGFuZ3VhZ2VzOiBbYmFzaCwg" + - "eWFtbF0KICBmcmFtZXdvcmtzOiBbaGVsbWZpbGUsIGhlbG0sIGt1YmVjdGxdCiAgZG9tYWluOiBj" + - "bG91ZC1pbmZyYXN0cnVjdHVyZQotLS0KCiMgTlZDRiBTZWxmLU1hbmFnZWQgU3RhY2sgT3BlcmF0" + - "aW9ucwoKT3BlcmF0aW9uYWwgZ3VpZGUgZm9yIG1hdGNoaW5nIE5WQ0YgY29udHJvbC1wbGFuZSBh" + - "bmQgY29tcHV0ZS1wbGFuZSBIZWxtZmlsZSBidW5kbGVzLgoKIyMgSW5zdHJ1Y3Rpb25zCgpVc2Ug" + - "dGhpcyBza2lsbCBmb3IgaW5zdGFsbCwgdXBncmFkZSwgb3IgdGVhcmRvd24gd29yayBpbiBtYXRj" + - "aGluZyBgbnZjZi1zZWxmLW1hbmFnZWQtc3RhY2tgIGFuZCBgbnZjZi1jb21wdXRlLXBsYW5lLXN0" + - "YWNrYCBidW5kbGVzOyBrZWVwIEhlbG1maWxlIHZhbHVlcyBhbmQgQ0xJIHByb2ZpbGUgaGFuZG9m" + - "ZnMgc2VwYXJhdGUuIEZvciBgZnVuY3Rpb25UeXBlOiAiTExNImAsIHJlYWQgW0xMTSBGdW5jdGlv" + - "biBFbmFibGVtZW50XShyZWZlcmVuY2VzL2hlbG1maWxlLXN0cnVjdHVyZS5tZCNsbG0tZnVuY3Rp" + - "b24tZW5hYmxlbWVudCkuCgojIyBQcmVyZXF1aXNpdGVzCgpBc2sgZm9yIHRoZSBleHRyYWN0ZWQg" + - "YG52Y2Ytc2VsZi1tYW5hZ2VkLXN0YWNrYCBwYXRoLiBGb3Igc3BsaXQgaW5zdGFsbGF0aW9uLAph" + - "bHNvIHJlcXVpcmUgdGhlIG1hdGNoaW5nLXZlcnNpb24gYG52Y2YtY29tcHV0ZS1wbGFuZS1zdGFj" + - "a2AgcGF0aC4gVmVyaWZ5IGJvdGg6CgpgYGBiYXNoCmxzIDxjb250cm9sLXBsYW5lLXN0YWNrPi9o" + - "ZWxtZmlsZS5kLyA8Y29udHJvbC1wbGFuZS1zdGFjaz4vZW52aXJvbm1lbnRzLyA8Y29udHJvbC1w" + - "bGFuZS1zdGFjaz4vc2VjcmV0cy8gPGNvbnRyb2wtcGxhbmUtc3RhY2s+L2dsb2JhbC55YW1sLmdv" + - "dG1wbApscyA8Y29tcHV0ZS1wbGFuZS1zdGFjaz4vaGVsbWZpbGUuZC8gPGNvbXB1dGUtcGxhbmUt" + - "c3RhY2s+L2Vudmlyb25tZW50cy8gPGNvbXB1dGUtcGxhbmUtc3RhY2s+L2dsb2JhbC55YW1sLmdv" + - "dG1wbApgYGAKCklmIGVpdGhlciBkaXJlY3RvcnkgaXMgbWlzc2luZywgZG93bmxvYWQgbWF0Y2hp" + - "bmcgYnVuZGxlIHZlcnNpb25zIGZyb20gTkdDOgoKYGBgYmFzaApuZ2MgcmVnaXN0cnkgcmVzb3Vy" + - "Y2UgZG93bmxvYWQtdmVyc2lvbiA8b3JnPi9udmNmLXNlbGYtbWFuYWdlZC1zdGFjazo8dmVyc2lv" + - "bj4KbmdjIHJlZ2lzdHJ5IHJlc291cmNlIGRvd25sb2FkLXZlcnNpb24gPG9yZz4vbnZjZi1jb21w" + - "dXRlLXBsYW5lLXN0YWNrOjx2ZXJzaW9uPgpgYGAKCkNvbnRyb2wtcGxhbmUgY29tbWFuZHMgYmVs" + - "b3cgYXNzdW1lIHRoZSBjb250cm9sLXBsYW5lIGJ1bmRsZSByb290LiBTcGxpdC1mbG93CmNvbW1h" + - "bmRzIGFuZCByb290IHN1YnN0aXR1dGlvbiBhcmUgaW4KW1NwbGl0IENvbXB1dGUtUGxhbmUgSW5z" + - "dGFsbGF0aW9uXShyZWZlcmVuY2VzL2NvbXB1dGUtcGxhbmUtaW5zdGFsbGF0aW9uLm1kKS4KCiMj" + - "IEJlZm9yZSBZb3UgU3RhcnQKClZlcmlmeSB0b29saW5nIGFuZCBjb250ZXh0IGJlZm9yZSBhbnkg" + - "b3BlcmF0aW9uOgoKYGBgYmFzaApoZWxtZmlsZSAtLXZlcnNpb24gICAjIE11c3QgYmUgMS4xLngg" + - "KDEuMi4wIHJlbW92ZWQgc2VxdWVudGlhbCBtb2RlKQpoZWxtIHZlcnNpb24gICAgICAgICAjIE11" + - "c3QgYmUgPj0gMy4xMgpoZWxtIHBsdWdpbiBsaXN0ICAgICAjIE11c3QgaW5jbHVkZSBoZWxtLWRp" + - "ZmYgPj0gMy4xMQprdWJlY3RsIHZlcnNpb24gICAgICAjIENsaWVudCBtdXN0IGJlIHdpdGhpbiAx" + - "IG1pbm9yIHZlcnNpb24gb2YgY2x1c3RlcgpgYGAKCkNvbnRyb2wtcGxhbmUgY29tbWFuZHMgcnVu" + - "IGZyb20gdGhlIGV4dHJhY3RlZCBgbnZjZi1zZWxmLW1hbmFnZWQtc3RhY2svYCByb290OgoKYGBg" + - "YmFzaApjZCBwYXRoL3RvL252Y2Ytc2VsZi1tYW5hZ2VkLXN0YWNrCmxzIGhlbG1maWxlLmQvIGVu" + - "dmlyb25tZW50cy8gc2VjcmV0cy8gZ2xvYmFsLnlhbWwuZ290bXBsCmBgYAoKSWRlbnRpZnkgdGhl" + - "IGVudmlyb25tZW50IGZyb20gYGVudmlyb25tZW50cy88bmFtZT4ueWFtbGAgYW5kCmBzZWNyZXRz" + - "LzxuYW1lPi1zZWNyZXRzLnlhbWxgOyBmb3IgRUtTLCB1c2UgdGhlIGNhbm9uaWNhbApbQ1NQIEVu" + - "ZC10by1FbmQgRXhhbXBsZV0oaHR0cHM6Ly9kb2NzLm52aWRpYS5jb20vbnZjZi92MC42LjAtcmMv" + - "Y3NwLWVuZC10by1lbmQtZXhhbXBsZSkuCgojIyBIb3cgVmFsdWVzIEZsb3cKClVuZGVyc3RhbmRp" + - "bmcgdmFsdWUgcHJlY2VkZW5jZSBwcmV2ZW50cyB0aGUgbW9zdCBjb21tb24gY29uZmlndXJhdGlv" + - "biBtaXN0YWtlcy4KCmBgYHRleHQKZW52aXJvbm1lbnRzL2Jhc2UueWFtbCAgICAgICAgICAoZGVm" + - "YXVsdHMpCiAgICAtPiBtZXJnZWQgd2l0aAplbnZpcm9ubWVudHMvPGVudj4ueWFtbCAgICAgICAg" + - "ICh5b3VyIG92ZXJyaWRlcykKICAgIC0+IGNvbnN1bWVkIGJ5Cmdsb2JhbC55YW1sLmdvdG1wbCAg" + - "ICAgICAgICAgICAgKEdvIHRlbXBsYXRlLCBjb25zdHJ1Y3RzIHBlci1jaGFydCB2YWx1ZXMpCiAg" + - "ICAtPiBjb25zdW1lZCBieQpzZWNyZXRzLzxlbnY+LXNlY3JldHMueWFtbCAgICAgIChzZW5zaXRp" + - "dmUgdmFsdWVzKQogICAgLT4gb3ZlcnJpZGRlbiBieQpyZWxlYXNlIGlubGluZSB2YWx1ZXM6IGJs" + - "b2NrcyAgIChoaWdoZXN0IHByZWNlZGVuY2UpCmBgYAoKQ3JpdGljYWw6IGBnbG9iYWwueWFtbC5n" + - "b3RtcGxgIG9ubHkgcGFzc2VzIHRocm91Z2ggc3BlY2lmaWMga2V5cyB0byBlYWNoIGNoYXJ0IChp" + - "bWFnZSByZWdpc3RyaWVzLCBub2RlIHNlbGVjdG9ycywgc3RvcmFnZSwgcmVwbGljYSBjb3VudHMp" + - "LiBTZXR0aW5nIGFuIGFyYml0cmFyeSBjaGFydCB2YWx1ZSBpbiB0aGUgZW52aXJvbm1lbnQgZmls" + - "ZSB3aWxsIGJlIHNpbGVudGx5IGlnbm9yZWQgaWYgYGdsb2JhbC55YW1sLmdvdG1wbGAgZG9lcyBu" + - "b3QgcHJvcGFnYXRlIGl0LgoKVG8gb3ZlcnJpZGUgYXJiaXRyYXJ5IGNoYXJ0IHZhbHVlcywgdXNl" + - "IGEgaGVsbWZpbGUgcmVsZWFzZSBgdmFsdWVzOmAgYmxvY2suIFNlZSBbT3ZlcnJpZGluZyBIZWxt" + - "IENoYXJ0IFZhbHVlc10oI292ZXJyaWRpbmctaGVsbS1jaGFydC12YWx1ZXMpLgoKIyMgQ2xlYW4g" + - "SW5zdGFsbGF0aW9uCgojIyMgMS4gQ3JlYXRlIG5hbWVzcGFjZXMgYW5kIGltYWdlIHB1bGwgc2Vj" + - "cmV0cyAoaWYgdXNpbmcgYSBwcml2YXRlIHJlZ2lzdHJ5KQoKYGBgYmFzaApmb3IgbnMgaW4gY2Fz" + - "c2FuZHJhLXN5c3RlbSBuYXRzLXN5c3RlbSBudmNmIGFwaS1rZXlzIGVzcyBzaXMgdmF1bHQtc3lz" + - "dGVtIGNlcnQtbWFuYWdlcjsgZG8KICBrdWJlY3RsIGNyZWF0ZSBuYW1lc3BhY2UgIiRucyIgLS1k" + - "cnktcnVuPWNsaWVudCAtbyB5YW1sIHwga3ViZWN0bCBhcHBseSAtZiAtCmRvbmUKCiMgT25seSBp" + - "ZiBwdWxsaW5nIGZyb20gYSBwcml2YXRlIHJlZ2lzdHJ5IChlLmcuLCBOR0MgbnZjci5pbykKZXhw" + - "b3J0IE5HQ19BUElfS0VZPSI8eW91ci1rZXk+Igpmb3IgbnMgaW4gY2Fzc2FuZHJhLXN5c3RlbSBu" + - "YXRzLXN5c3RlbSBudmNmIGFwaS1rZXlzIGVzcyBzaXMgdmF1bHQtc3lzdGVtIGNlcnQtbWFuYWdl" + - "cjsgZG8KICBrdWJlY3RsIGNyZWF0ZSBzZWNyZXQgZG9ja2VyLXJlZ2lzdHJ5IG52Y3ItY3JlZHMg" + - "XAogICAgLS1kb2NrZXItc2VydmVyPW52Y3IuaW8gXAogICAgLS1kb2NrZXItdXNlcm5hbWU9JyRv" + - "YXV0aHRva2VuJyBcCiAgICAtLWRvY2tlci1wYXNzd29yZD0iJE5HQ19BUElfS0VZIiBcCiAgICAt" + - "LW5hbWVzcGFjZT0iJG5zIiBcCiAgICAtLWRyeS1ydW49Y2xpZW50IC1vIHlhbWwgfCBrdWJlY3Rs" + - "IGFwcGx5IC1mIC0KZG9uZQpgYGAKCklmIHVzaW5nIHB1bGwgc2VjcmV0cywgeW91IG11c3QgYWxz" + - "byBjb25maWd1cmUgZWFjaCBoZWxtZmlsZSByZWxlYXNlIHRvIHJlZmVyZW5jZSB0aGVtLiBTZWUg" + - "W0ltYWdlIFB1bGwgU2VjcmV0c10oI2ltYWdlLXB1bGwtc2VjcmV0cykuCgojIyMgMi4gQXV0aGVu" + - "dGljYXRlIGhlbG0gdG8geW91ciBjaGFydCByZWdpc3RyeQoKQm90aCBgZG9ja2VyIGxvZ2luYCBB" + - "TkQgYGhlbG0gcmVnaXN0cnkgbG9naW5gIGFyZSByZXF1aXJlZCBmb3IgTkdDLiBIZWxtZmlsZSB1" + - "c2VzIGhlbG0gKG5vdCBkb2NrZXIpIHRvIHB1bGwgT0NJIGNoYXJ0cywgc28gdGhlIGhlbG0gY3Jl" + - "ZGVudGlhbCBzdG9yZSBtdXN0IGJlIGF1dGhlbnRpY2F0ZWQgc2VwYXJhdGVseS4KCmBgYGJhc2gK" + - "IyBOR0MgLS0gYm90aCBjb21tYW5kcyBhcmUgcmVxdWlyZWQKZG9ja2VyIGxvZ2luIG52Y3IuaW8g" + - "LXUgJyRvYXV0aHRva2VuJyAtcCAiJE5HQ19BUElfS0VZIgpoZWxtIHJlZ2lzdHJ5IGxvZ2luIG52" + - "Y3IuaW8gLXUgJyRvYXV0aHRva2VuJyAtcCAiJE5HQ19BUElfS0VZIgoKIyBFQ1IKYXdzIGVjciBn" + - "ZXQtbG9naW4tcGFzc3dvcmQgLS1yZWdpb24gPHJlZ2lvbj4gfCBcCiAgaGVsbSByZWdpc3RyeSBs" + - "b2dpbiAtLXVzZXJuYW1lIEFXUyAtLXBhc3N3b3JkLXN0ZGluIDxhY2NvdW50Pi5ka3IuZWNyLjxy" + - "ZWdpb24+LmFtYXpvbmF3cy5jb20KYGBgCgo+IE5vdGU6IGBoZWxtIHJlZ2lzdHJ5IGxvZ2luYCBr" + - "ZWVwcyBvbmx5IHRoZSBsYXN0IGxvZ2luIHBlciBob3N0IChtdWx0aS1vcmcgZ290Y2hhKS4KPiBU" + - "aGUgc3RhY2sgYW5kIHRoZSBjYWNoZXMgc3BhbiBzZXZlcmFsIE5HQyBvcmdzIHRoYXQgbmVlZCBk" + - "aWZmZXJlbnQga2V5cywgYnV0IHRoZXkgYWxsCj4gbGl2ZSB1bmRlciB0aGUgb25lIGhvc3QgYG52" + - "Y3IuaW9gLiBBIHNlY29uZCBgaGVsbSByZWdpc3RyeSBsb2dpbiBudmNyLmlvYCAob3IKPiBgZG9j" + - "a2VyIGxvZ2luYCkgd2l0aCBhIGRpZmZlcmVudCBrZXkgc2lsZW50bHkgb3ZlcndyaXRlcyB0aGUg" + - "Zmlyc3QsIHNvIHB1bGxzIGZvciB0aGUKPiBmaXJzdCBvcmcgdGhlbiBmYWlsIHdpdGggYSBtaXNs" + - "ZWFkaW5nIGA0MDMgQWNjZXNzIERlbmllZGAgKG5vdCBhIDQwMTogdGhlIGF1dGgKPiBzdWNjZWVk" + - "ZWQsIGl0J3MgdGhlIHdyb25nLW9yZyBrZXkpLiBJZiB5b3UgcHVsbCBjaGFydHMvaW1hZ2VzIGZy" + - "b20gbW9yZSB0aGFuIG9uZSBvcmcsCj4gZWl0aGVyIHJlLXJ1biB0aGUgbG9naW4gd2l0aCB0aGUg" + - "Y29ycmVjdCBrZXkgaW1tZWRpYXRlbHkgYmVmb3JlIGVhY2ggcHVsbCwgb3IgcGFzcwo+IGAtLXVz" + - "ZXJuYW1lICckb2F1dGh0b2tlbicgLS1wYXNzd29yZCA8a2V5PmAgcGVyIGBoZWxtIHB1bGxgIGlu" + - "dm9jYXRpb24gaW5zdGVhZCBvZiByZWx5aW5nCj4gb24gdGhlIHN0b3JlZCBsb2dpbi4gKFB1bGxp" + - "bmcgZXZlcnl0aGluZyBmcm9tIGEgc2luZ2xlIHJlZ2lzdHJ5IG1pcnJvciBhdm9pZHMgdGhpcwo+" + - "IGVudGlyZWx5LikKCiMjIyAyYi4gKFJlY29tbWVuZGVkKSBQdWxsIGZyb20gYSByZWdpc3RyeSB0" + - "aGUgY2x1c3RlciBhdXRoZW50aWNhdGVzIHRvIGF1dG9tYXRpY2FsbHkKCklmIHlvdSBtaXJyb3Ig" + - "dGhlIE5WQ0YgaW1hZ2VzIGludG8gYSByZWdpc3RyeSB0aGF0IHRoZSBjbHVzdGVyIGNhbiBwdWxs" + - "IGZyb20gd2l0aG91dCBhbgpleHBsaWNpdCBpbWFnZSBwdWxsIHNlY3JldCAoZm9yIGV4YW1wbGUs" + - "IGEgcmVnaXN0cnkgd2hlcmUgdGhlIG5vZGUgb3Igd29ya2xvYWQgaWRlbnRpdHkgaXMKZ3JhbnRl" + - "ZCBwdWxsIGFjY2VzcyBhdXRvbWF0aWNhbGx5KSwgcG9pbnQgdGhlIHN0YWNrIGF0IGl0IGFuZCBz" + - "a2lwIHRoZSBjcmVkZW50aWFsIHN0ZXBzCmFib3ZlLgoKMS4gUG9pbnQgdGhlIHN0YWNrIGF0IHRo" + - "ZSBtaXJyb3IgaW4gYGVudmlyb25tZW50cy88ZW52Pi55YW1sYDoKCiAgIGBgYHlhbWwKICAgZ2xv" + - "YmFsOgogICAgIGltYWdlOgogICAgICAgcmVnaXN0cnk6IDx5b3VyLXJlZ2lzdHJ5PiAgICAgICAg" + - "ICAgICMgZS5nLiByZWdpc3RyeS5leGFtcGxlLmNvbQogICAgICAgcmVwb3NpdG9yeTogPG1pcnJv" + - "ci1zdWItcGF0aD4KICAgICBoZWxtOgogICAgICAgc291cmNlczogICAgICAgICAgICAgICAgICAg" + - "ICAgICAgICAgICAjIG9ubHkgaWYgeW91IGFsc28gbWlycm9yZWQgdGhlIGhlbG0gY2hhcnRzCiAg" + - "ICAgICAgIHJlZ2lzdHJ5OiA8eW91ci1yZWdpc3RyeT4KICAgICAgICAgcmVwb3NpdG9yeTogPG1p" + - "cnJvci1zdWItcGF0aD4KICAgYGBgCgogICA+IFRoZSBtYW5pZmVzdCBoYXMgYSBtaXggb2YgT0NJ" + - "IGFuZCBub24tT0NJIGFydGlmYWN0cywgc28gY29udmVydCBub24tT0NJIGNoYXJ0cyBiZWZvcmUK" + - "ICAgPiBtaXJyb3JpbmcuIENvbnRhaW5lciBpbWFnZXMgKGBudmNyLmlvLy4uLmApIGFyZSBhbHJl" + - "YWR5IE9DSSBhbmQgbWlycm9yIGFzLWlzLiBTb21lIEhlbG0KICAgPiBjaGFydHMsIHRob3VnaCwg" + - "YXJlIHB1Ymxpc2hlZCB0byBhbiBgaHR0cHM6Ly9gIEhlbG0gcmVwb3NpdG9yeSAoZm9yIGV4YW1w" + - "bGUKICAgPiBgaGVsbS5uZ2MubnZpZGlhLmNvbWApLiBUaG9zZSBhcmUgbm90IE9DSSBhbmQgY2Fu" + - "bm90IGJlIHJlc29sdmVkIGJ5IGBnbG9iYWwuaGVsbS5zb3VyY2VzYAogICA+IChPQ0kpIG9yIG1p" + - "cnJvcmVkIGRpcmVjdGx5LiBCZWZvcmUgcG9pbnRpbmcgdGhlIHN0YWNrIGF0IHlvdXIgbWlycm9y" + - "LCBjb252ZXJ0IGVhY2gKICAgPiBgaHR0cHM6Ly9gIGNoYXJ0IHRvIE9DSTogYGhlbG0gcmVwbyBh" + - "ZGRgL2BoZWxtIHB1bGxgIHRoZSBgLnRnemAsIHRoZW4KICAgPiBgaGVsbSBwdXNoIDxjaGFydD4u" + - "dGd6IG9jaTovLzx5b3VyLXJlZ2lzdHJ5Pi88bWlycm9yLXN1Yi1wYXRoPmAuIE9ubHkgT0NJIGNo" + - "YXJ0cyBjYW4gbGl2ZQogICA+IHVuZGVyIGBnbG9iYWwuaGVsbS5zb3VyY2VzYC4KCjIuIFNraXAg" + - "dGhlIGNyZWRlbnRpYWwgc3RlcHMgYWJvdmUuIFdoZW4gdGhlIGNsdXN0ZXIgYXV0aGVudGljYXRl" + - "cyB0byB0aGUgcmVnaXN0cnkKICAgYXV0b21hdGljYWxseSAodGhlIG5vZGUgb3Igd29ya2xvYWQg" + - "aWRlbnRpdHkgaGFzIHB1bGwgYWNjZXNzKSwgdGhlIHB1bGwgbmVlZHMgbm8KICAgS3ViZXJuZXRl" + - "cyBzZWNyZXQsIHNvIHlvdSBkbyBub3QgbmVlZDogdGhlIHBlci1uYW1lc3BhY2UgYG52Y3ItY3Jl" + - "ZHNgIGZyb20gc3RlcCAxCiAgIChzdGlsbCBjcmVhdGUgdGhlIG5hbWVzcGFjZXMsIGp1c3Qgbm90" + - "IHRoZSBzZWNyZXQpLCB0aGUgYGdsb2JhbC5pbWFnZVB1bGxTZWNyZXRzYCBlbnRyeSwKICAgb3Ig" + - "dGhlIGBkb2NrZXIgbG9naW5gL2BoZWxtIHJlZ2lzdHJ5IGxvZ2luIG52Y3IuaW9gIGZyb20gc3Rl" + - "cCAyLgoKICAgPiBPbmUgcmVzaWR1YWwgY3JlZGVudGlhbDogbnZjZi1hcGkgc3RpbGwgdmFsaWRh" + - "dGVzIHRoZSB1c2VyIGZ1bmN0aW9uIGltYWdlIGF0CiAgID4gYGZ1bmN0aW9uIGNyZWF0ZWAgdmlh" + - "IHRoZSBhY2NvdW50IHJlZ2lzdHJ5IGNyZWRlbnRpYWwsIHNvIHRoYXQgY3JlZGVudGlhbCBtdXN0" + - "IHBvaW50IGF0CiAgID4gd2hpY2hldmVyIHJlZ2lzdHJ5IGhvbGRzIHRoZSB1c2VyIGltYWdlIChz" + - "ZXQgaXQgdG8geW91ciBtaXJyb3IgaWYgdGhlIGltYWdlIGlzIG1pcnJvcmVkCiAgID4gdGhlcmUp" + - "LgoKIyMjIDMuIFNldCB0aGUgcm91dGUgZG9tYWluCgpgZ2xvYmFsLmRvbWFpbmAgc2V0cyB0aGUg" + - "aG9zdG5hbWUgc3VmZml4IGZvciByb3V0ZXMgc3VjaCBhcyBgYXBpLjxkb21haW4+YC4gSXQgZG9l" + - "cyBub3QgY29uZmlndXJlIHRoZSBHYXRld2F5IG9yIGl0cyBsb2FkIGJhbGFuY2VyLiBRdWVyeSB0" + - "aGUgZXhhY3QgZXhpc3RpbmcgR2F0ZXdheToKCmBgYGJhc2gKR0FURVdBWV9BRERSRVNTPSQoa3Vi" + - "ZWN0bCBnZXQgZ2F0ZXdheSA8Z2F0ZXdheS1uYW1lPiAtbiA8Z2F0ZXdheS1uYW1lc3BhY2U+IC1v" + - "IGpzb25wYXRoPSd7LnN0YXR1cy5hZGRyZXNzZXNbMF0udmFsdWV9JykKdGVzdCAtbiAiJEdBVEVX" + - "QVlfQUREUkVTUyIKYGBgCgpUaGUgYWRkcmVzcyBtYXkgYmUgYSBob3N0bmFtZSBvciBJUC4gQ2xv" + - "dWQgcHJvdmlkZXIgYW5kIEdhdGV3YXkgY29uZmlndXJhdGlvbiBkZXRlcm1pbmUgdGhlIGxvYWQg" + - "YmFsYW5jZXIgdHlwZSBhbmQgYWRkcmVzcyBmb3JtYXQuIFRoZSBzZWxmLW1hbmFnZWQgc3RhY2sg" + - "ZG9lcyBub3Qgc2VsZWN0IGVpdGhlci4KCkZvciB0ZW1wb3JhcnkgdGVzdGluZyB3aXRob3V0IERO" + - "UywgdXNlIGEgc3RhYmxlIHJlc2VydmVkIHN1ZmZpeCBzdWNoIGFzIGBnbG9iYWwuZG9tYWluOiAi" + - "bnZjZi50ZXN0ImAsIGNvbm5lY3QgdG8gYEdBVEVXQVlfQUREUkVTU2AsIGFuZCBzZW5kIGBIb3N0" + - "OiBhcGkubnZjZi50ZXN0YC4gRm9yIHByb2R1Y3Rpb24sIHVzZSB5b3VyIGJhc2UgRE5TIGRvbWFp" + - "biBhbmQgcG9pbnQgaXRzIHJvdXRlIHJlY29yZHMgdG8gYEdBVEVXQVlfQUREUkVTU2AuIElmIHRo" + - "ZSBlbmRwb2ludCBsYXRlciBjaGFuZ2VzLCBrZWVwIGBnbG9iYWwuZG9tYWluYCBzdGFibGUgYW5k" + - "IHVwZGF0ZSBvbmx5IEROUyBvciB0aGUgY2xpZW50IGRlc3RpbmF0aW9uLiBTZWUgRXhhbXBsZSA0" + - "IGluIFtleGFtcGxlcy5tZF0oZXhhbXBsZXMubWQpLgoKIyMjIDQuIFByZXZpZXcgYW5kIGRlcGxv" + - "eQoKYGBgYmFzaApIRUxNRklMRV9FTlY9PGVudi1uYW1lPiBoZWxtZmlsZSB0ZW1wbGF0ZSAgICMg" + - "UHJldmlldyByZW5kZXJlZCBtYW5pZmVzdHMKSEVMTUZJTEVfRU5WPTxlbnYtbmFtZT4gaGVsbWZp" + - "bGUgc3luYyAgICAgICAjIERlcGxveQpgYGAKCj4gU2V0IEJPVEggQ2Fzc2FuZHJhIHBhc3N3b3Jk" + - "cyBpbiB0aGUgc2VjcmV0cyBmaWxlLiBUaGUgc2VjcmV0cyBmaWxlIGRvY3VtZW50cwo+IGBERUZB" + - "VUxUX0NBU1NBTkRSQV9QQVNTV09SRGAsIGJ1dCB0aGUgY2Fzc2FuZHJhIGNoYXJ0IGhhcyBhIHNl" + - "Y29uZCBjcmVkZW50aWFsLAo+IGBjYXNzYW5kcmEuc2VydmljZVJvbGVQYXNzd29yZGAsIHRoYXQg" + - "ZGVmYXVsdHMgdG8gYGNoQG5nM20zYC4gSWYgeW91IHNldCBvbmx5Cj4gYERFRkFVTFRfQ0FTU0FO" + - "RFJBX1BBU1NXT1JEYCwgdGhlIHNlcnZpY2Ugcm9sZSBrZWVwcyB0aGF0IGRlZmF1bHQgYW5kIGV2" + - "ZXJ5IERCLWJhY2tlZAo+IHNlcnZpY2UgY3Jhc2gtbG9vcHMgd2l0aCBgQmFkIGNyZWRlbnRpYWxz" + - "YCBvbmNlIGl0IHRyaWVzIHRvIGF1dGhlbnRpY2F0ZS4gU2V0Cj4gYGNhc3NhbmRyYS5zZXJ2aWNl" + - "Um9sZVBhc3N3b3JkYCB0byB0aGUgc2FtZSB2YWx1ZSBhcyBgREVGQVVMVF9DQVNTQU5EUkFfUEFT" + - "U1dPUkRgLgoKIyMjIDUuIERlcGxveW1lbnQgcGhhc2VzCgpIZWxtZmlsZSBkZXBsb3lzIGluIG9y" + - "ZGVyIHdpdGggZGVwZW5kZW5jaWVzOgoKfCBQaGFzZSB8IFNlbGVjdG9yIHwgU2VydmljZXMgfCBX" + - "YWl0IHRpbWUgfAp8LS0tLS0tLXwtLS0tLS0tLS0tfC0tLS0tLS0tLS18LS0tLS0tLS0tLS18Cnwg" + - "MSB8IGByZWxlYXNlLWdyb3VwPWRlcGVuZGVuY2llc2AgfCBOQVRTLCBPcGVuQmFvLCBDYXNzYW5k" + - "cmEgfCA1LTEwIG1pbiB8CnwgMiB8IGByZWxlYXNlLWdyb3VwPXNlcnZpY2VzYCB8IEFQSSwgU0lT" + - "LCBFU1MsIGludm9jYXRpb24sIGdycGMtcHJveHksIG5vdGFyeSwgYXBpLWtleXMsIG9wdGlvbmFs" + - "IExMTSBnYXRld2F5L3JvdXRlciwgb3B0aW9uYWwgVmFuaXR5IEdhdGV3YXksIE5WQ0YgVUkgd2hl" + - "biB0aGUgc3RhY2sgcGFja2FnZSBpbmNsdWRlcyB0aGUgYWRkb24gfCA1LTEwIG1pbiB8CnwgMyB8" + - "IGByZWxlYXNlLWdyb3VwPWluZ3Jlc3NgIHwgR2F0ZXdheSByb3V0ZXMgfCAxLTIgbWluIHwKfCA0" + - "IHwgYHJlbGVhc2UtZ3JvdXA9b2JzZXJ2YWJpbGl0eWAgfCBPYnNlcnZhYmlsaXR5IHN0YWNrIChp" + - "ZiBlbmFibGVkKSB8IDEtMiBtaW4gfAoKTW9uaXRvciBpbiBhIHNlcGFyYXRlIHRlcm1pbmFsOgoK" + - "YGBgYmFzaAprdWJlY3RsIGdldCBwb2RzIC1BIC13CmBgYAoKIyMjIDYuIEVuYWJsZSBWYW5pdHkg" + - "R2F0ZXdheSAob3B0aW9uYWwpCgpWYW5pdHkgR2F0ZXdheSBpcyBkaXNhYmxlZCBieSBkZWZhdWx0" + - "IGFuZCBpcyBvbmx5IG5lZWRlZCBmb3IgY3VzdG9tZXItZmFjaW5nCmhvc3RuYW1lcyBvciBwYXRo" + - "IG1hcHBpbmdzIGluIGZyb250IG9mIHRoZSBzdGFuZGFyZCBOVkNGIEFQSSBhbmQgaW52b2NhdGlv" + - "bgpyb3V0ZXMuIEl0IGlzIGF2YWlsYWJsZSBvbmx5IGluIHN0YWNrIHBhY2thZ2VzIHRoYXQgaW5j" + - "bHVkZSB0aGUgVmFuaXR5IEdhdGV3YXkKYWRkb24uIElmIHRoZSBleHRyYWN0ZWQgc3RhY2sgcGFj" + - "a2FnZSBkb2VzIG5vdCBjb250YWluIGEgYHZhbml0eS1nYXRld2F5YApyZWxlYXNlIGFuZCBgdmFu" + - "aXR5R2F0ZXdheWAgcm91dGUgdmFsdWVzLCBza2lwIHRoZXNlIGNvbW1hbmRzIGFuZCB1c2UgYSBu" + - "ZXdlcgpzdGFjayBwYWNrYWdlIHRoYXQgaW5jbHVkZXMgdGhlbS4KCklmIHRoZSBzdGFjayBwYWNr" + - "YWdlIGluY2x1ZGVzIHRoZSBhZGRvbiwgZW5hYmxlIGl0IGluCmBlbnZpcm9ubWVudHMvPGVudi1u" + - "YW1lPi55YW1sYDoKCmBgYHlhbWwKYWRkb25zOgogIHZhbml0eUdhdGV3YXk6CiAgICBlbmFibGVk" + - "OiB0cnVlCiAgICBtYXBwaW5nQ29uZmlnOiB7fQpgYGAKClRoZSBkZWZhdWx0IHJvdXRlIGhvc3Qg" + - "aXMgYHZhbml0eS48ZG9tYWluPmAgYW5kIHRoZSBiYWNrZW5kIGlzCmB2YW5pdHktZ2F0ZXdheS5u" + - "dmNmOjgwODBgLiBQdXQgZGVwbG95bWVudC1zcGVjaWZpYyBob3N0IGFuZCBwYXRoIG1hcHBpbmdz" + - "IHVuZGVyCmBhZGRvbnMudmFuaXR5R2F0ZXdheS5tYXBwaW5nQ29uZmlnYC4gSWYgeW91IG5lZWQg" + - "Y3VzdG9tIHZhbml0eSBob3N0cyBpbnN0ZWFkIG9mCmB2YW5pdHkuPGRvbWFpbj5gLCB1c2UgdGhl" + - "IHJvdXRlIGhvc3RuYW1lIG92ZXJyaWRlcyBzdXBwb3J0ZWQgYnkgdGhlIHN0YWNrCnBhY2thZ2Ug" + - "YW5kIGNyZWF0ZSBtYXRjaGluZyBETlMgcmVjb3Jkcy4KCkFmdGVyIGNvbmZpcm1pbmcgdGhlIHBh" + - "Y2thZ2UgaW5jbHVkZXMgdGhlIGB2YW5pdHktZ2F0ZXdheWAgcmVsZWFzZSwgcHJldmlldyBhbmQK" + - "YXBwbHkgdGhlIHNlcnZpY2UgYW5kIHJvdXRlOgoKYGBgYmFzaApIRUxNRklMRV9FTlY9PGVudi1u" + - "YW1lPiBoZWxtZmlsZSAtLXNlbGVjdG9yIG5hbWU9dmFuaXR5LWdhdGV3YXkgdGVtcGxhdGUKSEVM" + - "TUZJTEVfRU5WPTxlbnYtbmFtZT4gaGVsbWZpbGUgLS1zZWxlY3RvciBuYW1lPXZhbml0eS1nYXRl" + - "d2F5IHN5bmMKSEVMTUZJTEVfRU5WPTxlbnYtbmFtZT4gaGVsbWZpbGUgLS1zZWxlY3RvciByZWxl" + - "YXNlLWdyb3VwPWluZ3Jlc3Mgc3luYwpgYGAKClZlcmlmeSB0aGUgcm91dGUgb25seSB3aGVuIHRo" + - "ZSBhZGRvbiBpcyBwcmVzZW50IGFuZCBlbmFibGVkOgoKYGBgYmFzaAprdWJlY3RsIGdldCBkZXBs" + - "b3ksc3ZjIC1uIG52Y2YgLWwgYXBwLmt1YmVybmV0ZXMuaW8vbmFtZT12YW5pdHktZ2F0ZXdheQpr" + - "dWJlY3RsIGdldCBodHRwcm91dGUgLUEgfCBncmVwIC1pIHZhbml0eQpjdXJsIC1IICJIb3N0OiB2" + - "YW5pdHkuPGRvbWFpbj4iICJodHRwOi8vPGdhdGV3YXktYWRkcmVzcz4vaGVhbHRoIgpgYGAKCkRv" + - "IG5vdCBlbmFibGUgVmFuaXR5IEdhdGV3YXkgZm9yIHN0YW5kYXJkIEFQSSwgQVBJIEtleXMsIGlu" + - "dm9jYXRpb24sIExMTQppbnZvY2F0aW9uLCBvciBnUlBDIHRyYWZmaWMuIFRob3NlIHJvdXRlcyBh" + - "cmUgcHJvdmlkZWQgYnkgdGhlIGJhc2UgZ2F0ZXdheQpyb3V0ZXMuCgojIyMgNy4gRW5hYmxlIE5W" + - "Q0YgVUkgKG9wdGlvbmFsKQoKTlZDRiBVSSBpcyBhbiBvcHRpb25hbCwgY3VzdG9tZXItZmFjaW5n" + - "IGFkbWluLXBhbmVsIFVJLiBJdCBpcyBkaXNhYmxlZCBieQpkZWZhdWx0IGFuZCBhdmFpbGFibGUg" + - "b25seSBpbiBzdGFjayBwYWNrYWdlcyB0aGF0IGluY2x1ZGUgdGhlIE5WQ0YgVUkgYWRkb24uIElm" + - "CnlvdXIgZXh0cmFjdGVkIHN0YWNrIHBhY2thZ2UgZG9lcyBub3QgY29udGFpbiBhIGBudmNmLXVp" + - "YCByZWxlYXNlIGFuZCBgbnZjZlVpYApyb3V0ZSB2YWx1ZXMsIHNraXAgdGhpcyBzZWN0aW9uLiBU" + - "aGUgTlZDRiBVSSBhZG1pbiBwYW5lbCBpcyBjdXJyZW50bHkKdW5hdXRoZW50aWNhdGVkOyBkbyBu" + - "b3QgZXhwb3NlIGl0IHRvIHRoZSBwdWJsaWMgaW50ZXJuZXQuCgpGb3IgZW5hYmxlbWVudCwgbmFt" + - "ZXNwYWNlIGFuZCBwdWxsIHNlY3JldCBzZXR1cCwgYXBwbHksIGFuZCB2ZXJpZmljYXRpb24gc3Rl" + - "cHMsCnNlZSBbTlZDRiBVSSBBZGRvbl0ocmVmZXJlbmNlcy9udmNmLXVpLWFkZG9uLm1kKS4KCiMj" + - "IyA4LiBJbnN0YWxsIHRoZSBzZXBhcmF0ZSBjb21wdXRlLXBsYW5lIHN0YWNrCgpUaGUgY29udHJv" + - "bC1wbGFuZSBzdGFjayBkb2VzIG5vdCBpbnN0YWxsIGNvbXB1dGUtcGxhbmUgY29tcG9uZW50cy4g" + - "QWZ0ZXIgaXQgaXMgaGVhbHRoeSwgY2hvb3NlIGV4YWN0bHkgb25lIGNvbXB1dGUtcGxhbmUgaGFu" + - "ZG9mZjoKCi0gSGVsbWZpbGUgdmFsdWVzIGZsb3c6IGF1dGhvciBzZXBhcmF0ZSBlbnZpcm9ubWVu" + - "dCBmaWxlcywgdGhlbiByZWdpc3RlciBhbmQKICBpbnN0YWxsIHdpdGggdGhlIGNvbXB1dGUtcGxh" + - "bmUgTWFrZWZpbGUuCi0gQ0xJIHByb2ZpbGUgZmxvdzogdXNlIHRoZSBwcm9maWxlIGdlbmVyYXRl" + - "ZCBieSB0aGUgQ0xJIGNvbnRyb2wtcGxhbmUgaW5zdGFsbCwKICB0aGVuIHJ1biB0aGUgY29tcGxl" + - "dGUgQ0xJIHJlZ2lzdGVyIGFuZCBpbnN0YWxsIGNvbW1hbmRzLgoKRG8gbm90IG1peCB0aGVzZSBo" + - "YW5kb2Zmcy4gRm9sbG93IFtTcGxpdCBDb21wdXRlLVBsYW5lIEluc3RhbGxhdGlvbl0ocmVmZXJl" + - "bmNlcy9jb21wdXRlLXBsYW5lLWluc3RhbGxhdGlvbi5tZCkKZm9yIGNvbW1hbmRzLCBFS1Mgcm91" + - "dGluZywgcHVsbCBzZWNyZXRzLCB2ZXJpZmljYXRpb24sIGFuZCB0ZWFyZG93biBvcmRlci4KCiMj" + - "IENsZWFuIFRlYXJkb3duCgpEZXN0cm95IGV2ZXJ5IGNvbXB1dGUgcGxhbmUgYmVmb3JlIHRoZSBj" + - "b250cm9sIHBsYW5lLCB1c2luZyB0aGUgdGVhcmRvd24gZm9yCnRoZSBzYW1lIGhhbmRvZmY7IHNl" + - "ZSBbU3BsaXQgQ29tcHV0ZS1QbGFuZSBJbnN0YWxsYXRpb25dKHJlZmVyZW5jZXMvY29tcHV0ZS1w" + - "bGFuZS1pbnN0YWxsYXRpb24ubWQjdGVhcmRvd24pLgoKU2NvcGU6IE9ubHkgZGVzdHJveSByZWxl" + - "YXNlcyBtYW5hZ2VkIGJ5IHRoaXMgY29udHJvbC1wbGFuZSBoZWxtZmlsZSBzdGFjay4gVGhlIE5W" + - "Q0YgcmVsZWFzZXMgYXJlOiBgbmF0c2AsIGBvcGVuYmFvLXNlcnZlcmAsIGBjYXNzYW5kcmFgLCBg" + - "YXBpLWtleXNgLCBgc2lzYCwgYGFwaWAsIGBpbnZvY2F0aW9uLXNlcnZpY2VgLCBgZ3JwYy1wcm94" + - "eWAsIGBlc3MtYXBpYCwgYG5vdGFyeS1zZXJ2aWNlYCwgb3B0aW9uYWwgYHZhbml0eS1nYXRld2F5" + - "YCwgYG52Y2YtdWlgLCBgYWRtaW4taXNzdWVyLXByb3h5YCwgYW5kIGBpbmdyZXNzYDsgYGNlcnQt" + - "bWFuYWdlcmAgaXMgaW5jbHVkZWQgb25seSB3aGVuIGBjZXJ0TWFuYWdlci5lbmFibGVkOiB0cnVl" + - "YC4gVGhlIGNvbnRyb2wtcGxhbmUgbmFtZXNwYWNlIGludmVudG9yeSBhbHNvIGluY2x1ZGVzIGBj" + - "ZXJ0LW1hbmFnZXJgLCBidXQgZGVmYXVsdCBuYW1lc3BhY2UgY2xlYW51cCBwcmVzZXJ2ZXMgaXQu" + - "IE5hbWVzcGFjZSBwcmVzZXJ2YXRpb24gZG9lcyBub3QgcHJlc2VydmUgdGhlIEhlbG0gcmVsZWFz" + - "ZS4gRXh0ZXJuYWwgY2VydC1tYW5hZ2VyIG11c3QgdXNlIGBjZXJ0TWFuYWdlci5lbmFibGVkOiBm" + - "YWxzZWA7IG90aGVyd2lzZSBIZWxtZmlsZSB0cmVhdHMgdGhlIHJlbGVhc2UgYXMgc3RhY2stbWFu" + - "YWdlZC4gRG8gbm90IGRlbGV0ZSBvdGhlciBoZWxtIHJlbGVhc2VzIG9yIG5hbWVzcGFjZXMgb24g" + - "dGhlIGNsdXN0ZXIuCgpCZWZvcmUgY29udHJvbC1wbGFuZSBkZXN0cm95LCBpZGVudGlmeSB3aGV0" + - "aGVyIGNlcnQtbWFuYWdlciBpcyBleHRlcm5hbC4gRm9yIGFuIGV4dGVybmFsIGluc3RhbGxhdGlv" + - "biwgcmVxdWlyZSBlZmZlY3RpdmUgYGNlcnRNYW5hZ2VyLmVuYWJsZWQ6IGZhbHNlYCBhbmQgYWJv" + - "cnQgaWYgaXQgaXMgdHJ1ZS4gRm9yIGFuIGludGVuZGVkIHN0YWNrLW1hbmFnZWQgcmVsZWFzZSB3" + - "aXRoIGVmZmVjdGl2ZSBgY2VydE1hbmFnZXIuZW5hYmxlZDogdHJ1ZWAsIHZlcmlmeSBtYXRjaGlu" + - "ZyBzdGFjayBIZWxtIHJlbGVhc2UgbWV0YWRhdGEgYW5kIGFib3J0IG9uIG1pc3Npbmcgb3IgbWlz" + - "bWF0Y2hlZCBldmlkZW5jZS4gUHJlc2VydmUgdGhlIG5hbWVzcGFjZSBieSBkZWZhdWx0OyBvbmx5" + - "IHBsYW4gaXRzIHJlbW92YWwgYWZ0ZXIgdmVyaWZ5aW5nIG5hbWVzcGFjZSBwcm92ZW5hbmNlIGFu" + - "ZCBnZXR0aW5nIGV4cGxpY2l0IGNvbmZpcm1hdGlvbi4KCiMjIyBTdGFuZGFyZCB0ZWFyZG93bgoK" + - "UnVuIGZyb20gaW5zaWRlIHRoZSBgbnZjZi1zZWxmLW1hbmFnZWQtc3RhY2svYCBkaXJlY3Rvcnk6" + - "CgpgYGBiYXNoCkhFTE1GSUxFX0VOVj08ZW52LW5hbWU+IGhlbG1maWxlIGRlc3Ryb3kKYGBgCgoj" + - "IyMgRGVsZXRlIG5hbWVzcGFjZXMKCmBgYGJhc2gKZm9yIG5zIGluIGNhc3NhbmRyYS1zeXN0ZW0g" + - "bmF0cy1zeXN0ZW0gbnZjZiBhcGkta2V5cyBlc3Mgc2lzIG52Y2YtdWkgdmF1bHQtc3lzdGVtOyBk" + - "bwogIGt1YmVjdGwgZGVsZXRlIG5hbWVzcGFjZSAiJG5zIiAtLWlnbm9yZS1ub3QtZm91bmQKZG9u" + - "ZQpgYGAKCiMjIyBWZXJpZnkgY2xlYW4KCmBgYGJhc2gKa3ViZWN0bCBnZXQgbnMgfCBncmVwIC1F" + - "ICcoY2Fzc2FuZHJhfG5hdHN8dmF1bHR8bnZjZnxhcGkta2V5c3xlc3N8c2lzKScKIyBTaG91bGQg" + - "cmV0dXJuIGVtcHR5CmBgYAoKIyMgT3ZlcnJpZGluZyBIZWxtIENoYXJ0IFZhbHVlcwoKIyMjIEVu" + - "dmlyb25tZW50IGZpbGUgKGxpbWl0ZWQpCgpXb3JrcyBvbmx5IGZvciBrZXlzIHRoYXQgYGdsb2Jh" + - "bC55YW1sLmdvdG1wbGAgcHJvcGFnYXRlcyAoZS5nLiwgYGNhc3NhbmRyYS5yZXBsaWNhQ291bnRg" + - "LCBgZ2xvYmFsLnN0b3JhZ2VDbGFzc2AsIGBnbG9iYWwuaW1hZ2VgKS4KCiMjIyBSZWxlYXNlIHZh" + - "bHVlcyBibG9jayAoYW55IGNoYXJ0IHZhbHVlKQoKRWRpdCB0aGUgcmVsZWFzZSBpbiBgaGVsbWZp" + - "bGUuZC8qLnlhbWwuZ290bXBsYCBhbmQgYWRkIGEgYHZhbHVlczpgIGJsb2NrLiBJbiB0aGUgZXhh" + - "bXBsZXMgYmVsb3csIGA8cHJpdmF0ZS12YWx1ZXM+YCByZWZlcnMgdG8gdGhlIGBzZWNyZXRzL2Ag" + - "ZGlyZWN0b3J5IGF0IHRoZSBoZWxtZmlsZSBzdGFjayByb290LgoKYGBgeWFtbAotIG5hbWU6IGNh" + - "c3NhbmRyYQogIHZlcnNpb246IDAuOC4wCiAgY29uZGl0aW9uOiBjYXNzYW5kcmEuZW5hYmxlZAog" + - "IG5hbWVzcGFjZTogY2Fzc2FuZHJhLXN5c3RlbQogIDw8OiAqZGVwZW5kZW5jeQogIHZhbHVlczoK" + - "ICAgIC0gLi4vZ2xvYmFsLnlhbWwuZ290bXBsCiAgICAtIC4uLzxwcml2YXRlLXZhbHVlcz4ve3sg" + - "cmVxdWlyZWRFbnYgIkhFTE1GSUxFX0VOViIgfX0tc2VjcmV0cy55YW1sCiAgICAtIGNhc3NhbmRy" + - "YToKICAgICAgICByZXNvdXJjZXM6CiAgICAgICAgICBsaW1pdHM6CiAgICAgICAgICAgIGNwdTog" + - "IjgiCiAgICAgICAgICAgIG1lbW9yeTogODE5Mk1pCiAgICAgICAgICByZXF1ZXN0czoKICAgICAg" + - "ICAgICAgY3B1OiAiMiIKICAgICAgICAgICAgbWVtb3J5OiA0MDk2TWkKYGBgCgpZQU1MIG1lcmdl" + - "IGdvdGNoYTogV2hlbiBhIHJlbGVhc2UgdXNlcyBgPDw6ICpkZXBlbmRlbmN5YCBvciBgaW5oZXJp" + - "dGAsIHNwZWNpZnlpbmcgYHZhbHVlczpgIHJlcGxhY2VzIHRoZSB0ZW1wbGF0ZSdzIHZhbHVlcyBs" + - "aXN0LiBZb3UgbXVzdCByZS1pbmNsdWRlIGBnbG9iYWwueWFtbC5nb3RtcGxgIGFuZCB0aGUgc2Vj" + - "cmV0cyBmaWxlLgoKIyMjIFByZXZpZXcgYW5kIGFwcGx5IGEgc2luZ2xlIHJlbGVhc2UKCmBgYGJh" + - "c2gKSEVMTUZJTEVfRU5WPTxlbnY+IGhlbG1maWxlIC0tc2VsZWN0b3IgbmFtZT1jYXNzYW5kcmEg" + - "dGVtcGxhdGUgICMgUHJldmlldwpIRUxNRklMRV9FTlY9PGVudj4gaGVsbWZpbGUgLS1zZWxlY3Rv" + - "ciBuYW1lPWNhc3NhbmRyYSBzeW5jICAgICAgIyBBcHBseQpgYGAKCiMjIEltYWdlIFB1bGwgU2Vj" + - "cmV0cwoKVGhlcmUgYXJlIHRocmVlIGRpc3RpbmN0IGNyZWRlbnRpYWwgdHlwZXMuIERvIG5vdCBj" + - "b25mbGF0ZSB0aGVtLCBhbmQgbWluZCB0aGUKZm9ybWF0IHJ1bGUgYmVsb3cgKHR3byBvZiB0aGVt" + - "IGZhaWwgZXZlbiB3aXRoIHRoZSBjb3JyZWN0IGtleSBpZiB0aGUgZm9ybWF0IGlzIHdyb25nKToK" + - "CnwgfCBDb250cm9sIFBsYW5lIFB1bGwgU2VjcmV0cyB8IEFQSSBCb290c3RyYXAgUmVnaXN0cnkg" + - "Q3JlZHMgfCBTaWRlY2FyIEltYWdlLVB1bGwgU2VjcmV0IHwKfC0tLXwtLS18LS0tfC0tLXwKfCBQ" + - "dXJwb3NlIHwgSzhzIHB1bGxzIE5WQ0Ygc2VydmljZSBpbWFnZXMgfCBudmNmLWFwaSB2YWxpZGF0" + - "ZXMgdGhlIHVzZXIgZnVuY3Rpb24gaW1hZ2UgYXQgYGZ1bmN0aW9uIGNyZWF0ZWAgfCBudmNmLWFw" + - "aSBwdWxscyB0aGUgcGxhdGZvcm0gc2lkZWNhcnMgaW5qZWN0ZWQgaW50byBldmVyeSB3b3JrZXIg" + - "cG9kIHwKfCBXaGVyZSB8IEs4cyBgZG9ja2VyLXJlZ2lzdHJ5YCBTZWNyZXRzICsgS3l2ZXJubyBD" + - "bHVzdGVyUG9saWN5IHwgYDxwcml2YXRlLXZhbHVlcz4vPGVudj4tc2VjcmV0cy55YW1sYCAoYWNj" + - "b3VudC1ib290c3RyYXApIHwgYE5WQ0ZfQVBJX1NJREVDQVJTX0lNQUdFX1BVTExfU0VDUkVUYCBp" + - "biBgPGVudj4tc2VjcmV0cy55YW1sYCAtPiBPcGVuQmFvIGBzZXJ2aWNlcy9udmNmLWFwaS9rdi9z" + - "aWRlY2Fycy9pbWFnZS1wdWxsLXNlY3JldGAgKGZpZWxkIGBzZWNyZXRgKSB8CnwgRm9ybWF0IHwg" + - "c3RhbmRhcmQgYC5kb2NrZXJjb25maWdqc29uYCAoa3ViZWN0bCBidWlsZHMgaXQgZm9yIHlvdSkg" + - "fCBgYmFzZTY0KCIkb2F1dGh0b2tlbjo8a2V5PiIpYCB8IGBiYXNlNjQoIiRvYXV0aHRva2VuOjxr" + - "ZXk+IilgIHwKCj4gTm90ZTogdGhlIGFjY291bnQtYm9vdHN0cmFwIGNyZWQgYW5kIHRoZSBzaWRl" + - "Y2FyIHNlY3JldCBhcmUgTk9UIGEKPiBkb2NrZXJjb25maWdqc29uLiBUaGUgc2VjcmV0cyB0ZW1w" + - "bGF0ZSBzaGlwcyB0aGUgcGxhY2Vob2xkZXIKPiBgUkVQTEFDRV9XSVRIX0JBU0U2NF9ET0NLRVJf" + - "Q1JFREVOVElBTGAsIHdoaWNoIHJlYWRzIGFzICJiYXNlNjQgb2YgYSBkb2NrZXJjb25maWdqc29u" + - "LiIKPiBJdCBpc24ndC4gQm90aCB2YWx1ZXMgYXJlIGNvbnN1bWVkIGFzIGEgcmF3IGRvY2tlciBg" + - "YXV0aGAgc3RyaW5nOiBudmNmLWFwaSB0YWtlcyB0aGUKPiB2YWx1ZSB2ZXJiYXRpbSBhbmQgZHJv" + - "cHMgaXQgaW50byB0aGUgYGF1dGhgIGZpZWxkIG9mIHRoZSBkb2NrZXJjb25maWcgaXQgYnVpbGRz" + - "LiBTbyB0aGUKPiB2YWx1ZSBtdXN0IGJlIGBiYXNlNjQoIiRvYXV0aHRva2VuOjxrZXk+IilgLCBl" + - "LmcuIGBwcmludGYgJyRvYXV0aHRva2VuOiVzJyAiJEtFWSIgfCBvcGVuc3NsIGJhc2U2NCAtQWAs" + - "Cj4gbm90IGBiYXNlNjQoPGRvY2tlcmNvbmZpZ2pzb24+KWAuCj4KPiAtIFdyb25nIGFjY291bnQt" + - "Ym9vdHN0cmFwIHZhbHVlOiBgZnVuY3Rpb24gY3JlYXRlYCByZXR1cm5zCj4gICAgYDQwMCAibXVz" + - "dCBiZSBiYXNlNjQgZW5jb2RlZCB1c2VybmFtZTpwYXNzd29yZCBmb3JtYXQiYC4KPiAtIFdyb25n" + - "IHNpZGVjYXIgdmFsdWU6IGV2ZXJ5IHdvcmtlcidzIHNpZGVjYXIgcHVsbCBnZXRzIGEgbWFsZm9y" + - "bWVkIGNyZWRlbnRpYWwgYW5kCj4gICAgYDQwM2BzLCByZWdhcmRsZXNzIG9mIHdoaWNoIGtleSBp" + - "cyBpbnNpZGUuIFRoZSBzeW1wdG9tIGxvb2tzIGxpa2UgYSBiYWQgb3Igd3Jvbmctb3JnCj4gICAg" + - "a2V5LCBidXQgdGhlIGtleSBpcyBmaW5lIGFuZCB0aGUgZW5jb2RpbmcgaXMgdGhlIGJ1Zy4gVG8g" + - "cmVwYWlyIGFuIGFscmVhZHktaW5zdGFsbGVkCj4gICAgc3RhY2sgd2l0aG91dCBhIGZ1bGwgcmUt" + - "c3luYzoKPgo+ICAgIGBgYGJhc2gKPiAgICBBVVRIPSQocHJpbnRmICckb2F1dGh0b2tlbjolcycg" + - "IiRLRVkiIHwgb3BlbnNzbCBiYXNlNjQgLUEpCj4gICAgYmFvIGt2IHB1dCBzZXJ2aWNlcy9udmNm" + - "LWFwaS9rdi9zaWRlY2Fycy9pbWFnZS1wdWxsLXNlY3JldCBzZWNyZXQ9IiRBVVRIIgo+ICAgICMg" + - "aWYgTlZJRElBIENsb3VkIFRhc2tzIGlzIGVuYWJsZWQsIHJlcGVhdCBmb3Igc2VydmljZXMvbnZj" + - "dC1hcGkva3Yvc2lkZWNhcnMvaW1hZ2UtcHVsbC1zZWNyZXQKPiAgICBrdWJlY3RsIHJvbGxvdXQg" + - "cmVzdGFydCAtbiBudmNmIGRlcGxveS9udmNmLWFwaSAgICMgcGlja3MgdXAgdGhlIG5ldyBzaWRl" + - "Y2FyIGNyZWQKPiAgICBgYGAKPgo+IE5vdGUgKG11bHRpLW9yZyk6IHdoZW4gcHVsbGluZyBmcm9t" + - "IE5HQyAobm90IGEgbWlycm9yKSwgdGhlIHVzZXIgZnVuY3Rpb24gaW1hZ2UgYW5kCj4gdGhlIHBs" + - "YXRmb3JtIHNpZGVjYXJzIG9mdGVuIGxpdmUgaW4gZGlmZmVyZW50IG52Y3IuaW8gb3JncywgZWFj" + - "aCBuZWVkaW5nIGEgZGlmZmVyZW50Cj4ga2V5LCBzbyB0aGUgYWNjb3VudC1ib290c3RyYXAgY3Jl" + - "ZCAoZnVuY3Rpb24gaW1hZ2UpIGFuZCB0aGUgc2lkZWNhciBzZWNyZXQgKHNpZGVjYXJzKQo+IGFy" + - "ZSBmcmVxdWVudGx5IHR3byBkaWZmZXJlbnQga2V5cy4gTWlycm9yaW5nIGV2ZXJ5dGhpbmcgaW50" + - "byBhIHNpbmdsZSByZWdpc3RyeSB0aGUKPiBjbHVzdGVyIGF1dGhlbnRpY2F0ZXMgdG8gYXV0b21h" + - "dGljYWxseSBjb2xsYXBzZXMgdGhpcyB0byBvbmUgY3JlZGVudGlhbCBwYXRoLgoKIyMjIENvbmZp" + - "Z3VyaW5nIHdpdGggS3l2ZXJubyAocmVjb21tZW5kZWQpCgpVc2UgYSBLeXZlcm5vIG11dGF0aW5n" + - "IGFkbWlzc2lvbiBwb2xpY3kgdG8gYXV0b21hdGljYWxseSBpbmplY3QgYGltYWdlUHVsbFNlY3Jl" + - "dHNgIGludG8gYWxsIHBvZHMgaW4gTlZDRiBuYW1lc3BhY2VzLiBUaGlzIHdvcmtzIHVuaWZvcm1s" + - "eSBmb3IgYWxsIGNoYXJ0cyAtLSBubyBwZXItY2hhcnQgY29uZmlndXJhdGlvbiBvciBoZWxtZmls" + - "ZSBtb2RpZmljYXRpb25zIG5lZWRlZC4KCmBgYGJhc2gKIyAxLiBJbnN0YWxsIEt5dmVybm8KaGVs" + - "bSByZXBvIGFkZCBreXZlcm5vIGh0dHBzOi8va3l2ZXJuby5naXRodWIuaW8va3l2ZXJuby8KaGVs" + - "bSByZXBvIHVwZGF0ZQpoZWxtIGluc3RhbGwga3l2ZXJubyBreXZlcm5vL2t5dmVybm8gLW4ga3l2" + - "ZXJubyAtLWNyZWF0ZS1uYW1lc3BhY2UKCiMgMi4gQ3JlYXRlIHB1bGwgc2VjcmV0IGluIGVhY2gg" + - "bmFtZXNwYWNlCmV4cG9ydCBOR0NfQVBJX0tFWT0iPHlvdXIta2V5PiIKZm9yIG5zIGluIGNhc3Nh" + - "bmRyYS1zeXN0ZW0gbmF0cy1zeXN0ZW0gbnZjZiBhcGkta2V5cyBlc3Mgc2lzIHZhdWx0LXN5c3Rl" + - "bSBjZXJ0LW1hbmFnZXI7IGRvCiAga3ViZWN0bCBjcmVhdGUgc2VjcmV0IGRvY2tlci1yZWdpc3Ry" + - "eSBudmNyLWNyZWRzIFwKICAgIC0tZG9ja2VyLXNlcnZlcj1udmNyLmlvIFwKICAgIC0tZG9ja2Vy" + - "LXVzZXJuYW1lPSckb2F1dGh0b2tlbicgXAogICAgLS1kb2NrZXItcGFzc3dvcmQ9IiROR0NfQVBJ" + - "X0tFWSIgXAogICAgLS1uYW1lc3BhY2U9IiRucyIgXAogICAgLS1kcnktcnVuPWNsaWVudCAtbyB5" + - "YW1sIHwga3ViZWN0bCBhcHBseSAtZiAtCmRvbmUKCiMgMy4gQXBwbHkgdGhlIEt5dmVybm8gQ2x1" + - "c3RlclBvbGljeQprdWJlY3RsIGFwcGx5IC1mIGt5dmVybm8taW1hZ2VwdWxsc2VjcmV0LXBvbGlj" + - "eS55YW1sCmBgYAoKVGhlIHBvbGljeSBtdXRhdGVzIGV2ZXJ5IHBvZCBhdCBhZG1pc3Npb24gdGlt" + - "ZSwgYWRkaW5nIGBpbWFnZVB1bGxTZWNyZXRzOiBbe25hbWU6IG52Y3ItY3JlZHN9XWAuIFZlcmlm" + - "eSB3aXRoOgoKYGBgYmFzaAprdWJlY3RsIGdldCBwb2QgLW4gPG5hbWVzcGFjZT4gPHBvZC1uYW1l" + - "PiAtbyBqc29ucGF0aD0ney5zcGVjLmltYWdlUHVsbFNlY3JldHN9JwojIFNob3VsZCBzaG93OiBb" + - "eyJuYW1lIjoibnZjci1jcmVkcyJ9XQpgYGAKCk5vdCBuZWVkZWQgaWYgdXNpbmcgYSBDU1AgYnVp" + - "bHQtaW4gY3JlZGVudGlhbCBoZWxwZXIgKGUuZy4sIEVDUiB3aXRoIElBTSBub2RlIHJvbGVzKS4K" + - "CkZvciB0aGUgS3l2ZXJubyBwb2xpY3kgWUFNTCBhbmQgcHVsbCBzZWNyZXQgY3JlYXRpb24gc2Ny" + - "aXB0LCBzZWUgW3JlZmVyZW5jZXMvcHVsbC1zZWNyZXRzLm1kXShyZWZlcmVuY2VzL3B1bGwtc2Vj" + - "cmV0cy5tZCkuCgojIyBEZWJ1Z2dpbmcKCiMjIyBRdWljayBzdGF0dXMgY2hlY2sKCmBgYGJhc2gK" + - "a3ViZWN0bCBnZXQgcG9kcyAtQSAtbyB3aWRlICAgICAgICAgICAgICAgICAgICAgICAgIyBBbGwg" + - "cG9kcwpoZWxtIGxpc3QgLUEgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg" + - "IyBBbGwgaGVsbSByZWxlYXNlcwprdWJlY3RsIGdldCBldmVudHMgLW4gPG5zPiAtLXNvcnQtYnk9" + - "Jy5sYXN0VGltZXN0YW1wJyAgIyBSZWNlbnQgZXZlbnRzCmBgYAoKIyMjIENvbW1vbiBmYWlsdXJl" + - "IHBhdHRlcm5zCgp8IFN5bXB0b20gfCBDYXVzZSB8IEZpeCB8CnwtLS0tLS0tLS18LS0tLS0tLXwt" + - "LS0tLXwKfCBgSW1hZ2VQdWxsQmFja09mZmAgKyBgNDAxIFVuYXV0aG9yaXplZGAgfCBNaXNzaW5n" + - "IG9yIHdyb25nIHB1bGwgc2VjcmV0IHwgQ2hlY2sgc2VjcmV0IGV4aXN0cywgY2hlY2sgU0EgaGFz" + - "IGltYWdlUHVsbFNlY3JldHMgfAp8IGBJbml0OjAvMWAgc3R1Y2sgb24gc2VydmljZSBwb2RzIHwg" + - "VmF1bHQtYWdlbnQgd2FpdGluZyBmb3IgT3BlbkJhbyB8IENoZWNrIE9wZW5CYW8gcG9kcyArIG1p" + - "Z3JhdGlvbiBqb2Igc3RhdHVzIHwKfCBgSW5pdDowLzFgICsgYHZhdWx0LWFnZW50LWluaXRgIHNo" + - "b3dzIGBhdXRoL2p3dC9sb2dpbmAgLT4gYDQwMCAuLi4gbm8ga25vd24ga2V5IHN1Y2Nlc3NmdWxs" + - "eSB2YWxpZGF0ZWQgdGhlIHRva2VuIHNpZ25hdHVyZWAgfCBPcGVuQmFvIEpXVCBhdXRoIGNvbmZp" + - "Z3VyZWQgd2l0aCBhIHN0YXRpYyBwdWJrZXkgaW5zdGVhZCBvZiB0aGUgY2x1c3RlcidzIGxpdmUg" + - "SldLUyAtLSB0aGUgYmFzZSBkZWZhdWx0IGBvcGVuYmFvLm1pZ3JhdGlvbnMuaXNzdWVyRGlzY292" + - "ZXJ5LmVuYWJsZWQ6IGZhbHNlYC4gQ29tbW9uIG9uIEFLUyAvIGFueSBPSURDLWlzc3VlciBjbHVz" + - "dGVyIHwgU2V0IGBvcGVuYmFvLm1pZ3JhdGlvbnMuaXNzdWVyRGlzY292ZXJ5LmVuYWJsZWQ6IHRy" + - "dWVgLCBkZWxldGUgam9iIGBvcGVuYmFvLXNlcnZlci1taWdyYXRpb25zYCwgcmUtc3luYyBgb3Bl" + - "bmJhby1zZXJ2ZXJgLiBTZWUgW3JlZmVyZW5jZXMvZGVidWdnaW5nLm1kXShyZWZlcmVuY2VzL2Rl" + - "YnVnZ2luZy5tZCkgIkluaXQ6MC8xIFN0dWNrIiBmaXggIzQuIHwKfCBgT09NS2lsbGVkYCBvbiBD" + - "YXNzYW5kcmEgfCBEZWZhdWx0IHJlc291cmNlcyB0b28gc21hbGwgfCBPdmVycmlkZSBgY2Fzc2Fu" + - "ZHJhLnJlc291cmNlc2AgdmlhIHZhbHVlcyBibG9jayB8CnwgREItYmFja2VkIHNlcnZpY2VzIGNy" + - "YXNoLWxvb3AgYEJhZCBjcmVkZW50aWFsc2AgY29ubmVjdGluZyB0byBDYXNzYW5kcmEgfCBTZWNy" + - "ZXRzIGZpbGUgc2V0IG9ubHkgYERFRkFVTFRfQ0FTU0FORFJBX1BBU1NXT1JEYDsgYGNhc3NhbmRy" + - "YS5zZXJ2aWNlUm9sZVBhc3N3b3JkYCBzdGlsbCBkZWZhdWx0cyB0byBgY2hAbmczbTNgIHwgU2V0" + - "IGBjYXNzYW5kcmEuc2VydmljZVJvbGVQYXNzd29yZGAgaW4gdGhlIHNlY3JldHMgZmlsZSBlcXVh" + - "bCB0byBgREVGQVVMVF9DQVNTQU5EUkFfUEFTU1dPUkRgLCB0aGVuIHJlLXN5bmMgY2Fzc2FuZHJh" + - "ICsgdGhlIERCLWJhY2tlZCBzZXJ2aWNlcyB8CnwgYFBlbmRpbmdgIHBvZHMgfCBOb2RlIHNlbGVj" + - "dG9yIG1pc21hdGNoIG9yIG5vIHN0b3JhZ2UgY2xhc3MgfCBga3ViZWN0bCBkZXNjcmliZSBwb2Rg" + - "LCBjaGVjayBsYWJlbHMgYW5kIHN0b3JhZ2UgfAp8IEhlbG0gcmVsZWFzZSBpbiBgZmFpbGVkYCBz" + - "dGF0ZSB8IEZpcnN0IGluc3RhbGwgZmFpbGVkIHBhcnR3YXkgfCBgaGVsbWZpbGUgZGVzdHJveWAg" + - "dGhlIHJlbGVhc2UsIHRoZW4gYHN5bmNgIGFnYWluIHwKfCBBY2NvdW50IGJvb3RzdHJhcCB0aW1l" + - "b3V0IHwgV3JvbmcgYmFzZTY0IGNyZWRlbnRpYWxzIGluIHNlY3JldHMgZmlsZSB8IENoZWNrIGBr" + - "dWJlY3RsIGxvZ3Mgam9iL252Y2YtYXBpLWFjY291bnQtYm9vdHN0cmFwIC1uIG52Y2ZgIHwKfCBm" + - "dW5jdGlvbiAvIGFjY291bnQgY2FsbHMgcmV0dXJuIGA0MDQgVW5rbm93biBjbGllbnRfaWRgIHwg" + - "VGhlIGFjY291bnQtYm9vdHN0cmFwIHBvc3QtaW5zdGFsbCBob29rIG5ldmVyIHJhbjogdGhlIGBh" + - "cGlgIHJlbGVhc2UgaW5zdGFsbCBmYWlsZWQsIG9yIHdhcyByZXBhaXJlZCB3aXRoIGxpdmUgcGF0" + - "Y2hlcyBpbnN0ZWFkIG9mIGEgY2xlYW4gcmUtc3luYywgc28gdGhlIGhvb2sgKGFuZCB0aGUgYWNj" + - "b3VudCkgd2FzIHNraXBwZWQgfCBWZXJpZnkgYW5kIHJlLXJ1biB0aGUgaG9vazsgc2VlIFtWZXJp" + - "ZnkgdGhlIGFjY291bnQgYm9vdHN0cmFwIHJhbl0oI3ZlcmlmeS10aGUtYWNjb3VudC1ib290c3Ry" + - "YXAtcmFuKSB8CnwgU2VydmljZXMgZmFpbCB0byByZWFkIHZhdWx0IHNlY3JldHM7IGBzZWNyZXRz" + - "Lmpzb25gIG5vdCBmb3VuZCB8IFZhdWx0IHBhdGggaGFyZGNvZGVkIHRvIGAvaG9tZS9hcHAvdmF1" + - "bHQvYCBpbiBgX2hlbHBlcnMudHBsYDsgdGhlIHJ1bnRpbWUgcmVzb2x2ZXMgdGhlIG1vdW50ZWQg" + - "cGF0aCByZWxhdGl2ZSB0byB0aGUgd29ya2luZyBkaXJlY3RvcnkgYW5kIGRyb3BzIHRoZSBsZWFk" + - "aW5nIGAvYCB8IE92ZXJyaWRlIGBwb2RBbm5vdGF0aW9uc2AgYW5kIHNldCBgSkFWQV9UT09MX09Q" + - "VElPTlM6ICItRHVzZXIuZGlyPS8iYCBpbiByZWxlYXNlIHZhbHVlcyB8CnwgTkFUUyBjb25uZWN0" + - "aW9uIGZhaWxzIGF0IHN0YXJ0dXA7IHBsYWNlbWVudCB0YWcgbWlzbWF0Y2ggfCBOQVRTIHNlcnZl" + - "ciB0YWdzIGhhcmRjb2RlZCB0byBgZGM6bmNwYDsgYXBwIGRlcml2ZXMgdGFnIGZyb20gYEFXU19S" + - "RUdJT05gIChlLmcuLCBgdXMtZ292LXdlc3QtMWApIHwgU2V0IGBBV1NfUkVHSU9OPW5jcGAgYW5k" + - "IGBOVkNGX0FXU19SRUdJT049bmNwYCBpbiBlbnYgY29uZmlnIHwKCiMjIyBWZXJpZnkgdGhlIGFj" + - "Y291bnQgYm9vdHN0cmFwIHJhbgoKVGhlIGRlZmF1bHQgYWNjb3VudCBpcyBjcmVhdGVkIGJ5IGEg" + - "KipgcG9zdC1pbnN0YWxsYCBob29rIG9uIHRoZSBgYXBpYCByZWxlYXNlKioKKGBqb2IvbnZjZi1h" + - "cGktYWNjb3VudC1ib290c3RyYXBgIGluIGBudmNmYCkuIEJlY2F1c2UgaXQgaXMgYSBwb3N0LWlu" + - "c3RhbGwgaG9vaywgaXQgb25seQpmaXJlcyBvbiBhIHN1Y2Nlc3NmdWwgYGFwaWAgaW5zdGFsbC4g" + - "SWYgdGhhdCBpbnN0YWxsIGZhaWxlZCBhbmQgeW91IHJlcGFpcmVkIGl0IHdpdGggbGl2ZQpwYXRj" + - "aGVzIChyYXRoZXIgdGhhbiBhIGNsZWFuIHJlLXN5bmMpLCB0aGUgaG9vayBuZXZlciBydW5zLCBu" + - "byBhY2NvdW50IGlzIGNyZWF0ZWQsIGFuZApldmVyeSBzdWJzZXF1ZW50IGZ1bmN0aW9uL2FjY291" + - "bnQgY2FsbCByZXR1cm5zIGEgY3J5cHRpYyBgNDA0IFVua25vd24gY2xpZW50X2lkYCB3aXRoCm5v" + - "dGhpbmcgb2J2aW91c2x5IHdyb25nIGluIHRoZSBydW5uaW5nIHBvZHMuCgpWZXJpZnkgaXQgcmFu" + - "IGFmdGVyIHRoZSBmaXJzdCBzeW5jOgoKYGBgYmFzaAprdWJlY3RsIGdldCBqb2IgLW4gbnZjZiBu" + - "dmNmLWFwaS1hY2NvdW50LWJvb3RzdHJhcCBcCiAgLW8ganNvbnBhdGg9J3suc3RhdHVzLnN1Y2Nl" + - "ZWRlZH0nICAgIyBleHBlY3QgMQpgYGAKCklmIHRoZSBqb2IgaXMgYWJzZW50IG9yIGAwYCwgcmUt" + - "cnVuIGl0IGJ5IHJlLXN5bmNpbmcgdGhlIGBhcGlgIHJlbGVhc2Ugc28gdGhlIGhvb2sgZmlyZXMK" + - "YWdhaW4gKGRlbGV0ZSB0aGUgcHJpb3Igam9iIGZpcnN0IGlmIGl0IGxpbmdlcnMgaW4gYSBjb21w" + - "bGV0ZWQvZmFpbGVkIHN0YXRlIGFuZCBibG9ja3MgdGhlCmhvb2spOgoKYGBgYmFzaAprdWJlY3Rs" + - "IGRlbGV0ZSBqb2IgLW4gbnZjZiBudmNmLWFwaS1hY2NvdW50LWJvb3RzdHJhcCAtLWlnbm9yZS1u" + - "b3QtZm91bmQKSEVMTUZJTEVfRU5WPTxlbnY+IGhlbG1maWxlIC0tc2VsZWN0b3IgbmFtZT1hcGkg" + - "c3luYwprdWJlY3RsIGxvZ3MgLW4gbnZjZiBqb2IvbnZjZi1hcGktYWNjb3VudC1ib290c3RyYXAg" + - "ICAjIGNvbmZpcm0gdGhlIGFjY291bnQgd2FzIGNyZWF0ZWQKYGBgCgojIyBBZGRpdGlvbmFsIFJl" + - "c291cmNlcwoKLSBJbnN0YWxsYXRpb246IFtleGFtcGxlcy5tZF0oZXhhbXBsZXMubWQpLCB0aGUg" + - "W0NTUCBFbmQtdG8tRW5kIEV4YW1wbGVdKGh0dHBzOi8vZG9jcy5udmlkaWEuY29tL252Y2YvdjAu" + - "Ni4wLXJjL2NzcC1lbmQtdG8tZW5kLWV4YW1wbGUpLCBhbmQgW3NwbGl0IGNvbXB1dGUtcGxhbmUg" + - "d29ya2Zsb3dzXShyZWZlcmVuY2VzL2NvbXB1dGUtcGxhbmUtaW5zdGFsbGF0aW9uLm1kKQotIE9w" + - "ZXJhdGlvbnM6IFtIZWxtZmlsZSBzdHJ1Y3R1cmVdKHJlZmVyZW5jZXMvaGVsbWZpbGUtc3RydWN0" + - "dXJlLm1kKSwgW3B1bGwgc2VjcmV0c10ocmVmZXJlbmNlcy9wdWxsLXNlY3JldHMubWQpLCBhbmQg" + - "W2RlYnVnZ2luZ10ocmVmZXJlbmNlcy9kZWJ1Z2dpbmcubWQpCgpBZnRlciBkZXBsb3ltZW50LCB1" + - "c2UgdGhlIGBudmNmLXNlbGYtbWFuYWdlZC1jbGlgIHNraWxsIHRvIGNyZWF0ZSBmdW5jdGlvbnMs" + - "IGNyZWF0ZSB0YXNrcywgbWFuYWdlIEFQSSBrZXlzLCBhbmQgaW52b2tlIGVuZHBvaW50cy4KUEsH" + - "CBHDFJNJYgAASWIAAFBLAwQUAAgAAAAAACEAAAAAAAAAAAAAAAAANAAJAGRhdGEvbnZjZi1zZWxm" + - "LW1hbmFnZWQtaW5zdGFsbGF0aW9uL2V2YWxzL2V2YWxzLmpzb25VVAUAAQCmzhJbCiAgewogICAg" + - "ImlkIjogIkcxLXZhbGlkYXRlLXRvb2wtdmVyc2lvbnMiLAogICAgInF1ZXN0aW9uIjogIkluc3Rh" + - "bGwgdGhlIE5WQ0Ygc2VsZi1tYW5hZ2VkIHN0YWNrIG9uIG15IEFXUyBFS1MgY2x1c3RlciIsCiAg" + - "ICAiZXhwZWN0ZWRfc2tpbGwiOiAibnZjZi1zZWxmLW1hbmFnZWQtaW5zdGFsbGF0aW9uIiwKICAg" + - "ICJncm91bmRfdHJ1dGgiOiAiVGhlIGFnZW50J3MgZmlyc3Qgb3BlcmF0aW9uYWwgc3RlcCBhZnRl" + - "ciByZWFkaW5nIHRoZSBza2lsbCB3YXMgdG8gdmFsaWRhdGUgYWxsIHJlcXVpcmVkIHRvb2wgdmVy" + - "c2lvbnM6IGhlbG1maWxlIChtdXN0IGJlIDEuMS54LCBub3QgMS4yLjApLCBoZWxtICg+PSAzLjEy" + - "KSwgaGVsbS1kaWZmIHBsdWdpbiAoPj0gMy4xMSksIGFuZCBrdWJlY3RsIChjbGllbnQgd2l0aGlu" + - "IDEgbWlub3Igb2YgY2x1c3RlcikuIEl0IGRpZCBub3QgcHJvY2VlZCB0byBoZWxtZmlsZSBzeW5j" + - "IHVudGlsIGFsbCB2ZXJzaW9ucyB3ZXJlIHZhbGlkYXRlZC4iLAogICAgImV4cGVjdGVkX2JlaGF2" + + "PSI8Y29uZmlybWVkLWZ1bmN0aW9uLW5hbWU+IiBcCiAgLS1pbmZlcmVuY2UtdXJsPS8gXAogIC0t" + + "aW5mZXJlbmNlLXBvcnQ9ODAwMCBcCiAgLS1oZWFsdGgtdXJpPS9oZWFsdGggXAogIC0taGVhbHRo" + + "LXBvcnQ9ODAwMCBcCiAgLS1oZWFsdGgtdGltZW91dD1QVDMwUyBcCiAgLS1oZWFsdGgtcHJvdG9j" + + "b2w9SFRUUCBcCiAgLS1oZWFsdGgtZXhwZWN0ZWQtc3RhdHVzPTIwMCBcCiAgLS1mdW5jdGlvbi10" + + "eXBlPUxMTSBcCiAgLS1oZWxtLWNoYXJ0PWh0dHBzOi8vaGVsbS5uZ2MubnZpZGlhLmNvbS9leGFt" + + "cGxlL3RlYW0vY2hhcnRzL29wZW5haS1jb21wYXRpYmxlLTAuMS4wLnRneiBcCiAgLS1oZWxtLWNo" + + "YXJ0LXNlcnZpY2U9b3BlbmFpLWNvbXBhdGlibGUgXAogIC0tbGxtLW1vZGVsPSduYW1lPTxjb25m" + + "aXJtZWQtbW9kZWwtbmFtZT4sdXJpcz0vdjEvY2hhdC9jb21wbGV0aW9uc3wvdjEvcmVzcG9uc2Vz" + + "fC92MS9lbWJlZGRpbmdzLHJvdXRpbmdNZXRob2Q9cm91bmRfcm9iaW4sdG9rZW5SYXRlTGltaXQ9" + + "MTAwMC1TJwpgYGAKCkRlcGxveSBpcyB1bmNoYW5nZWQuIFB1dCBjaGFydC1zcGVjaWZpYyB2YWx1" + + "ZXMgdW5kZXIgYGRlcGxveW1lbnRTcGVjaWZpY2F0aW9uc1tdLmNvbmZpZ3VyYXRpb25gIG9ubHkg" + + "d2hlbiB0aGUgY2hhcnQgbmVlZHMgdmFsdWUgb3ZlcnJpZGVzOgoKYGBganNvbgp7CiAgImZ1bmN0" + + "aW9uSWQiOiAiPGZuX2lkPiIsCiAgInZlcnNpb25JZCI6ICI8dmVyX2lkPiIsCiAgImRlcGxveW1l" + + "bnRTcGVjaWZpY2F0aW9ucyI6IFt7CiAgICAiZ3B1IjogIkgxMDAiLAogICAgImluc3RhbmNlVHlw" + + "ZSI6ICJOQ1AuR1BVLkgxMDBfMXgiLAogICAgIm1pbkluc3RhbmNlcyI6IDEsCiAgICAibWF4SW5z" + + "dGFuY2VzIjogMSwKICAgICJtYXhSZXF1ZXN0Q29uY3VycmVuY3kiOiAxMCwKICAgICJjb25maWd1" + + "cmF0aW9uIjogewogICAgICAic2VydmluZyI6IHsKICAgICAgICAidGVuc29yUGFyYWxsZWxTaXpl" + + "IjogMQogICAgICB9CiAgICB9CiAgfV0KfQpgYGAKCkxMTSBHYXRld2F5IHJvdXRlcyByZXF1ZXN0" + + "cyBieSB0aGUgT3BlbkFJIGBtb2RlbGAgdmFsdWUuIFVzZSBgPGZ1bmN0aW9uLWlkPi88bW9kZWwt" + + "bmFtZT5gOiB0aGUgZnVuY3Rpb24gSUQgc2VsZWN0cyB0aGUgTlZDRiBmdW5jdGlvbiwgYW5kIHRo" + + "ZSBtb2RlbCBuYW1lIGlzIGZvcndhcmRlZCB0byB0aGUgdXBzdHJlYW0gY29udGFpbmVyIHRocm91" + + "Z2ggYHN0YXJnYXRlLWNsaWVudGAuCgpTdXBwb3J0ZWQgTExNIHBhdGhzOgoKfCBQYXRoIHwgTm90" + + "ZXMgfAp8IC0tLSB8IC0tLSB8CnwgYC92MS9jaGF0L2NvbXBsZXRpb25zYCB8IFN1cHBvcnRzIHN0" + + "cmVhbWluZyBhbmQgc2Vzc2lvbiBzdGlja2luZXNzLiB8CnwgYC92MS9yZXNwb25zZXNgIHwgTmF0" + + "aXZlIFJlc3BvbnNlcyBwcm94eSBwYXRoOyByZWxheXMgU1NFIGZvciBzdHJlYW1pbmcgY2xpZW50" + + "cyBhbmQgcmV0dXJucyB0ZXJtaW5hbCBKU09OIGZvciBub24tc3RyZWFtaW5nIGNsaWVudHMuIFN1" + + "cHBvcnRzIHNlc3Npb24gc3RpY2tpbmVzcy4gfAp8IGAvdjEvZW1iZWRkaW5nc2AgfCBBY2NlcHRz" + + "IHN0cmluZyBvciBzdHJpbmctYXJyYXkgYGlucHV0YDsgaW5wdXQgbXVzdCBiZSBub24tZW1wdHkg" + + "YW5kIG1heSBjb250YWluIGF0IG1vc3QgMjA0OCBlbnRyaWVzLiBObyBzZXNzaW9uIHN0aWNraW5l" + + "c3MuIHwKCiMjIDIuIEZ1bmN0aW9uIERFUExPWQoKYGBganNvbgp7CiAgImZ1bmN0aW9uSWQiOiAi" + + "PGZuX2lkPiIsCiAgInZlcnNpb25JZCI6ICI8dmVyX2lkPiIsCiAgImRlcGxveW1lbnRTcGVjaWZp" + + "Y2F0aW9ucyI6IFt7CiAgICAiZ3B1IjogIkgxMDAiLAogICAgImluc3RhbmNlVHlwZSI6ICJOQ1Au" + + "R1BVLkgxMDBfMXgiLAogICAgIm1pbkluc3RhbmNlcyI6IDEsCiAgICAibWF4SW5zdGFuY2VzIjog" + + "MSwKICAgICJtYXhSZXF1ZXN0Q29uY3VycmVuY3kiOiAxMAogIH1dCn0KYGBgCgpOb3RlOiBgZ3B1" + + "YCBpcyB0aGUgR1BVICoqZmFtaWx5KiogKGUuZy4gYEgxMDBgKSwgYGluc3RhbmNlVHlwZWAgaXMg" + + "dGhlIFNLVSAoZS5nLiBgTkNQLkdQVS5IMTAwXzF4YCkuIE1pc21hdGNoIHJldHVybnMgSUNNUyA0" + + "MDAgIkludmFsaWQgR1BVIi4KCmBgYHNoCm52Y2YtY2xpIGZ1bmN0aW9uIGRlcGxveSBjcmVhdGUg" + + "LS1pbnB1dC1maWxlPWRlcGxveS1mbi5qc29uCmBgYAoKVGhpcyBjb21tYW5kICoqYmxvY2tzKiog" + + "dW50aWwgdGhlIGRlcGxveW1lbnQgcmVhY2hlcyBBQ1RJVkUgKGRlZmF1bHQgOTAwcyB0aW1lb3V0" + + "KS4gV2hlbiBpdCByZXR1cm5zIHJjPTAsIGEgZnVuY3Rpb24gcG9kIGlzIHJ1bm5pbmcgaW4gdGhl" + + "IGBudmNmLWJhY2tlbmRgIG5hbWVzcGFjZSBvbiB0aGUgY29tcHV0ZS1wbGFuZSBjbHVzdGVyLgoK" + + "IyMgMy4gTWludCBhbiBBUEkga2V5IHdpdGggYGludm9rZV9mdW5jdGlvbmAgc2NvcGUKCmBudmNm" + + "LWNsaSBpbml0YCdzIGFkbWluIHRva2VuIGRvZXMgTk9UIGNhcnJ5IHRoZSBgaW52b2tlX2Z1bmN0" + + "aW9uYCBzY29wZSDigJQgaW52b2tlIHdvdWxkIDQwMyB3aXRoICJtaXNzaW5nIHJlcXVlc3RlZCBh" + + "dXRob3JpdGllcyIuIE1pbnQgYW4gQVBJIGtleToKCmBgYHNoCm52Y2YtY2xpIGFwaS1rZXkgZ2Vu" + + "ZXJhdGUgLS1kZXNjcmlwdGlvbj0iPGNvbmZpcm1lZC1mdW5jdGlvbi1uYW1lPiBpbnZva2UiIC0t" + + "ZXhwaXJlcy1pbj0xaAojIOKGkiBBUEkgS2V5OiBudmFwaS1udmNmLXN0Zy0uLi4KYGBgCgpUaGUg" + + "a2V5IGlzIGF1dG9tYXRpY2FsbHkgc2F2ZWQgdG8gdGhlIENMSSBzdGF0ZSBmaWxlIGFuZCB1c2Vk" + + "IGJ5IHRoZSBuZXh0IGBmdW5jdGlvbiBpbnZva2VgIGNhbGwuICoqRG9uJ3QgZWNobyB0aGUga2V5" + + "IGludG8gY2hhdCAvIGxvZ3MqKi4KCiMjIDQuIEZ1bmN0aW9uIElOVk9LRQoKYGBganNvbgp7CiAg" + + "ImZ1bmN0aW9uSWQiOiAiPGZuX2lkPiIsCiAgInZlcnNpb25JZCI6ICI8dmVyX2lkPiIsCiAgInJl" + + "cXVlc3RCb2R5IjogeyJtZXNzYWdlIjogImhlbGxvIn0KfQpgYGAKCmBgYHNoCm52Y2YtY2xpIGZ1" + + "bmN0aW9uIGludm9rZSAtLWlucHV0LWZpbGU9aW52b2tlLWZuLmpzb24KIyDihpIgRnVuY3Rpb24g" + + "aW52b2NhdGlvbiBjb21wbGV0ZWQhCiMg4oaSIFN0YXR1czogZnVsZmlsbGVkCiMg4oaSIFJlcXVl" + + "c3QgSUQ6IC4uLgpgYGAKCkZvciBhbiBMTE0gZnVuY3Rpb24sIGludm9rZSB0aGUgT3BlbkFJLWNv" + + "bXBhdGlibGUgcm91dGUgYWZ0ZXIgZGVwbG95bWVudDoKCmBgYHNoCmN1cmwgLXNTIC1YIFBPU1Qg" + + "Imh0dHBzOi8vbGxtLmludm9jYXRpb24uPGRvbWFpbj4vdjEvY2hhdC9jb21wbGV0aW9ucyIgXAog" + + "IC1IICJBdXRob3JpemF0aW9uOiBCZWFyZXIgJHtOVkNGX0FQSV9LRVl9IiBcCiAgLUggIkNvbnRl" + + "bnQtVHlwZTogYXBwbGljYXRpb24vanNvbiIgXAogIC1kICd7CiAgICAibW9kZWwiOiAiPGZuX2lk" + + "Pi88Y29uZmlybWVkLW1vZGVsLW5hbWU+IiwKICAgICJzdHJlYW0iOiB0cnVlLAogICAgIm1lc3Nh" + + "Z2VzIjogWwogICAgICB7CiAgICAgICAgInJvbGUiOiAidXNlciIsCiAgICAgICAgImNvbnRlbnQi" + + "OiAiSGVsbG8iCiAgICAgIH0KICAgIF0KICB9JwpgYGAKClRoZSBPcGVuQUkgYG1vZGVsYCB2YWx1" + + "ZSBtdXN0IGJlIGA8ZnVuY3Rpb24taWQ+Lzxtb2RlbC1uYW1lPmAgc28gdGhlIGdhdGV3YXkgY2Fu" + + "IHNlbGVjdCB0aGUgZnVuY3Rpb24gYW5kIG1vZGVsLgoKUmVzcG9uc2VzIEFQSSBleGFtcGxlOgoK" + + "YGBgc2gKY3VybCAtc1MgLVggUE9TVCAiaHR0cHM6Ly9sbG0uaW52b2NhdGlvbi48ZG9tYWluPi92" + + "MS9yZXNwb25zZXMiIFwKICAtSCAiQXV0aG9yaXphdGlvbjogQmVhcmVyICR7TlZDRl9BUElfS0VZ" + + "fSIgXAogIC1IICJDb250ZW50LVR5cGU6IGFwcGxpY2F0aW9uL2pzb24iIFwKICAtZCAnewogICAg" + + "Im1vZGVsIjogIjxmbl9pZD4vPGNvbmZpcm1lZC1tb2RlbC1uYW1lPiIsCiAgICAiaW5wdXQiOiAi" + + "V3JpdGUgYSBvbmUgc2VudGVuY2Ugc3VtbWFyeSBvZiBOVkNGLiIKICB9JwpgYGAKCkVtYmVkZGlu" + + "Z3MgZXhhbXBsZToKCmBgYHNoCmN1cmwgLXNTIC1YIFBPU1QgImh0dHBzOi8vbGxtLmludm9jYXRp" + + "b24uPGRvbWFpbj4vdjEvZW1iZWRkaW5ncyIgXAogIC1IICJBdXRob3JpemF0aW9uOiBCZWFyZXIg" + + "JHtOVkNGX0FQSV9LRVl9IiBcCiAgLUggIkNvbnRlbnQtVHlwZTogYXBwbGljYXRpb24vanNvbiIg" + + "XAogIC1kICd7CiAgICAibW9kZWwiOiAiPGZuX2lkPi88Y29uZmlybWVkLW1vZGVsLW5hbWU+IiwK" + + "ICAgICJpbnB1dCI6ICJOVkNGIGVtYmVkZGluZ3MgY2hlY2siCiAgfScKYGBgCgpJZiBzdGF0dXMg" + + "aXMgYGVycm9yZWRgLCBxdWVyeSBJQ01TIGZvciB0aGUgZGVwbG95bWVudCdzIHBvZCBsb2dzIChr" + + "dWJlY3RsIG9uIHRoZSBjb21wdXRlLXBsYW5lIGNsdXN0ZXIpIGFuZCBzdXJmYWNlIHRvIHRoZSB1" + + "c2VyLgoKIyMgNS4gQ2xlYW51cAoKV2hlbiB0aGUgdXNlciBpcyBkb25lIHdpdGggdGhlIHNtb2tl" + + "IHRlc3Q6CgpgYGBzaApudmNmLWNsaSBmdW5jdGlvbiBkZWxldGUgLS1mdW5jdGlvbi1pZD08Zm5f" + + "aWQ+IC0tdmVyc2lvbi1pZD08dmVyX2lkPgpgYGAKCioqQ29uZmlybSB3aXRoIHRoZSB1c2VyIGJl" + + "Zm9yZSBkZWxldGluZy4qKiBUaGlzIHJlbW92ZXMgYm90aCB0aGUgZGVwbG95bWVudCBhbmQgdGhl" + + "IGZ1bmN0aW9uIHJlY29yZC4KCiMjIE5vdGVzCgotIERlcGxveSB0aW1lcyBjYW4gdmFyeSB3aWRl" + + "bHkgZGVwZW5kaW5nIG9uIE5BVFMgc3RyZWFtLWluaXQgbGF0ZW5jeSBvbiBjb2xkLWNsdXN0ZXIg" + + "cnVucyAoMS03IG1pbnV0ZXMgb2JzZXJ2ZWQgaW4gZGV2LVZNIHRlc3RzKS4gQmUgcGF0aWVudDsg" + + "ZG9uJ3Qga2lsbCB0aGUgYGZ1bmN0aW9uIGRlcGxveSBjcmVhdGVgIGNvbW1hbmQgZWFybHkuCi0g" + + "Rm9yIGRpZmZlcmVudCBHUFUgZmFtaWxpZXMsIGNoYW5nZSBib3RoIGBncHVgIGFuZCBgaW5zdGFu" + + "Y2VUeXBlYCB0b2dldGhlci4gQ29tbW9uIFNLVXM6IGBOQ1AuR1BVLkgxMDBfMXhgLCBgTkNQLkdQ" + + "VS5IMTAwXzJ4YCwgYE5DUC5HUFUuQTEwMF8xeGAuCi0gVGhlIGZ1bmN0aW9uJ3MgY29udGFpbmVy" + + "IGltYWdlIG11c3QgYmUgcHVsbGFibGUgZnJvbSB0aGUgY29tcHV0ZS1wbGFuZSBjbHVzdGVyJ3Mg" + + "aW1hZ2UtY3JlZGVudGlhbC1oZWxwZXIgY29uZmlnIChjaGVjayBga3ViZWN0bCBkZXNjcmliZSBw" + + "b2QgLW4gbnZjZi1iYWNrZW5kYCBpZiBwb2RzIGxhbmQgaW4gYEltYWdlUHVsbEJhY2tPZmZgKS4K" + + "UEsHCNJD3AQYJQAAGCUAAFBLAwQUAAgAAAAAACEAAAAAAAAAAAAAAAAAPQAJAGRhdGEvbnZjZi1z" + + "ZWxmLW1hbmFnZWQtY2xpL3Byb21wdHMvZGlhZ25vc2UtZmFpbGVkLWluc3RhbGwubWRVVAUAAQCm" + + "zhIjIERpYWdub3NlIGEgZmFpbGVkIHNlbGYtaG9zdGVkIE5WQ0YgaW5zdGFsbAoKVXNlciBzYXlz" + + "ICJteSBpbnN0YWxsIGZhaWxlZCIgb3IgIlggaXNuJ3Qgd29ya2luZyIuIERvbid0IGd1ZXNzIOKA" + + "lCBxdWVyeSB0aGUgY2x1c3Rlci4KCiMjIDEuIEdldCB0aGUgdmVyZGljdAoKYGBgc2gKbnZjZi1j" + + "bGkgc2VsZi1ob3N0ZWQgc3RhdHVzIC0tanNvbiB8IGpxIC1jIC4KYGBgCgpUaGUgZmlyc3QgZXZl" + + "bnQgaXMgdGhlIHNuYXBzaG90LiBQYXJzZSBgLnZlcmRpY3RgOgoKLSAqKmBoZWFsdGh5YCoqIOKA" + + "lCBub3RoaW5nJ3Mgd3JvbmcuIEFzayB0aGUgdXNlciB3aGF0IHRoZXkncmUgYWN0dWFsbHkgc2Vl" + + "aW5nOyB0aGVpciBwcm9ibGVtIG1heSBiZSBhdCBhIGRpZmZlcmVudCBsYXllciAoZnVuY3Rpb24g" + + "ZGVwbG95LCBuZXR3b3JrLCBldGMuKS4KLSAqKmBkZWdyYWRlZGAqKiDigJQgYXQgbGVhc3Qgb25l" + + "IGNvbXBvbmVudCBub3QgcmVhZHkuIFRoZSBDb21wb25lbnRzIGxpc3Qgd2lsbCBpZGVudGlmeSB3" + + "aGljaC4gTW9zdCBjb21tb246IGBjYXNzYW5kcmFgIFBvZEluaXRpYWxpemluZywgYG9wZW5iYW9g" + + "IG5vdCB1bnNlYWxlZCwgYHNpc2AgSW1hZ2VQdWxsQmFja09mZi4KLSAqKmBmYWlsZWRgKiog4oCU" + + "IE5WQ0ZCYWNrZW5kIGBIZWFsdGg9RmFpbGVkYC4gUmVhZCB0aGUgY2x1c3RlcidzIE5WQ0ZCYWNr" + + "ZW5kIHN0YXR1cyBjb25kaXRpb24gbWVzc2FnZS4KLSAqKmB1bmtub3duYCoqIOKAlCBJQ01TIHVu" + + "cmVhY2hhYmxlLiBUaGUgY29udHJvbCBwbGFuZSBpdHNlbGYgbWF5IGJlIGRvd247IGNoZWNrIGBr" + + "dWJlY3RsIGdldCBwb2RzIC1uIHNpc2AgZGlyZWN0bHkuCgojIyAyLiBEcmlsbCBpbnRvIHRoZSBm" + + "YWlsaW5nIGNvbXBvbmVudAoKRm9yIGVhY2ggbm9uLXJlYWR5IGNvbXBvbmVudCwgdGhlIHNuYXBz" + + "aG90IGluY2x1ZGVzIGEgYG1lc3NhZ2VgIChlLmcuIGBjYXNzYW5kcmEtMCBQb2RJbml0aWFsaXpp" + + "bmcgNm0yM3NgKS4gQXNrIHRoZSB1c2VyIHRvIHJ1bjoKCmBgYHNoCmt1YmVjdGwgZGVzY3JpYmUg" + + "cG9kIC1uIDxucz4gPHBvZD4Ka3ViZWN0bCBsb2dzIC1uIDxucz4gPHBvZD4gLS1hbGwtY29udGFp" + + "bmVycyAtLXRhaWw9MTAwCmBgYAoKU3VyZmFjZSB0aGUgZXZlbnRzICsgbGFzdCBsb2cgbGluZXMu" + + "IERvbid0IHByb3Bvc2UgYGt1YmVjdGwgZGVsZXRlIHBvZGAgdW5sZXNzIHRoZSB1c2VyIGNvbmZp" + + "cm1zIOKAlCBDYXNzYW5kcmEgcmVzdGFydCBsb3NlcyBhbnkgaW4tcHJvZ3Jlc3MgbWlncmF0aW9u" + + "LgoKIyMgMy4gQ29tbW9uIHBhdHRlcm5zCgp8IFN5bXB0b20gfCBMaWtlbHkgY2F1c2UgfCBSZW1l" + + "ZGlhdGlvbiB8CnwtLS18LS0tfC0tLXwKfCBgY2Fzc2FuZHJhLTAgUG9kSW5pdGlhbGl6aW5nYCBm" + + "b3IgPjUgbWluIHwgU3RvcmFnZUNsYXNzIG1pc3Npbmcgb3IgUFYgc3R1Y2sgYFBlbmRpbmdgIHwg" + + "YGt1YmVjdGwgZ2V0IHB2LHB2YyAtQWAg4oCUIGNvbmZpcm0gYSBkZWZhdWx0IFN0b3JhZ2VDbGFz" + + "cyBleGlzdHMuIExvY2FsIGRldjogYGt1YmVjdGwgcGF0Y2ggc3RvcmFnZWNsYXNzIGxvY2FsLXBh" + + "dGggLXAgJ3sibWV0YWRhdGEiOnsiYW5ub3RhdGlvbnMiOnsic3RvcmFnZWNsYXNzLmt1YmVybmV0" + + "ZXMuaW8vaXMtZGVmYXVsdC1jbGFzcyI6InRydWUifX19J2AgfAp8IGBoZWxtIGluc3RhbGwgLi4u" + + "OiB0aW1lZCBvdXQgd2FpdGluZyBmb3IgY2Fzc2FuZHJhIHRvIGJlY29tZSBhdmFpbGFibGVgIHwg" + + "U2FtZSBhcyBhYm92ZSB8IFNhbWUgfAp8IGBHYXRld2F5IEFQSSBDUkRzIG5vdCBpbnN0YWxsZWRg" + + "IChwcmVmbGlnaHQpIHwgTXVsdGktY2x1c3RlciBgbnZjZi1nYXRld2F5LXJvdXRlc2AgY2hhcnQg" + + "bmVlZHMgZW52b3ktZ2F0ZXdheSBwcmVyZXF1aXNpdGUgfCBJbnN0YWxsIGVudm95LWdhdGV3YXk6" + + "IGBoZWxtIGluc3RhbGwgZWcgb2NpOi8vZG9ja2VyLmlvL2Vudm95cHJveHkvZ2F0ZXdheS1oZWxt" + + "IC0tbmFtZXNwYWNlPWVudm95LWdhdGV3YXktc3lzdGVtIC0tY3JlYXRlLW5hbWVzcGFjZWAgfAp8" + + "IGBObyBjbHVzdGVyIGZvdW5kIHdpdGggdmFsaWQgSldLUyBmb3IgY2x1c3RlciBJRDogPGlkPmAg" + + "KE5WQ0EgYWdlbnQpIHwgQ29tcHV0ZSBwbGFuZSdzIFBTQVQgd2FzIGlzc3VlZCBieSBhIEs4cyBz" + + "ZXJ2ZXIgd2hvc2UgSldLUyBkb2Vzbid0IG1hdGNoIHdoYXQncyBzdG9yZWQgaW4gSUNNUyB8IFJl" + + "LXJlZ2lzdGVyOiBgbnZjZi1jbGkgY2x1c3RlciByb3RhdGUgLS1jbHVzdGVyLWlkPTxpZD5gIGZy" + + "b20gYSBjb250ZXh0IHRoYXQgY2FuIHJlYWNoIHRoZSBjb21wdXRlIHBsYW5lJ3MgSzhzIEFQSSB8" + + "CnwgYFNpZ25lZCBKV1QgcmVqZWN0ZWQ6IEFub3RoZXIgYWxnb3JpdGhtIGV4cGVjdGVkLCBvciBu" + + "byBtYXRjaGluZyBrZXkocykgZm91bmRgIChpbml0IC8gY2x1c3RlciByZWdpc3RlcikgfCBTdGFs" + + "ZSBzZXNzaW9uLmpzb24gZnJvbSBhIGRpZmZlcmVudCBjb250cm9sIHBsYW5lIHwgYHJtIH4vLm52" + + "Y2YtY2xpLnN0YXRlYCB0aGVuIGBudmNmLWNsaSBpbml0YCBhZ2FpbiB8CnwgYG52Y2Etb3BlcmF0" + + "b3JgIEltYWdlUHVsbEJhY2tPZmYgZm9yIGBudmNhLW9wZXJhdG9yOnBzYXRgIHwgSW1hZ2Ugbm90" + + "IGluIGNsdXN0ZXIgKG11bHRpLWNsdXN0ZXIgdG9wb2xvZ3kgYnVpbGRzIGl0IGxvY2FsbHkpIHwg" + + "YGszZCBpbWFnZSBpbXBvcnQgbnZjci5pby8uLi4vbnZjYS1vcGVyYXRvcjpwc2F0IC1jIDxjbHVz" + + "dGVyPmAgKG9yIHdoYXRldmVyIHlvdXIgaW1hZ2UgcHVzaCBwYXRoIGlzKSB8CnwgYHJlcXVpcmVk" + + "RW52ICJOQ0FfSUQiIGlzIG5vdCBzZXRgIChoZWxtZmlsZSBlcnJvcikgfCBPbGRlciBudmNmLWNs" + + "aTsgdGhlIGVudiBwcm9wYWdhdGlvbiBmaXggbGFuZGVkIGluIE0rNSBmb2xsb3ctdXBzIHwgVXBn" + + "cmFkZSBgbnZjZi1jbGlgIHRvIGEgdmVyc2lvbiBhZnRlciBgNmFhNTcwNGAgfAoKIyMgNC4gV2hl" + + "biB0aGUgdXNlciB3YW50cyB0byBzdGFydCBvdmVyCgpJZiB0aGV5IGNvbmZpcm0gYSB0ZWFyZG93" + + "bjoKCi0gU2luZ2xlLWNsdXN0ZXI6IGBjZCBudmNmLXNlbGYtbWFuYWdlZC1zdGFjayAmJiBtYWtl" + + "IGRlc3Ryb3lgIChvciBgaGVsbSB1bmluc3RhbGxgIHBlciByZWxlYXNlIGluIHJldmVyc2Ugb3Jk" + + "ZXIpLgotIE11bHRpLWNsdXN0ZXI6IGBoZWxtIHVuaW5zdGFsbCBudmNhLW9wZXJhdG9yIC1uIG52" + + "Y2Etb3BlcmF0b3JgIG9uIHRoZSBjb21wdXRlIHBsYW5lIEZJUlNUOyB0aGVuIGNvbnRyb2wgcGxh" + + "bmU7IHRoZW4gYG52Y2YtY2xpIGNsdXN0ZXIgZGVsZXRlIC0tY2x1c3Rlci1pZD08aWQ+YCB0byBy" + + "ZW1vdmUgdGhlIElDTVMgcm93LgoKQWx3YXlzIGNvbmZpcm0gYmVmb3JlIGFueSB0ZWFyZG93biDi" + + "gJQgQ2Fzc2FuZHJhIFN0YXRlZnVsU2V0IFBWQyBkZWxldGlvbiBpcyBkZXN0cnVjdGl2ZSBhbmQg" + + "bG9zZXMgY2x1c3Rlcl9vaWRjIHJvd3MuCgojIyBXaGF0IG5vdCB0byBkbwoKLSAqKkRvbid0IHBy" + + "b3Bvc2UgYC0tZGVidWdgIGFzIGEgZml4LioqIEl0J3MgZGlhZ25vc3RpYyBvbmx5LgotICoqRG9u" + + "J3QgcHJvcG9zZSBgaGVsbSB1bmluc3RhbGxgKiogdW5sZXNzIHRoZSB1c2VyIGV4cGxpY2l0bHkg" + + "YXNrZWQgdG8gc3RhcnQgb3Zlci4KLSAqKkRvbid0IHByb3Bvc2UgYGt1YmVjdGwgZGVsZXRlIHB2" + + "Y2AqKiBldmVyIHdpdGhvdXQgY29uZmlybWF0aW9uIOKAlCBpdCBkZWxldGVzIHRoZSB1bmRlcmx5" + + "aW5nIFBWLgotICoqRG9uJ3QgcmUtcnVuIGB1cGAgd2l0aCBkaWZmZXJlbnQgaW5wdXRzKiogdG8g" + + "InNlZSB3aGF0IGhhcHBlbnMiLiBEaWFnbm9zZSB3aXRoIGBzdGF0dXNgIC8gYGt1YmVjdGwgZGVz" + + "Y3JpYmVgIGZpcnN0LgpQSwcIcpIsJr4OAAC+DgAAUEsDBBQACAAAAAAAIQAAAAAAAAAAAAAAAAA6" + + "AAkAZGF0YS9udmNmLXNlbGYtbWFuYWdlZC1jbGkvcHJvbXB0cy9pbnN0YWxsLWZyb20tc2NyYXRj" + + "aC5tZFVUBQABAKbOEiMgSW5zdGFsbCBzZWxmLWhvc3RlZCBOVkNGIGZyb20gc2NyYXRjaAoKVXNl" + + "ciB3YW50cyB0byBicmluZyB1cCBzZWxmLWhvc3RlZCBOVkNGIG9uIGEgZnJlc2ggS3ViZXJuZXRl" + + "cyBjbHVzdGVyIChvciBrM2QgZm9yIGxvY2FsIGRldikuIFRoaXMgaXMgdGhlIG1vc3QgY29tbW9u" + + "IGVudHJ5cG9pbnQuCgojIyBTdGVwcwoKMS4gQ29uZmlybSB0aGUgdG9wb2xvZ3kuIEFzayB0aGUg" + + "dXNlcjoKICAgLSAiU2luZ2xlLWNsdXN0ZXIgKGNvbnRyb2wgcGxhbmUgYW5kIGNvbXB1dGUgcGxh" + + "bmUgb24gb25lIGNsdXN0ZXIsIHNpbXBsZXIpIG9yIHNwbGl0IChjb250cm9sIHBsYW5lIG9uIGNs" + + "dXN0ZXIgQSwgY29tcHV0ZSBwbGFuZSBvbiBjbHVzdGVyIEIsIHByb2R1Y3Rpb24tc2hhcGVkKT8i" + + "CiAgIC0gIldoYXQncyB0aGUgY2x1c3RlciBuYW1lICh0aGUgYC0tY2x1c3Rlci1uYW1lYCBmbGFn" + + "LCBiZWNvbWVzIHRoZSBJQ01TIHJvdyBpZGVudGlmaWVyKT8gRXhhbXBsZXM6IGBuY3AtbG9jYWxg" + + "IGZvciBsb2NhbCBkZXYsIGBwcm9kLXVzLWVhc3QtMWAgZm9yIHByb2R1Y3Rpb24uIgogICAtIEZv" + + "ciBzcGxpdDogIkNvbnRyb2wtcGxhbmUga3ViZWNvbmZpZyBjb250ZXh0IG5hbWU/IENvbXB1dGUt" + + "cGxhbmUgY29udGV4dD8gUHVibGljIElDTVMgVVJMPyIKCjIuIFByZXBhcmUgcmVtb3RlIEdhdGV3" + + "YXkgYW5kIENMSSBjb25maWcgaWYgdGhpcyBpcyBub3QgbG9jYWwgazNkLiBPbmUtY2xpY2sKICAg" + + "YXBwbGllcyB0aGUgY29udHJvbCBwbGFuZSwgdGhlbiBpbW1lZGlhdGVseSBjYWxscyBBUEksIEFQ" + + "SSBLZXlzLCBpbnZvY2F0aW9uLAogICBhbmQgZ1JQQyBlbmRwb2ludHMuIEZvciByZW1vdGUgY2x1" + + "c3RlcnMsIHRoZSBHYXRld2F5IG11c3QgYmUgcHJvZ3JhbW1lZCBhbmQKICAgdGhlIENMSSBjb25m" + + "aWcgbXVzdCBwb2ludCBhdCB0aGUgR2F0ZXdheSBsb2FkIGJhbGFuY2VyIGJlZm9yZSBgdXBgIHJ1" + + "bnMuCgogICBJZiBHYXRld2F5IEFQSSBpbmdyZXNzIGlzIG5vdCBhbHJlYWR5IHByZXNlbnQsIGNy" + + "ZWF0ZSBpdCBmaXJzdC4gVGhlc2UgYXJlIHRoZQogICBzaGFyZWQgR2F0ZXdheSBzZXR1cCBzdGVw" + + "cyB1c2VkIGJ5IG9uZS1jbGljaywgSGVsbWZpbGUsIGFuZCBzdGFuZGFsb25lIGNoYXJ0CiAgIGlu" + + "c3RhbGwgcGF0aHM6CgogICBgYGBzaAogICBrdWJlY3RsIGFwcGx5IC1mIGh0dHBzOi8vZ2l0aHVi" + + "LmNvbS9rdWJlcm5ldGVzLXNpZ3MvZ2F0ZXdheS1hcGkvcmVsZWFzZXMvZG93bmxvYWQvdjEuMi4w" + + "L2V4cGVyaW1lbnRhbC1pbnN0YWxsLnlhbWwKCiAgIGZvciBuYW1lc3BhY2UgaW4gZW52b3ktZ2F0" + + "ZXdheS1zeXN0ZW0gZW52b3ktZ2F0ZXdheSBhcGkta2V5cyBlc3Mgc2lzIG52Y2Y7IGRvCiAgICAg" + + "a3ViZWN0bCBjcmVhdGUgbmFtZXNwYWNlICIkbmFtZXNwYWNlIiAtLWRyeS1ydW49Y2xpZW50IC1v" + + "IHlhbWwgfCBrdWJlY3RsIGFwcGx5IC1mIC0KICAgZG9uZQoKICAgZm9yIG5hbWVzcGFjZSBpbiBl" + + "bnZveS1nYXRld2F5IGFwaS1rZXlzIGVzcyBzaXMgbnZjZjsgZG8KICAgICBrdWJlY3RsIGxhYmVs" + + "IG5hbWVzcGFjZSAiJG5hbWVzcGFjZSIgbnZjZi9wbGF0Zm9ybT10cnVlIC0tb3ZlcndyaXRlCiAg" + + "IGRvbmUKCiAgIGhlbG0gdXBncmFkZSAtLWluc3RhbGwgZWcgb2NpOi8vZG9ja2VyLmlvL2Vudm95" + + "cHJveHkvZ2F0ZXdheS1oZWxtIFwKICAgICAtLXZlcnNpb24gdjEuMS4zIFwKICAgICAtbiBlbnZv" + + "eS1nYXRld2F5LXN5c3RlbQogICBgYGAKCiAgIEJlZm9yZSBhcHBseWluZyB0aGUgYEVudm95UHJv" + + "eHlgLCBkZXRlcm1pbmUgd2hpY2ggU2VydmljZSBjb250cm9sbGVyIG93bnMKICAgbG9hZCBiYWxh" + + "bmNlcnMgb24gRUtTLiBEbyBub3QgYXBwbHkgdGhlIG1hbmlmZXN0IHVudGlsIHlvdSBoYXZlIHNl" + + "bGVjdGVkIHRoZQogICBtYXRjaGluZyBgZW52b3lTZXJ2aWNlYCBjb25maWd1cmF0aW9uLiBVc2Ug" + + "dGhlIEVLUyBBUEkgYW5kIHRoZSBpbi1jbHVzdGVyCiAgIGNvbnRyb2xsZXIgRGVwbG95bWVudCBp" + + "bnN0ZWFkIG9mIGd1ZXNzaW5nIGZyb20gdGhlIGNsdXN0ZXIgYWdlIG9yIGV4aXN0aW5nCiAgIFNl" + + "cnZpY2VzOgoKICAgYGBgc2gKICAgZXhwb3J0IEVLU19DTFVTVEVSX05BTUU9IjxjbHVzdGVyLW5h" + + "bWU+IgoKICAgYXdzIGVrcyBkZXNjcmliZS1jbHVzdGVyIC0tbmFtZSAiJEVLU19DTFVTVEVSX05B" + + "TUUiIFwKICAgICAtLXF1ZXJ5ICdjbHVzdGVyLmt1YmVybmV0ZXNOZXR3b3JrQ29uZmlnLmVsYXN0" + + "aWNMb2FkQmFsYW5jaW5nLmVuYWJsZWQnIFwKICAgICAtLW91dHB1dCB0ZXh0CiAgIGt1YmVjdGwg" + + "LW4ga3ViZS1zeXN0ZW0gZ2V0IGRlcGxveW1lbnQgYXdzLWxvYWQtYmFsYW5jZXItY29udHJvbGxl" + + "cgogICBgYGAKCiAgIEFuIEVLUyBBUEkgcmVzdWx0IG9mIGBUcnVlYCBzZWxlY3RzCiAgIFtFS1Mg" + + "QXV0byBNb2RlXShodHRwczovL2RvY3MuYXdzLmFtYXpvbi5jb20vZWtzL2xhdGVzdC91c2VyZ3Vp" + + "ZGUvYXV0by1jb25maWd1cmUtbmxiLmh0bWwpLgogICBPdGhlcndpc2UsIGEgc3VjY2Vzc2Z1bCBE" + + "ZXBsb3ltZW50IGxvb2t1cCBzZWxlY3RzIHRoZSBBV1MgTG9hZCBCYWxhbmNlcgogICBDb250cm9s" + + "bGVyLiBJZiBuZWl0aGVyIGlzIHByZXNlbnQsIGNvbmZpcm0gdGhhdCB0aGUgY2x1c3RlciBpbnRl" + + "bnRpb25hbGx5CiAgIHVzZXMgdGhlIGxlZ2FjeSBBV1MgY2xvdWQgcHJvdmlkZXIgU2VydmljZSBj" + + "b250cm9sbGVyLCBvciBpbnN0YWxsIHRoZSBBV1MKICAgTG9hZCBCYWxhbmNlciBDb250cm9sbGVy" + + "LCBiZWZvcmUgYXBwbHlpbmcgdGhlIGV4YW1wbGUuCgogICBUaGUgYXBwbGllZCBtYW5pZmVzdCBi" + + "ZWxvdyBpcyBmb3IgdGhlIEFXUyBMb2FkIEJhbGFuY2VyIENvbnRyb2xsZXIuIEZvciBFS1MKICAg" + + "QXV0byBNb2RlLCByZXBsYWNlIGl0cyBgZW52b3lTZXJ2aWNlYCBtYXAgYmVmb3JlIGFwcGx5aW5n" + + "IGl0IHdpdGg6CgogICBgYGB5YW1sCiAgIGVudm95U2VydmljZToKICAgICBsb2FkQmFsYW5jZXJD" + + "bGFzczogZWtzLmFtYXpvbmF3cy5jb20vbmxiCiAgICAgYW5ub3RhdGlvbnM6CiAgICAgICBzZXJ2" + + "aWNlLmJldGEua3ViZXJuZXRlcy5pby9hd3MtbG9hZC1iYWxhbmNlci1zY2hlbWU6ICJpbnRlcm5l" + + "dC1mYWNpbmciCiAgIGBgYAoKICAgRm9yIHRoZSBsZWdhY3kgQVdTIGNsb3VkIHByb3ZpZGVyIFNl" + + "cnZpY2UgY29udHJvbGxlciwgcmVwbGFjZSB0aGUgbWFwIHdpdGg6CgogICBgYGB5YW1sCiAgIGVu" + + "dm95U2VydmljZToKICAgICBhbm5vdGF0aW9uczoKICAgICAgIHNlcnZpY2UuYmV0YS5rdWJlcm5l" + + "dGVzLmlvL2F3cy1sb2FkLWJhbGFuY2VyLXR5cGU6ICJubGIiCiAgIGBgYAoKICAgVGhlIGxlZ2Fj" + + "eSBjb250cm9sbGVyIGNyZWF0ZXMgYW4gaW50ZXJuZXQtZmFjaW5nIGxvYWQgYmFsYW5jZXIgYnkg" + + "ZGVmYXVsdC4KICAgRG8gbm90IGNvbWJpbmUgdGhlIGxlZ2FjeSBgbmxiYCBzZWxlY3RvciB3aXRo" + + "IHRoZSBBV1MgTG9hZCBCYWxhbmNlcgogICBDb250cm9sbGVyIHRhcmdldCB0eXBlIGFuZCBzY2hl" + + "bWUgYW5ub3RhdGlvbnMuIEl0IHN1cHBvcnRzIGFuIE5MQiBTZXJ2aWNlCiAgIHdob3NlIHBvcnRz" + + "IHVzZSBvbmx5IFRDUCBvciBvbmx5IFVEUCwgYnV0IHJlamVjdHMgb25lIFNlcnZpY2UgdGhhdCBj" + + "b21iaW5lcwogICBUQ1AgYW5kIFVEUCBwb3J0cy4gVXNlIHRoZSBBV1MgTG9hZCBCYWxhbmNlciBD" + + "b250cm9sbGVyIHdoZW4gdGhlIGdlbmVyYXRlZAogICBFbnZveSBTZXJ2aWNlIGNvbWJpbmVzIHBy" + + "b3RvY29scy4gT24gbm9uLUFXUyBjbHVzdGVycywgcmVwbGFjZSB0aGUgbWFwIHdpdGgKICAgdGhl" + + "IHByb3ZpZGVyJ3MgU2VydmljZSBjb25maWd1cmF0aW9uIGJlZm9yZSBhcHBseWluZyBpdC4KCiAg" + + "IGBgYHNoCiAgICMgTm9uLUVLUy1BdXRvLU1vZGUgZXhhbXBsZTogdXNlIEFXUyBMQkMgd2l0aCBp" + + "bnN0YW5jZSB0YXJnZXRzLgogICBrdWJlY3RsIGFwcGx5IC1mIC0gPDxFT0YKICAgYXBpVmVyc2lv" + + "bjogZ2F0ZXdheS5lbnZveXByb3h5LmlvL3YxYWxwaGExCiAgIGtpbmQ6IEVudm95UHJveHkKICAg" + + "bWV0YWRhdGE6CiAgICAgbmFtZTogZWcKICAgICBuYW1lc3BhY2U6IGVudm95LWdhdGV3YXktc3lz" + + "dGVtCiAgIHNwZWM6CiAgICAgcHJvdmlkZXI6CiAgICAgICB0eXBlOiBLdWJlcm5ldGVzCiAgICAg" + + "ICBrdWJlcm5ldGVzOgogICAgICAgICBlbnZveVNlcnZpY2U6CiAgICAgICAgICAgYW5ub3RhdGlv" + + "bnM6CiAgICAgICAgICAgICBzZXJ2aWNlLmJldGEua3ViZXJuZXRlcy5pby9hd3MtbG9hZC1iYWxh" + + "bmNlci10eXBlOiAiZXh0ZXJuYWwiCiAgICAgICAgICAgICBzZXJ2aWNlLmJldGEua3ViZXJuZXRl" + + "cy5pby9hd3MtbG9hZC1iYWxhbmNlci1ubGItdGFyZ2V0LXR5cGU6ICJpbnN0YW5jZSIKICAgICAg" + + "ICAgICAgIHNlcnZpY2UuYmV0YS5rdWJlcm5ldGVzLmlvL2F3cy1sb2FkLWJhbGFuY2VyLXNjaGVt" + + "ZTogImludGVybmV0LWZhY2luZyIKICAgLS0tCiAgIGFwaVZlcnNpb246IGdhdGV3YXkubmV0d29y" + + "a2luZy5rOHMuaW8vdjEKICAga2luZDogR2F0ZXdheUNsYXNzCiAgIG1ldGFkYXRhOgogICAgIG5h" + + "bWU6IGVnCiAgIHNwZWM6CiAgICAgY29udHJvbGxlck5hbWU6IGdhdGV3YXkuZW52b3lwcm94eS5p" + + "by9nYXRld2F5Y2xhc3MtY29udHJvbGxlcgogICAgIHBhcmFtZXRlcnNSZWY6CiAgICAgICBncm91" + + "cDogZ2F0ZXdheS5lbnZveXByb3h5LmlvCiAgICAgICBraW5kOiBFbnZveVByb3h5CiAgICAgICBu" + + "YW1lOiBlZwogICAgICAgbmFtZXNwYWNlOiBlbnZveS1nYXRld2F5LXN5c3RlbQogICBFT0YKCiAg" + + "IGt1YmVjdGwgYXBwbHkgLWYgLSA8PEVPRgogICBhcGlWZXJzaW9uOiBnYXRld2F5Lm5ldHdvcmtp" + + "bmcuazhzLmlvL3YxCiAgIGtpbmQ6IEdhdGV3YXkKICAgbWV0YWRhdGE6CiAgICAgbmFtZTogbnZj" + + "Zi1nYXRld2F5CiAgICAgbmFtZXNwYWNlOiBlbnZveS1nYXRld2F5CiAgIHNwZWM6CiAgICAgZ2F0" + + "ZXdheUNsYXNzTmFtZTogZWcKICAgICBsaXN0ZW5lcnM6CiAgICAgLSBuYW1lOiBodHRwCiAgICAg" + + "ICBwcm90b2NvbDogSFRUUAogICAgICAgcG9ydDogODAKICAgICAgIGFsbG93ZWRSb3V0ZXM6CiAg" + + "ICAgICAgIG5hbWVzcGFjZXM6CiAgICAgICAgICAgZnJvbTogU2VsZWN0b3IKICAgICAgICAgICBz" + + "ZWxlY3RvcjoKICAgICAgICAgICAgIG1hdGNoTGFiZWxzOgogICAgICAgICAgICAgICBudmNmL3Bs" + + "YXRmb3JtOiAidHJ1ZSIKICAgICAtIG5hbWU6IHRjcAogICAgICAgcHJvdG9jb2w6IFRDUAogICAg" + + "ICAgcG9ydDogMTAwODEKICAgICAgIGFsbG93ZWRSb3V0ZXM6CiAgICAgICAgIG5hbWVzcGFjZXM6" + + "CiAgICAgICAgICAgZnJvbTogU2VsZWN0b3IKICAgICAgICAgICBzZWxlY3RvcjoKICAgICAgICAg" + + "ICAgIG1hdGNoTGFiZWxzOgogICAgICAgICAgICAgICBudmNmL3BsYXRmb3JtOiAidHJ1ZSIKICAg" + + "RU9GCiAgIGBgYAoKICAgU2VydmljZSBhbm5vdGF0aW9ucyBtdXN0IGJlIHVuZGVyCiAgIGBFbnZv" + + "eVByb3h5LnNwZWMucHJvdmlkZXIua3ViZXJuZXRlcy5lbnZveVNlcnZpY2UuYW5ub3RhdGlvbnNg" + + "LiBFbnZveSBHYXRld2F5CiAgIGNvcGllcyB0aGVtIHRvIHRoZSBnZW5lcmF0ZWQgRW52b3kgU2Vy" + + "dmljZS4gRG8gbm90IHB1dCBTZXJ2aWNlIGFubm90YXRpb25zCiAgIG9uIHRoZSBgR2F0ZXdheWAg" + + "cmVzb3VyY2UuCgogICBDYXB0dXJlIHRoZSBHYXRld2F5IHZhbHVlcyBhbmQgd3JpdGUgdGhlIENM" + + "SSBjb25maWc6CgogICBgYGBzaAogICBleHBvcnQgQ0xVU1RFUl9OQU1FPSJudmNmLXJlbW90ZSIK" + + "ICAgZXhwb3J0IE5DQV9JRD0ibnZjZi1kZWZhdWx0IgogICBleHBvcnQgUkVHSU9OPSJ1cy13ZXN0" + + "LTEiCiAgIGV4cG9ydCBIVFRQX0dBVEVXQVlfTkFNRVNQQUNFPSJlbnZveS1nYXRld2F5IgogICBl" + + "eHBvcnQgSFRUUF9HQVRFV0FZX05BTUU9Im52Y2YtZ2F0ZXdheSIKICAgZXhwb3J0IEdSUENfR0FU" + + "RVdBWV9OQU1FU1BBQ0U9ImVudm95LWdhdGV3YXkiCiAgIGV4cG9ydCBHUlBDX0dBVEVXQVlfTkFN" + + "RT0ibnZjZi1nYXRld2F5IgoKICAga3ViZWN0bCAtbiAiJEhUVFBfR0FURVdBWV9OQU1FU1BBQ0Ui" + + "IHdhaXQgImdhdGV3YXkvJEhUVFBfR0FURVdBWV9OQU1FIiBcCiAgICAgLS1mb3I9Y29uZGl0aW9u" + + "PVByb2dyYW1tZWQ9VHJ1ZSAtLXRpbWVvdXQ9MTBtCiAgIGt1YmVjdGwgLW4gIiRHUlBDX0dBVEVX" + + "QVlfTkFNRVNQQUNFIiB3YWl0ICJnYXRld2F5LyRHUlBDX0dBVEVXQVlfTkFNRSIgXAogICAgIC0t" + + "Zm9yPWNvbmRpdGlvbj1Qcm9ncmFtbWVkPVRydWUgLS10aW1lb3V0PTEwbQoKICAgZXhwb3J0IEdB" + + "VEVXQVlfQUREUj0iJChrdWJlY3RsIC1uICIkSFRUUF9HQVRFV0FZX05BTUVTUEFDRSIgZ2V0ICJn" + + "YXRld2F5LyRIVFRQX0dBVEVXQVlfTkFNRSIgXAogICAgIC1vIGpzb25wYXRoPSd7LnN0YXR1cy5h" + + "ZGRyZXNzZXNbMF0udmFsdWV9JykiCiAgIGV4cG9ydCBHUlBDX0dBVEVXQVlfQUREUj0iJChrdWJl" + + "Y3RsIC1uICIkR1JQQ19HQVRFV0FZX05BTUVTUEFDRSIgZ2V0ICJnYXRld2F5LyRHUlBDX0dBVEVX" + + "QVlfTkFNRSIgXAogICAgIC1vIGpzb25wYXRoPSd7LnN0YXR1cy5hZGRyZXNzZXNbMF0udmFsdWV9" + + "JykiCiAgIHRlc3QgLW4gIiRHQVRFV0FZX0FERFIiIHx8IGV4aXQgMQogICB0ZXN0IC1uICIkR1JQ" + + "Q19HQVRFV0FZX0FERFIiIHx8IGV4aXQgMQogICBleHBvcnQgU1RBQ0tfRE9NQUlOPSIkR0FURVdB" + + "WV9BRERSIgogICBgYGAKCiAgIFNldCBgU1RBQ0tfRE9NQUlOYCB0byB0aGUgaG9zdG5hbWUgc3Vm" + + "Zml4IHVzZWQgYnkgdGhlIGluc3RhbGxlZCBIVFRQUm91dGVzLgogICBGb3IgcmVtb3RlIHRlc3Qg" + + "Zmxvd3Mgd2l0aG91dCBwcm9kdWN0aW9uIEROUywgdXNlIHRoZSBHYXRld2F5IGxvYWQgYmFsYW5j" + + "ZXIKICAgYWRkcmVzcy4KCiAgIGBgYHNoCiAgIGNhdCA+IC5udmNmLWNsaS55YW1sIDw8RU9GCiAg" + + "IGJhc2VfaHR0cF91cmw6ICJodHRwOi8vJHtHQVRFV0FZX0FERFJ9IgogICBpbnZva2VfdXJsOiAi" + + "aHR0cDovLyR7R0FURVdBWV9BRERSfSIKICAgYmFzZV9ncnBjX3VybDogIiR7R1JQQ19HQVRFV0FZ" + + "X0FERFJ9OjEwMDgxIgogICBhcGlfa2V5c19zZXJ2aWNlX3VybDogImh0dHA6Ly8ke0dBVEVXQVlf" + + "QUREUn0iCgogICBhcGlfa2V5c19ob3N0OiAiYXBpLWtleXMuJHtTVEFDS19ET01BSU59IgogICBh" + + "cGlfaG9zdDogImFwaS4ke1NUQUNLX0RPTUFJTn0iCiAgIGludm9rZV9ob3N0OiAiaW52b2NhdGlv" + + "bi4ke1NUQUNLX0RPTUFJTn0iCgogICBhcGlfa2V5c19zZXJ2aWNlX2lkOiAibnZpZGlhLWNsb3Vk" + + "LWZ1bmN0aW9ucy1uY3Atc2VydmljZS1pZC1ha2V0bSIKICAgYXBpX2tleXNfaXNzdWVyX3NlcnZp" + + "Y2U6ICJudmNmLWFwaSIKICAgYXBpX2tleXNfb3duZXJfaWQ6ICJzdmNAbnZjZi1hcGkubG9jYWwi" + + "CgogICBjbGllbnRfaWQ6ICIke05DQV9JRH0iCiAgIEVPRgogICBgYGAKCjMuIFJ1biBwcmUtZmxp" + + "Z2h0LiBDaG9vc2UgdGhlIGZsYXZvciBtYXRjaGluZyB0aGUgdG9wb2xvZ3kuIGAtLWpzb25gIHdy" + + "aXRlcwogICBKU09OTCB0byBzdGRlcnIsIHNvIHJlZGlyZWN0IGl0IHRvIHRoZSBwYXJzZXIgYW5k" + + "IGtlZXAgb25seSBKU09OIGxpbmVzOgoKICAgYGBgc2gKICAgIyBTaW5nbGUtY2x1c3RlcgogICBu" + + "dmNmLWNsaSBzZWxmLWhvc3RlZCBjaGVjayAtLXByZSAtLWpzb24gMj4mMSA+L2Rldi9udWxsIHwg" + + "Z3JlcCAnXnsnID4gY2hlY2suanNvbmwKCiAgICMgU3BsaXQ6IHBhc3MgYm90aCBjb250ZXh0IGZs" + + "YWdzCiAgIG52Y2YtY2xpIHNlbGYtaG9zdGVkIGNoZWNrIC0tcHJlIFwKICAgICAtLWNvbnRyb2wt" + + "cGxhbmUtY29udGV4dD1hZG1pbkBjcCBcCiAgICAgLS1jb21wdXRlLXBsYW5lLWNvbnRleHQ9YWRt" + + "aW5AZ3B1MSBcCiAgICAgLS1qc29uIDI+JjEgPi9kZXYvbnVsbCB8IGdyZXAgJ157JyA+IGNoZWNr" + + "Lmpzb25sCgogICAjIEdhdGUgb24gdGhlIGZpbmFsIGV2ZW50LiBBIG1pc3NpbmcgZmluYWwgZXZl" + + "bnQgYWxzbyBmYWlscyB0aGUgZ2F0ZS4KICAganEgLXNlICdhbnkoLltdOyAuZXZlbnQgPT0gImZp" + + "bmFsIiBhbmQgLnN1Y2Nlc3MpJyBjaGVjay5qc29ubAogICBgYGAKCiAgIElmIHRoZSBnYXRlIGZh" + + "aWxzLCBzaG93IHRoZSB1c2VyIGVhY2ggYGNoZWNrX2NvbXBsZXRlZGAgZXZlbnQgd2l0aAogICBg" + + "cGFzc2VkOiBmYWxzZWAgYW5kIGBzZXZlcml0eTogImVycm9yImAgKGl0cyBgbWVzc2FnZWAgYW5k" + + "IGBoaW50VVJMYCksIGFuZAogICBzdG9wLiBEb24ndCBwcm9jZWVkIHRvIGluc3RhbGwgd2l0aCBi" + + "cm9rZW4gcHJlcmVxcy4gUm93cyBhdAogICBgc2V2ZXJpdHk6ICJ3YXJuaW5nImAgZG8gbm90IGZh" + + "aWwgdGhlIGdhdGU7IG1lbnRpb24gdGhlbS4gRm9yIGFuIG9wZXJhdG9yCiAgIHdpdGgga3ViZWN0" + + "bCBhY2Nlc3MgdG8gdGhlIGNvbXB1dGUgcGxhbmUgb25seSwgdXNlIHRoZSBjb21wdXRlLW9ubHkg" + + "Zm9ybSBpbgogICBbYWRkLWNvbXB1dGUtcGxhbmUubWRdKGFkZC1jb21wdXRlLXBsYW5lLm1kKSBp" + + "bnN0ZWFkLgoKNC4gTWludCBhbiBhZG1pbiB0b2tlbiBpZiBuZWVkZWQuIGBudmNmLWNsaSBpbml0" + + "YCBpcyBpZGVtcG90ZW50LiBDYWxsIGl0IGlmIHRoZQogICB1c2VyIGRvZXMgbm90IGhhdmUgYSBz" + + "ZXNzaW9uIHlldC4gSW5pdCB0YWxrcyB0byBBUEkgS2V5cyB0aHJvdWdoIHRoZSBwdWJsaWMKICAg" + + "QVBJIGdhdGV3YXksIHNvIGl0IGFsc28gd29ya3MgaW4gY29tcHV0ZS1vbmx5IG1vZGUuCgo1LiBS" + + "dW4gYHVwYC4gVXNlIGAtLWpzb25gIGZvciB0aGUgSlNPTkwgZXZlbnQgc3RyZWFtOgoKICAgYGBg" + + "c2gKICAgbnZjZi1jbGkgc2VsZi1ob3N0ZWQgdXAgXAogICAgIC0tY2x1c3Rlci1uYW1lPSIke0NM" + + "VVNURVJfTkFNRX0iIFwKICAgICAtLW5jYS1pZD0iJHtOQ0FfSUR9IiBcCiAgICAgLS1yZWdpb249" + + "IiR7UkVHSU9OfSIgXAogICAgIC0tanNvbgogICBgYGAKCiAgIEZvciBzcGxpdC1jbHVzdGVyIGlu" + + "c3RhbGxzLCBhZGQgYm90aCBjb250ZXh0IGZsYWdzOgoKICAgYGBgc2gKICAgZXhwb3J0IENPTlRS" + + "T0xfUExBTkVfQ09OVEVYVD0iYWRtaW5AY29udHJvbC1wbGFuZSIKICAgZXhwb3J0IENPTVBVVEVf" + + "UExBTkVfQ09OVEVYVD0iYWRtaW5AZ3B1LWNsdXN0ZXIiCgogICBudmNmLWNsaSBzZWxmLWhvc3Rl" + + "ZCB1cCBcCiAgICAgLS1jb250cm9sLXBsYW5lLWNvbnRleHQ9IiR7Q09OVFJPTF9QTEFORV9DT05U" + + "RVhUfSIgXAogICAgIC0tY29tcHV0ZS1wbGFuZS1jb250ZXh0PSIke0NPTVBVVEVfUExBTkVfQ09O" + + "VEVYVH0iIFwKICAgICAtLWNsdXN0ZXItbmFtZT0iJHtDTFVTVEVSX05BTUV9IiBcCiAgICAgLS1u" + + "Y2EtaWQ9IiR7TkNBX0lEfSIgXAogICAgIC0tcmVnaW9uPSIke1JFR0lPTn0iIFwKICAgICAtLWpz" + + "b24KICAgYGBgCgogICBQYXJzZSBldmVudHM6CiAgIC0gYHBoYXNlX3N0YXJ0ZWRgIC8gYHBoYXNl" + + "X2NvbXBsZXRlZGA6IGxvZyBwcm9ncmVzcwogICAtIGBwaGFzZV9wcm9ncmVzc2A6IHNob3cgcHJv" + + "Z3Jlc3MgZm9yIHRoZSBsb25nIGFwcGx5IHBoYXNlcyAocmVzb3VyY2UgY291bnRzKQogICAtIGB3" + + "YWl0aW5nYDogc3VyZmFjZSB0byB0aGUgdXNlciBpZiBpdCBwZXJzaXN0cyBmb3IgbW9yZSB0aGFu" + + "IDIgbWludXRlcwogICAtIGBwaGFzZV9mYWlsZWRgOiBzdG9wIGFuZCBzdXJmYWNlIGBlcnJNZXNz" + + "YWdlYCBwbHVzIGVhY2ggYHJlbWVkaWF0aW9uYCBsaW5lLgogICAgIERlY2lkZSBiYXNlZCBvbiBg" + + "cmV0cnlDbGFzc2A6IGBpbW1lZGlhdGVgIC0+IG1heSByZS1ydW4gbm93IHdpdGggdXNlcgogICAg" + + "IGFwcHJvdmFsOyBgYmFja29mZmAgLT4gd2FpdCBgcmV0cnlBZnRlclNlY2AgYW5kIHRoZW4gcmUt" + + "cnVuIHdpdGggdXNlcgogICAgIGFwcHJvdmFsOyBgYWZ0ZXJfcmVtZWRpYXRpb25gLCBgbm9uZWAs" + + "IG9yIGB1bmtub3duYCAtPiB0aGUgb3BlcmF0b3IgbXVzdAogICAgIGFjdCBmaXJzdCwgc28gZG8g" + + "bm90IGF1dG8tcmV0cnkuCiAgIC0gYGZpbmFsYDogZG9uZS4gUHJpbnQgY2x1c3RlcklkIGFuZCBO" + + "VkNGQmFja2VuZCBoZWFsdGguCgo2LiBWZXJpZnkgd2l0aCBzdGF0dXMuIFJ1biBgbnZjZi1jbGkg" + + "c2VsZi1ob3N0ZWQgc3RhdHVzIC0tanNvbiB8IGpxYCBhbmQgZXhwZWN0CiAgIGB2ZXJkaWN0OiAi" + + "aGVhbHRoeSJgLiBPdGhlcndpc2UsIHVzZQogICBbZGlhZ25vc2UtZmFpbGVkLWluc3RhbGwubWRd" + + "KGRpYWdub3NlLWZhaWxlZC1pbnN0YWxsLm1kKS4KCjcuIE9wdGlvbmFsbHkgc21va2UgdGVzdCBh" + + "IGZ1bmN0aW9uLiBVc2UKICAgW2RlcGxveS1hbmQtaW52b2tlLm1kXShkZXBsb3ktYW5kLWludm9r" + + "ZS5tZCkgZm9yIHRoZSBjcmVhdGUgLT4gZGVwbG95IC0+CiAgIGludm9rZSBmbG93LgoKIyMgTm90" + + "ZXMKCi0gU2luZ2xlLWNsdXN0ZXIgYHVwYCBhZ2FpbnN0IGEgZnJlc2ggY2x1c3RlciB0YWtlcyBh" + + "Ym91dCAxMC0xMyBtaW51dGVzLAogIGRlcGVuZGluZyBvbiBjaGFydCBwdWxsIHNwZWVkIGFuZCBO" + + "QVRTIHN0cmVhbS1pbml0IGxhdGVuY3kuCi0gYHVwYCBpcyBpZGVtcG90ZW50LiBJdCBpcyBzYWZl" + + "IHRvIHJlLXJ1biBhZnRlciBmaXhpbmcgYSBwcmVyZXF1aXNpdGUuCi0gTmV2ZXIgcHJvcG9zZSBg" + + "LS1mb3JjZWAgKG5vIGNvbW1hbmQgdGFrZXMgb25lIGFueXdheSkuCi0gSWYgdGhlIHVzZXIgaGFz" + + "IENJIC8gYCRDSWAgc2V0LCBhbHdheXMgdXNlIGAtLW5vbi1pbnRlcmFjdGl2ZSAtLXRva2VuPSRK" + + "V1RgOyBuZXZlciBwcm9wb3NlIGBudmNmLWNsaSBpbml0YCBpbnRlcmFjdGl2ZWx5LgpQSwcItEnf" + + "J1YpAABWKQAAUEsDBBQACAAAAAAAIQAAAAAAAAAAAAAAAAA5AAkAZGF0YS9udmNmLXNlbGYtbWFu" + + "YWdlZC1jbGkvcHJvbXB0cy9yb3RhdGUtY2x1c3Rlci1qd2tzLm1kVVQFAAEAps4SIyBSb3RhdGUg" + + "YSBjbHVzdGVyJ3MgSldLUwoKVXNlciBzYXlzIE5WQ0Egc3RvcHBlZCBhdXRoZW50aWNhdGluZywg" + + "b3IgUFNBVCBhdXRoIGlzIDQwMS1pbmcgYWdhaW5zdCBJQ01TLiBUaGUgSzhzIEFQSSBzZXJ2ZXIn" + + "cyBKV1Qgc2lnbmluZyBrZXlzIG1heSBoYXZlIHJvdGF0ZWQgYW5kIHRoZSBKV0tTIHN0b3JlZCBp" + + "biBJQ01TIGlzIG5vdyBzdGFsZS4KCiMjIFdoZW4gdG8gcm90YXRlCgotIGBrdWJlY3RsIGxvZ3Mg" + + "LW4gbnZjYS1zeXN0ZW0gLWwgYXBwLmt1YmVybmV0ZXMuaW8vbmFtZT1udmNhYCBzaG93cyBgSFRU" + + "UCA0MDFgIG9uIGAvdjEvbnZjYS9jbHVzdGVycy88aWQ+L3JlZ2lzdGVyYCByZXBlYXRlZGx5Ci0g" + + "SUNNUyBsb2dzIHNob3cgYE5vIGNsdXN0ZXIgZm91bmQgd2l0aCB2YWxpZCBKV0tTIGZvciBjbHVz" + + "dGVyIElEOiA8aWQ+YCAoY2F1c2VkIGJ5IGlzc3VlciBtaXNtYXRjaCwgbm90IEpXS1MsIGJ1dCBy" + + "b3RhdGUgZml4ZXMgYm90aCkKLSBBZnRlciBhIGNvbnRyb2wtcGxhbmUgSzhzIHVwZ3JhZGUgb3Ig" + + "Y29tcHV0ZS1wbGFuZSBLOHMgdXBncmFkZQoKIyMgU3RlcHMKCjEuIENvbmZpcm0gdGhlIGNsdXN0" + + "ZXIgSUQuIFJ1biBgbnZjZi1jbGkgY2x1c3RlciBsaXN0LXJlZ2lzdGVyZWQgLS1uY2EtaWQ9JE5D" + + "QV9JRCAtLWljbXMtdXJsPSRJQ01TYC4gSWRlbnRpZnkgdGhlIHJvdyBtYXRjaGluZyB0aGUgdXNl" + + "cidzIGNvbXB1dGUgcGxhbmUuCgoyLiAqKlJvdGF0ZS4qKiBVc2UgYSBjb250ZXh0IHRoYXQgY2Fu" + + "IHJlYWNoIHRoZSBjb21wdXRlIHBsYW5lJ3MgSzhzIEFQSSAoYmVjYXVzZSByb3RhdGlvbiByZS1m" + + "ZXRjaGVzIHRoZSBLOHMgQVBJJ3MgYC9vcGVuaWQvdjEvandrc2ApLiBXcml0ZSB0aGF0IGNvbnRl" + + "eHQgYWxvbmUgdG8gYSB0ZW1wb3Jhcnkga3ViZWNvbmZpZyBhbmQgcGFzcyBpdCB3aXRoIGAtLWt1" + + "YmVjb25maWdgLCBzbyB0aGUgY3VycmVudCBjb250ZXh0IG9mIHlvdXIga3ViZWNvbmZpZyBzdGF5" + + "cyBhcyBpdCB3YXM6CgogICBgYGBzaAogICBLQz0kKG1rdGVtcCkKICAga3ViZWN0bCBjb25maWcg" + + "dmlldyAtLW1pbmlmeSAtLWZsYXR0ZW4gLS1jb250ZXh0PTxjdHg+ID4gIiRLQyIgJiYKICAgICBu" + + "dmNmLWNsaSBjbHVzdGVyIHJvdGF0ZSAtLWNsdXN0ZXItaWQ9PGlkPiAtLWt1YmVjb25maWc9IiRL" + + "QyIKICAgcm0gLWYgIiRLQyIKICAgYGBgCgogICBgY2x1c3RlciByb3RhdGVgIHJlLWZldGNoZXMg" + + "SldLUyBmcm9tIHRoZSBjb21wdXRlIHBsYW5lJ3MgSzhzIEFQSSBhbmQgUFVUcyBpdCB0byBJQ01T" + + "LiBUaGUgY2x1c3RlciBJRCBzdGF5cyB0aGUgc2FtZS4gVGhlIHRlbXBvcmFyeSBmaWxlIGhvbGRz" + + "IHRoZSBjb250ZXh0J3MgY3JlZGVudGlhbHM6IGBta3RlbXBgIG1ha2VzIGl0IHJlYWRhYmxlIG9u" + + "bHkgYnkgeW91LCBhbmQgYHJtYCByZW1vdmVzIGl0LgoKMy4gKipWZXJpZnkuKiogYG52Y2YtY2xp" + + "IGNsdXN0ZXIgZ2V0IC0tY2x1c3Rlci1pZD08aWQ+YCDigJQgY29uZmlybSBgdXBkYXRlZEF0YCBh" + + "ZHZhbmNlZC4gVGhlbiBga3ViZWN0bCBsb2dzIC1uIG52Y2Etc3lzdGVtIC1sIGFwcC5rdWJlcm5l" + + "dGVzLmlvL25hbWU9bnZjYSAtLXRhaWw9MjBgIOKAlCByZWdpc3RlciBhdHRlbXB0cyBzaG91bGQg" + + "c3VjY2VlZCAoMjAwLCBub3QgNDAxKS4KCiMjIFdoYXQgbm90IHRvIGRvCgotICoqRG9uJ3QgYGNs" + + "dXN0ZXIgZGVsZXRlYC4qKiBSb3RhdGlvbiBwcmVzZXJ2ZXMgdGhlIGNsdXN0ZXIgSUQgKyBncm91" + + "cCBJRDsgZGVsZXRlIGNyZWF0ZXMgYSBuZXcgcm93IGFuZCB5b3UgaGF2ZSB0byByZWRlcGxveSB0" + + "aGUgY29tcHV0ZSBwbGFuZSB3aXRoIG5ldyBJRHMuCi0gKipEb24ndCBtYW51YWxseSBlZGl0IGBv" + + "dXQvPGNsdXN0ZXI+LXJlZ2lzdGVyLXZhbHVlcy55YW1sYC4qKiBSb3RhdGUgdXBkYXRlcyBJQ01T" + + "IG9ubHk7IHRoZSBoZWxtIHZhbHVlcyBmaWxlIGFscmVhZHkgaGFzIHRoZSByaWdodCBjbHVzdGVy" + + "IElELgoKIyMgSWYgcm90YXRpb24gZG9lc24ndCBmaXggaXQKClRoZSBhdXRoIGZhaWx1cmUgbWF5" + + "IGJlIGF0IGEgZGlmZmVyZW50IGxheWVyIOKAlCBjaGVjazoKCi0gTkFUUyBhdXRoLWNhbGxvdXQg" + + "aGVhbHRoIChga3ViZWN0bCBsb2dzIC1uIG5hdHMtc3lzdGVtIC1sIGFwcD1uYXRzLWF1dGgtY2Fs" + + "bG91dC1zZXJ2aWNlYCkKLSBDbHVzdGVyIHJvdyBleGlzdHMgaW4gYGNsdXN0ZXJfb2lkY19ieV9j" + + "bHVzdGVyX2lkYCBDYXNzYW5kcmEgdGFibGUKLSBgaGVsbS1yZXZhbC1zZXJ2aWNlYCBjb25maWdt" + + "YXAgaGFzIGBhdXRoei5vaWRjLmVuYWJsZWQ9dHJ1ZWAKClJvdXRlIHRvIFtkaWFnbm9zZS1mYWls" + + "ZWQtaW5zdGFsbC5tZF0oZGlhZ25vc2UtZmFpbGVkLWluc3RhbGwubWQpIGZvciB0aG9zZSBjaGVj" + + "a3MuClBLBwipwSb3LgkAAC4JAABQSwMEFAAIAAAAAAAhAAAAAAAAAAAAAAAAAC4ACQBkYXRhL252" + + "Y2Ytc2VsZi1tYW5hZ2VkLWNsaS9wcm9tcHRzL3RlYXJkb3duLm1kVVQFAAEAps4SIyBUZWFyIGRv" + + "d24gYSBzZWxmLWhvc3RlZCBOVkNGIGRlcGxveW1lbnQKCioqU0FGRVRZOioqIFRlYXJkb3duIGRl" + + "c3Ryb3lzIHBlcnNpc3RlbnQgc3RhdGUgKENhc3NhbmRyYSByb3dzLCBPcGVuQmFvIHNlYWwga2V5" + + "cywgcmVnaXN0ZXJlZCBjbHVzdGVycykuIEFsd2F5cyBjb25maXJtIHdpdGggdGhlIHVzZXIgYmVm" + + "b3JlIGFueSBzdGVwLgoKIyMgVEw7RFIg4oCUIGBkb3duYCBmb3Igb25lLXNob3QsIGB1bmluc3Rh" + + "bGxgIGZvciBwZXItcGxhbmUKClRoZSBDTEkgcHJvdmlkZXMgYSB0d28tdGllciB0ZWFyZG93biBz" + + "dXJmYWNlIHN5bW1ldHJpYyB3aXRoIHRoZSBpbnN0YWxsIHNpZGUgKFJFUS0yMiwgTSsxMSk6Cgp8" + + "IFRpZXIgfCBVcCBzaWRlIChleGlzdGluZykgfCBEb3duIHNpZGUgfAp8LS0tfC0tLXwtLS18Cnwg" + + "UGVyLXBsYW5lIHByaW1pdGl2ZSAocmVuZGVycyBZQU1MOyBHaXRPcHMtZnJpZW5kbHkpIHwgYGlu" + + "c3RhbGwgLS1jb250cm9sLXBsYW5lYCAvIGBpbnN0YWxsIC0tY29tcHV0ZS1wbGFuZWAgfCBgdW5p" + + "bnN0YWxsIC0tY29udHJvbC1wbGFuZWAgLyBgdW5pbnN0YWxsIC0tY29tcHV0ZS1wbGFuZWAgfAp8" + + "IE9yY2hlc3RyYXRvciAob25lLXNob3QsIG11bHRpLXBsYW5lICsgSUNNUykgfCBgdXAgLS1jbHVz" + + "dGVyLW5hbWU9WGAgfCBgZG93biAtLWNsdXN0ZXItbmFtZT1YYCAvIGBkb3duIC0tYWxsYCB8CgpV" + + "c2UgYGRvd25gIGZvciBub3JtYWwgdGVhcmRvd24uIFVzZSBgdW5pbnN0YWxsYCBmb3IgR2l0T3Bz" + + "IChjb21taXQgWUFNTDsgQXJnby9GbHV4IGFwcGxpZXMpIG9yIHNjcmlwdGVkIHBpcGVsaW5lcy4g" + + "QWx3YXlzIHN0YXJ0IHdpdGggYSBkcnktcnVuLgoKIyMgRHJ5LXJ1biBzaGFwZXMKCnwgTW9kZSB8" + + "IExpdmVzIG9uIHwgSW52b2tlcyBoZWxtL2hlbG1maWxlPyB8IE91dHB1dCB8CnwtLS18LS0tfC0t" + + "LXwtLS18CnwgYC0tcGxhbi1vbmx5YCB8IGB1cGAgLyBgZG93bmAgKG9yY2hlc3RyYXRvcnMpIHwg" + + "KipOby4qKiBXYWxrcyByZXNvbHZlZCBzdGFjayBlbnRyeXBvaW50IGZpbGVzLCBlbWl0cyBwaGFz" + + "ZSBwbGFuICsgRVRBcyArIGhlbG0gdW5pbnN0YWxsIGNvbW1hbmQgc3RyaW5ncy4gfCBFdmVudCBz" + + "dHJlYW0gfAp8IGAtLW5vLWFwcGx5YCB8IGBpbnN0YWxsYCAvIGB1bmluc3RhbGxgIChwcmltaXRp" + + "dmVzKSB8ICoqWWVzLioqIGBoZWxtZmlsZSB0ZW1wbGF0ZWAgZm9yIGluc3RhbGw7IGBoZWxtIGdl" + + "dCBtYW5pZmVzdGAgZm9yIHVuaW5zdGFsbC4gfCBZQU1MIG9uIHN0ZG91dCB8CgpTbzoKCi0gYGRv" + + "d24gLS1wbGFuLW9ubHkgLS1jbHVzdGVyLW5hbWU9WGAg4oCUIHNob3cgbWUgdGhlIG9yY2hlc3Ry" + + "YXRvciBwaGFzZXMgd2l0aG91dCBpbnZva2luZyBhbnl0aGluZy4KLSBgdW5pbnN0YWxsIC0tbm8t" + + "YXBwbHkgLS1jb21wdXRlLXBsYW5lIC0tY2x1c3Rlci1uYW1lPVhgIOKAlCBnaXZlIG1lIHRoZSBZ" + + "QU1MIEknZCBga3ViZWN0bCBkZWxldGUgLWYgLWAuCgpgYGBzaAojIFN0ZXAgMTogQUxXQVlTIHBy" + + "ZXZpZXcgZmlyc3QuCm52Y2YtY2xpIHNlbGYtaG9zdGVkIGRvd24gLS1wbGFuLW9ubHkgLS1jbHVz" + + "dGVyLW5hbWU9bmNwLWxvY2FsIC0tanNvbiB8IGpxCgojIFN0ZXAgMjogUmVhbCBydW4uCm52Y2Yt" + + "Y2xpIHNlbGYtaG9zdGVkIGRvd24gLS1jbHVzdGVyLW5hbWU9bmNwLWxvY2FsCmBgYAoKIyMgV2hl" + + "biB0byB1c2Ugd2hpY2ggZm9ybQoKfCBVc2VyIGludGVudCB8IENvbW1hbmQgfAp8LS0tfC0tLXwK" + + "fCAiUmVtb3ZlIHRoaXMgb25lIEdQVSBjbHVzdGVyIiB8IGBudmNmLWNsaSBzZWxmLWhvc3RlZCBk" + + "b3duIC0tY2x1c3Rlci1uYW1lPVhgIHwKfCAiVGVhciBkb3duIGV2ZXJ5dGhpbmciIHwgYG52Y2Yt" + + "Y2xpIHNlbGYtaG9zdGVkIGRvd24gLS1hbGwgLS1jb25maXJtYCB8CnwgIlNob3cgbWUgdGhlIHN0" + + "ZXBzIHdpdGhvdXQgaW52b2tpbmcgYW55dGhpbmciIHwgYG52Y2YtY2xpIHNlbGYtaG9zdGVkIGRv" + + "d24gLS1wbGFuLW9ubHkgLS1jbHVzdGVyLW5hbWU9WGAgfAp8ICJHaXZlIG1lIFlBTUwgdG8gY29t" + + "bWl0ICsgQXJnbyBkZWxldGVzIGxhdGVyIiB8IGBudmNmLWNsaSBzZWxmLWhvc3RlZCB1bmluc3Rh" + + "bGwgLS1uby1hcHBseSAtLWNvbXB1dGUtcGxhbmUgLS1jbHVzdGVyLW5hbWU9WCA+IGRlbGV0ZS55" + + "YW1sYCB8CnwgIkp1c3QgcmVtb3ZlIHRoZSBoZWxtIHJlbGVhc2VzIG9uIGEgY29tcHV0ZSBwbGFu" + + "ZSAobm8gSUNNUyB1bnJlZ2lzdGVyLCBubyBkcmFpbikiIHwgYG52Y2YtY2xpIHNlbGYtaG9zdGVk" + + "IHVuaW5zdGFsbCAtLWNvbXB1dGUtcGxhbmUgLS1jbHVzdGVyLW5hbWU9WGAgfAp8ICJKdXN0IHJl" + + "bW92ZSB0aGUgY29udHJvbC1wbGFuZSBoZWxtIHJlbGVhc2VzIiB8IGBudmNmLWNsaSBzZWxmLWhv" + + "c3RlZCB1bmluc3RhbGwgLS1jb250cm9sLXBsYW5lYCB8CgojIyBTdGVwcyBmb3IgYGRvd24gLS1j" + + "bHVzdGVyLW5hbWU9WGAgKHRoZSBzdGFuZGFyZCBwYXRoKQoKIyMjIDEuIENvbmZpcm0gc2NvcGUg" + + "d2l0aCB0aGUgdXNlcgoKQXNrOgoKLSAiVGVhcmluZyBkb3duIGV2ZXJ5dGhpbmcgKGNvbXB1dGUg" + + "cGxhbmVzICsgY29udHJvbCBwbGFuZSArIElDTVMgcm93cykgb3IganVzdCBvbmUgY29tcHV0ZSBw" + + "bGFuZT8iCi0gIkFyZSB0aGVyZSBBQ1RJVkUgZnVuY3Rpb24gZGVwbG95bWVudHMgb24gdGhpcyBj" + + "bHVzdGVyPyBgbnZjZi1jbGkgZnVuY3Rpb24gZGVwbG95IGxpc3RgIHRvIGNoZWNrLiIgKEltcG9y" + + "dGFudCDigJQgYGRvd25gIHdpbGwgcmVmdXNlIHdpdGhvdXQgYC0tZHJhaW4tYWN0aXZlPXRydWV8" + + "cHJvbXB0YCBpZiBhbnkgYXJlIEFDVElWRS4pCi0gIlNob3VsZCB3ZSB3aXBlIHBlcnNpc3RlbnQg" + + "c3RhdGUgKENhc3NhbmRyYSBkYXRhLCBPcGVuQmFvIHNlYWwga2V5cywgc3ItZGVmYXVsdCB1c2Vy" + + "IGRhdGEpPyBEZWZhdWx0IGlzIHRvICoqcHJlc2VydmUqKiB0aGVtLiBgLS1yZW1vdmUtcGVyc2lz" + + "dGVudGAgb3B0cyBpbiB0byBkZWxldGlvbi4gKipMb3NzIGlzIHVucmVjb3ZlcmFibGUuKioiCgoj" + + "IyMgMi4gUnVuIHBsYW4tb25seSBmaXJzdAoKYGBgc2gKbnZjZi1jbGkgc2VsZi1ob3N0ZWQgZG93" + + "biAtLXBsYW4tb25seSAtLWNsdXN0ZXItbmFtZT0kTkFNRSAtLWpzb24gfCBqcQpgYGAKClRoaXMg" + + "ZW1pdHMgYSBgcGxhbm5lZGAgZXZlbnQgbGlzdGluZyBlYWNoIHBoYXNlLCB0aGUgaGVsbSByZWxl" + + "YXNlcyB0aGF0IHdvdWxkIGJlIHVuaW5zdGFsbGVkLCB0aGUgSUNNUyByb3dzIHRoYXQgd291bGQg" + + "YmUgcmVtb3ZlZCwgYW5kIEVUQXMuIENvcHkgdGhlIGB3aWxsVW5pbnN0YWxsLmNvbW1hbmRzW11g" + + "IGFycmF5IHRvIHRoZSB1c2VyIOKAlCB0aG9zZSBhcmUgdGhlIGxpdGVyYWwgYGhlbG0gdW5pbnN0" + + "YWxsIOKApmAgaW52b2NhdGlvbnMgdGhlIG9yY2hlc3RyYXRvciB3aWxsIHJ1bi4KCiMjIyAzLiBF" + + "eGVjdXRlIHRoZSBkb3duCgpgYGBzaApudmNmLWNsaSBzZWxmLWhvc3RlZCBkb3duIC0tY2x1c3Rl" + + "ci1uYW1lPSROQU1FIC0tanNvbgpgYGAKClBoYXNlcyAoMS03LCBtaXJyb3JzIMKnNi43LjEpOgoK" + + "MS4gUHJlZmxpZ2h0IOKAlCBrdWJlY3RsIGFjY2VzcyArIElDTVMgcmVhY2hhYmxlCjIuIFJlc29s" + + "dmUgc3RhY2sgYnVuZGxlCjMuIERyYWluIGFjdGl2ZSBkZXBsb3ltZW50cyDigJQgcnVucyBgZnVu" + + "Y3Rpb24gZGVwbG95IHJlbW92ZWAgZm9yIGVhY2ggQUNUSVZFIGRlcGxveW1lbnQsIHBvbGxzIHVu" + + "dGlsIFNUT1BQRUQuIFN1YmplY3QgdG8gYC0tZHJhaW4tYWN0aXZlPXRydWV8ZmFsc2V8cHJvbXB0" + + "YCAoZGVmYXVsdCBpcyBgcHJvbXB0YCBpbiBUVFkpLgo0LiBVbmluc3RhbGwgY29tcHV0ZSBwbGFu" + + "ZSDigJQgZGVsZWdhdGVzIHRvIGB1bmluc3RhbGwgLS1jb21wdXRlLXBsYW5lYDsgYGhlbG0gdW5p" + + "bnN0YWxsIDxyZWxlYXNlPmAgcGVyIHJlbGVhc2UgaW4gcmV2ZXJzZS1EQUcgb3JkZXIKNS4gUmVt" + + "b3ZlIHBlcnNpc3RlbnQgKHNraXBwZWQgdW5sZXNzIGAtLXJlbW92ZS1wZXJzaXN0ZW50YCkKNi4g" + + "VW5yZWdpc3RlciBjbHVzdGVyIGZyb20gSUNNUwo3LiBGaW5hbCB2ZXJpZnkKCiMjIyA0LiBUZWFy" + + "IGRvd24gdGhlIGNvbnRyb2wgcGxhbmUgKGlmIGFwcGxpY2FibGUpCgpgYGBzaApudmNmLWNsaSBz" + + "ZWxmLWhvc3RlZCB1bmluc3RhbGwgLS1jb250cm9sLXBsYW5lCmBgYAoKT3IgdmlhIHRoZSBvcmNo" + + "ZXN0cmF0b3IncyBgLS1hbGxgIG1vZGUgaWYgZXZlcnkgY29tcHV0ZSBwbGFuZSBpcyBhbHNvIGdv" + + "aW5nIGF3YXkgKHNlZSBzdGVwIDUpLgoKYHVuaW5zdGFsbCAtLWNvbnRyb2wtcGxhbmVgIHJlZnVz" + + "ZXMgaWYgYW55IGNvbXB1dGUgcGxhbmUgaXMgc3RpbGwgcmVnaXN0ZXJlZCBpbiBJQ01TLiBVc2Ug" + + "YC0tZm9yY2Utd2l0aC1yZWdpc3RlcmVkLWNsdXN0ZXJzYCB0byBvdmVycmlkZSAqKm9ubHkqKiBp" + + "ZiB0aGUgdXNlciBleHBsaWNpdGx5IHdhbnRzIHRvIG9ycGhhbiBjb21wdXRlIHBsYW5lcyAoUFNB" + + "VCBhdXRoIGJyZWFrcyBmb3IgdGhvc2UgY2x1c3RlcnMg4oCUIHRoZXkgd2lsbCBzdGFydCBmYWls" + + "aW5nIGV2ZXJ5IGhlYWx0aCBjaGVjaykuIEFsd2F5cyBzdGF0ZSB0aGUgY29uc2VxdWVuY2UgYmVm" + + "b3JlIHBhc3NpbmcgdGhlIGZsYWcuCgojIyMgNS4gT3IsIG51a2UgZXZlcnl0aGluZwoKYGBgc2gK" + + "bnZjZi1jbGkgc2VsZi1ob3N0ZWQgZG93biAtLWFsbCAtLWNvbmZpcm0KYGBgCgpUaGlzIGl0ZXJh" + + "dGVzIGV2ZXJ5IHJlZ2lzdGVyZWQgY29tcHV0ZSBwbGFuZSBpbiBJQ01TLCBydW5zIHRoZSBwZXIt" + + "Y2x1c3RlciBkb3duIGZvciBlYWNoIGluIHBhcmFsbGVsIChib3VuZGVkIGJ5IGAtLWFsbC1jb25j" + + "dXJyZW5jeWAsIGRlZmF1bHQgNCksIHRoZW4gcnVucyBgdW5pbnN0YWxsIC0tY29udHJvbC1wbGFu" + + "ZWAuIEludGVyYWN0aXZlIG1vZGUgcHJvbXB0cyB0aGUgb3BlcmF0b3Igd2l0aCB0aGUgY2x1c3Rl" + + "ciBsaXN0IGJlZm9yZSBwcm9jZWVkaW5nOyBgLS1jb25maXJtYCBza2lwcyB0aGUgcHJvbXB0IGZv" + + "ciBub24taW50ZXJhY3RpdmUgdXNlLgoKIyMgR2l0T3BzIHBhdGggKGlmIHVzZXIgaGFzIEFyZ28g" + + "LyBGbHV4KQoKYGBgc2gKIyBSZW5kZXIgZGVsZXRlIFlBTUwgZm9yIHRoZSBjb21wdXRlIHBsYW5l" + + "OgpudmNmLWNsaSBzZWxmLWhvc3RlZCB1bmluc3RhbGwgLS1uby1hcHBseSAtLWNvbXB1dGUtcGxh" + + "bmUgLS1jbHVzdGVyLW5hbWU9JE5BTUUgPiBkZWxldGUueWFtbAoKIyBPcGVyYXRvciBjb21taXRz" + + "IGRlbGV0ZS55YW1sIHRvIHRoZWlyIEdpdE9wcyByZXBvLiBBcmdvL0ZsdXggYXBwbGllcyB0aGUg" + + "ZGVsZXRpb24gb24gbmV4dCBzeW5jLgpnaXQgYWRkIGRlbGV0ZS55YW1sOyBnaXQgY29tbWl0IC1t" + + "ICJyZW1vdmUgY29tcHV0ZSBwbGFuZSAkTkFNRSI7IGdpdCBwdXNoCgojIEFmdGVyIEFyZ28gc3lu" + + "YyByZW1vdmVzIHRoZSBLOHMgcmVzb3VyY2VzLCBzZXBhcmF0ZWx5IHJlbW92ZSB0aGUgSUNNUyBy" + + "b3c6Cm52Y2YtY2xpIGNsdXN0ZXIgZGVsZXRlIC0tY2x1c3Rlci1pZD0kQ0xVU1RFUl9JRApgYGAK" + + "Ck5vdGU6IGAtLW5vLWFwcGx5YCBkb2VzIE5PVCBpbmNsdWRlIHRoZSBJQ01TIGBjbHVzdGVyIGRl" + + "bGV0ZWAgKGhlbG0gZG9lc24ndCBtYW5hZ2UgdGhhdCkuIE9wZXJhdG9ycyBmb2xsb3dpbmcgR2l0" + + "T3BzIG11c3QgcnVuIGBudmNmLWNsaSBjbHVzdGVyIGRlbGV0ZWAgc2VwYXJhdGVseSwgb3IgdXNl" + + "IGBkb3duYCB0byBvcmNoZXN0cmF0ZSBib3RoLgoKIyMgRmFpbHVyZSByZWNvdmVyeQoKSWYgYGRv" + + "d25gIHBhcnRpYWwtZmFpbHMgbWlkLXdheSwgKipyZS1ydW4gdGhlIHNhbWUgY29tbWFuZCoqLiBU" + + "aGUgb3JjaGVzdHJhdG9yIGlzIGlkZW1wb3RlbnQ6IGBoZWxtIHVuaW5zdGFsbGAgc2tpcHMgcmVs" + + "ZWFzZXMgdGhhdCBhcmUgYWxyZWFkeSBnb25lLCBhbmQgYGNsdXN0ZXIgZGVsZXRlIC0taWdub3Jl" + + "LW1pc3NpbmdgIHNraXBzIElDTVMgcm93cyB0aGF0IGFyZSBhbHJlYWR5IGdvbmUuIFBoYXNlIDQg" + + "KyA2IGVtaXQgYHBoYXNlOiBza2lwcGVkIChhbHJlYWR5IGNsZWFuKWAgb24gdGhlIHNlY29uZCBy" + + "dW4uCgpJZiBhIGBwaGFzZV9mYWlsZWRgIGV2ZW50IHN1cmZhY2VzOgoKLSBgZXJyQ2F0ZWdvcnk6" + + "ICJoZWxtX3BlbmRpbmdfdXBncmFkZSIsIHJldHJ5Q2xhc3M6ICJhZnRlcl9yZW1lZGlhdGlvbiJg" + + "IOKGkiBydW4gYGhlbG0gcm9sbGJhY2sgPHJlbGVhc2U+IDAgLS1rdWJlLWNvbnRleHQ94oCmYCAo" + + "dGhlIG1lc3NhZ2UgaW5jbHVkZXMgdGhlIGV4YWN0IHJlbGVhc2UgbmFtZSkgdGhlbiByZS1ydW4g" + + "YGRvd25gLiBEbyBOT1QgYXV0by1yb2xsYmFjayB3aXRob3V0IHVzZXIgY29uZmlybWF0aW9uLgot" + + "IGBlcnJDYXRlZ29yeTogImNvbXB1dGVfcGxhbmUiLCByZXRyeUNsYXNzOiAiYWZ0ZXJfcmVtZWRp" + + "YXRpb24iYCB3aXRoIG1lc3NhZ2UgYWJvdXQgQUNUSVZFIGRlcGxveW1lbnRzIOKGkiB1c2VyIHBh" + + "c3NlZCBgLS1kcmFpbi1hY3RpdmU9ZmFsc2VgIGJ1dCBkZXBsb3ltZW50cyBleGlzdC4gUmUtcnVu" + + "IHdpdGggYC0tZHJhaW4tYWN0aXZlPXRydWVgIG9yIGAtLWRyYWluLWFjdGl2ZT1wcm9tcHRgIGFu" + + "ZCBjb25maXJtIHdpdGggdGhlIHVzZXIuCi0gT3RoZXIgY2F0ZWdvcmllcyDihpIgc3VyZmFjZSBg" + + "ZXJyTWVzc2FnZWAgKyBlYWNoIGByZW1lZGlhdGlvbmAgc3RyaW5nIHZlcmJhdGltLiBTVE9QLiBE" + + "byBub3QgcHJvcG9zZSByZXRyeSB3aXRob3V0IHVzZXIgaW5wdXQuCgojIyBBbHdheXMgY29uZmly" + + "bQoKRm9yIGV2ZXJ5IGBudmNmLWNsaSBzZWxmLWhvc3RlZCBkb3duYCBvciBgdW5pbnN0YWxsYCBp" + + "bnZvY2F0aW9uLCBlc3BlY2lhbGx5IHdpdGggZGVzdHJ1Y3RpdmUgZmxhZ3M6CgotIFN0YXRlIHRo" + + "ZSBleGFjdCBgLS1jbHVzdGVyLW5hbWVgIChvciBgLS1hbGxgKS4KLSBTdGF0ZSB3aGF0J3MgbG9z" + + "dCAoZS5nLiAiYWxsIGZ1bmN0aW9uIGRlcGxveW1lbnRzIHNjaGVkdWxlZCBvbiBgbmNwLWxvY2Fs" + + "YCB3aWxsIHN0b3A7IGNvbXB1dGUgcGxhbmUgd2lsbCBkZXJlZ2lzdGVyIGZyb20gSUNNUyIpLgot" + + "IElmIGAtLXJlbW92ZS1wZXJzaXN0ZW50YCBpcyBiZWluZyBjb25zaWRlcmVkLCBzdGF0ZSB0aGF0" + + "IENhc3NhbmRyYSByb3dzICsgT3BlbkJhbyBzZWFsIGtleXMgd2lsbCBiZSBkZWxldGVkIGFuZCB0" + + "aGF0IHRoaXMgaXMgdW5yZWNvdmVyYWJsZS4KLSBXYWl0IGZvciBleHBsaWNpdCB1c2VyIGNvbmZp" + + "cm1hdGlvbi4KLSBOZXZlciBwcm9wb3NlIGAtLWZvcmNlLXdpdGgtcmVnaXN0ZXJlZC1jbHVzdGVy" + + "c2Agd2l0aG91dCBleHBsYWluaW5nIHRoYXQgY29tcHV0ZSBwbGFuZXMgd2lsbCBiZSBvcnBoYW5l" + + "ZC4KLSBOZXZlciBwcm9wb3NlIGAtLXJlbW92ZS1wZXJzaXN0ZW50YCB3aXRob3V0IGV4cGxhaW5p" + + "bmcgdGhlIGxvc3MuCgojIyBNYW51YWwgZmFsbGJhY2sgKGlmIGBudmNmLWNsaSBzZWxmLWhvc3Rl" + + "ZCB7dW5pbnN0YWxsLGRvd259YCBpcyB1bmF2YWlsYWJsZSkKCklmIHRoZSB1c2VyIGlzIG9uIGEg" + + "Q0xJIHZlcnNpb24gb2xkZXIgdGhhbiBNKzExLCBmYWxsIGJhY2sgdG86CgpgYGBzaAojIFBlciBj" + + "b21wdXRlIHBsYW5lIChvbiBpdHMga3ViZWN0bCBjb250ZXh0KToKaGVsbSB1bmluc3RhbGwgbnZj" + + "YS1hZ2VudCAtbiBudmNhLXN5c3RlbQpoZWxtIHVuaW5zdGFsbCBudmNhLW9wZXJhdG9yIC1uIG52" + + "Y2Etb3BlcmF0b3IKaGVsbSB1bmluc3RhbGwgaW1hZ2UtY3JlZGVudGlhbC1oZWxwZXIgLW4gbnZj" + + "YS1zeXN0ZW0KaGVsbSB1bmluc3RhbGwgc2hhcmVkc3RvcmFnZSAtbiBudmNhLXN5c3RlbQoKIyBE" + + "cmFpbiB0aGUgSUNNUyByb3c6Cm52Y2YtY2xpIGNsdXN0ZXIgZGVsZXRlIC0tY2x1c3Rlci1pZD08" + + "aWQ+CgojIENvbnRyb2wgcGxhbmUgdGVhcmRvd24gKG9uIGl0cyBrdWJlY3RsIGNvbnRleHQpOgpj" + + "ZCBudmNmLXNlbGYtbWFuYWdlZC1zdGFjawptYWtlIGRlc3Ryb3kgICAjIGludm9rZXMgaGVsbWZp" + + "bGUgZGVzdHJveSBhY3Jvc3MgYWxsIHJlbGVhc2VzCgojIElmIHVzaW5nIHNwbGl0IHN0YWNrIGJ1" + + "bmRsZXMsIHRlYXJkb3duIGNvbXB1dGUgcGxhbmUgcmVsZWFzZXMgdG9vOgpjZCAuLi9udmNmLWNv" + + "bXB1dGUtcGxhbmUtc3RhY2sKbWFrZSBkZXN0cm95CmBgYAoKVGhpcyBpcyB0aGUgbWFudWFsIHBh" + + "dGg7IHByZWZlciB0aGUgbmV3IHN1YmNvbW1hbmRzIHdoZW5ldmVyIHRoZXkncmUgaW5zdGFsbGVk" + + "LgoKIyMgV2hhdCBga3ViZWN0bCBkZWxldGUgcHZjYCBkb2VzCgpJZiB0aGUgdXNlciBhc2tzICJ3" + + "aHkgaXMgbXkgbmV3IGluc3RhbGwgc2VlaW5nIHRoZSBvbGQgZGF0YT8iIOKAlCB0aGUgU3RhdGVm" + + "dWxTZXQgUFZDcyAoQ2Fzc2FuZHJhLCBPcGVuQmFvKSBhcmUgTk9UIGRlbGV0ZWQgYnkgYGhlbG0g" + + "dW5pbnN0YWxsYCAoYW5kIHNvIGFyZSBOT1QgZGVsZXRlZCBieSBgbnZjZi1jbGkgc2VsZi1ob3N0" + + "ZWQgZG93bmAgZWl0aGVyLCBieSBkZWZhdWx0KS4gVGhleSBwZXJzaXN0IGludGVudGlvbmFsbHkg" + + "c28gYSByZS1gdXBgIGNhbiByZXN1bWUgYWdhaW5zdCBleGlzdGluZyBkYXRhLiBUbyB3aXBlIGNv" + + "bXBsZXRlbHkgb24gdGhlIG5leHQgYGRvd25gOgoKYGBgc2gKbnZjZi1jbGkgc2VsZi1ob3N0ZWQg" + + "ZG93biAtLWNsdXN0ZXItbmFtZT1uY3AtbG9jYWwgLS1yZW1vdmUtcGVyc2lzdGVudApgYGAKCk9y" + + "LCBwb3N0LWRvd24sIG1hbnVhbGx5OgoKYGBgc2gKa3ViZWN0bCBkZWxldGUgcHZjIC1uIGNhc3Nh" + + "bmRyYS1zeXN0ZW0gLS1hbGwKa3ViZWN0bCBkZWxldGUgcHZjIC1uIHZhdWx0LXN5c3RlbSAtLWFs" + + "bApgYGAKCioqQ29uZmlybSB3aXRoIHVzZXIuKiogVGhpcyBpcyB0aGUgZGVzdHJ1Y3RpdmUgc3Rl" + + "cC4KUEsHCNqHY1bkIQAA5CEAAFBLAwQUAAgAAAAAACEAAAAAAAAAAAAAAAAAMAAJAGRhdGEvbnZj" + + "Zi1zZWxmLW1hbmFnZWQtY2xpL3JlZmVyZW5jZS9jb21tYW5kcy5tZFVUBQABAKbOEiMgYG52Y2Yt" + + "Y2xpYCBjb21tYW5kIHJlZmVyZW5jZQoKRnVsbCBzdWJjb21tYW5kIGxpc3QuIEFsd2F5cyBwYWly" + + "IHdpdGggW2ZsYWdzLm1kXShmbGFncy5tZCkgZm9yIGdsb2JhbCBmbGFncyBhbmQgW2V4aXQtY29k" + + "ZXMubWRdKGV4aXQtY29kZXMubWQpIGZvciByZXR1cm4gY29kZXMuCgojIyBTZWxmLWhvc3RlZCBs" + + "aWZlY3ljbGUKCnwgQ29tbWFuZCB8IFB1cnBvc2UgfCBPdXRwdXQgfCBOb3RlcyB8CnwtLS18LS0t" + + "fC0tLXwtLS18CnwgYHNlbGYtaG9zdGVkIGNoZWNrIC0tcHJlIFtmbGFnc11gIHwgUHJlLWZsaWdo" + + "dCBiZWZvcmUgaW5zdGFsbCAobm8gYWRtaW4gY3JlZHMpOiBsb2NhbCB0b29scywgcmVnaXN0cnkg" + + "Y3JlZGVudGlhbHMsIGFuZCBib3RoIHBsYW5lcycgY2x1c3RlciBjaGVja3MgfCBzdHJlYW1pbmcg" + + "ZXZlbnRzIG9uIHN0ZGVyciB8IFNwbGl0IGNsdXN0ZXJzIG5lZWQgYm90aCBgLS1jb250cm9sLXBs" + + "YW5lLWNvbnRleHRgIGFuZCBgLS1jb21wdXRlLXBsYW5lLWNvbnRleHRgOyBgLS1sb2NhbC1vbmx5" + + "YCBtYWtlcyBubyBjbHVzdGVyIG9yIHJlZ2lzdHJ5IGNvbnRhY3QgfAp8IGBzZWxmLWhvc3RlZCBj" + + "aGVjayAtLWNvbnRyb2wtcGxhbmUgWy0td2FpdCBEVVJdYCB8IEluc3RhbGxlZCBjb250cm9sIHBs" + + "YW5lOiBsZWZ0b3ZlciBuYW1lc3BhY2VzIGFuZCB0aGUgY29udHJvbC1wbGFuZSBjbHVzdGVyLXZh" + + "bGlkYXRvciB8IHN0cmVhbWluZyBldmVudHMgb24gc3RkZXJyIHwgYC0td2FpdGAgcG9sbHMgdW50" + + "aWwgcGFzcyBvciBEVVIgZWxhcHNlcyB8CnwgYHNlbGYtaG9zdGVkIGNoZWNrIC0tY29tcHV0ZS1w" + + "bGFuZSBbLS13YWl0IERVUl1gIHwgSW5zdGFsbGVkIGNvbXB1dGUgcGxhbmU6IGxlZnRvdmVyIG5h" + + "bWVzcGFjZXMsIG5vZGUgaW5vdGlmeSBsaW1pdHMsIFNJUyByZWFjaGFiaWxpdHksIGFuZCB0aGUg" + + "Y29tcHV0ZS1wbGFuZSBjbHVzdGVyLXZhbGlkYXRvciB8IHN0cmVhbWluZyBldmVudHMgb24gc3Rk" + + "ZXJyIHwgU0lTIGlzIHByb2JlZCBvbmx5IGF0IGEgY29uZmlndXJlZCBgLS1pY21zLXVybGAuIFdp" + + "dGhvdXQgY29udGV4dCBmbGFncyBpdCBjaGVja3MgdGhlIGN1cnJlbnQgY29udGV4dCB8CnwgYHNl" + + "bGYtaG9zdGVkIGNoZWNrIC0tYWxsIFstLXdhaXQgRFVSXWAgfCBCb3RoIG9mIHRoZSBhYm92ZSB8" + + "IHN0cmVhbWluZyBldmVudHMgb24gc3RkZXJyIHwgR2F0ZSBvbiB0aGUgYGZpbmFsYCBldmVudDsg" + + "c2VlIFtleGl0LWNvZGVzLm1kXShleGl0LWNvZGVzLm1kKSB8CnwgYHNlbGYtaG9zdGVkIGluc3Rh" + + "bGwgLS1jb250cm9sLXBsYW5lYCB8IFJlbmRlciBjb250cm9sLXBsYW5lIG1hbmlmZXN0cyB8IFlB" + + "TUwgb24gc3Rkb3V0IHwgUGlwZSB0byBga3ViZWN0bCBhcHBseSAtZiAtYCB8CnwgYHNlbGYtaG9z" + + "dGVkIGluc3RhbGwgLS1jb21wdXRlLXBsYW5lIC0tY2x1c3Rlci1uYW1lPVhgIHwgUmVnaXN0ZXIg" + + "Y2x1c3RlciArIHJlbmRlciBjb21wdXRlIG1hbmlmZXN0cyB8IFlBTUwgb24gc3Rkb3V0IHwgT25l" + + "IGludm9jYXRpb24gcGVyIEdQVSBjbHVzdGVyIHwKfCBgc2VsZi1ob3N0ZWQgdXAgLS1jbHVzdGVy" + + "LW5hbWU9WGAgfCBPbmUtc2hvdCBmaXJzdCBpbnN0YWxsIChib3RoIHBsYW5lcykgfCAoZW1wdHkp" + + "IHN0ZG91dCwgcHJvZ3Jlc3Mgb24gc3RkZXJyIHwgQWx3YXlzIGluc3RhbGxzIGJvdGggcGxhbmVz" + + "OyBmb3IgY29tcHV0ZS1vbmx5IHVzZSBgY29tcHV0ZS1wbGFuZSByZWdpc3RlcmAgYW5kIGBjb21w" + + "dXRlLXBsYW5lIGluc3RhbGxgIHwKfCBgc2VsZi1ob3N0ZWQgdXAgLS1wbGFuLW9ubHkgLS1jbHVz" + + "dGVyLW5hbWU9WGAgfCBEcnktcnVuIHByZXZpZXcgfCBKU09OTCBvbiBzdGRlcnIgfCBBZ2VudC1m" + + "cmllbmRseSwgbm8gY2x1c3RlciBjaGFuZ2VzIHwKfCBgc2VsZi1ob3N0ZWQgY29tcHV0ZS1wbGFu" + + "ZSByZWdpc3RlciAtLWNvbnRyb2wtcGxhbmUtcHJvZmlsZT1QIC0tY2x1c3Rlci1uYW1lPVggLS1r" + + "dWJlLWNvbnRleHQ9WSBbLS1vdXRwdXQ9Vl1gIHwgUmVnaXN0ZXIgYSBjb21wdXRlIGNsdXN0ZXIg" + + "d2l0aCBhbiBleGlzdGluZyBjb250cm9sIHBsYW5lIGFuZCB3cml0ZSBpdHMgTlZDQSBvcGVyYXRv" + + "ciB2YWx1ZXMgfCBzdW1tYXJ5IG9uIHN0ZG91dCB8IEZvciB0aGUgMm5kLCAzcmQsIC4uLiBHUFUg" + + "Y2x1c3RlciBhZnRlciB0aGUgZmlyc3QgaW5zdGFsbC4gQ1Agbm90IHRvdWNoZWQgfAp8IGBzZWxm" + + "LWhvc3RlZCBjb21wdXRlLXBsYW5lIGluc3RhbGwgLS12YWx1ZXM9ViAtLWt1YmUtY29udGV4dD1Z" + + "YCB8IEluc3RhbGwgYSBjb21wdXRlIHBsYW5lIGZyb20gdGhlIHZhbHVlcyBgY29tcHV0ZS1wbGFu" + + "ZSByZWdpc3RlcmAgd3JvdGUgfCBwcm9ncmVzcyBvbiBzdGRlcnIgfCBSdW4gYWZ0ZXIgYGNvbXB1" + + "dGUtcGxhbmUgcmVnaXN0ZXJgLiBJbnN0YWxscyBWIHdoYXRldmVyIGl0cyBuYW1lLCBuZXZlciBh" + + "bm90aGVyIHZhbHVlcyBmaWxlIGJlc2lkZSBpdCB8CnwgYHNlbGYtaG9zdGVkIHN0YXR1cyBbLS1j" + + "bHVzdGVyLW5hbWU9WF0gWy0td2F0Y2hdYCB8IFNuYXBzaG90IGNsdXN0ZXIgaGVhbHRoIHwgVFRZ" + + "L3BsYWluL0pTT05MIHwgYC0td2F0Y2hgIGZvciBsaXZlIHJlLXJlbmRlciB8CnwgYHNlbGYtaG9z" + + "dGVkIHVuaW5zdGFsbCAtLWNvbXB1dGUtcGxhbmUgLS1jbHVzdGVyLW5hbWU9WGAgfCBQZXItcGxh" + + "bmUgcHJpbWl0aXZlOiBgaGVsbWZpbGUgZGVzdHJveWAgb24gb25lIGNvbXB1dGUgcGxhbmUgKG5v" + + "IElDTVMgdW5yZWdpc3Rlciwgbm8gZHJhaW4pIHwgcHJvZ3Jlc3MgfCBKdXN0IHRoZSBoZWxtIHJl" + + "bGVhc2VzIG9uIHRoYXQgY29tcHV0ZSBwbGFuZSB8CnwgYHNlbGYtaG9zdGVkIHVuaW5zdGFsbCAt" + + "LWNvbnRyb2wtcGxhbmUgWy0tZm9yY2Utd2l0aC1yZWdpc3RlcmVkLWNsdXN0ZXJzXWAgfCBQZXIt" + + "cGxhbmUgcHJpbWl0aXZlOiBgaGVsbWZpbGUgZGVzdHJveWAgb24gdGhlIGNvbnRyb2wgcGxhbmUg" + + "fCBwcm9ncmVzcyB8IFJlZnVzZXMgaWYgY29tcHV0ZSBwbGFuZXMgc3RpbGwgcmVnaXN0ZXJlZCB1" + + "bmxlc3MgYC0tZm9yY2Utd2l0aC1yZWdpc3RlcmVkLWNsdXN0ZXJzYCB8CnwgYHNlbGYtaG9zdGVk" + + "IHVuaW5zdGFsbCAtLW5vLWFwcGx5IDxwbGFuZT5gIHwgUmVuZGVyIGRlbGV0ZSBZQU1MIHZpYSBg" + + "aGVsbSBnZXQgbWFuaWZlc3RgIHBlciByZWxlYXNlIHwgWUFNTCBvbiBzdGRvdXQgfCBHaXRPcHM7" + + "IHBpcGUgdG8gYGt1YmVjdGwgZGVsZXRlIC1mIC1gIG9yIGNvbW1pdCArIEFyZ28gYXBwbGllcy4g" + + "TWlycm9ycyBgaW5zdGFsbCAtLW5vLWFwcGx5YCB8CnwgYHNlbGYtaG9zdGVkIGRvd24gLS1jbHVz" + + "dGVyLW5hbWU9WCBbLS1kcmFpbi1hY3RpdmVdIFstLXJlbW92ZS1wZXJzaXN0ZW50XWAgfCBPcmNo" + + "ZXN0cmF0b3I6IGRyYWluIOKGkiB1bmluc3RhbGwgLS1jb21wdXRlLXBsYW5lIOKGkiBjbHVzdGVy" + + "IGRlbGV0ZSBpbiBJQ01TIHwgcHJvZ3Jlc3MgfCBTeW1tZXRyaWMgY29tcGFuaW9uIHRvIGB1cCAt" + + "LWNsdXN0ZXItbmFtZT1YYCB8CnwgYHNlbGYtaG9zdGVkIGRvd24gLS1hbGwgWy0tY29uZmlybV1g" + + "IHwgT3JjaGVzdHJhdG9yOiB0ZWFyIGRvd24gZXZlcnkgcmVnaXN0ZXJlZCBjb21wdXRlIHBsYW5l" + + "ICsgY29udHJvbCBwbGFuZSB8IHByb2dyZXNzIHwgQm91bmRlZCBwYXJhbGxlbGlzbSB2aWEgYC0t" + + "YWxsLWNvbmN1cnJlbmN5YCAoZGVmYXVsdCA0KTsgYC0tY29uZmlybWAgcmVxdWlyZWQgaW4gbm9u" + + "LWludGVyYWN0aXZlIHwKfCBgc2VsZi1ob3N0ZWQgZG93biAtLXBsYW4tb25seSAuLi5gIHwgT3Jj" + + "aGVzdHJhdG9yIGRyeS1ydW46IHBoYXNlcyArIGhlbG0gcmVsZWFzZXMgKyBJQ01TIHJvd3MgKyBF" + + "VEFzIChubyBoZWxtL2hlbG1maWxlIGNvbnRhY3QpIHwgSlNPTkwgfCAqKkFsd2F5cyBydW4gYmVm" + + "b3JlIGFueSBhY3R1YWwgYGRvd25gKiogfAoKIyMgQ2x1c3RlciBtYW5hZ2VtZW50Cgp8IENvbW1h" + + "bmQgfCBQdXJwb3NlIHwgTm90ZXMgfAp8LS0tfC0tLXwtLS18CnwgYGNsdXN0ZXIgcmVnaXN0ZXIg" + + "LS1uYW1lPVggLS1uY2EtaWQ9WSBbLS1yZWdpb249Wl0gWy0taWdub3JlLWV4aXN0aW5nXWAgfCBS" + + "ZWdpc3RlciBhIEpXS1MrT0lEQyB3aXRoIElDTVMgfCBVc2VkIGJ5IGB1cGAgUGhhc2UgNSBpbnRl" + + "cm5hbGx5OyBzdGFuZGFsb25lIGZvciBtYW51YWwgcmVnaXN0cmF0aW9uIHwKfCBgY2x1c3RlciBs" + + "aXN0LXJlZ2lzdGVyZWQgLS1uY2EtaWQ9WSBbLS1pY21zLXVybD1VUkxdYCB8IExpc3Qgc2VsZi1o" + + "b3N0ZWQgY2x1c3RlciByZWdpc3RyYXRpb25zIGZyb20gSUNNUyB8IFVzZXMgdGhlIGFkbWluIHRv" + + "a2VuOyBgLS1qc29uYCBmb3IgbWFjaGluZS1yZWFkYWJsZSBvdXRwdXQgfAp8IGBjbHVzdGVyIHJv" + + "dGF0ZSAtLWNsdXN0ZXItaWQ9SURgIHwgUmUtZmV0Y2ggSldLUyBmcm9tIEs4cyBhbmQgUFVUIHRv" + + "IElDTVMgfCBBZnRlciBLOHMgQVBJIHNlcnZlciBzaWduaW5nIGtleSByb3RhdGlvbiB8CnwgYGNs" + + "dXN0ZXIgZGVsZXRlIC0tY2x1c3Rlci1pZD1JRGAgfCBSZW1vdmUgSUNNUyByb3cgfCAqKkRFU1RS" + + "VUNUSVZFOiBjb25maXJtIHdpdGggdXNlcioqIHwKCiMjIEZ1bmN0aW9ucwoKfCBDb21tYW5kIHwg" + + "UHVycG9zZSB8IE5vdGVzIHwKfC0tLXwtLS18LS0tfAp8IGBmdW5jdGlvbiBjcmVhdGUgLS1pbnB1" + + "dC1maWxlPUZJTEVgIHwgQ3JlYXRlIGZ1bmN0aW9uIG1ldGFkYXRhIHwgUmV0dXJucyBGdW5jdGlv" + + "biBJRCArIFZlcnNpb24gSUQ7IExMTSBmdW5jdGlvbnMgdXNlIGBmdW5jdGlvblR5cGU6ICJMTE0i" + + "YCBhbmQgYG1vZGVsc1tdLmxsbUNvbmZpZy51cmlzYCBmb3IgYC92MS9jaGF0L2NvbXBsZXRpb25z" + + "YCwgYC92MS9yZXNwb25zZXNgLCBhbmQgYC92MS9lbWJlZGRpbmdzYCB8CnwgYGZ1bmN0aW9uIGxp" + + "c3RgIC8gYGZ1bmN0aW9uIGxpc3QtaWRzYCB8IExpc3QgZnVuY3Rpb25zIC8gSURzIG9ubHkgfCB8" + + "CnwgYGZ1bmN0aW9uIGdldCAtLWZ1bmN0aW9uLWlkPUlEIC0tdmVyc2lvbi1pZD1WSURgIHwgRnVu" + + "Y3Rpb24gbWV0YWRhdGEgfCB8CnwgYGZ1bmN0aW9uIHVwZGF0ZSAtLWZ1bmN0aW9uLWlkPUlEIC0t" + + "dmVyc2lvbi1pZD1WSUQgLS10YWdzPVRBR1ssVEFHXWAgfCBVcGRhdGUgZnVuY3Rpb24gdGFncyB8" + + "IHwKfCBgZnVuY3Rpb24gdXBkYXRlIC0tZnVuY3Rpb24taWQ9SUQgLS12ZXJzaW9uLWlkPVZJRCAt" + + "LWxsbS1tb2RlbC11cGRhdGU9U1BFQ2AgfCBVcGRhdGUgTExNIG1vZGVsIHJvdXRpbmcgY29uZmln" + + "IHwgYFNQRUNgIHVzZXMgYG5hbWU9PG1vZGVsPixyb3V0aW5nTWV0aG9kPTxtZXRob2Q+LHRva2Vu" + + "UmF0ZUxpbWl0PTxsaW1pdD5gOyB0aGUgcm91dGluZyBtZXRob2QgZm9ybWF0IGlzIGluIFtmbGFn" + + "cy5tZF0oZmxhZ3MubWQpIHwKfCBgZnVuY3Rpb24gZGVwbG95IGNyZWF0ZSAtLWlucHV0LWZpbGU9" + + "RklMRWAgfCBTY2hlZHVsZSBhIGRlcGxveW1lbnQgfCBCbG9ja3MgdW50aWwgQUNUSVZFIChkZWZh" + + "dWx0IDkwMHMpIHwKfCBgZnVuY3Rpb24gZGVwbG95IGdldCAtLWZ1bmN0aW9uLWlkPUlEIC0tdmVy" + + "c2lvbi1pZD1WSUQgWy0tanNvbl1gIHwgRGVwbG95bWVudCBzdGF0dXMgfCBgZnVuY3Rpb25TdGF0" + + "dXM6IEFDVElWRVx8REVQTE9ZSU5HXHxFUlJPUlx8RkFJTEVEYCB8CnwgYGZ1bmN0aW9uIGRlcGxv" + + "eSB1cGRhdGUgLS1pbnB1dC1maWxlPUZJTEVgIHwgTW9kaWZ5IGEgZGVwbG95bWVudCBpbiBwbGFj" + + "ZSB8IHwKfCBgZnVuY3Rpb24gZGVwbG95IHJlbW92ZSAtLWZ1bmN0aW9uLWlkPUlEIC0tdmVyc2lv" + + "bi1pZD1WSURgIHwgU3RvcCBhIGRlcGxveW1lbnQsIGtlZXAgZnVuY3Rpb24gcmVjb3JkIHwgfAp8" + + "IGBmdW5jdGlvbiBpbnZva2UgLS1pbnB1dC1maWxlPUZJTEVgIHwgQ2FsbCBhIHJlZ3VsYXIgZnVu" + + "Y3Rpb24gfCBSZXF1aXJlcyBBUEkga2V5IChub3QgYWRtaW4gdG9rZW4pOyBMTE0gZnVuY3Rpb25z" + + "IGFyZSBpbnZva2VkIHRocm91Z2ggYGxsbS5pbnZvY2F0aW9uLjxkb21haW4+YCB3aXRoIGBtb2Rl" + + "bDogIjxmdW5jdGlvbi1pZD4vPG1vZGVsLW5hbWU+ImAgfAp8IGBmdW5jdGlvbiBkZWxldGUgLS1m" + + "dW5jdGlvbi1pZD1JRCAtLXZlcnNpb24taWQ9VklEYCB8IFJlbW92ZSBmdW5jdGlvbiArIGRlcGxv" + + "eW1lbnQgfCAqKkRFU1RSVUNUSVZFOiBjb25maXJtIHdpdGggdXNlcioqIHwKCiMjIFRhc2tzCgpU" + + "YXNrIGNvbW1hbmRzIHJlcXVpcmUgYE5WQ0ZfQVBJX0tFWWAgc2V0IHRvIGEgdGFzay1zY29wZWQg" + + "a2V5IGFuZCBgTlZDRl9CQVNFX05WQ1RfVVJMYCBzZXQgdG8gdGhlIE5WQ1QgZ2F0ZXdheSBlbmRw" + + "b2ludC4gSW4gYSBmcmVzaCBzZXNzaW9uLCByZWFkIGBudmN0QXBpS2V5YCBmcm9tIGB+Ly5udmNm" + + "LWNsaS5zdGF0ZWAgYW5kIGluamVjdCBpdCBhcyBgTlZDRl9BUElfS0VZYCB3aXRob3V0IGxvZ2dp" + + "bmcgdGhlIHZhbHVlOyBjaGVjayBgbnZjdEFwaUtleUV4cGlyYXRpb25gIGZpcnN0IGFuZCByZWdl" + + "bmVyYXRlIHdpdGggYGFwaS1rZXkgZ2VuZXJhdGUgLS1mb3IgdGFzayAtLXZhbGlkYXRlYCBpZiBl" + + "eHBpcmVkLiBSdW4gYGFwaS1rZXkgZ2VuZXJhdGUgLS1mb3IgdGFza2AgYWZ0ZXIgYGluaXRgIHRv" + + "IG1pbnQgYSBuZXcga2V5LgoKfCBDb21tYW5kIHwgUHVycG9zZSB8IE5vdGVzIHwKfC0tLXwtLS18" + + "LS0tfAp8IGB0YXNrIGNyZWF0ZSAtLW5hbWU9WCAtLWdwdT1HUFUgLS1pbnN0YW5jZS10eXBlPVRZ" + + "UEUgLS1pbWFnZT1JTUdgIHwgU3VibWl0IGEgY29udGFpbmVyIHRhc2sgfCBTYXZlcyB0YXNrIElE" + + "IHRvIENMSSBzdGF0ZTsgdXNlIGAtLWlucHV0LWZpbGVgIGZvciByZXBlYXRhYmxlIGNvbmZpZ3Mg" + + "fAp8IGB0YXNrIGNyZWF0ZSAtLW5hbWU9WCAtLWdwdT1HUFUgLS1pbnN0YW5jZS10eXBlPVRZUEUg" + + "LS1oZWxtLWNoYXJ0PUNIQVJUYCB8IFN1Ym1pdCBhIEhlbG0gdGFzayB8IGAtLWhlbG0tY2hhcnRg" + + "IHJlcGxhY2VzIGAtLWltYWdlYCB8CnwgYHRhc2sgY3JlYXRlIC0taW5wdXQtZmlsZT1GSUxFYCB8" + + "IFN1Ym1pdCBhIHRhc2sgZnJvbSBhIEpTT04gY29uZmlnIGZpbGUgfCBSZWNvbW1lbmRlZCBmb3Ig" + + "cmVwZWF0YWJsZSBjb25maWd1cmF0aW9ucyB8CnwgYHRhc2sgbGlzdCBbLS1zdGF0dXM9U1RBVFVT" + + "XSBbLS1saW1pdD1OXWAgfCBMaXN0IHRhc2tzLCBvcHRpb25hbGx5IGZpbHRlcmVkIGJ5IHN0YXR1" + + "cyB8IFN0YXR1c2VzOiBgUVVFVUVEYCwgYExBVU5DSEVEYCwgYFJVTk5JTkdgLCBgQ09NUExFVEVE" + + "YCwgYEVSUk9SRURgLCBgQ0FOQ0VMRURgLCBgRVhDRUVERURfTUFYX1JVTlRJTUVfRFVSQVRJT05g" + + "LCBgRVhDRUVERURfTUFYX1FVRVVFRF9EVVJBVElPTmAgfAp8IGB0YXNrIGdldCBbdGFza0lkXWAg" + + "fCBHZXQgdGFzayBkZXRhaWxzIHwgVXNlcyBzYXZlZCB0YXNrIElEIGZyb20gc3RhdGUgaWYgb21p" + + "dHRlZCB8CnwgYHRhc2sgZXZlbnRzIFt0YXNrSWRdIFstLWxpbWl0PU5dYCB8IExpc3QgbGlmZWN5" + + "Y2xlIGV2ZW50cyBmb3IgYSB0YXNrIHwgfAp8IGB0YXNrIHJlc3VsdHMgW3Rhc2tJZF1gIHwgTGlz" + + "dCByZXN1bHQgYXJ0aWZhY3RzIGZvciBhIGNvbXBsZXRlZCB0YXNrIHwgUmV0dXJucyBlbXB0eSBs" + + "aXN0IHdoZW4gYHJlc3VsdEhhbmRsaW5nU3RyYXRlZ3lgIGlzIGBOT05FYDsgcmVzdWx0IHVwbG9h" + + "ZCBub3QgeWV0IHN1cHBvcnRlZCB8CnwgYHRhc2sgY2FuY2VsIFt0YXNrSWRdYCB8IENhbmNlbCBh" + + "IHJ1bm5pbmcgdGFzayB8IE5vLW9wIGlmIGFscmVhZHkgaW4gdGVybWluYWwgc3RhdGUgfAp8IGB0" + + "YXNrIGRlbGV0ZSBbdGFza0lkXWAgfCBEZWxldGUgYSB0YXNrIHJlY29yZCB8ICoqU3RvcCwgc3Rh" + + "dGUgdGFzayBJRCArIGN1cnJlbnQgc3RhdHVzLCBhbmQgd2FpdCBmb3IgYSBzdWJzZXF1ZW50IHVz" + + "ZXIgcmVwbHkgZXhwbGljaXRseSBjb25maXJtaW5nIGRlbGV0aW9uIG9mIHRoYXQgc3BlY2lmaWMg" + + "dGFzay4gRG8gbm90IHRyZWF0IHRoZSBvcmlnaW5hbCBkZWxldGUgcmVxdWVzdCBhcyBjb25maXJt" + + "YXRpb24uKiogfAp8IGB0YXNrIHVwZGF0ZS1zZWNyZXRzIFt0YXNrSWRdIC0tc2VjcmV0cyBOQU1F" + + "PXZhbHVlYCB8IFVwZGF0ZSBzZWNyZXRzIG9uIGEgdGFzazsgc3VwcGxpZWQgc2VjcmV0cyBhcmUg" + + "YWRkZWQgb3IgdXBkYXRlZCBieSBuYW1lLCBleGlzdGluZyBzZWNyZXRzIG5vdCBpbiB0aGUgcmVx" + + "dWVzdCBhcmUgcHJlc2VydmVkIHwgVmFsdWVzIGFyZSBlbmNyeXB0ZWQgYXQgcmVzdCB8CnwgYHRh" + + "c2sgYnVsayAtLXRhc2staWRzPUlEMVssSUQyLOKApl1gIHwgRmV0Y2ggZGV0YWlscyBmb3IgbXVs" + + "dGlwbGUgdGFza3MgaW4gb25lIHJlcXVlc3QgfCB8CgojIyBBdXRoCgp8IENvbW1hbmQgfCBQdXJw" + + "b3NlIHwgTm90ZXMgfAp8LS0tfC0tLXwtLS18CnwgYGluaXRgIHwgTWludCBhZG1pbiB0b2tlbiBm" + + "cm9tIEFQSSBLZXlzIHZpYSBwdWJsaWMgYXBpIGdhdGV3YXkgfCBJZGVtcG90ZW50OyB3cml0ZXMg" + + "YH4vLm52Y2YtY2xpLnN0YXRlYDsgY2xlYXJzIHNhdmVkIGZ1bmN0aW9uIHN0YXRlIGFuZCBhbGwg" + + "QVBJIGtleXMgKGZ1bmN0aW9uIGFuZCB0YXNrKSB8CnwgYHN0YXR1c2AgfCBTaG93IENMSSBzdGF0" + + "ZSAodG9rZW4sIGxhc3QgZW5kcG9pbnRzLCBmdW5jdGlvbiBjb250ZXh0KSB8IERpc3RpbmN0IGZy" + + "b20gYHNlbGYtaG9zdGVkIHN0YXR1c2AgfAp8IGBhcGkta2V5IGdlbmVyYXRlIFstLWZvciBmdW5j" + + "dGlvblx8dGFza10gWy0tZGVzY3JpcHRpb2494oCmXSBbLS1leHBpcmVzLWluPeKApl1gIHwgTWlu" + + "dCBBUEkga2V5cyB8IERlZmF1bHQgKG5vIGAtLWZvcmApOiBnZW5lcmF0ZXMgYm90aCBhIGZ1bmN0" + + "aW9uIGtleSBhbmQgYSB0YXNrIGtleTsgYC0tZm9yIGZ1bmN0aW9uYCBvciBgLS1mb3IgdGFza2Ag" + + "Z2VuZXJhdGVzIG9uZSBvbmx5OyBgLS1zY29wZXNgIHJlcXVpcmVzIGAtLWZvcmAgfAp8IGBhcGkt" + + "a2V5IGdlbmVyYXRlIC0tdmFsaWRhdGVgIHwgTWludCBhbmQgaW1tZWRpYXRlbHkgdmFsaWRhdGUg" + + "ZWFjaCBnZW5lcmF0ZWQga2V5IHwgVmFsaWRhdGVzIGVhY2gga2V5IGFnYWluc3QgaXRzIGNvcnJl" + + "Y3QgYXVkaWVuY2UgKE5WQ0YgZm9yIGZ1bmN0aW9uIGtleXMsIE5WQ1QgZm9yIHRhc2sga2V5cykg" + + "fAp8IGBhcGkta2V5IGxpc3RgIHwgTGlzdCBBUEkga2V5cyBmb3IgY3VycmVudCBvd25lciB8IExp" + + "c3RzIE5WQ0Ytc2NvcGVkIGtleXMgb25seSB8CnwgYGFwaS1rZXkgZGVsZXRlIEtFWS1JRGAgLyBg" + + "YXBpLWtleSByZXZva2UgS0VZLUlEYCB8IFJlbW92ZSBhbiBBUEkga2V5IHwgKipDb25maXJtIHdp" + + "dGggdXNlcioqIHwKfCBgYXBpLWtleSBzaG93YCB8IFNob3cgY3VycmVudGx5IHNhdmVkIEFQSSBr" + + "ZXkgZnJvbSBzdGF0ZSB8IHwKCiMjIEFnZW50IHNraWxsCgp8IENvbW1hbmQgfCBQdXJwb3NlIHwg" + + "Tm90ZXMgfAp8LS0tfC0tLXwtLS18CnwgYGFnZW50LXNraWxsIGluc3RhbGwgWy0tdGFyZ2V0PURJ" + + "Ul1gIHwgSW5zdGFsbCBhbGwgYnVuZGxlZCBwdWJsaWMgdXNlciBza2lsbHMgaW50byBgfi8uY2xh" + + "dWRlL3NraWxscy9gIGFuZCBgfi8uYWdlbnRzL3NraWxscy9gIHwgRGVmYXVsdDogYm90aCBiYXNl" + + "IHNraWxscyBkaXJlY3RvcmllcyB8CnwgYGFnZW50LXNraWxsIHVuaW5zdGFsbCBbLS10YXJnZXQ9" + + "RElSXWAgfCBSZW1vdmUgb25seSBidW5kbGVkIHB1YmxpYyB1c2VyIHNraWxscyBmcm9tIHRoZSB0" + + "YXJnZXQgYmFzZSBza2lsbHMgZGlyZWN0b3JpZXMgfCBJZGVtcG90ZW50OyB1bnJlbGF0ZWQgc2tp" + + "bGxzIHJlbWFpbiB8CnwgYGFnZW50LXNraWxsIHNob3cgWy0tZmlsZT1SRUxdYCB8IFByaW50IGJ1" + + "bmRsZWQgYG52Y2Ytc2VsZi1tYW5hZ2VkLWNsaS9TS0lMTC5tZGAgb3IgYW5vdGhlciBlbWJlZGRl" + + "ZCBmaWxlIHwgRm9yIGRlYnVnZ2luZyB8CnwgYGFnZW50LXNraWxsIHZlcnNpb25gIHwgUHJpbnQg" + + "YnVpbGQgU0hBIGFuZCBlbWJlZGRlZCBwdWJsaWMgdXNlciBza2lsbCBzdW1tYXJ5IHwgfApQSwcI" + + "xDKuZBcmAAAXJgAAUEsDBBQACAAAAAAAIQAAAAAAAAAAAAAAAAAyAAkAZGF0YS9udmNmLXNlbGYt" + + "bWFuYWdlZC1jbGkvcmVmZXJlbmNlL2V4aXQtY29kZXMubWRVVAUAAQCmzhIjIGBudmNmLWNsaWAg" + + "ZXhpdCBjb2RlcwoKU3RhYmxlIGFjcm9zcyBzdWJjb21tYW5kcy4gVXNlIHRoZXNlIHRvIGRyaXZl" + + "IGFnZW50IHJldHJ5IC8gc3VyZmFjaW5nIGxvZ2ljLgoKfCBDb2RlIHwgTWVhbmluZyB8IEV4YW1w" + + "bGUgY2F1c2VzIHwKfC0tLXwtLS18LS0tfAp8IGAwYCB8IFN1Y2Nlc3MgfCBBbGwgY2hlY2tzIHBh" + + "c3NlZDsgaW5zdGFsbCBjb21wbGV0ZWQ7IGRlcGxveW1lbnQgQUNUSVZFIHwKfCBgMWAgfCBHZW5l" + + "cmljIGVycm9yIHwgSGVsbSByZW5kZXIgZmFpbGVkOyBuZXR3b3JrIHVucmVhY2hhYmxlOyBmaWxl" + + "IG5vdCBmb3VuZDsgWUFNTCBwYXJzZSBlcnJvciB8CnwgYDJgIHwgUHJlLWZsaWdodCBjaGVjayBm" + + "YWlsZWQgfCBBIGBjaGVja2AgcmVzdWx0IGF0IGVycm9yIHNldmVyaXR5OiBHYXRld2F5IEFQSSBD" + + "UkRzIG1pc3Npbmc7IGt1YmVjdGwgbm90IG9uIFBBVEggd2l0aCBgLS1wcmVgOyBkZWZhdWx0IFN0" + + "b3JhZ2VDbGFzcyBhYnNlbnQ7IGEgY2x1c3RlciB0aGUgQ0xJIGNhbm5vdCByZWFjaCAobWlzc2lu" + + "ZyBrdWJlY29uZmlnIG9yIGNvbnRleHQsIG9yIGEgZmlyc3QgQVBJIGNhbGwgdGhhdCBmYWlscyBm" + + "b3IgYW55IHJlYXNvbjogcmVmdXNlZCBvciBkcm9wcGVkIGNvbm5lY3Rpb24sIHJlamVjdGVkIGNy" + + "ZWRlbnRpYWxzLCBhIGZhaWxpbmcgY3JlZGVudGlhbCBwbHVnaW4sIGEgc2VydmVyIGVycm9yKTsg" + + "YSBjbHVzdGVyLXZhbGlkYXRvciB0aGF0IGNvdWxkIG5vdCBydW4gKFJCQUMgZGVuaWVkLCBpbWFn" + + "ZSBwdWxsIGZhaWx1cmUsIGl0cyBvd24gdGltZW91dCwgbm8gdGFnIGZvdW5kIGZvciBhbiB1bnRh" + + "Z2dlZCBpbWFnZSk7IGEgYHN0YWxlLW5hbWVzcGFjZXNgIHJvdyBuYW1pbmcgYSBuYW1lc3BhY2Ug" + + "YHN0dWNrIFRlcm1pbmF0aW5nYC4gU3VjaCBhIHJlc3VsdCBpcyBleGl0IGAyYCBldmVuIHdoZW4g" + + "dGhlIHRpbWUgYnVkZ2V0IGN1dCBhbm90aGVyIGNoZWNrIHNob3J0LiBXYXJuaW5nLXNldmVyaXR5" + + "IHJlc3VsdHMgZXhpdCBgMGAsIGluY2x1ZGluZyBhIG5hbWVzcGFjZSBzdGlsbCBkZWxldGluZywg" + + "YSBIZWxtIHJlbGVhc2UgbGVmdCBtaWQtb3BlcmF0aW9uLCBhIG5hbWVzcGFjZSB3aXRoIG5vIEhl" + + "bG0gcmVsZWFzZSwgYW5kIGEgYGNsdXN0ZXItdmFsaWRhdG9yYCByb3cgc2F5aW5nIG5vIGBjbHVz" + + "dGVyX3ZhbGlkYXRvcl9pbWFnZWAgaXMgc2V0IHwKfCBgM2AgfCBBZG1pbiBhdXRoIGZhaWxlZCB8" + + "IE5vIHRva2VuICsgYC0tbm9uLWludGVyYWN0aXZlYCBzZXQ7IElDTVMgcmVqZWN0ZWQgSldUOyBp" + + "bml0IGVuZHBvaW50IHVucmVhY2hhYmxlIHwKfCBgNWAgfCBNYW5pZmVzdCBhcHBseSBvciBgLS13" + + "YWl0YCB0aW1lZCBvdXQgfCBIZWxtIGluc3RhbGwgdGltZW91dDsgY2hlY2sgcG9sbGVkIGJ1dCBk" + + "aWQgbm90IHBhc3MgYmVmb3JlIERVUkFUSU9OLCBpbmNsdWRpbmcgYSByb2xsb3V0IHN0aWxsIGlu" + + "IHByb2dyZXNzOyB0aGUgY2hlY2sncyB0aW1lIGJ1ZGdldCBzdG9wcGVkIGEgY2hlY2sgdGhhdCBj" + + "YW4gZmFpbCB0aGUgcnVuIGJlZm9yZSBpdCBmaW5pc2hlZCAoaXRzIHJvdyBzYXlzIG5vdCBydW4g" + + "b3IgY3V0IHNob3J0KSBhbmQgbm8gb3RoZXIgY2hlY2sgZmFpbGVkLiBBIGN1dC1zaG9ydCByb3cg" + + "a2VlcHMgdGhlIHdvcnN0IHNldmVyaXR5IGl0cyBjaGVjayBjYW4gcmVwb3J0LCBzbyBhIGNoZWNr" + + "IHRoYXQgY2FuIG9ubHkgd2Fybiwgc3VjaCBhcyBhIHJlZ2lzdHJ5IHRoYXQgaXMgbm90IGNyaXRp" + + "Y2FsLCBzdGF5cyBhIHdhcm5pbmcuIEEgY2hlY2sgdGhhdCBhbHJlYWR5IGhhZCBpdHMgcmVzdWx0" + + "IGtlZXBzIGl0LCBzbyBhIHZhbGlkYXRvcidzIG93biBmYWlsdXJlIGlzIGV4aXQgYDJgIHwKfCBg" + + "MTMwYCB8IENhbmNlbGxlZCBieSBTSUdJTlQsIFNJR1RFUk0gb3IgU0lHSFVQLCBvciBieSBhIHF1" + + "aXQga2V5IGluIHRoZSBgY2hlY2sgLS13YWl0YCBkYXNoYm9hcmQgfCBVc2VyIEN0cmwtQzsgYHFg" + + "LCBgRXNjYCBvciBgQ3RybC1DYCBpbiB0aGUgZGFzaGJvYXJkOyBDSSBidWRnZXQgZXhjZWVkZWQ7" + + "IHBvZCBldmljdGVkOyB0ZXJtaW5hbCBvciBTU0ggc2Vzc2lvbiBjbG9zZWQuIEEgc2lnbmFsIGBj" + + "aGVja2Agd2FzIHN0YXJ0ZWQgd2l0aCBpZ25vcmVkIHN0YXlzIGlnbm9yZWQsIHNvIGEgYGNoZWNr" + + "YCBydW4gdW5kZXIgYG5vaHVwYCBvdXRsaXZlcyB0aGUgc2Vzc2lvbiB8CgojIyBIb3cgYW4gYWdl" + + "bnQgc2hvdWxkIHJlYWN0Cgp8IENvZGUgfCBBZ2VudCBiZWhhdmlvciB8CnwtLS18LS0tfAp8IGAw" + + "YCB8IENvbnRpbnVlIC8gcmVwb3J0IHN1Y2Nlc3MuIHwKfCBgMWAgfCBTdXJmYWNlIHN0ZGVyciB0" + + "byB1c2VyOyBhc2sgYmVmb3JlIGFueSByZXRyeS4gR2VuZXJpYyBlcnJvcnMgdXN1YWxseSBuZWVk" + + "IGEgaHVtYW4gdG8gcmVhZCB0aGUgbWVzc2FnZS4gfAp8IGAyYCB8IFN1cmZhY2UgZmFpbGVkIGNo" + + "ZWNrcyArIHRoZWlyIGBoaW50VVJMYC4gRG9uJ3QgcHJvcG9zZSBgdXBgIHVudGlsIHRoZSB1c2Vy" + + "IGZpeGVzIHByZXJlcXMuIHwKfCBgM2AgfCBTdWdnZXN0IGBudmNmLWNsaSBpbml0YCAoaW50ZXJh" + + "Y3RpdmUpIE9SIGAtLXRva2VuPSRKV1RgIChDSSkuIERvbid0IGF1dG8tbWludCB3aXRob3V0IHVz" + + "ZXIgT0suIHwKfCBgNWAgfCBBc2sgdXNlciBpZiB0aGV5IHdhbnQgdG8gd2FpdCBsb25nZXIsIGRp" + + "ZyBpbnRvIHRoZSBzdGFsbGVkIGNvbXBvbmVudCwgb3IgY2FuY2VsLiB8CnwgYDEzMGAgfCBEb24n" + + "dCByZXRyeSBhdXRvbWF0aWNhbGx5OyB1c2VyIGV4cGxpY2l0bHkgY2FuY2VsbGVkLiBDb25maXJt" + + "IGJlZm9yZSByZS1ydW5uaW5nLiB8CgojIyBXaGVyZSB0byBmaW5kIG1vcmUgZGV0YWlsCgpgY2hl" + + "Y2tgIHJlcG9ydHMgaXRzIG91dGNvbWUgaW4gb25lIGBmaW5hbGAgZXZlbnQgKGBzdWNjZXNzYCwg" + + "YHZlcmRpY3RgLCBgdG90YWxDaGVja3NgLCBgcGFzc2VkQ291bnRgLCBgZmFpbGVkQ291bnRgLCBg" + + "d2FybmluZ0NvdW50YCkgYW5kIG5ldmVyIGVtaXRzIGBwaGFzZV9mYWlsZWRgOyBzZWUgYGV4YW1w" + + "bGVzL2NpLXBpcGVsaW5lcy5tZGAuIEEgdXNhZ2UgZXJyb3IsIHN1Y2ggYXMgYSBtYWxmb3JtZWQg" + + "YC0td2FpdGAsIGV4aXRzIGAxYCBiZWZvcmUgYW55IGV2ZW50LiBJdHMgZXhpdCBgNWAgaGFzIGBz" + + "dWNjZXNzOiBmYWxzZWAgYW5kIGB2ZXJkaWN0OiAidGltZW91dCJgLiBBIGBjaGVja19jb21wbGV0" + + "ZWRgIGV2ZW50IGZvciBhIHdhcm5pbmcgdGhhdCBgLS13YWl0YCBwb2xscyBvbiBjYXJyaWVzIGB0" + + "cmFuc2llbnQ6IHRydWVgLCBhbmQgb25lIGZvciBhIGNoZWNrIHRoZSB0aW1lIGJ1ZGdldCBzdG9w" + + "cGVkIGNhcnJpZXMgYGN1dFNob3J0OiB0cnVlYC4gQSBjYW5jZWxsYXRpb24gKGV4aXQgYDEzMGAp" + + "IGVtaXRzIGBmaW5hbGAgd2l0aCBgY2FuY2VsbGVkOiB0cnVlYCBpbnN0ZWFkIG9mIGBwaGFzZV9m" + + "YWlsZWRgOyBgdXBgIGVtaXRzIGBwaGFzZV9jYW5jZWxsZWRgIGJlZm9yZSBpdC4gV2hlbiBhIGNs" + + "dXN0ZXItdmFsaWRhdG9yIHJ1biBsZWZ0IG9iamVjdHMgaW4gdGhlIGNsdXN0ZXIgKGAtLW5vLWNs" + + "ZWFudXBgLCBhIHBvZCB0aGF0IHdvdWxkIG5vdCBzdG9wLCBvciBhIGNsZWFudXAgdGhhdCBmYWls" + + "ZWQpLCBgY2hlY2tgIGxpc3RzIHRoZSBjb21tYW5kcyB0aGF0IHJlbW92ZSB0aGVtIGluIHRoZSBg" + + "ZmluYWxgIGV2ZW50J3MgYGNsZWFudXBgIGFycmF5IGFuZCBpbiB0aGF0IHJvdydzIGBjbGVhbnVw" + + "YCBmaWVsZCwgb24gZXZlcnkgZXhpdCBjb2RlLCBhbmQgcHJpbnRzIHRoZW0gYWdhaW4gYXMgaXRz" + + "IGxhc3Qgc3RkZXJyIGxpbmVzLiBPbiBhbiBpbnRlcnJ1cHQgaXQgcHJpbnRzIHRoZW0gYXQgb25j" + + "ZSwgYmVmb3JlIGl0cyBvd24gY2xlYW51cCwgd2hpY2ggYSBzZWNvbmQgQ3RybC1DIGN1dHMgc2hv" + + "cnQuIEV2ZXJ5IG90aGVyIG5vbi16ZXJvIGV4aXQgZW1pdHMgYSBzdHJ1Y3R1cmVkIGBwaGFzZV9m" + + "YWlsZWRgIEpTT04gZXZlbnQgd2l0aCBgZXJyQ2F0ZWdvcnlgLCBgZXJyTWVzc2FnZWAsIGByZW1l" + + "ZGlhdGlvbmAgKGFycmF5KSwgYHJldHJ5Q2xhc3NgIChlbnVtOiBgbm9uZXxpbW1lZGlhdGV8YmFj" + + "a29mZnxhZnRlcl9yZW1lZGlhdGlvbnx1bmtub3duYCksIGByZXRyeUFmdGVyU2VjYCAoaW50LCBv" + + "cHRpb25hbCksIGFuZCBgcmF3YCAoc3VicHJvY2VzcyArIEhUVFAgKyBLdWJlcm5ldGVzIHNpZ25h" + + "bCkuIEFsd2F5cyBjb25zdW1lIHRoZXNlIGluIGAtLWpzb25gIG1vZGUgcmF0aGVyIHRoYW4gcGFy" + + "c2luZyBFbmdsaXNoIGZyb20gc3RkZXJyLgpQSwcIyflFb2gQAABoEAAAUEsDBBQACAAAAAAAIQAA" + + "AAAAAAAAAAAAAAAtAAkAZGF0YS9udmNmLXNlbGYtbWFuYWdlZC1jbGkvcmVmZXJlbmNlL2ZsYWdz" + + "Lm1kVVQFAAEAps4SIyBgbnZjZi1jbGlgIGZsYWcgcmVmZXJlbmNlCgojIyBHbG9iYWwgZmxhZ3Mg" + + "KGV2ZXJ5IHN1YmNvbW1hbmQpCgp8IEZsYWcgfCBQdXJwb3NlIHwgRGVmYXVsdCB8CnwtLS18LS0t" + + "fC0tLXwKfCBgLS1jb25maWcgRklMRWAgfCBDb25maWcgZmlsZSBwYXRoIHwgYCRIT01FLy5udmNm" + + "LWNsaS55YW1sYCB8CnwgYC0tZGVidWdgIHwgVmVyYm9zZSBIVFRQIGxvZ2dpbmcgb24gc3RkZXJy" + + "IHwgYGZhbHNlYCB8CnwgYC0tanNvbmAgfCBKU09OTCBldmVudHMgb24gc3RkZXJyIHwgYXV0byAo" + + "b2ZmIGluIFRUWSwgb24gdW5kZXIgYC0tbm9uLWludGVyYWN0aXZlYCkgfAoKIyMgYHNlbGYtaG9z" + + "dGVkYCBwYXJlbnQgKHBlcnNpc3RlbnQgYWNyb3NzIHN1YmNvbW1hbmRzKQoKfCBGbGFnIHwgUHVy" + + "cG9zZSB8IERlZmF1bHQgfAp8LS0tfC0tLXwtLS18CnwgYC0tY29udHJvbC1wbGFuZS1zdGFjaz0u" + + "Li5gIHwgQ29udHJvbC1wbGFuZSBidW5kbGUgc291cmNlOiBsb2NhbCBwYXRoLCBnaXQgVVJMLCBv" + + "ciBgb2NpOi8vYCBVUkwgfCBlbWJlZGRlZCBPQ0kgVVJMIHBpbm5lZCBieSBDTEkgdmVyc2lvbiB8" + + "CnwgYC0tY29tcHV0ZS1wbGFuZS1zdGFjaz0uLi5gIHwgQ29tcHV0ZS1wbGFuZSBidW5kbGUgc291" + + "cmNlOiBsb2NhbCBwYXRoLCBnaXQgVVJMLCBvciBgb2NpOi8vYCBVUkwgfCBlbWJlZGRlZCBPQ0kg" + + "VVJMIHBpbm5lZCBieSBDTEkgdmVyc2lvbiB8CnwgYC0tZW52PWxvY2FsXHxwcmRcfC4uLmAgfCBI" + + "ZWxtZmlsZSBlbnZpcm9ubWVudCBuYW1lLiBgY2hlY2tgIHJlYWRzIHRoZSBzdGFjaydzIGBlbnZp" + + "cm9ubWVudHMvPGVudj4ueWFtbGAgb25seSBmb3IgYW4gZW52aXJvbm1lbnQgbmFtZWQgd2l0aCBg" + + "LS1lbnZgIG9yIGBIRUxNRklMRV9FTlZgLCBvciBmb3IgYSBwbGFuZSBgLS1wcmVgIGNoZWNrcyBi" + + "ZWZvcmUgaXRzIGluc3RhbGwsIHNvIHBhc3MgdGhlIGAtLWVudmAgdGhlIGluc3RhbGwgdXNlZDsg" + + "c2VlIFtTZXR0aW5ncyByZWFkIGZyb20gdGhlIHN0YWNrXSgjc2V0dGluZ3MtcmVhZC1mcm9tLXRo" + + "ZS1zdGFjaykgfCBgbG9jYWxgIHwKfCBgLS1ub24taW50ZXJhY3RpdmVgIHwgRGlzYWJsZSBhbGwg" + + "c3RkaW4gcHJvbXB0cyB8IGBmYWxzZWAgfAp8IGAtLXRva2VuPSRKV1RgIHwgQWRtaW4gSldULCBv" + + "dmVycmlkZXMgc3RvcmVkIHNlc3Npb24gfCAtIHwKfCBgLS1uby1hcHBseWAgfCBgaW5zdGFsbGAg" + + "b25seSAtIGVtaXQgWUFNTCwgZG8gbm90IGt1YmVjdGwgYXBwbHkgfCBgZmFsc2VgIHwKfCBgLS1v" + + "dXRwdXQ9dGV4dFx8anNvbmAgfCBMZWdhY3kgYWxpYXMgZm9yIGAtLWpzb25gIChkZXByZWNhdGVk" + + "LCByZW1vdmVkIGluIG5leHQgbWFqb3IpIHwgYHRleHRgIHwKfCBgLS1wbGFpbmAgfCBGb3JjZSBw" + + "bGFpbiBzdHJlYW1pbmcgb3V0cHV0IHwgYXV0by1kZXRlY3QgfAp8IGAtLXdhaXQgRFVSQVRJT05g" + + "IHwgYGNoZWNrYCBvbmx5OyBydW4gdGhlIGNoZWNrcyBhZ2FpbiwgNXMgYXBhcnQsIHVudGlsIHRo" + + "ZXkgcGFzcyBvciBEVVJBVElPTiBydW5zIG91dCAoZXhpdCBgNWAsIGBmaW5hbGAgd2l0aCBgc3Vj" + + "Y2VzczogZmFsc2VgKS4gRWFjaCBydW4gaW5jbHVkZXMgdGhlIGNsdXN0ZXItdmFsaWRhdG9yIEpv" + + "Yiwgd2hpY2ggY2FuIHRha2UgdXAgdG8gNW0sIHNvIGFsbG93IGZvciBpdCBvciBwYXNzIGAtLXNr" + + "aXAtY2x1c3Rlci12YWxpZGF0aW9uYC4gQSB3YXJuaW5nIHRoYXQgaXMgZXhwZWN0ZWQgdG8gY2xl" + + "YXIsIHN1Y2ggYXMgYSByb2xsb3V0IGluIHByb2dyZXNzLCBrZWVwcyBpdCBwb2xsaW5nLCBhbmQg" + + "aXRzIGBjaGVja19jb21wbGV0ZWRgIGV2ZW50IGNhcnJpZXMgYHRyYW5zaWVudDogdHJ1ZWAuIEEg" + + "Y2hlY2sgdGhlIHRpbWUgYnVkZ2V0IHN0b3BwZWQga2VlcHMgaXQgcG9sbGluZyB0b28sIGFuZCBp" + + "dHMgZXZlbnQgY2FycmllcyBgY3V0U2hvcnQ6IHRydWVgLiBBIGNoZWNrIHRoYXQgY2FuIG9ubHkg" + + "d2FybiBhbmQgaXMgc3RvcHBlZCBvbiB0d28gcG9sbHMgaW4gYSByb3cgZW5kcyB0aGUgd2FpdCBh" + + "cyBhIHNpbmdsZSBydW4gd291bGQ6IGV4aXQgYDBgIHdpdGggdGhlIHdhcm5pbmcuIENhbm5vdCBi" + + "ZSBjb21iaW5lZCB3aXRoIGAtLW5vLWNsZWFudXBgLiBBIG1hbGZvcm1lZCBvciBub24tcG9zaXRp" + + "dmUgRFVSQVRJT04gaXMgYSB1c2FnZSBlcnJvciAoZXhpdCBgMWAsIG5vIGV2ZW50cykgfCAtIHwK" + + "fCBgLS1jb250cm9sLXBsYW5lLWNvbnRleHQgQ1RYYCB8IGt1YmVjdGwgY29udGV4dCBmb3IgY29u" + + "dHJvbCBwbGFuZSAoUkVRLTIwKSB8IGN1cnJlbnQgY29udGV4dCB8CnwgYC0tY29tcHV0ZS1wbGFu" + + "ZS1jb250ZXh0IENUWGAgfCBrdWJlY3RsIGNvbnRleHQgZm9yIGNvbXB1dGUgcGxhbmUgKFJFUS0y" + + "MCkgfCBjdXJyZW50IGNvbnRleHQgfAp8IGAtLWljbXMtdXJsIFVSTGAgfCBQdWJsaWMgSUNNUyBV" + + "Ukw7IHJlcXVpcmVkIHdoZW4gY29udGV4dHMgZGlmZmVyLiBgY2hlY2tgIHByb2JlcyBTSVMgb25s" + + "eSBhdCBhIFVSTCBzZXQgaGVyZSwgaW4gYE5WQ0ZfSUNNU19VUkxgIG9yIGBOVkNGX1NJU19VUkxg" + + "LCBvciBpbiB0aGUgY29uZmlnIGFzIGBpY21zX3VybGAgb3IgYGJhc2VfaHR0cF91cmxgLCBhbmQg" + + "bm90IGF0IG9uZSBkZXJpdmVkIGZyb20gYSBgYmFzZV9odHRwX3VybGAgb24gTlZJRElBJ3MgaG9z" + + "dGVkIGBudmNmLm52aWRpYS5jb21gLCBhcyB0aGUgY29uZmlnIHRlbXBsYXRlJ3MgaXM7IHdpdGgg" + + "bm9uZSwgaXRzIGBzaXMtcmVhY2hhYmlsaXR5YCByb3cgaXMgYSB3YXJuaW5nIHwgZGVyaXZlZCBm" + + "cm9tIGBiYXNlX2h0dHBfdXJsYCB8CnwgYC0tbG9jYWwtb25seWAgfCBgY2hlY2tgIG9ubHk7IHJ1" + + "biB0aGUgbG9jYWwtaG9zdCBjaGVja3MgYW5kIHNraXAgYWxsIGNsdXN0ZXIgYW5kIHJlZ2lzdHJ5" + + "IGNvbnRhY3QsIHdoYXRldmVyIHNjb3BlIGZsYWcgaXMgcGFzc2VkLiBUaGUgcmVnaXN0cnkgY3Jl" + + "ZGVudGlhbCBjaGVjayBhbmQgZWFjaCBzZWxlY3RlZCByb2xlJ3MgY2x1c3RlciBjaGVja3MgYXJl" + + "IHJlcG9ydGVkIGFzIHJvd3MgdGhhdCBwYXNzIGF0IGluZm8gYW5kIHNheSBgc2tpcHBlZCAoLS1s" + + "b2NhbC1vbmx5KWAuIEVudjogYE5WQ0ZfQ0xJX1NFTEZIT1NURURfTE9DQUxfT05MWWAsIG9uIHdp" + + "dGggYHRydWVgLCBgMWAsIGB5ZXNgLCBgeWAgb3IgYG9uYCBhbmQgb2ZmIHdpdGggYGZhbHNlYCwg" + + "YDBgLCBgbm9gLCBgbmAgb3IgYG9mZmAsIGluIGFueSBjYXNlLiBBbnkgb3RoZXIgdmFsdWUgaXMg" + + "YW4gZXJyb3IgfCBgZmFsc2VgIHwKCiMjIGBjaGVja2Atc3BlY2lmaWMKCkF0IGxlYXN0IG9uZSBv" + + "ZiBgLS1wcmVgLCBgLS1jb250cm9sLXBsYW5lYCwgYC0tY29tcHV0ZS1wbGFuZWAsIG9yIGAtLWFs" + + "bGAgaXMKcmVxdWlyZWQuIGAtLXByZWAgc2VsZWN0cyBib3RoIHJvbGVzOyBlYWNoIG90aGVyIGZs" + + "YWcgc2VsZWN0cyBpdHMgb3duLiBPbmx5CnRoZSBzZWxlY3RlZCByb2xlcyBjb250YWN0IGEgY2x1" + + "c3Rlci4gUGFzcyBib3RoIGNvbnRleHQgZmxhZ3Mgb3IgbmVpdGhlcjoKd2l0aG91dCB0aGVtIGV2" + + "ZXJ5IHNlbGVjdGVkIHJvbGUgY2hlY2tzIHRoZSBjdXJyZW50IGt1YmVjb25maWcgY29udGV4dC4K" + + "CnwgRmxhZyB8IFB1cnBvc2UgfCBEZWZhdWx0IHwKfC0tLXwtLS18LS0tfAp8IGAtLXByZWAgfCBQ" + + "cmUtZmxpZ2h0OiBsb2NhbC1ob3N0IHRvb2xzIHBsdXMgY2x1c3RlciByZWFkaW5lc3MuIFNraXBz" + + "IFNJUyByZWFjaGFiaWxpdHksIHNpbmNlIFNJUyBpcyBub3QgaW5zdGFsbGVkIHlldCwgdW5sZXNz" + + "IGAtLWFsbGAgb3IgYC0tY29tcHV0ZS1wbGFuZWAgaXMgYWxzbyBwYXNzZWQuIFdpdGggYC0tYWxs" + + "YCwgb3Igd2l0aCBhIHJvbGUncyBvd24gZmxhZywgdGhhdCByb2xlJ3MgdmFsaWRhdG9yIGNoZWNr" + + "cyB0aGUgY2x1c3RlciBhcyBpbnN0YWxsZWQuIEEgbWlzc2luZyBvciB1bnN1cHBvcnRlZCBga3Vi" + + "ZWN0bGAsIGBoZWxtZmlsZWAgb3IgYGhlbG1gIGZhaWxzIG9ubHkgd2l0aCBgLS1wcmVgOyBvdGhl" + + "ciBzY29wZXMgY2hlY2sgYW4gaW5zdGFsbGVkIHN0YWNrLCB3aGljaCBydW5zIG5vbmUgb2YgdGhl" + + "bSwgc28gaXQgd2FybnMgfCBgZmFsc2VgIHwKfCBgLS1jb250cm9sLXBsYW5lYCB8IENoZWNrIHRo" + + "ZSBpbnN0YWxsZWQgY29udHJvbCBwbGFuZTogbGVmdG92ZXIgbmFtZXNwYWNlcyBhbmQgdGhlIGNv" + + "bnRyb2wtcGxhbmUgY2x1c3Rlci12YWxpZGF0b3IuIE5ldmVyIHByb2JlcyBTSVMgfCBgZmFsc2Vg" + + "IHwKfCBgLS1jb21wdXRlLXBsYW5lYCB8IENoZWNrIHRoZSBpbnN0YWxsZWQgY29tcHV0ZSBwbGFu" + + "ZTogbGVmdG92ZXIgbmFtZXNwYWNlcywgbm9kZSBpbm90aWZ5IGxpbWl0cywgU0lTIHJlYWNoYWJp" + + "bGl0eSBhdCBhIGNvbmZpZ3VyZWQgSUNNUyBVUkwsIGFuZCB0aGUgY29tcHV0ZS1wbGFuZSBjbHVz" + + "dGVyLXZhbGlkYXRvciB8IGBmYWxzZWAgfAp8IGAtLWFsbGAgfCBCb3RoIGAtLWNvbnRyb2wtcGxh" + + "bmVgIGFuZCBgLS1jb21wdXRlLXBsYW5lYCwgU0lTIHJlYWNoYWJpbGl0eSBpbmNsdWRlZCwgd2l0" + + "aCBvciB3aXRob3V0IGAtLXByZWAgfCBgZmFsc2VgIHwKfCBgLS1jbHVzdGVyLW5hbWUgTkFNRWAg" + + "fCBDbHVzdGVyIG5hbWUgc2hvd24gaW4gdGhlIG91dHB1dCBoZWFkZXIuIE5vIGNoZWNrIHVzZXMg" + + "aXQsIGFuZCBgY2hlY2tgIGRvZXMgbm90IHJlcXVpcmUgaXQgfCAtIHwKfCBgLS1za2lwLWlub3Rp" + + "ZnktY2hlY2tgIHwgU2tpcCB0aGUgcGVyLW5vZGUgaW5vdGlmeS1saW1pdHMgcHJvYmUuIE5lZWRl" + + "ZCB3aGVuIHRoZSBrdWJlY29uZmlnIHVzZXIgY2Fubm90IGNyZWF0ZSBwb2RzIGluIGBkZWZhdWx0" + + "YCwgb3Igd2hlbiB0aGUgcHJvYmUgaW1hZ2UgaXMgb24gYSByZWdpc3RyeSB0aGF0IG5lZWRzIGNy" + + "ZWRlbnRpYWxzLCBzaW5jZSB0aGUgcHJvYmUgcG9kcyBnZXQgbm8gcHVsbCBzZWNyZXQuIEl0cyBg" + + "bm9kZS1pbm90aWZ5LWxpbWl0c2Agcm93IHBhc3NlcyBhdCBpbmZvIGFuZCBzYXlzIGl0IHdhcyBz" + + "a2lwcGVkLiBFbnY6IGBOVkNGX0NMSV9TRUxGSE9TVEVEX1NLSVBfSU5PVElGWWAsIG9uIHdpdGgg" + + "YHRydWVgLCBgMWAsIGB5ZXNgLCBgeWAgb3IgYG9uYCBhbmQgb2ZmIHdpdGggYGZhbHNlYCwgYDBg" + + "LCBgbm9gLCBgbmAgb3IgYG9mZmAsIGluIGFueSBjYXNlLiBBbnkgb3RoZXIgdmFsdWUgaXMgYW4g" + + "ZXJyb3IgfCBgZmFsc2VgIHwKCiMjIyBDbHVzdGVyLXZhbGlkYXRvciBmbGFncwoKVGhlIHZhbGlk" + + "YXRvciBydW5zIGFzIGEgSm9iIGluIHRoZSBjbHVzdGVyIGJlaW5nIGNoZWNrZWQuIFRoZSBDTEkg" + + "Y3JlYXRlcyBhClNlcnZpY2VBY2NvdW50LCBDbHVzdGVyUm9sZSwgYW5kIENsdXN0ZXJSb2xlQmlu" + + "ZGluZyBmb3IgaXQsIHBsdXMgYSBSb2xlIGFuZApSb2xlQmluZGluZyBpbiBgZGVmYXVsdGAgdGhh" + + "dCBsZXQgaXQgcmVhZCBvbmx5IGl0cyBvd24gQ29uZmlnTWFwLCBhbmQgcmVtb3Zlcwp0aGVtIGFm" + + "dGVyIHRoZSBydW4sIHNvIHRoZSBrdWJlY29uZmlnIGNvbnRleHQgbmVlZHMgcGVybWlzc2lvbiB0" + + "byBtYW5hZ2UgdGhvc2UuCkl0IHJlYWRzIHRoZSB2YWxpZGF0b3IncyB0cmFuc2NyaXB0IHdpdGgg" + + "YGdldGAgb24gYHBvZHMvbG9nYCBpbiBgZGVmYXVsdGAuCldpdGhvdXQgaXQsIGEgdmFsaWRhdG9y" + + "IEpvYiB0aGF0IHN1Y2NlZWRlZCBzdGlsbCBwYXNzZXMsIGFuZCBpdHMgcm93IHNheXMgdGhlCnRy" + + "YW5zY3JpcHQgY291bGQgbm90IGJlIHJlYWQsIHNvIG5laXRoZXIgdGhlIGNoZWNrcyBpdCByYW4g" + + "bm9yIHdhcm5pbmdzIGl0CnJlcG9ydGVkIGFyZSBzaG93bi4KVGhlIENsdXN0ZXJSb2xlIGdyYW50" + + "cyBvbmx5IHRoZSBjYWxscyBlYWNoIHJvbGUncyBjaGVja3MgbWFrZTogcmVhZHMsIHRoZQpuYW1l" + + "c3BhY2UsIERhZW1vblNldCBhbmQgcG9kIHdyaXRlcyBvZiB0aGUgY29udHJvbC1wbGFuZSBub2Rl" + + "LXRvLW5vZGUgcHJvYmUsCmFuZCBkZWxldGluZyB0ZXN0IG5hbWVzcGFjZXMgYW4gZWFybGllciB2" + + "YWxpZGF0b3IgcnVuIGxlZnQgYmVoaW5kLgoKfCBGbGFnIHwgUHVycG9zZSB8IERlZmF1bHQgfAp8" + + "LS0tfC0tLXwtLS18CnwgYC0tY2x1c3Rlci12YWxpZGF0b3ItaW1hZ2UgUkVGYCB8IFZhbGlkYXRv" + + "ciBpbWFnZS4gUmVzb2x1dGlvbiBvcmRlcjogZmxhZywgYE5WQ0ZfQ0xJX0NMVVNURVJfVkFMSURB" + + "VE9SX0lNQUdFYCwgY29uZmlnIGtleSBgY2x1c3Rlcl92YWxpZGF0b3JfaW1hZ2VgLiBBIHJlZiB3" + + "aXRoIG5vIHRhZyBkaXNjb3ZlcnMgdGhlIGxhdGVzdCBzdGFibGUgdGFnIGZyb20gdGhlIHJlZ2lz" + + "dHJ5LiBJZiBubyB0YWcgY2FuIGJlIGRpc2NvdmVyZWQsIHRoZSB2YWxpZGF0b3IgZG9lcyBub3Qg" + + "cnVuIGFuZCBpdHMgcm93IGZhaWxzIHRoZSBjaGVjaywgc2F5aW5nIHRvIHBpbiBhIHRhZzsgYC0t" + + "d2FpdGAgdHJpZXMgYWdhaW4gb24gZXZlcnkgcG9sbC4gVW5zZXQgZXZlcnl3aGVyZSwgdGhlIHZh" + + "bGlkYXRvciBkb2VzIG5vdCBydW4gYW5kIGVhY2ggcm9sZSdzIGBjbHVzdGVyLXZhbGlkYXRvcmAg" + + "cm93IGlzIGEgd2FybmluZywgc28gdGhlIHZlcmRpY3QgaXMgYHdhcm5pbmdzYCB8IC0gfAp8IGAt" + + "LWNsdXN0ZXItdmFsaWRhdG9yLXJlZ2lzdHJpZXMgaG9zdFs6cG9ydF1bL3BhdGhdLC4uLmAgfCBF" + + "eHRyYSByZWdpc3RyaWVzIHRvIGNoZWNrOiB0aGVpciBjcmVkZW50aWFscyBmcm9tIHRoaXMgbWFj" + + "aGluZSwgYW5kIHRoZWlyIHJlYWNoYWJpbGl0eSBmcm9tIGEgcG9kIG9mIHRoZSBjb250cm9sLXBs" + + "YW5lIHZhbGlkYXRvci4gQSBwYXRoIHNjb3BlcyB0aGUgY3JlZGVudGlhbCBjaGVjaywgYXMgaW4g" + + "YGhhcmJvci5leGFtcGxlLmNvbS9udmNmYC4gVGhleSBhcmUgYWRkZWQgdG8gdGhlIHJlZ2lzdHJp" + + "ZXMgdGhlIGluc3RhbGwgcHVsbHMgZnJvbTogdGhlIHZhbGlkYXRvciBpbWFnZSdzIHJlZ2lzdHJ5" + + "LCB0aGUgc3RhY2sncyBgZ2xvYmFsLmltYWdlLnJlZ2lzdHJ5YCBhbmQgYGdsb2JhbC5pbWFnZS5y" + + "ZXBvc2l0b3J5YCwgaXRzIE9DSSBjaGFydCBzb3VyY2UsIGFuZCBgcXVheS5pb2AgZm9yIHRoZSBj" + + "ZXJ0LW1hbmFnZXIgQUNNRSBzb2x2ZXIgdW5sZXNzIHRoZSBzdGFjayBsZWF2ZXMgY2VydC1tYW5h" + + "Z2VyIG91dCBvciBzZXRzIGBjZXJ0TWFuYWdlci5hY21lc29sdmVyLmltYWdlYC4gYG52Y3IuaW9g" + + "IGlzIHByb2JlZCwgYXMgYSB3YXJuaW5nIG9ubHksIHdoZW4gbmVpdGhlciB0aGUgaW1hZ2Ugbm9y" + + "IHRoZSBzdGFjayBuYW1lcyBhIHJlZ2lzdHJ5LiBUaGUgaW4tcG9kIHByb2JlcyBsZWF2ZSBvdXQg" + + "YSByZWdpc3RyeSBvbmx5IHRoZSBjaGFydHMgY29tZSBmcm9tLCBzaW5jZSBgaGVsbWAgcHVsbHMg" + + "dGhlbSBvbiB0aGlzIG1hY2hpbmUsIGFuZCBhcmUgd2FybmluZ3Mgb25seSwgc2luY2UgdGhlIHBv" + + "ZCBoYXMgbm8gcHJveHkuIEEgbWFsZm9ybWVkIGVudHJ5IGZhaWxzIHRoZSBjb21tYW5kLiBSZXBl" + + "YXRhYmxlIG9yIGNvbW1hLXNlcGFyYXRlZC4gRW52OiBgTlZDRl9DTElfQ0xVU1RFUl9WQUxJREFU" + + "T1JfUkVHSVNUUklFU2A7IGNvbmZpZyBrZXkgYGNsdXN0ZXJfdmFsaWRhdG9yX3JlZ2lzdHJpZXNg" + + "LCBhIGxpc3Qgb2Ygc3RyaW5ncyBpbiB0aGlzIGZvcm0gfCAtIHwKfCBgLS1jbHVzdGVyLXZhbGlk" + + "YXRvci1wcm9iZS1pbWFnZSBSRUZgIHwgSW1hZ2UgZm9yIHRoZSBub2RlIGlub3RpZnkgcHJvYmUg" + + "YW5kIHRoZSBjb250cm9sLXBsYW5lIHZhbGlkYXRvcidzIG5vZGUtdG8tbm9kZSBvdmVybGF5IHBy" + + "b2JlLiBOZWVkcyBgc2hgIGFuZCBhIGJ1c3lib3gtc3R5bGUgYG5jYC4gU2V0IGEgbWlycm9yIGZv" + + "ciBhaXItZ2FwcGVkIGNsdXN0ZXJzLiBFbnY6IGBOVkNGX0NMSV9DTFVTVEVSX1ZBTElEQVRPUl9Q" + + "Uk9CRV9JTUFHRWA7IGNvbmZpZyBrZXkgYGNsdXN0ZXJfdmFsaWRhdG9yX3Byb2JlX2ltYWdlYCB8" + + "IGBidXN5Ym94OjEuMzZgIGZyb20gRG9ja2VyIEh1YiB8CnwgYC0tY2x1c3Rlci12YWxpZGF0b3It" + + "ZXh0ZXJuYWwtY29tcG9uZW50cyBOQU1FLC4uLmAgfCBTdGFjayBkZXBlbmRlbmNpZXMgdGhhdCBy" + + "dW4gb3V0c2lkZSB0aGUgc3RhY2ssIG9yIG5vdCBhdCBhbGwsIHNvIHRoZSB2YWxpZGF0b3IgZG9l" + + "cyBub3QgbG9vayBmb3IgdGhlbSBpbiB0aGUgY2x1c3RlcjogYSBzdWJzZXQgb2YgYG5hdHNgLCBg" + + "b3BlbmJhb2AgYW5kIGBjYXNzYW5kcmFgLiBBbiB1bmtub3duIG5hbWUgZmFpbHMgdGhlIGNvbW1h" + + "bmQuIFJlcGVhdGFibGUgb3IgY29tbWEtc2VwYXJhdGVkLiBGYWxscyBiYWNrIHRvIGBOVkNGX0VY" + + "VEVSTkFMX0NPTVBPTkVOVFNgLCB0aGVuIHRvIHRoZSBjb21wb25lbnRzIHdob3NlIGA8bmFtZT4u" + + "ZW5hYmxlZGAgdGhlIHN0YWNrJ3MgZW52aXJvbm1lbnQgZmlsZSBzZXRzIHRvIGFueXRoaW5nIGJ1" + + "dCBgdHJ1ZWAuIEVudjogYE5WQ0ZfQ0xJX0NMVVNURVJfVkFMSURBVE9SX0VYVEVSTkFMX0NPTVBP" + + "TkVOVFNgOyBjb25maWcga2V5IGBjbHVzdGVyX3ZhbGlkYXRvcl9leHRlcm5hbF9jb21wb25lbnRz" + + "YCwgYSBsaXN0IG9mIG5hbWVzIHwgZnJvbSB0aGUgc3RhY2sgfAp8IGAtLWNsdXN0ZXItdmFsaWRh" + + "dG9yLXRvbGVyYXRpb25zIGtleVs9dmFsdWVdWzplZmZlY3RdLC4uLmAgfCBUb2xlcmF0aW9ucyBh" + + "ZGRlZCB0byB0aGUgdmFsaWRhdG9yIEpvYiwgYmVzaWRlIHRoZSBjb250cm9sLXBsYW5lIG9uZXMg" + + "aXQgYWx3YXlzIGNhcnJpZXMsIGZvciBjbHVzdGVycyB3aG9zZSBub2RlcyB1c2Ugb3RoZXIgdGFp" + + "bnRzLiBFZmZlY3QgaXMgYE5vU2NoZWR1bGVgLCBgUHJlZmVyTm9TY2hlZHVsZWAgb3IgYE5vRXhl" + + "Y3V0ZWAuIEEgbWFsZm9ybWVkIGVudHJ5IGZhaWxzIHRoZSBjb21tYW5kLiBSZXBlYXRhYmxlIG9y" + + "IGNvbW1hLXNlcGFyYXRlZC4gRW52OiBgTlZDRl9DTElfQ0xVU1RFUl9WQUxJREFUT1JfVE9MRVJB" + + "VElPTlNgOyBjb25maWcga2V5IGBjbHVzdGVyX3ZhbGlkYXRvcl90b2xlcmF0aW9uc2AsIGEgbGlz" + + "dCBvZiBzdHJpbmdzIGluIHRoaXMgZm9ybSwgbm90IEt1YmVybmV0ZXMgdG9sZXJhdGlvbiBvYmpl" + + "Y3RzIHwgLSB8CnwgYC0tY2x1c3Rlci12YWxpZGF0b3ItcHVsbC1zZWNyZXQgTkFNRWAgfCBkb2Nr" + + "ZXItcmVnaXN0cnkgU2VjcmV0IGluIGBkZWZhdWx0YCB1c2VkIHRvIHB1bGwgdGhlIHZhbGlkYXRv" + + "ciBpbWFnZS4gV2hlbiBlbXB0eSwgdGhlIENMSSB1c2VzIGEgU2VjcmV0IGFuIG9wZXJhdG9yIGNy" + + "ZWF0ZWQgaW4gYGRlZmF1bHRgIGZvciB0aGUgaW1hZ2UncyByZWdpc3RyeS4gRmFpbGluZyB0aGF0" + + "LCBpdCBjb3BpZXMgdGhlIHJlZ2lzdHJ5J3MgZW50cnksIGFuZCBvbmx5IHRoYXQgZW50cnksIGZy" + + "b20gYSBTZWNyZXQgaW4gdGhlIE5WQ0YgbmFtZXNwYWNlcyBpbnRvIGBkZWZhdWx0YCBmb3IgdGhl" + + "IHJ1bi4gRmFpbGluZyB0aGF0LCBpdCBjcmVhdGVzIG9uZSBmb3IgdGhlIHJ1biBmcm9tIHRoaXMg" + + "bWFjaGluZSdzIGNyZWRlbnRpYWwgZm9yIHRoZSByZWdpc3RyeSwgdGhlIG9uZSB0aGUgYHJlZ2lz" + + "dHJ5LWNyZWRlbnRpYWxzYCByb3cgY2hlY2tzLiBUaGUgdmFsaWRhdG9yIHJvdydzIGRldGFpbCBz" + + "YXlzIHdoYXQgd2FzIGNvcGllZCBvciBjcmVhdGVkLCBhbmQgZnJvbSB3aGVyZSB8IGF1dG8tZGV0" + + "ZWN0IHwKfCBgLS1za2lwLWNsdXN0ZXItdmFsaWRhdGlvbmAgfCBTa2lwIHRoZSBpbi1jbHVzdGVy" + + "IHZhbGlkYXRvciBwcm9iZSBlbnRpcmVseS4gQSB2YWxpZGF0b3IgdGhhdCBpcyBjb25maWd1cmVk" + + "IGJ1dCBjYW5ub3QgcnVuIGZhaWxzIHRoZSBjaGVjaywgc28gdXNlIHRoaXMgdG8gb3B0IG91dCBl" + + "eHBsaWNpdGx5LCBmb3IgZXhhbXBsZSB3aGVuIHRoZSBjbHVzdGVyIGNhbm5vdCBwdWxsIHRoZSBp" + + "bWFnZS4gRWFjaCByb2xlJ3MgYGNsdXN0ZXItdmFsaWRhdG9yYCByb3cgdGhlbiBwYXNzZXMgYXQg" + + "aW5mbyBhbmQgc2F5cyBpdCB3YXMgc2tpcHBlZC4gRW52OiBgTlZDRl9DTElfU0VMRkhPU1RFRF9T" + + "S0lQX0NMVVNURVJfVkFMSURBVElPTmAsIG9uIHdpdGggYHRydWVgLCBgMWAsIGB5ZXNgLCBgeWAg" + + "b3IgYG9uYCBhbmQgb2ZmIHdpdGggYGZhbHNlYCwgYDBgLCBgbm9gLCBgbmAgb3IgYG9mZmAsIGlu" + + "IGFueSBjYXNlLiBBbnkgb3RoZXIgdmFsdWUgaXMgYW4gZXJyb3IgfCBgZmFsc2VgIHwKfCBgLS1u" + + "by1jbGVhbnVwYCB8IEtlZXAgdGhlIHZhbGlkYXRvciBKb2IsIGl0cyBwb2QsIFJCQUMsIHB1bGwg" + + "c2VjcmV0IGFuZCBDb25maWdNYXAgZm9yIGRlYnVnZ2luZy4gQSBrZXB0IEpvYiB3aG9zZSBwb2Qg" + + "Y2Fubm90IHB1bGwgaXRzIGltYWdlIGlzIHN1c3BlbmRlZCwgc28gaXRzIHBvZCBpcyBkZWxldGVk" + + "IHJhdGhlciB0aGFuIHJldHJ5aW5nIGZvcmV2ZXI7IGluc3BlY3QgaXQgd2l0aCBga3ViZWN0bCBk" + + "ZXNjcmliZSBqb2JgLiBBIGxhdGVyIGNoZWNrIHRoYXQgcnVucyB0aGUgdmFsaWRhdG9yIHJlbW92" + + "ZXMgdGhlbSBvbmNlIHRoZXkgYXJlIDI0IGhvdXJzIG9sZC4gVGhlIHJvdywgdGhlIGBmaW5hbGAg" + + "ZXZlbnQncyBgY2xlYW51cGAgbGlzdCBhbmQgdGhlIGxhc3Qgc3RkZXJyIGxpbmVzIHByaW50IHRo" + + "ZSBjb21tYW5kIHRoYXQgcmVtb3ZlcyB0aGVtIG5vdy4gQ2Fubm90IGJlIGNvbWJpbmVkIHdpdGgg" + + "YC0td2FpdGAgfCBgZmFsc2VgIHwKfCBgLS1zaG93LWxvZ3NgIHwgUHJpbnQgZWFjaCB2YWxpZGF0" + + "b3IgdHJhbnNjcmlwdCB0byBzdGRlcnIgYWZ0ZXIgdGhlIGNoZWNrIGV2ZW50cywgZnJhbWVkIGJ5" + + "IGAtLS0gY2x1c3Rlci12YWxpZGF0b3IgbG9ncyAoPGNhdGVnb3J5PikgLS0tYCBhbmQgYSBtYXRj" + + "aGluZyBlbmQgbGluZSwgd2hlcmUgdGhlIGNhdGVnb3J5IGlzIGBjb250cm9sLXBsYW5lLWNsdXN0" + + "ZXJgIG9yIGBjb21wdXRlLXBsYW5lLWNsdXN0ZXJgLiBUaGUgdHJhbnNjcmlwdCBpcyBub3QgSlNP" + + "TiwgYW5kIGAtLWpzb25gIGFsc28gd3JpdGVzIHRvIHN0ZGVyciwgc28gbGVhdmUgdGhpcyBvZmYg" + + "d2hlbiBhIHBhcnNlciBpcyByZWFkaW5nIHRoZSBzdHJlYW0gfCBgZmFsc2VgIHwKCiMjIyBSZWdp" + + "c3RyeSBjcmVkZW50aWFscwoKYGNoZWNrYCBwcm9iZXMgZWFjaCByZWdpc3RyeSBhYm92ZSBmcm9t" + + "IHRoaXMgbWFjaGluZSB3aXRoIGl0cyBsb2NhbApjcmVkZW50aWFsLCB0aGUgb25lIGBkb2NrZXJg" + + "IHdvdWxkIHVzZTogYCRET0NLRVJfQ09ORklHL2NvbmZpZy5qc29uYCwgb3IKYH4vLmRvY2tlci9j" + + "b25maWcuanNvbmAsIHRocm91Z2ggYW55IGBjcmVkc1N0b3JlYCBvciBgY3JlZEhlbHBlcnNgIGhl" + + "bHBlci4KRm9yIGBudmNyLmlvYCB0aGUgTkdDIEFQSSBrZXkgaXMgYWxzbyByZWFkLCBmcm9tIHRo" + + "ZSBmaXJzdCBvZgpgTkdDX0lNQUdFX1BVTExfQVBJX0tFWWAsIGBOVkNGX05HQ1JfQVBJX0tFWWAs" + + "IGBOVkNGX05HQ19BUElfS0VZYCBhbmQKYE5HQ19BUElfS0VZYCB0aGF0IGlzIHNldC4gSXQgZ29l" + + "cyBhaGVhZCBvZiB0aGUgZG9ja2VyIGxvZ2luIG9ubHkgd2hlbiB0aGUKcmVnaXN0cnkgY2hlY2sn" + + "cyBwbGFuZSwgYmVsb3csIGlzIHRoZSBjb250cm9sIHBsYW5lLCBjaGVja2VkIGJlZm9yZSBpdHMK" + + "aW5zdGFsbCB3aXRoIHRoZSBgbG9jYWxgIGVudmlyb25tZW50LCBzaW5jZSBgdXBgIGNyZWF0ZXMg" + + "aXRzIHB1bGwgc2VjcmV0cwpmcm9tIGl0LCBhbmQgaXQgaXMgbmV2ZXIgc2VudCB0byBhbm90aGVy" + + "IHJlZ2lzdHJ5LiBUaGVyZSB0aGUgZG9ja2VyIGxvZ2luIGlzCnN0aWxsIHNlbnQgZm9yIHRoZSBy" + + "b3cgdGhlIHN0YWNrJ3MgY2hhcnRzIGNvbWUgZnJvbSwgYmVsb3csIGFuZCBncmFkZWQgYXMKZWxz" + + "ZXdoZXJlLCBzaW5jZSBgaGVsbWAgcHVsbHMgdGhlIGNoYXJ0cyB3aXRoIGl0LiBFbHNld2hlcmUg" + + "dGhlIGtleQppcyBzZW50IHdoZW4gdGhlcmUgaXMgbm8gZG9ja2VyIGxvZ2luLCBvciBmb3IgYSBy" + + "ZXBvc2l0b3J5IHdoZXJlIGBudmNyLmlvYApyZWplY3RzIHRoZSBkb2NrZXIgbG9naW4gKGEgNDAx" + + "KSBvciBnaXZlcyBpdCBubyBhY2Nlc3MgKGEgNDAzKS4gVGhpcyBpcwpkZWNpZGVkIG9uY2UgcGVy" + + "IHJlcG9zaXRvcnksIGJlZm9yZSB0YWcgZGlzY292ZXJ5LCB0aGUgcm93IGFuZCB0aGUKdmFsaWRh" + + "dG9yJ3MgcHVsbCBzZWNyZXQgdXNlIGl0LCBzbyBhbGwgdGhyZWUgZ2V0IHRoZSBzYW1lIGNyZWRl" + + "bnRpYWwsIGFuZCBhCmxvZ2luIHdpdGggbm8gYWNjZXNzIHRvIG9uZSBvcmcgc3RpbGwgc2VydmVz" + + "IHRoZSBvcmdzIGl0IHJlYWNoZXMuIEEgNDAzIGlzCnJlcG9ydGVkIGFzIG5vIGFjY2VzcyB0byB0" + + "aGF0IHJlcG9zaXRvcnksIG5ldmVyIGFzIGEgcmVqZWN0ZWQgbG9naW4uIEVhY2gKYC0td2FpdGAg" + + "cG9sbCByZWFkcyB0aGUgY3JlZGVudGlhbHMgYWdhaW4sIHNvIGEgbG9naW4gcmVuZXdlZCB3aGls" + + "ZSB0aGUgcnVuCndhaXRzIGNvdW50cyBvbiB0aGUgbmV4dCBwb2xsLgoKVGhlIHByb2JlIGFza3Mg" + + "Zm9yIHB1bGwgYWNjZXNzIHRvIHRoZSB2YWxpZGF0b3IgaW1hZ2UncyByZXBvc2l0b3J5IGFuZCB0" + + "byB0aGUKc3RhY2sncyBpbWFnZSBwYXRoOiBhbnkgcGF0aCBpbiBgZ2xvYmFsLmltYWdlLnJlZ2lz" + + "dHJ5YCwgdGhlbgpgZ2xvYmFsLmltYWdlLnJlcG9zaXRvcnlgLiBBIHN0YWNrIHBhdGggb2Ygb25l" + + "IHNlZ21lbnQsIHN1Y2ggYXMgYW4gb3JnIHdpdGggbm8KdGVhbSwgaXMgbm90IHNlbnQgYXMgdGhl" + + "IHNjb3BlLCBzaW5jZSBgbnZjci5pb2AgcmVmdXNlcyBpdC4gVGhlIHN0YWNrJ3MgT0NJCmNoYXJ0" + + "IHNvdXJjZSwgYGdsb2JhbC5oZWxtLnNvdXJjZXMucmVnaXN0cnlgIGFuZCBgLnJlcG9zaXRvcnlg" + + "LCByZWFkIHRoZSBzYW1lCndheSwgaXMgdGhlIHJvdyBgaGVsbWAgb24gdGhpcyBtYWNoaW5lIHB1" + + "bGxzIHRoZSBzdGFjaydzIGNoYXJ0cyBmcm9tLCB3aXRoIHRoZQpkb2NrZXIgbG9naW46IHRoZSBp" + + "bWFnZSByb3cgd2hlbiBpdCBpcyB0aGUgc2FtZSBwYXRoLCBlbHNlIGEgcm93IG9mIGl0cyBvd24s" + + "CmNyaXRpY2FsIGZvciBhbiBOVklESUEgcmVnaXN0cnkgbGlrZSB0aGUgaW1hZ2Ugcm93LiBBbiBI" + + "VFRQUyBjaGFydCBzb3VyY2UsCmBnbG9iYWwuaGVsbS5zb3VyY2VzLnVybGAsIGhhcyBjcmVkZW50" + + "aWFscyBvZiBpdHMgb3duIGFuZCBnZXRzIG5vIHJvdy4KClRoZSByZWdpc3RyeSBjaGVjayBiZWxv" + + "bmdzIHRvIG9uZSBwbGFuZSwgd2hvc2Ugc3RhY2sgbmFtZXMgdGhlIHJlZ2lzdHJpZXMKYW5kIHdo" + + "b3NlIGluc3RhbGwgc3RhdGUgZ3JhZGVzIHRoZW06IGEgcGxhbmUgdGhlIHJ1biBjaGVja3MgYmVm" + + "b3JlIGl0cwppbnN0YWxsLCB3aGljaCB0aGlzIG1hY2hpbmUgaW5zdGFsbHMgbmV4dCAodGhlIGNv" + + "bnRyb2wgcGxhbmUgZm9yIGEgYmFyZQpgLS1wcmVgIGFuZCBmb3IgYC0tcHJlIC0tY29tcHV0ZS1w" + + "bGFuZWAsIHRoZSBjb21wdXRlIHBsYW5lIGZvcgpgLS1wcmUgLS1jb250cm9sLXBsYW5lYCksIGVs" + + "c2UgdGhlIGNvbnRyb2wgcGxhbmUgd2hlbiB0aGUgcnVuIHZpc2l0cyBpdCwKZWxzZSB0aGUgY29t" + + "cHV0ZSBwbGFuZS4KCk9ubHkgYSBjcmVkZW50aWFsIHRoZSByZWdpc3RyeSByZWplY3RzIGZhaWxz" + + "IHRoZSBydW4sIGFuZCBvbmx5IGZvciBhbiBOVklESUEKcmVnaXN0cnkgdGhlIGltYWdlIG9yIHRo" + + "ZSBzdGFjayBuYW1lcy4gVGhhdCBpbmNsdWRlcyBhIGRvY2tlciBsb2dpbgpgbnZjci5pb2AgcmVq" + + "ZWN0cyBldmVuIHdoZW4gdGhlIE5HQyBrZXkgd29ya3MgaW4gaXRzIHBsYWNlLCBiZWZvcmUgYW5k" + + "IGFmdGVyCmluc3RhbGwsIHNpbmNlIGBkb2NrZXJgIGFuZCBgaGVsbWAgb24gdGhpcyBtYWNoaW5l" + + "IHN0aWxsIHNlbmQgaXQuIFRoZSBrZXkgaXMKc3RpbGwgdXNlZCBmb3IgdGFnIGRpc2NvdmVyeSBh" + + "bmQgdGhlIHZhbGlkYXRvcidzIHB1bGwgc2VjcmV0LCBhbmQgdGhlIHJvdwpuYW1lcyBib3RoLiBB" + + "ZnRlciBpbnN0YWxsIHRoZSByb3cgYWxzbyBzYXlzIHdoZXRoZXIgb25lIG9mIHRoZSBwdWxsIHNl" + + "Y3JldHMKYmVsb3cgaG9sZHMgdGhhdCBkb2NrZXIgbG9naW4uIEJlZm9yZSBpbnN0YWxsLCBhIGRv" + + "Y2tlciBsb2dpbiB3aXRoIG5vIGFjY2Vzcwp0byB0aGUgcm93IHRoZSBzdGFjaydzIGNoYXJ0cyBj" + + "b21lIGZyb20gZmFpbHMgaXQgdG9vLCBhbHRob3VnaCB0aGUgTkdDIGtleQpyZWFjaGVzIGl0LCBz" + + "aW5jZSBgaGVsbWAgcHVsbHMgdGhlIGNoYXJ0cyB3aXRoIHRoZSBkb2NrZXIgbG9naW4uIFRoYXQg" + + "aXMKcmVwb3J0ZWQgYXMgbm8gYWNjZXNzLCBub3QgYXMgYSByZWplY3RlZCBsb2dpbi4gRWxzZXdo" + + "ZXJlLCBhbmQgYWZ0ZXIgaW5zdGFsbCwKYSBkb2NrZXIgbG9naW4gd2l0aCBubyBhY2Nlc3MgcGFz" + + "c2VzIHdoZXJlIHRoZSBOR0Mga2V5IHJlYWNoZXMgdGhlCnJlcG9zaXRvcnkuIEEgcmVqZWN0ZWQg" + + "TkdDIGtleSBpcyBhIHdhcm5pbmcgYWZ0ZXIgaW5zdGFsbCBvbmx5IHdoZW4gYGNoZWNrYApyZWFk" + + "cyB0aGUgcHVsbCBzZWNyZXRzIG9mIHRoZSBjbHVzdGVycyBpdCB2aXNpdHMsIGluIHRoZSBuYW1l" + + "c3BhY2VzIHRoZQp2YWxpZGF0b3IgY29waWVzIHB1bGwgc2VjcmV0cyBmcm9tLCBhbmQgbm9uZSBv" + + "ZiB0aGVtIGhvbGRzIHRoYXQga2V5OyB3aGVuCm9uZSBkb2VzLCBvciB0aGV5IGNhbm5vdCBhbGwg" + + "YmUgcmVhZCwgaXQgaXMgYW4gZXJyb3IuIEEgcmVnaXN0cnkgdGhpcyBtYWNoaW5lCmNhbm5vdCBy" + + "ZWFjaCwgYSB0b2tlbiBzZXJ2aWNlIHRoYXQgZmFpbHMsIGFuZCBhIG1pc3NpbmcgbG9jYWwgY3Jl" + + "ZGVudGlhbCBhcmUKd2FybmluZ3MuIEEKcmVnaXN0cnkgdGhlIHByb2JlIGNhbm5vdCBzcGVhayB0" + + "bywgc3VjaCBhcyBFQ1Igb3Igb25lIHVzaW5nIEJhc2ljIGF1dGgsIGlzCnNraXBwZWQgd2l0aCBh" + + "IGNvbW1hbmQgdG8gY2hlY2sgaXQgYnkgaGFuZC4gQSByZWdpc3RyeSB0aGF0IGxldHMgdGhpcwpt" + + "YWNoaW5lIGluIGFub255bW91c2x5IGlzIHJlcG9ydGVkIGFzIHN1Y2gsIG5vdCBhcyB2YWxpZCBj" + + "cmVkZW50aWFscy4KCiMjIyBTZXR0aW5ncyByZWFkIGZyb20gdGhlIHN0YWNrCgpUaGUgdmFsaWRh" + + "dG9yIGdldHMgdGhlc2Ugc2V0dGluZ3MgZnJvbSB0aGUgQ0xJLiBFYWNoIG9uZSBzZXQgd2l0aCBp" + + "dHMKZW52aXJvbm1lbnQgdmFyaWFibGUgb3IgY29uZmlnIGtleSB3aW5zIG92ZXIgdGhlIHN0YWNr" + + "LgoKfCBTZXR0aW5nIHwgUmVhZCBmcm9tIHRoZSBzdGFjayB8IEVudiBhbmQgY29uZmlnIGtleSB8" + + "CnwtLS18LS0tfC0tLXwKfCBOVkNGIEdhdGV3YXlzIHwgYGluZ3Jlc3MuZ2F0ZXdheUFwaS5nYXRl" + + "d2F5c2AsIGdhdGVkIHRoZSB3YXkgdGhlIHN0YWNrIHdpcmVzIHJvdXRlczogbm9uZSB1bmxlc3Mg" + + "YGluZ3Jlc3MuZ2F0ZXdheUFwaS5lbmFibGVkYCBpcyBgdHJ1ZWAsIGBuYXRzYCBvbmx5IHdpdGgg" + + "YHJvdXRlcy5uYXRzLmVuYWJsZWRgLCBgbGxtR3JwY2AgYW5kIGBsbG1RdWljYCBvbmx5IHdpdGgg" + + "YHJvdXRlcy5sbG1Xb3JrZXIuZW5hYmxlZGAgfCBgTlZDRl9HQVRFV0FZX05BTUVTYCwgYXMgY29t" + + "bWEtc2VwYXJhdGVkIGBuYW1lc3BhY2UvbmFtZWAgZW50cmllcy4gQSBiYXJlIG5hbWUgaXMgZHJv" + + "cHBlZCBieSB0aGUgdmFsaWRhdG9yLiBTZXQgdGhpcyB3YXkgaXQgc2tpcHMgdGhlIHN0YWNrJ3Mg" + + "Z2F0ZXMgfAp8IEVudm95IEdhdGV3YXkgbmFtZXNwYWNlIHwgYGluZ3Jlc3MuZ2F0ZXdheUFwaS5j" + + "b250cm9sbGVyTmFtZXNwYWNlYCB8IGBOVkNGX0VOVk9ZX0dBVEVXQVlfTkFNRVNQQUNFYCB8Cnwg" + + "RXh0ZXJuYWwgY29tcG9uZW50cyB8IGBuYXRzYCwgYG9wZW5iYW9gIG9yIGBjYXNzYW5kcmFgIHdo" + + "b3NlIGBlbmFibGVkYCBpcyBzZXQgYW5kIGlzIG5vdCBgdHJ1ZWAgfCBgLS1jbHVzdGVyLXZhbGlk" + + "YXRvci1leHRlcm5hbC1jb21wb25lbnRzYCwgb3IgYE5WQ0ZfRVhURVJOQUxfQ09NUE9ORU5UU2Ag" + + "fAp8IFN0b3JhZ2VDbGFzcyB8IGBnbG9iYWwuc3RvcmFnZUNsYXNzYC4gV2hlbiBzZXQsIHRoZSB2" + + "YWxpZGF0b3IgY2hlY2tzIHRoYXQgdGhpcyBjbGFzcyBleGlzdHMgaW5zdGVhZCBvZiByZXF1aXJp" + + "bmcgYSBkZWZhdWx0IGNsYXNzIHwgYE5WQ0ZfU1RPUkFHRV9DTEFTU2AgfAp8IE9wZW5CYW8gbmFt" + + "ZXNwYWNlIHwgLSB8IGBOVkNGX09QRU5CQU9fTkFNRVNQQUNFYCB8CnwgT3ZlcmxheSBwcm9iZSBp" + + "bWFnZSB8IC0gfCBgLS1jbHVzdGVyLXZhbGlkYXRvci1wcm9iZS1pbWFnZWAsIG9yIGBOVkNGX04y" + + "Tl9QUk9CRV9JTUFHRWAgfAoKVGhlIHJlZ2lzdHJpZXMgYWJvdmUgYWxzbyBjb21lIGZyb20gdGhl" + + "IHN0YWNrLiBgY2hlY2tgIHJlYWRzIHN0YWNrIHZhbHVlcyBvbmx5CndoZW4gdGhleSBkZXNjcmli" + + "ZSB0aGUgaW5zdGFsbDoKCi0gVGhlIGVudmlyb25tZW50IGlzIG5hbWVkIHdpdGggYC0tZW52YCBv" + + "ciBgSEVMTUZJTEVfRU5WYC4gVGhlIGAtLWVudmAKICBkZWZhdWx0IGlzIHVzZWQgb25seSBmb3Ig" + + "YSBwbGFuZSBgLS1wcmVgIGNoZWNrcyBiZWZvcmUgaXRzIGluc3RhbGwsCiAgc2luY2UgYHVwYCBp" + + "bnN0YWxscyB3aXRoIGl0LiBPdGhlcndpc2UgcGFzcyB0aGUgYC0tZW52YCB0aGUgaW5zdGFsbCB1" + + "c2VkLgotIGBlbnZpcm9ubWVudHMvPGVudj4ueWFtbGAgaXMgcmVhZCwgbGF5ZXJlZCBvdmVyIGBi" + + "YXNlLnlhbWxgLiBgYmFzZS55YW1sYAogIGFsb25lIGlzIG5vdCB1c2VkLgotIFRoZSBmaWxlIGNv" + + "bWVzIGZyb20gdGhlIHN0YWNrIHRoZSBpbnN0YWxsIHVzZWQ6IGAtLWNvbnRyb2wtcGxhbmUtc3Rh" + + "Y2tgCiAgKGAtLWNvbXB1dGUtcGxhbmUtc3RhY2tgIGZvciB0aGUgcmVnaXN0cmllcyB3aGVuIHRo" + + "ZSByZWdpc3RyeSBjaGVjawogIGJlbG9uZ3MgdG8gdGhlIGNvbXB1dGUgcGxhbmUpLCBlbHNlIHRo" + + "ZSBDTEkncyBidWlsdC1pbiBzdGFjay4gQSBsb2NhbAogIGRpcmVjdG9yeSBpcyByZWFkIGRpcmVj" + + "dGx5LCBhbmQgYW4gYG9jaTovL2Agc3RhY2sgZnJvbSB0aGUgY29weSBhbiBlYXJsaWVyCiAgY29t" + + "bWFuZCBleHRyYWN0ZWQgaW50byB0aGUgQ0xJIGNhY2hlLiBBIGdpdCBzdGFjaywgYGZpbGU6Ly9g" + + "IGluY2x1ZGVkLCBpcwogIG5vdCByZWFkLCBzaW5jZSB0aGUgaW5zdGFsbCBjbG9uZWQgaXRzIGNv" + + "bW1pdHRlZCBzdGF0ZS4gQSBDTEkgd2l0aCBubwogIGJ1aWx0LWluIHN0YWNrIGFuZCBubyBzdGFj" + + "ayBmbGFnIHJlYWRzIHRoZSBzdGFjayBjaGVja291dCBhYm92ZSB0aGUKICB3b3JraW5nIGRpcmVj" + + "dG9yeS4KCk90aGVyd2lzZSBub3RoaW5nIGZyb20gdGhlIHN0YWNrIGlzIGZvcndhcmRlZDogdGhl" + + "IHZhbGlkYXRvciBmaW5kcyB0aGUKR2F0ZXdheXMgZnJvbSB0aGUgcm91dGVzLCBhbmQgYG52Y3Iu" + + "aW9gIGlzIHByb2JlZCBhcyBhIHdhcm5pbmcgb25seS4KCiMjIGB1cGAtc3BlY2lmaWMKCnwgRmxh" + + "ZyB8IFB1cnBvc2UgfCBEZWZhdWx0IHwKfC0tLXwtLS18LS0tfAp8IGAtLWNsdXN0ZXItbmFtZSBO" + + "QU1FYCB8IElDTVMgY2x1c3RlciByb3cgaWRlbnRpZmllciAocmVxdWlyZWQpIHwgLSB8CnwgYC0t" + + "bmNhLWlkIElEYCB8IE5DQSBhY2NvdW50IElEIHwgYG52Y2YtZGVmYXVsdGAgfAp8IGAtLXJlZ2lv" + + "biBSRUdJT05gIHwgQ2x1c3RlciByZWdpb24gfCBgdXMtd2VzdC0xYCB8CnwgYC0tcGxhbi1vbmx5" + + "YCB8IERyeS1ydW47IGVtaXQgcGhhc2UgcGxhbiArIEVUQXMgd2l0aG91dCBjaGFuZ2VzIHwgYGZh" + + "bHNlYCB8CgojIyBgc3RhdHVzYC1zcGVjaWZpYwoKfCBGbGFnIHwgUHVycG9zZSB8IERlZmF1bHQg" + + "fAp8LS0tfC0tLXwtLS18CnwgYC0tY2x1c3Rlci1uYW1lIE5BTUVgIHwgTGltaXQgdG8gb25lIGNv" + + "bXB1dGUgcGxhbmUgfCAtIChjb250cm9sICsgYWxsIGNsdXN0ZXJzKSB8CnwgYC0td2F0Y2hgIHwg" + + "TGl2ZSByZS1yZW5kZXIgfCBgZmFsc2VgIHwKfCBgLS13YXRjaC1pbnRlcnZhbCBEVVJgIHwgYC0t" + + "d2F0Y2hgIGNhZGVuY2UgfCBgNXNgIHwKfCBgLS1jb21wb25lbnQgTkFNRWAgfCBGaWx0ZXIgY29t" + + "cG9uZW50cyBwYW5lbCB8IGFsbCB8CnwgYC0tbm8tZXZlbnRzYCB8IFNraXAgZXZlbnRzIHBhbmVs" + + "IHwgYGZhbHNlYCB8CgojIyBgY2x1c3RlciByZWdpc3RlcmAtc3BlY2lmaWMKCnwgRmxhZyB8IFB1" + + "cnBvc2UgfCBEZWZhdWx0IHwKfC0tLXwtLS18LS0tfAp8IGAtLW5hbWUgTkFNRWAgfCBDbHVzdGVy" + + "IG5hbWUgKHJlcXVpcmVkKSB8IC0gfAp8IGAtLW5jYS1pZCBJRGAgfCBOQ0EgSUQgKHJlcXVpcmVk" + + "KSB8IC0gfAp8IGAtLXJlZ2lvbiBSRUdJT05gIHwgUmVnaW9uIHwgYHVzLXdlc3QtMWAgfAp8IGAt" + + "LWljbXMtdXJsIFVSTGAgfCBJQ01TIGVuZHBvaW50IHwgZnJvbSBjb25maWcgfAp8IGAtLWlnbm9y" + + "ZS1leGlzdGluZ2AgfCBNYXRjaC1vci1jcmVhdGUgaW5zdGVhZCBvZiBmYWlsLW9uLWV4aXN0cyB8" + + "IGBmYWxzZWAgfAoKIyMgYGFwaS1rZXkgZ2VuZXJhdGVgLXNwZWNpZmljCgp8IEZsYWcgfCBQdXJw" + + "b3NlIHwgRGVmYXVsdCB8CnwtLS18LS0tfC0tLXwKfCBgLS1mb3IgZnVuY3Rpb25cfHRhc2tgIHwg" + + "R2VuZXJhdGUgb25seSB0aGUgc3BlY2lmaWVkIGtleSB0eXBlIHwgKG9taXQgdG8gZ2VuZXJhdGUg" + + "Ym90aCkgfAp8IGAtLWRlc2NyaXB0aW9uIFRFWFRgIHwgSHVtYW4tcmVhZGFibGUgbGFiZWwgZm9y" + + "IHRoZSBrZXkgfCBgR2VuZXJhdGVkIGJ5IG52Y2YtY2xpYCB8CnwgYC0tZXhwaXJlcy1pbiBEVVJB" + + "VElPTmAgfCBFeHBpcnkgb2Zmc2V0IChlLmcuIGAyNGhgLCBgN2RgKTsgcGFyc2VkIGFzIGV4dGVu" + + "ZGVkIGR1cmF0aW9uIHwgYDI0aGAgfAp8IGAtLXNjb3BlcyBTQ09QRVssU0NPUEVdYCB8IE92ZXJy" + + "aWRlIGRlZmF1bHQgc2NvcGVzOyByZXF1aXJlcyBgLS1mb3JgIHwgKHNlcnZpY2UgZGVmYXVsdHMp" + + "IHwKfCBgLS12YWxpZGF0ZWAgfCBWYWxpZGF0ZSBlYWNoIGdlbmVyYXRlZCBrZXkgaW1tZWRpYXRl" + + "bHkgYWZ0ZXIgbWludGluZyB8IGBmYWxzZWAgfAoKRXh0ZW5kZWQgZHVyYXRpb24gdW5pdHM6IGBz" + + "YCAoc2Vjb25kcyksIGBtYCAobWludXRlcyksIGBoYCAoaG91cnMpLCBgZGAgKGRheXMpLCBgd2Ag" + + "KHdlZWtzKS4KCiMjIGBmdW5jdGlvbiBjcmVhdGVgLXNwZWNpZmljCgpgLS1pbnB1dC1maWxlIEZJ" + + "TEVgIGlzIHJlY29tbWVuZGVkIGZvciByZXBlYXRhYmxlIGZ1bmN0aW9uIGRlZmluaXRpb25zLiBG" + + "b3IgYWQtaG9jIGNyZWF0ZXM6Cgp8IEZsYWcgfCBQdXJwb3NlIHwgRGVmYXVsdCB8CnwtLS18LS0t" + + "fC0tLXwKfCBgLS1uYW1lIE5BTUVgIHwgRnVuY3Rpb24gbmFtZSB8IC0gfAp8IGAtLWltYWdlIElN" + + "QUdFYCB8IENvbnRhaW5lciBpbWFnZSB8IC0gfAp8IGAtLWluZmVyZW5jZS11cmwgVVJJYCB8IENv" + + "bnRhaW5lciBpbmZlcmVuY2UgZW5kcG9pbnQgfCAtIHwKfCBgLS1pbmZlcmVuY2UtcG9ydCBQT1JU" + + "YCB8IENvbnRhaW5lciBpbmZlcmVuY2UgcG9ydCB8IC0gfAp8IGAtLWZ1bmN0aW9uLXR5cGUgREVG" + + "QVVMVFx8U1RSRUFNSU5HXHxMTE1gIHwgRnVuY3Rpb24gdHlwZSB8IGBERUZBVUxUYCB8CnwgYC0t" + + "aGVsbS1jaGFydCBDSEFSVGAgfCBIZWxtIGNoYXJ0IFVSTCBvciBPQ0kgcmVmZXJlbmNlLiBDYW4g" + + "YmUgdXNlZCB3aXRoIGAtLWZ1bmN0aW9uLXR5cGU9TExNYCBmb3IgY2hhcnQtcGFja2FnZWQgTExN" + + "IHdvcmtsb2Fkcy4gfCAtIHwKfCBgLS1oZWxtLWNoYXJ0LXNlcnZpY2UgTkFNRWAgfCBLdWJlcm5l" + + "dGVzIFNlcnZpY2UgbmFtZSBleHBvc2VkIGJ5IHRoZSBjaGFydC4gUmVxdWlyZWQgd2hlbiBgLS1o" + + "ZWxtLWNoYXJ0YCBpcyBzZXQuIHwgLSB8CnwgYC0tbW9kZWxzIE5BTUU6VkVSU0lPTjpVUklgIHwg" + + "U3RhbmRhcmQgbW9kZWwgYXJ0aWZhY3Q7IHJlcGVhdGFibGUgfCAtIHwKfCBgLS1sbG0tbW9kZWwg" + + "U1BFQ2AgfCBMTE0gbW9kZWwgY29uZmlnOyBmb3JtYXQgYG5hbWU9PG1vZGVsPix1cmlzPTx1cmk+" + + "XHw8dXJpPixyb3V0aW5nTWV0aG9kPTxtZXRob2Q+Wzs8cGFyYW0+PTx2YWx1ZT4uLi5dLHRva2Vu" + + "UmF0ZUxpbWl0PTxsaW1pdD5gOyByZXBlYXRhYmxlLiBUb2tlbiBsaW1pdHMgdXNlIGA8dmFsdWU+" + + "LTx1bml0PmAgd2l0aCBgU2AsIGBNYCwgYEhgLCBgRGAsIG9yIGBXYCwgZm9yIGV4YW1wbGUgYDEw" + + "MDAtU2AuIFVzZSBpbnB1dCBKU09OIGZvciBjb21iaW5lZCB0b2tlbiBsaW1pdHMgYmVjYXVzZSBp" + + "bmxpbmUgc3BlY3MgdXNlIGNvbW1hcyBhcyBmaWVsZCBzZXBhcmF0b3JzLiB8IC0gfAp8IGAtLWxs" + + "bS1kZWZhdWx0LXByaW9yaXR5IFBSSU9SSVRZYCB8IEZ1bmN0aW9uLWxldmVsIGRlZmF1bHQgcmVx" + + "dWVzdCBwcmlvcml0eS4gTG93ZXIgdmFsdWVzIGhhdmUgaGlnaGVyIHByaW9yaXR5LiB8IC0gfAp8" + + "IGAtLWxsbS1wZXItYWNjb3VudC1wcmlvcml0eSBOQ0EtSUQ6UFJJT1JJVFlgIHwgUGVyLWFjY291" + + "bnQgcHJpb3JpdHkgb3ZlcnJpZGU7IHJlcGVhdGFibGU7IHN1cHBvcnRzIHVwIHRvIDY0IGRpc3Rp" + + "bmN0IE5DQSBJRCBvdmVycmlkZXMuIFJlcXVpcmVzIGEgZGVmYXVsdCBwcmlvcml0eS4gfCAtIHwK" + + "CkluIEpTT04gYW5kIGlubGluZSBzcGVjcywgTExNIGZ1bmN0aW9ucyBzZXQgYGZ1bmN0aW9uVHlw" + + "ZTogIkxMTSJgIGFuZCBtb2RlbCByb3V0aW5nIG1ldGFkYXRhIHVuZGVyIGBtb2RlbHNbXS5sbG1D" + + "b25maWdgLiBgbGxtQ29uZmlnLnVyaXNgIGRlY2xhcmVzIHRoZSBPcGVuQUktY29tcGF0aWJsZSB1" + + "cHN0cmVhbSBwYXRocyBleHBvc2VkIGJ5IHRoZSBtb2RlbC4gQ3VycmVudCBzdXBwb3J0ZWQgcGF0" + + "aHMgYXJlIGAvdjEvY2hhdC9jb21wbGV0aW9uc2AsIGAvdjEvcmVzcG9uc2VzYCwgYW5kIGAvdjEv" + + "ZW1iZWRkaW5nc2AuIGBsbG1Db25maWcucm91dGluZ01ldGhvZGAgaXMgYSByb3V0aW5nIGFsZ29y" + + "aXRobSBuYW1lLCBvcHRpb25hbGx5IGZvbGxvd2VkIGJ5IGA7bmFtZT12YWx1ZWAgdHVuaW5nIHBh" + + "cmFtZXRlcnMsIHN1Y2ggYXMgYHB1bHNhcjtzZWVkPXN0YWJsZS1hYC4gVGhlIENMSSBzZW5kcyBp" + + "dCB1bmNoYW5nZWQ7IHRoZSBBUEkgcmVqZWN0cyBhIG1hbGZvcm1lZCB2YWx1ZSBhbmQgdGhlIENM" + + "SSBwcmludHMgdGhhdCBlcnJvci4gUXVvdGUgaW5saW5lIHNwZWNzIHRoYXQgY29udGFpbiBgO2Au" + + "CmBsbG1Db25maWcudG9rZW5SYXRlTGltaXRgIGFjY2VwdHMgb25lIG9yIG1vcmUgY29tbWEtc2Vw" + + "YXJhdGVkIHBvc2l0aXZlIGludGVnZXIgdG9rZW4gbGltaXRzIGluIGA8dmFsdWU+LTx1bml0PmAg" + + "Zm9ybWF0LiBTdXBwb3J0ZWQgdW5pdHMgYXJlIGBTYCAoc2Vjb25kcyksIGBNYCAobWludXRlcyks" + + "IGBIYCAoaG91cnMpLCBgRGAgKGRheXMpLCBhbmQgYFdgICh3ZWVrcykuIFVzZSBkaXN0aW5jdCB1" + + "bml0cyB3aGVuIGNvbWJpbmluZyBsaW1pdHMsIGZvciBleGFtcGxlIGAxMDAwLVMsNTAwMC1NLDEw" + + "MDAwMC1ILDUwMDAwMC1ELDEwMDAwMDAtV2AgaW4gaW5wdXQgSlNPTi4KCkhlbG0gY2hhcnQgcGFj" + + "a2FnaW5nIGlzIGluZGVwZW5kZW50IG9mIGBmdW5jdGlvblR5cGVgLiBGb3IgYSBIZWxtLWNoYXJ0" + + "IGJhY2tlZCBMTE0gZnVuY3Rpb24sIHNldCBgZnVuY3Rpb25UeXBlOiAiTExNImAsIGBoZWxtQ2hh" + + "cnRgLCBgaGVsbUNoYXJ0U2VydmljZU5hbWVgLCBhbmQgYG1vZGVsc1tdLmxsbUNvbmZpZ2AgaW4g" + + "dGhlIHNhbWUgY3JlYXRlIHJlcXVlc3QuIGBpbmZlcmVuY2VQb3J0YCBtdXN0IGJlIHRoZSBLdWJl" + + "cm5ldGVzIFNlcnZpY2UgcG9ydCBleHBvc2VkIGJ5IGBoZWxtQ2hhcnRTZXJ2aWNlTmFtZWAuCgpM" + + "TE0gaW52b2NhdGlvbiByZXF1ZXN0cyB1c2UgYG1vZGVsOiAiPGZ1bmN0aW9uLWlkPi88bW9kZWwt" + + "bmFtZT4iYC4gVGhlIGZ1bmN0aW9uIElEIHNlbGVjdHMgdGhlIE5WQ0YgZnVuY3Rpb24sIGFuZCB0" + + "aGUgbW9kZWwgbmFtZSBpcyBmb3J3YXJkZWQgdXBzdHJlYW0uIENoYXQgY29tcGxldGlvbnMgYW5k" + + "IFJlc3BvbnNlcyBBUEkgcmVxdWVzdHMgY2FuIHVzZSBgeC1tdWx0aS10dXJuLXNlc3Npb24taWRg" + + "IGZvciBzZXNzaW9uIHN0aWNraW5lc3M7IGVtYmVkZGluZ3MgcmVxdWVzdHMgZG8gbm90LgoKIyMg" + + "YGZ1bmN0aW9uIHVwZGF0ZWAtc3BlY2lmaWMKCnwgRmxhZyB8IFB1cnBvc2UgfCBEZWZhdWx0IHwK" + + "fC0tLXwtLS18LS0tfAp8IGAtLXRhZ3MgVEFHWyxUQUddYCB8IFJlcGxhY2UgZnVuY3Rpb24gdGFn" + + "cyB8IC0gfAp8IGAtLWxsbS1tb2RlbC11cGRhdGUgU1BFQ2AgfCBMTE0gbW9kZWwgdXBkYXRlOyBm" + + "b3JtYXQgYG5hbWU9PG1vZGVsPixyb3V0aW5nTWV0aG9kPTxtZXRob2Q+Wzs8cGFyYW0+PTx2YWx1" + + "ZT4uLi5dLHRva2VuUmF0ZUxpbWl0PTxsaW1pdD5gOyByZXBlYXRhYmxlLiBSb3V0aW5nIG1ldGhv" + + "ZHMgbWF0Y2ggYC0tbGxtLW1vZGVsYC4gVG9rZW4gbGltaXQgZXhhbXBsZTogYDEwMDAtU2AuIFVz" + + "ZSBpbnB1dCBKU09OIGZvciBjb21iaW5lZCB0b2tlbiBsaW1pdHMuIHwgLSB8CnwgYC0tbGxtLWRl" + + "ZmF1bHQtcHJpb3JpdHkgUFJJT1JJVFlgIHwgUmVwbGFjZSB0aGUgZnVuY3Rpb24tbGV2ZWwgcHJp" + + "b3JpdHkgY29uZmlndXJhdGlvbiB3aXRoIHRoaXMgZGVmYXVsdCBhbmQgYW55IHN1cHBsaWVkIHBl" + + "ci1hY2NvdW50IG92ZXJyaWRlcy4gfCAtIHwKfCBgLS1sbG0tcGVyLWFjY291bnQtcHJpb3JpdHkg" + + "TkNBLUlEOlBSSU9SSVRZYCB8IFBlci1hY2NvdW50IHByaW9yaXR5IG92ZXJyaWRlOyByZXBlYXRh" + + "YmxlOyBzdXBwb3J0cyB1cCB0byA2NCBkaXN0aW5jdCBOQ0EgSUQgb3ZlcnJpZGVzLiBSZXF1aXJl" + + "cyBhIGRlZmF1bHQgcHJpb3JpdHkuIHwgLSB8CgpJbiBKU09OLCBgZnVuY3Rpb24gdXBkYXRlYCBh" + + "Y2NlcHRzIGBtb2RlbFVwZGF0ZXNbXWAgZW50cmllcyB3aXRoIGBtb2RlbE5hbWVgIGFuZCBgbGxt" + + "Q29uZmlnLnJvdXRpbmdNZXRob2RgIGFuZC9vciBgbGxtQ29uZmlnLnRva2VuUmF0ZUxpbWl0YC4g" + + "YHVyaXNgIGFyZSBjcmVhdGUtdGltZSBtb2RlbCBtZXRhZGF0YSBhbmQgYXJlIG5vdCBwYXJ0IG9m" + + "IG1vZGVsIHVwZGF0ZXMuCgojIyBgZnVuY3Rpb24gZGVwbG95IGNyZWF0ZWAtc3BlY2lmaWMKCmAt" + + "LWlucHV0LWZpbGUgRklMRWAgaXMgcmVjb21tZW5kZWQgKGZ1bGwgSlNPTiBzcGVjKS4gRm9yIGFk" + + "LWhvYyBvbmUtc2hvdCB1c2U6Cgp8IEZsYWcgfCBQdXJwb3NlIHwgRGVmYXVsdCB8CnwtLS18LS0t" + + "fC0tLXwKfCBgLS1mdW5jdGlvbi1pZCBJRGAgfCBGdW5jdGlvbiBJRCAocmVxdWlyZWQpIHwgLSB8" + + "CnwgYC0tZ3B1IE5BTUVgIHwgR1BVIGZhbWlseSB8IGBIMTAwYCB8CnwgYC0taW5zdGFuY2UtdHlw" + + "ZSBUWVBFYCB8IFNLVSB8IGBOQ1AuR1BVLkgxMDBfMXhgIHwKfCBgLS1taW4taW5zdGFuY2VzIE5g" + + "IHwgTWluIHJlcGxpY2FzIHwgYDFgIHwKfCBgLS1tYXgtaW5zdGFuY2VzIE5gIHwgTWF4IHJlcGxp" + + "Y2FzIHwgYDFgIHwKfCBgLS1tYXgtcmVxdWVzdC1jb25jdXJyZW5jeSBOYCB8IENvbmN1cnJlbmN5" + + "IHwgYDBgICh1bnNldDsgc2VydmVyIGRlZmF1bHQgYXBwbGllcykgfAp8IGAtLWNsdXN0ZXJzIE5B" + + "TUVbLE5BTUVdYCB8IFRhcmdldCBzcGVjaWZpYyBjb21wdXRlIGNsdXN0ZXJzIHwgKGFueSBtYXRj" + + "aGluZyBTS1UpIHwKCiMjIGB0YXNrIGNyZWF0ZWAtc3BlY2lmaWMKCmAtLWlucHV0LWZpbGUgRklM" + + "RWAgaXMgcmVjb21tZW5kZWQgZm9yIHJlcGVhdGFibGUgdGFzayBkZWZpbml0aW9ucy4gRm9yIGFk" + + "LWhvYyB1c2U6Cgp8IEZsYWcgfCBQdXJwb3NlIHwgTm90ZXMgfAp8LS0tfC0tLXwtLS18CnwgYC0t" + + "bmFtZSBOQU1FYCB8IFRhc2sgbmFtZSAocmVxdWlyZWQpIHwgMS0xMjggY2hhcnMsIGBeW2EtejAt" + + "OUEtWl1bYS16MC05QS1aXC1fXSokYCB8CnwgYC0tZ3B1IEdQVWAgfCBHUFUgbmFtZSAocmVxdWly" + + "ZWQgd2l0aG91dCBgLS1pbnB1dC1maWxlYCkgfCBFeGFtcGxlOiBgSDEwMGAgfAp8IGAtLWluc3Rh" + + "bmNlLXR5cGUgVFlQRWAgfCBJbnN0YW5jZSB0eXBlIChyZXF1aXJlZCB3aXRob3V0IGAtLWlucHV0" + + "LWZpbGVgKSB8IEV4YW1wbGU6IGBOQ1AuR1BVLkgxMDBfMXhgIHwKfCBgLS1pbWFnZSBJTUFHRWAg" + + "fCBDb250YWluZXIgaW1hZ2UgfCBNdXR1YWxseSBleGNsdXNpdmUgd2l0aCBgLS1oZWxtLWNoYXJ0" + + "YCB8CnwgYC0taGVsbS1jaGFydCBDSEFSVGAgfCBIZWxtIGNoYXJ0IFVSTCBvciBPQ0kgcmVmZXJl" + + "bmNlIHwgTXV0dWFsbHkgZXhjbHVzaXZlIHdpdGggYC0taW1hZ2VgIHwKfCBgLS1pbnB1dC1maWxl" + + "IEZJTEVgIHwgSlNPTiB0YXNrIHNwZWMgZmlsZSB8IENMSSBmbGFncyBvdmVycmlkZSBKU09OIHZh" + + "bHVlcyB3aGVuIGJvdGggYXJlIHNldCB8CnwgYC0tYmFja2VuZCBCQUNLRU5EYCB8IEJhY2tlbmQg" + + "LyBDU1AgfCAtIHwKfCBgLS1jbHVzdGVycyBOQU1FWyxOQU1FXWAgfCBTcGVjaWZpYyBjbHVzdGVy" + + "cyB3aXRoaW4gdGhlIGluc3RhbmNlIHwgQ29tbWEtc2VwYXJhdGVkIHwKfCBgLS1jb250YWluZXIt" + + "YXJncyBBUkdTYCB8IEFyZ3MgcGFzc2VkIHdoZW4gbGF1bmNoaW5nIHRoZSBjb250YWluZXIgfCBT" + + "aW5nbGUgc3RyaW5nLCBlLmcuIGAiLS1lcG9jaHMgMTAgLS1iYXRjaC1zaXplIDMyImAgfAp8IGAt" + + "LWNvbnRhaW5lci1lbnYgTkFNRT12YWx1ZWAgfCBDb250YWluZXIgZW52aXJvbm1lbnQgdmFyaWFi" + + "bGU7IHJlcGVhdGFibGUgfCAtIHwKfCBgLS1zZWNyZXRzIE5BTUU9dmFsdWVgIHwgU2VjcmV0IGVu" + + "dmlyb25tZW50IHZhcmlhYmxlOyByZXBlYXRhYmxlOyBlbmNyeXB0ZWQgYXQgcmVzdCB8IC0gfAp8" + + "IGAtLW1heC1ydW50aW1lIERVUkFUSU9OYCB8IE1heCB3YWxsLWNsb2NrIHJ1biB0aW1lIChJU08g" + + "ODYwMSwgZS5nLiBgUFQ0SGApIHwgTm8gZGVmYXVsdDsgb21pdCBmb3Igbm8gdGltZSBsaW1pdCB8" + + "CnwgYC0tbWF4LXF1ZXVlZCBEVVJBVElPTmAgfCBNYXggdGltZSBpbiBxdWV1ZSBiZWZvcmUgY2Fu" + + "Y2VsbGF0aW9uIChJU08gODYwMSkgfCBTZXJ2ZXIgZGVmYXVsdCBgUFQ3MkhgIHwKfCBgLS10ZXJt" + + "aW5hdGlvbi1ncmFjZSBEVVJBVElPTmAgfCBHcmFjZSBwZXJpb2QgYWZ0ZXIgc3RvcCBzaWduYWwg" + + "KElTTyA4NjAxKSB8IFNlcnZlciBkZWZhdWx0IGBQVDFIYCB8CnwgYC0tcmVzdWx0LXN0cmF0ZWd5" + + "IFVQTE9BRFx8Tk9ORWAgfCBIb3cgdG8gaGFuZGxlIHRhc2sgb3V0cHV0IGFydGlmYWN0cyB8IC0g" + + "fAp8IGAtLXJlc3VsdHMtbG9jYXRpb24gTE9DQVRJT05gIHwgUmVzdWx0IHVwbG9hZCB0YXJnZXQs" + + "IGZvcm1hdCBgb3JnLW5hbWUvW3RlYW0tbmFtZS9dbW9kZWwtbmFtZWA7IHJlcXVpcmVkIHdoZW4g" + + "YC0tcmVzdWx0LXN0cmF0ZWd5PVVQTE9BRGAgfCAtIHwKfCBgLS1tb2RlbHMgTkFNRTpWRVJTSU9O" + + "OlVSSWAgfCBNb2RlbCBhcnRpZmFjdDsgcmVwZWF0YWJsZSB8IC0gfAp8IGAtLXJlc291cmNlcyBO" + + "QU1FOlZFUlNJT046VVJJYCB8IFJlc291cmNlIGFydGlmYWN0OyByZXBlYXRhYmxlIHwgLSB8Cnwg" + + "YC0tdGFncyBUQUdbLFRBR11gIHwgVGFzayB0YWdzIHwgQ29tbWEtc2VwYXJhdGVkIHwKfCBgLS1k" + + "ZXNjcmlwdGlvbiBURVhUYCB8IFRhc2sgZGVzY3JpcHRpb24gfCAtIHwKfCBgLS1sb2dzLXRlbGVt" + + "ZXRyeS1pZCBVVUlEYCB8IExvZ3MgdGVsZW1ldHJ5IGVuZHBvaW50IElEIHwgLSB8CnwgYC0tbWV0" + + "cmljcy10ZWxlbWV0cnktaWQgVVVJRGAgfCBNZXRyaWNzIHRlbGVtZXRyeSBlbmRwb2ludCBJRCB8" + + "IC0gfAp8IGAtLXRyYWNlcy10ZWxlbWV0cnktaWQgVVVJRGAgfCBUcmFjZXMgdGVsZW1ldHJ5IGVu" + + "ZHBvaW50IElEIHwgLSB8ClBLBwi1LviSw2EAAMNhAABQSwMEFAAIAAAAAAAhAAAAAAAAAAAAAAAA" + + "ADcACQBkYXRhL252Y2Ytc2VsZi1tYW5hZ2VkLWNsaS9yZWZlcmVuY2UvdHJvdWJsZXNob290aW5n" + + "Lm1kVVQFAAEAps4SIyBUcm91Ymxlc2hvb3RpbmcKCktub3duIGVycm9ycywgdGhlIGRpYWdub3N0" + + "aWMgY29tbWFuZCwgYW5kIHRoZSByZW1lZGlhdGlvbi4gS2VlcCBpbiBzeW5jIHdpdGggdGhlIHN0" + + "cnVjdHVyZWQgZXZlbnRzIHRoZSBDTEkgZW1pdHMgKFJFUS0xNSk6IG91dHNpZGUgcHJlLWZsaWdo" + + "dCwgZXZlcnkgZW50cnkgYmVsb3cgc2hvdWxkIG1hcCB0byBhIGBwaGFzZV9mYWlsZWRgIGBlcnJD" + + "YXRlZ29yeWAgYW5kIGEgYHJlbWVkaWF0aW9uYCBhcnJheS4KCiMjIFByZS1mbGlnaHQKCmBjaGVj" + + "a2AgcmVwb3J0cyBlYWNoIGZpbmRpbmcgYXMgYSBgY2hlY2tfY29tcGxldGVkYCBldmVudCwgbm90" + + "IGFzCmBwaGFzZV9mYWlsZWRgLiBUaGUgc3ltcHRvbSBpcyB0aGUgZXZlbnQncyBgbWVzc2FnZWA7" + + "IGNsdXN0ZXItdmFsaWRhdG9yIHJvd3MKcXVvdGUgdGhlIHZhbGlkYXRvcidzIGZhaWxlZCBzdW1t" + + "YXJ5IHJvd3MsIGFuZCBgLS1zaG93LWxvZ3NgIHByaW50cyB0aGUgZnVsbAp0cmFuc2NyaXB0LgoK" + + "fCBTeW1wdG9tIHwgQ2F1c2UgfCBSZW1lZGlhdGlvbiB8CnwtLS18LS0tfC0tLXwKfCBga3ViZWN0" + + "bCBub3QgZm91bmQgb24gUEFUSGAgfCBPcGVyYXRvcidzIGAkUEFUSGAgZG9lc24ndCBpbmNsdWRl" + + "IGt1YmVjdGwuIEFuIGVycm9yIHdpdGggYC0tcHJlYDsgb3RoZXIgc2NvcGVzIHdhcm4sIGFkZGlu" + + "ZyBgOyBvbmx5IGFuIGluc3RhbGwgbmVlZHMgaXRgIHwgSW5zdGFsbCBrdWJlY3RsIDEuMjgrOiBo" + + "dHRwczovL2t1YmVybmV0ZXMuaW8vZG9jcy90YXNrcy90b29scy9pbnN0YWxsLWt1YmVjdGwvIHwK" + + "fCBgaGVsbWZpbGUgbm90IGZvdW5kIG9uIFBBVEhgIHwgU2FtZSB8IGh0dHBzOi8vZ2l0aHViLmNv" + + "bS9oZWxtZmlsZS9oZWxtZmlsZSNpbnN0YWxsYXRpb24gfAp8IGBoZWxtIG5vdCBmb3VuZCBvbiBQ" + + "QVRIYCB8IFNhbWUgfCBodHRwczovL2hlbG0uc2gvZG9jcy9pbnRyby9pbnN0YWxsLyB8CnwgYGNs" + + "dXN0ZXItdmFsaWRhdG9yIG5vdCBydW46IGNsdXN0ZXJfdmFsaWRhdG9yX2ltYWdlIGlzIG5vdCBz" + + "ZXQ7IC4uLmAsIHdhcm5pbmcgfCBObyB2YWxpZGF0b3IgaW1hZ2UgaXMgY29uZmlndXJlZCwgc28g" + + "dGhlIGNsdXN0ZXIgd2FzIG5vdCB2YWxpZGF0ZWQgfCBTZXQgYGNsdXN0ZXJfdmFsaWRhdG9yX2lt" + + "YWdlYCBpbiB0aGUgbnZjZi1jbGkgY29uZmlnLCBgTlZDRl9DTElfQ0xVU1RFUl9WQUxJREFUT1Jf" + + "SU1BR0VgIG9yIGAtLWNsdXN0ZXItdmFsaWRhdG9yLWltYWdlYCB8CnwgYGNsdXN0ZXItdmFsaWRh" + + "dG9yIGRpZCBub3QgY29tcGxldGUsIHNvIHRoZSBjbHVzdGVyIHdhcyBub3QgdmFsaWRhdGVkOiAu" + + "Li5gLCBlcnJvciB8IFRoZSB2YWxpZGF0b3IgY291bGQgbm90IHJ1biBvciBlbmRlZCBiZWZvcmUg" + + "aXRzIHZlcmRpY3Q6IFJCQUMgZGVuaWVkLCBpbWFnZSBwdWxsIGZhaWx1cmUsIGl0cyBvd24gdGlt" + + "ZW91dCB8IEZpeCB0aGUgY2F1c2UgdGhlIG1lc3NhZ2UgbmFtZXMsIG9yIHBhc3MgYC0tc2tpcC1j" + + "bHVzdGVyLXZhbGlkYXRpb25gIHwKfCBgY2x1c3Rlci12YWxpZGF0b3IgSm9iIHN1Y2NlZWRlZDsg" + + "aXRzIHRyYW5zY3JpcHQgY291bGQgbm90IGJlIHJlYWQgKC4uLilgLCBwYXNzZWQgfCBUaGUgdmFs" + + "aWRhdG9yIHBhc3NlZCwgYnV0IGl0cyBsb2cgY291bGQgbm90IGJlIHJlYWQ6IHRoZSBrdWJlY29u" + + "ZmlnIHVzZXIgbGFja3MgYGdldGAgb24gYHBvZHMvbG9nYCBpbiBgZGVmYXVsdGAsIG9yIHRoZSBB" + + "UEkgc2VydmVyIGNhbm5vdCByZWFjaCB0aGUga3ViZWxldC4gVGhlIGNoZWNrcyBpdCByYW4gYW5k" + + "IHdhcm5pbmdzIGl0IHJlcG9ydGVkIGFyZSBub3Qgc2hvd246IGFuIGltYWdlIHRoYXQgcHJlZGF0" + + "ZXMgdmFsaWRhdG9yIHJvbGVzIHJ1bnMgdGhlIGNvbXB1dGUtcGxhbmUgY2hlY2tzIGZvciBlaXRo" + + "ZXIgcm9sZSB8IEdyYW50IGBnZXRgIG9uIGBwb2RzL2xvZ2AgaW4gYGRlZmF1bHRgLCBvciByZWFk" + + "IHRoZSB0cmFuc2NyaXB0IHdpdGggdGhlIGBrdWJlY3RsIGxvZ3NgIGNvbW1hbmQgaW4gdGhlIHJv" + + "dyB8CnwgYGNsdXN0ZXItdmFsaWRhdG9yIHJlcG9ydGVkIGZhaWx1cmVzOiBHYXRld2F5IEFQSSBD" + + "UkRzOiBOb3QgSW5zdGFsbGVkYCB8IEVudm95IEdhdGV3YXkgcHJlcmVxIG1pc3NpbmcgfCBgaGVs" + + "bSBpbnN0YWxsIGVnIG9jaTovL2RvY2tlci5pby9lbnZveXByb3h5L2dhdGV3YXktaGVsbSAtLW5h" + + "bWVzcGFjZT1lbnZveS1nYXRld2F5LXN5c3RlbSAtLWNyZWF0ZS1uYW1lc3BhY2VgIHwKfCBgY2x1" + + "c3Rlci12YWxpZGF0b3IgcmVwb3J0ZWQgZmFpbHVyZXM6IERlZmF1bHQgU3RvcmFnZUNsYXNzOiBO" + + "b3QgRm91bmRgIHwgQ2x1c3RlciBoYXMgbm8gZGVmYXVsdCBTQywgYW5kIHRoZSBzdGFjayBkb2Vz" + + "IG5vdCBuYW1lIG9uZSB8IEFubm90YXRlIG9uZTogYGt1YmVjdGwgcGF0Y2ggc2MgPG5hbWU+IC1w" + + "ICd7Im1ldGFkYXRhIjp7ImFubm90YXRpb25zIjp7InN0b3JhZ2VjbGFzcy5rdWJlcm5ldGVzLmlv" + + "L2lzLWRlZmF1bHQtY2xhc3MiOiJ0cnVlIn19fSdgLCBvciBzZXQgYGdsb2JhbC5zdG9yYWdlQ2xh" + + "c3NgIGluIHRoZSBzdGFjaydzIGVudmlyb25tZW50IGZpbGUgKG9yIGBOVkNGX1NUT1JBR0VfQ0xB" + + "U1NgKSB0byB0aGUgY2xhc3MgdGhlIHN0YWNrIHVzZXMgfAp8IGBjbHVzdGVyLXZhbGlkYXRvciBy" + + "ZXBvcnRlZCBmYWlsdXJlczogR1BVIFJlc291cmNlczogTm90IEF2YWlsYWJsZWAgKGNvbXB1dGUt" + + "cGxhbmUpIHwgTm8gbm9kZSBhZHZlcnRpc2VzIGFuIGFsbG9jYXRhYmxlIEdQVTogbm8gR1BVIG5v" + + "ZGVzLCBvciB0aGUgTlZJRElBIEdQVSBPcGVyYXRvciBpcyBtaXNzaW5nIG9yIG5vdCB3b3JraW5n" + + "IHwgSW5zdGFsbCBwZXIgaHR0cHM6Ly9kb2NzLm52aWRpYS5jb20vZGF0YWNlbnRlci9jbG91ZC1u" + + "YXRpdmUvZ3B1LW9wZXJhdG9yL2xhdGVzdC9pbnN0YWxsLWdwdS1vcGVyYXRvci5odG1sLCBPUiB1" + + "c2UgYGZha2UtZ3B1LW9wZXJhdG9yYCBmb3IgZGV2LiBgR1BVIE9wZXJhdG9yOiBOb3QgSW5zdGFs" + + "bGVkYCBhbG9uZSBpcyBhIHdhcm5pbmcgfAp8IGBzdGFsZS1uYW1lc3BhY2VzYDogYDxucz4gKHN0" + + "dWNrIFRlcm1pbmF0aW5nOiAuLi4pYCwgZXJyb3IsIGV4aXQgYDJgIHwgVGhlIG5hbWVzcGFjZSBo" + + "YXMgYmVlbiBkZWxldGluZyBmb3Igb3ZlciAyIG1pbnV0ZXMsIG9yIGl0cyBjb25kaXRpb25zIHJl" + + "cG9ydCBhIGRlbGV0aW9uIGZhaWx1cmUuIFVzdWFsbHkgYW4gb2JqZWN0IGluc2lkZSBpdCBzdGls" + + "bCBob2xkcyBhIGZpbmFsaXplciB8IFJ1biB0aGUgY29tbWFuZHMgaW4gdGhlIHJvdyBpbiBvcmRl" + + "cjogcmVhZCBga3ViZWN0bCBnZXQgbnMgPG5zPiAtbyBqc29ucGF0aD0ney5zdGF0dXMuY29uZGl0" + + "aW9uc30nYCwgbGlzdCB3aGF0IGlzIGxlZnQgd2l0aCB0aGUgYGFwaS1yZXNvdXJjZXNgIGNvbW1h" + + "bmQsIGFuZCByZXNvbHZlIHRob3NlIG9iamVjdHMuIEZvcmNlLWNsZWFyIHRoZSBuYW1lc3BhY2Ug" + + "ZmluYWxpemVycyBvbmx5IGFzIGEgbGFzdCByZXNvcnQgfAp8IGBzdGFsZS1uYW1lc3BhY2VzYDog" + + "YDxucz4gKFRlcm1pbmF0aW5nOiBkZWxldGluZyBmb3IgPGFnZT4pYCwgd2FybmluZyB8IEEgbm9y" + + "bWFsIGRlbGV0aW9uIGlzIHN0aWxsIGRyYWluaW5nIHZvbHVtZXMgYW5kIGZpbmFsaXplcnMgfCBX" + + "YWl0IGFuZCByZXJ1biBgY2hlY2tgOyBgLS13YWl0YCBwb2xscyB0aGlzIHJvdy4gSXQgYmVjb21l" + + "cyBgc3R1Y2sgVGVybWluYXRpbmdgIGFmdGVyIDIgbWludXRlcyB8CnwgYHN0YWxlLW5hbWVzcGFj" + + "ZXNgOiBgPG5zPiAoSGVsbSByZWxlYXNlIG1pZC1vcGVyYXRpb246IDxyZWxlYXNlPiA8c3RhdHVz" + + "PilgLCB3YXJuaW5nIHwgQW4gaW50ZXJydXB0ZWQgaW5zdGFsbCwgdXBncmFkZSwgcm9sbGJhY2sg" + + "b3IgdGVhcmRvd24gbGVmdCB0aGUgcmVsZWFzZSBgcGVuZGluZy0qYCBvciBgdW5pbnN0YWxsaW5n" + + "YC4gVGhlIG5leHQgYHVwYCBmYWlscyBvbiBpdCB8IElmIG5vIGluc3RhbGwgb3IgdGVhcmRvd24g" + + "aXMgcnVubmluZywgcmVhZCBgaGVsbSBoaXN0b3J5IDxyZWxlYXNlPiAtbiA8bnM+YCwgdGhlbiBm" + + "aW5pc2ggaXQgd2l0aCBgaGVsbSByb2xsYmFja2Agb3IgYGhlbG0gdW5pbnN0YWxsYCB8CnwgYHN0" + + "YWxlLW5hbWVzcGFjZXNgOiBgPG5zPiAobm8gSGVsbSByZWxlYXNlKWAsIHdhcm5pbmcgfCBUaGUg" + + "bmFtZXNwYWNlIHJ1bnMgbm8gd29ya2xvYWQgYW5kIGhhcyBubyBIZWxtIHJlbGVhc2UsIGJ1dCBz" + + "dGlsbCBob2xkcyB2b2x1bWUgY2xhaW1zIG9yIHdvcmtsb2FkIG9iamVjdHMsIGFzIGBkb3duYCBs" + + "ZWF2ZXMgdGhlbS4gQSByZWluc3RhbGwgcmVhdHRhY2hlcyB0aGUgb2xkIHZvbHVtZXMgfCBJbnNw" + + "ZWN0IG9ubHk6IGBrdWJlY3RsIGFwaS1yZXNvdXJjZXMgLS12ZXJicz1saXN0IC0tbmFtZXNwYWNl" + + "ZCAtbyBuYW1lIFx8IHhhcmdzIC1uMSBrdWJlY3RsIGdldCAtbiA8bnM+IC0tc2hvdy1raW5kIC0t" + + "aWdub3JlLW5vdC1mb3VuZGAuIEFuIEFyZ28gQ0Qgb3IgdXBzdHJlYW0gaW5zdGFsbCBvZiBhIGdh" + + "dGVkIGNvbXBvbmVudCBhbHNvIGhhcyBubyBIZWxtIHJlbGVhc2UgfAp8IGBzdGFsZS1uYW1lc3Bh" + + "Y2VzYCByZXBvcnRzIGBubyBIZWxtIHJlbGVhc2VgIG9uIGEgaGVhbHRoeSBpbnN0YWxsIHwgSGVs" + + "bSBzdG9yZXMgcmVsZWFzZXMgd2l0aCBgSEVMTV9EUklWRVI9c3FsYCwgYW5kIGBjaGVja2AgcmVh" + + "ZHMgYEhFTE1fRFJJVkVSYCBmcm9tIGl0cyBvd24gZW52aXJvbm1lbnQgb25seSwgbm90IGZyb20g" + + "dGhlIGhlbG1maWxlIGVudmlyb25tZW50IHwgRXhwb3J0IGBIRUxNX0RSSVZFUj1zcWxgIGJlZm9y" + + "ZSBgbnZjZi1jbGkgc2VsZi1ob3N0ZWQgY2hlY2tgLiBXaXRoIGl0LCBvbmx5IFRlcm1pbmF0aW5n" + + "IG5hbWVzcGFjZXMgYXJlIGNoZWNrZWQgfAp8IGBzdGFsZS1uYW1lc3BhY2VzYCBkb2VzIG5vdCBz" + + "Y2FuIGFuIGFkZC1vbiBvciBnYXRlZCBuYW1lc3BhY2UgfCBOYW1lc3BhY2VzIGJlaGluZCBhIHN0" + + "YWNrIGBjb25kaXRpb246YCBhcmUgcHJvYmVkIG9ubHkgd2hlbiB0aGF0IGdhdGUgaXMgb24uIEdh" + + "dGVzIGNvbWUgZnJvbSB0aGUgc3RhY2sgdGhlIGluc3RhbGwgdXNlZCwgZm91bmQgYXMgZm9yIHRo" + + "ZSBbc2V0dGluZ3MgcmVhZCBmcm9tIHRoZSBzdGFja10oZmxhZ3MubWQjc2V0dGluZ3MtcmVhZC1m" + + "cm9tLXRoZS1zdGFjayk6IGl0cyBgZW52aXJvbm1lbnRzL2Jhc2UueWFtbGAgYW5kLCB3aGVuIGAt" + + "LWVudmAgb3IgYEhFTE1GSUxFX0VOVmAgbmFtZXMgdGhlIGVudmlyb25tZW50IG9yIGAtLXByZWAg" + + "Y2hlY2tzIHRoZSBwbGFuZSBiZWZvcmUgaXRzIGluc3RhbGwsIGBlbnZpcm9ubWVudHMvPGVudj4u" + + "eWFtbGAuIE90aGVyd2lzZSB0aGUgc3RhY2sgZGVmYXVsdHMgZGVjaWRlLCB3aGljaCBsZWF2ZSBg" + + "a2FpLXNjaGVkdWxlcmAsIGBncm92ZS1zeXN0ZW1gLCBgZHluYW1vLXN5c3RlbWAgYW5kIGBudmNm" + + "LXVpYCBvZmYgfCBQYXNzIHRoZSBgLS1lbnZgIHRoZSBpbnN0YWxsIHVzZWQsIGFuZCB0aGUgc3Rh" + + "Y2sgZmxhZyB0aGUgaW5zdGFsbCB1c2VkLCBpZiBhbnkgfAoKIyMgQXV0aAoKfCBTeW1wdG9tIHwg" + + "Q2F1c2UgfCBSZW1lZGlhdGlvbiB8CnwtLS18LS0tfC0tLXwKfCBgYWRtaW4gdG9rZW4gcmVxdWly" + + "ZWRgIChpbml0IG5vdCBydW4pIHwgTm8gYH4vLm52Y2YtY2xpLnN0YXRlYCBhbmQgYC0tdG9rZW5g" + + "IG5vdCBwYXNzZWQgfCBgbnZjZi1jbGkgaW5pdGAgKGludGVyYWN0aXZlKSBvciBgLS10b2tlbj0k" + + "SldUIC0tbm9uLWludGVyYWN0aXZlYCAoQ0kpIHwKfCBgU2lnbmVkIEpXVCByZWplY3RlZDogQW5v" + + "dGhlciBhbGdvcml0aG0gZXhwZWN0ZWQsIG9yIG5vIG1hdGNoaW5nIGtleShzKSBmb3VuZGAgfCBT" + + "dGFsZSBzZXNzaW9uLmpzb24gZnJvbSBhIGRpZmZlcmVudCBjb250cm9sIHBsYW5lIHwgYHJtIH4v" + + "Lm52Y2YtY2xpLnN0YXRlYCB0aGVuIGBudmNmLWNsaSBpbml0YCBhZ2FpbiB8CnwgYDQwMyBtaXNz" + + "aW5nIHJlcXVlc3RlZCBhdXRob3JpdGllc2AgKGZ1bmN0aW9uIGludm9rZSkgfCBBZG1pbiB0b2tl" + + "biBkb2Vzbid0IGhhdmUgYGludm9rZV9mdW5jdGlvbmAgc2NvcGUgfCBgbnZjZi1jbGkgYXBpLWtl" + + "eSBnZW5lcmF0ZSAtLWRlc2NyaXB0aW9uPeKApmAgdGhlbiByZS1ydW4gaW52b2tlIHwKCiMjIElu" + + "c3RhbGwgLyBgdXBgCgp8IFN5bXB0b20gfCBDYXVzZSB8IFJlbWVkaWF0aW9uIHwKfC0tLXwtLS18" + + "LS0tfAp8IEhlbG0gYHRpbWVkIG91dCB3YWl0aW5nIGZvciBjYXNzYW5kcmFgIHwgQ2Fzc2FuZHJh" + + "IFN0YXRlZnVsU2V0IFBWQyBzdHVjayBgUGVuZGluZ2AgfCBDb25maXJtIGRlZmF1bHQgU3RvcmFn" + + "ZUNsYXNzOyBjaGVjayBga3ViZWN0bCBnZXQgcHYscHZjIC1uIGNhc3NhbmRyYS1zeXN0ZW1gIHwK" + + "fCBgSW52YWxpZCBHUFUgJ05DUC5HUFUuSDEwMCcgc3BlY2lmaWVkYCAoZnVuY3Rpb24gZGVwbG95" + + "KSB8IFdyb25nIGZpZWxkIOKAlCBgZ3B1YCBpcyB0aGUgZmFtaWx5LCBub3QgdGhlIFNLVSB8IFVz" + + "ZSBgImdwdSI6ICJIMTAwImAsIGAiaW5zdGFuY2VUeXBlIjogIk5DUC5HUFUuSDEwMF8xeCJgIHwK" + + "fCBgcmVnaW9uIG11c3QgYmUgcHJvdmlkZWRgIChjbHVzdGVyIHJlZ2lzdGVyIC8gaW5zdGFsbCAt" + + "LWNvbXB1dGUtcGxhbmUpIHwgYC0tcmVnaW9uYCBlbXB0eSArIGBDTFVTVEVSX1JFR0lPTmAgZW52" + + "IGVtcHR5IHwgUGFzcyBgLS1yZWdpb249dXMtd2VzdC0xYCAob3IgYW55IG5vbi1lbXB0eSB2YWx1" + + "ZSkgfAp8IGByZXF1aXJlZEVudiAiTkNBX0lEIiBpcyBub3Qgc2V0YCAoaGVsbWZpbGUgZXJyb3Ig" + + "ZHVyaW5nIGNvbXB1dGUtcGxhbmUgYXBwbHkpIHwgT2xkZXIgbnZjZi1jbGkgYmVmb3JlIGNvbW1p" + + "dCBgNmFhNTcwNGA7IEV4dHJhRW52IG5vdCBwcm9wYWdhdGluZyBOQ0FfSUQgfCBVcGdyYWRlIGBu" + + "dmNmLWNsaWAgdG8gYSByZWxlYXNlIGFmdGVyIGA2YWE1NzA0YCB8CnwgYG52Y2Etb3BlcmF0b3I6" + + "IEltYWdlUHVsbEJhY2tPZmZgIGZvciBgbnZjYS1vcGVyYXRvcjpwc2F0YCB8IEltYWdlIG5vdCBp" + + "biBjbHVzdGVyIHJlZ2lzdHJ5OyBtdWx0aS1jbHVzdGVyIGJ1aWxkIGlzIGxvY2FsIHwgYGszZCBp" + + "bWFnZSBpbXBvcnQgbnZjci5pby8uLi4vbnZjYS1vcGVyYXRvcjpwc2F0IC1jIDxjbHVzdGVyPmAg" + + "fAoKIyMgQ29tcHV0ZS1wbGFuZSBydW50aW1lCgp8IFN5bXB0b20gfCBDYXVzZSB8IFJlbWVkaWF0" + + "aW9uIHwKfC0tLXwtLS18LS0tfAp8IE5WQ0EgYWdlbnQgQ3Jhc2hMb29wQmFja09mZiB3aXRoIGBI" + + "VFRQIDQwMWAgb24gYC92MS9udmNhL2NsdXN0ZXJzLy4uLi9yZWdpc3RlcmAgfCBTdGFsZSBgb3V0" + + "LzxjbHVzdGVyPi1yZWdpc3Rlci12YWx1ZXMueWFtbGAgZnJvbSBwcmlvciByZWdpc3RlcjsgY2x1" + + "c3RlciBJRCBkb2Vzbid0IG1hdGNoIElDTVMgfCBEZWxldGUgdGhlIHN0YWxlIGZpbGU7IHJlLXJ1" + + "biBgbnZjZi1jbGkgc2VsZi1ob3N0ZWQgdXBgIChpZGVtcG90ZW50IHJlZ2lzdGVyIHdyaXRlcyBh" + + "IGZyZXNoIGZpbGUpIHwKfCBgTm8gY2x1c3RlciBmb3VuZCB3aXRoIHZhbGlkIEpXS1MgZm9yIGNs" + + "dXN0ZXIgSUQ6IDxpZD5gIChJQ01TIGxvZykgfCBKV0tTIGluIElDTVMgZG9lc24ndCBtYXRjaCB3" + + "aGF0IGNvbXB1dGUtcGxhbmUgSzhzIGlzIGN1cnJlbnRseSBzaWduaW5nIHwgYG52Y2YtY2xpIGNs" + + "dXN0ZXIgcm90YXRlIC0tY2x1c3Rlci1pZD08aWQ+YCB8CnwgRnVuY3Rpb24gcG9kIGBJbWFnZVB1" + + "bGxCYWNrT2ZmYCBpbiBgbnZjZi1iYWNrZW5kYCB8IEltYWdlLWNyZWRlbnRpYWwtaGVscGVyIGNv" + + "bmZpZyBtaXNzaW5nIG9yIHdyb25nIHwgYGt1YmVjdGwgZGVzY3JpYmUgcG9kIC1uIG52Y2YtYmFj" + + "a2VuZCA8cG9kPmA7IGNoZWNrIHRoZSBwdWxsIHNlY3JldCBjb25maWcgfAp8IEZ1bmN0aW9uIGRl" + + "cGxveSBzdHVjayBgREVQTE9ZSU5HYCBmb3IgPjUgbWluIHwgTkFUUyBzdHJlYW0gaW5pdCBsYWcg" + + "KGNvbGQtY2x1c3Rlcikg4oCUIHdhaXQsIG9yIGNoZWNrIE5BVFMgYXV0aC1jYWxsb3V0IGhlYWx0" + + "aCB8IGBrdWJlY3RsIGxvZ3MgLW4gbmF0cy1zeXN0ZW0gLWwgYXBwLmt1YmVybmV0ZXMuaW8vbmFt" + + "ZT1uYXRzLWF1dGgtY2FsbG91dC1zZXJ2aWNlYCB8CgojIyBUYXNrcwoKfCBTeW1wdG9tIHwgQ2F1" + + "c2UgfCBSZW1lZGlhdGlvbiB8CnwtLS18LS0tfC0tLXwKfCBgdGFzayBjcmVhdGVgIHJldHVybnMg" + + "YE1pc3NpbmcgPFRZUEU+IHJlZ2lzdHJ5IGNyZWRlbnRpYWwgZm9yIGhvc3RuYW1lYCwgb3Iga2Vl" + + "cHMgdXNpbmcgYSBwcmV2aW91cyBjcmVkZW50aWFsIHZhbHVlLCBzaG9ydGx5IGFmdGVyIGByZWdp" + + "c3RyeS1jcmVkZW50aWFsIGFkZC91cGRhdGUvZGVsZXRlYCB3aGlsZSBgcmVnaXN0cnktY3JlZGVu" + + "dGlhbCBsaXN0YC9gZ2V0YCBhbHJlYWR5IHNob3cgdGhlIG5ldyB2YWx1ZSB8IEV4cGVjdGVkIHBy" + + "b3BhZ2F0aW9uIGRlbGF5OiBOVkNUIGNhY2hlcyB0aGUgYWNjb3VudCdzIHJlZ2lzdHJ5IGNyZWRl" + + "bnRpYWxzIHdpdGggYG52Y3QubnZjZi5jYWNoZS10dGxgIChkZWZhdWx0IGBQVDVNYCwgYWJvdXQg" + + "NSBtaW4pIGFuZCBhcHBsaWVzIHRoZSBjaGFuZ2Ugd2hlbiB0aGF0IGNhY2hlZCBjb3B5IHJlZnJl" + + "c2hlcyB8IFdhaXQgdXAgdG8gYWJvdXQgNSBtaW4gYW5kIHJldHJ5LCBvciBga3ViZWN0bCAtbiBu" + + "dmNmIHJvbGxvdXQgcmVzdGFydCBkZXBsb3ltZW50L252Y3QtYXBpYCB0byBhcHBseSBpbW1lZGlh" + + "dGVseSB8ClBLBwgauLPzYSEAAGEhAABQSwMEFAAIAAAAAAAhAAAAAAAAAAAAAAAAACwACQBkYXRh" + + "L252Y2Ytc2VsZi1tYW5hZ2VkLWluc3RhbGxhdGlvbi9TS0lMTC5tZFVUBQABAKbOEi0tLQpuYW1l" + + "OiBudmNmLXNlbGYtbWFuYWdlZC1pbnN0YWxsYXRpb24KZGVzY3JpcHRpb246ID4tCiAgSW5zdGFs" + + "bCBhbmQgb3BlcmF0ZSBOVkNGIHNlbGYtaG9zdGVkIGNvbnRyb2wtcGxhbmUgYW5kIHNlcGFyYXRl" + + "IGNvbXB1dGUtcGxhbmUKICBzdGFja3MuIENvdmVycyBIZWxtZmlsZSB2YWx1ZXMgYW5kIENMSSBw" + + "cm9maWxlIGluc3RhbGxhdGlvbiBmbG93cywgdGVhcmRvd24sCiAgdmFsdWVzIG92ZXJyaWRlcywg" + + "cHVsbCBzZWNyZXRzLCBhbmQgdHJvdWJsZXNob290aW5nLiBVc2UgZm9yCiAgbnZjZi1zZWxmLW1h" + + "bmFnZWQtc3RhY2ssIG52Y2YtY29tcHV0ZS1wbGFuZS1zdGFjaywgc3BsaXQgY29tcHV0ZS1wbGFu" + + "ZQogIGluc3RhbGxhdGlvbiwgY29udHJvbC1wbGFuZSBpbnN0YWxsYXRpb24sIENMSS1nZW5lcmF0" + + "ZWQgY29udHJvbC1wbGFuZQogIHByb2ZpbGVzLCBIZWxtZmlsZSwgc2VsZi1tYW5hZ2VkLCBvciBz" + + "ZWxmLWhvc3RlZCBkZXBsb3ltZW50cy4gRG8gTk9UIHVzZSBmb3IKICBsb2NhbCBrM2QgZW52aXJv" + + "bm1lbnRzOyB1c2UgdGhlIGxvY2FsIGszZCBkZXZlbG9wbWVudCB3b3JrZmxvdyBpbnN0ZWFkLgps" + + "aWNlbnNlOiBBcGFjaGUtMi4wCmNvbXBhdGliaWxpdHk6IFJlcXVpcmVzIGhlbG1maWxlID49IDEu" + + "MS4wIDwgMS4yLjAsIGhlbG0gPj0gMy4xMiwgaGVsbS1kaWZmIHBsdWdpbiwga3ViZWN0bCBtYXRj" + + "aGluZyBjbHVzdGVyIHZlcnNpb24KYXV0aG9yOiAibnZjZi1jb3JlLWVuZyA8bnZjZi1jb3JlLWVu" + + "Z0BleGNoYW5nZS5udmlkaWEuY29tPiIKdmVyc2lvbjogIjEuMC4wIgp0YWdzOiBbbnZjZiwgc2Vs" + + "Zi1tYW5hZ2VkLCBoZWxtZmlsZSwgc2VsZi1ob3N0ZWQsIGNvbnRyb2wtcGxhbmUsIGNvbXB1dGUt" + + "cGxhbmUsIGNsaS1wcm9maWxlLCBpbnN0YWxsYXRpb24sIGRlcGxveW1lbnQsIHB1bGwtc2VjcmV0" + + "c10KdG9vbHM6IFtTaGVsbCwgUmVhZCwgRWRpdCwgR3JlcCwgR2xvYl0KbWV0YWRhdGE6CiAgaW50" + + "ZXJuYWw6IGZhbHNlCiAgYXV0aG9yOiAibnZjZi1jb3JlLWVuZyA8bnZjZi1jb3JlLWVuZ0BleGNo" + + "YW5nZS5udmlkaWEuY29tPiIKICB2ZXJzaW9uOiAiMS4wIgogIHRhZ3M6IFtudmNmLCBzZWxmLW1h" + + "bmFnZWQsIGhlbG1maWxlLCBzZWxmLWhvc3RlZCwgY29udHJvbC1wbGFuZSwgY29tcHV0ZS1wbGFu" + + "ZSwgY2xpLXByb2ZpbGUsIGluc3RhbGxhdGlvbiwgZGVwbG95bWVudCwgcHVsbC1zZWNyZXRzXQog" + + "IGxhbmd1YWdlczogW2Jhc2gsIHlhbWxdCiAgZnJhbWV3b3JrczogW2hlbG1maWxlLCBoZWxtLCBr" + + "dWJlY3RsXQogIGRvbWFpbjogY2xvdWQtaW5mcmFzdHJ1Y3R1cmUKLS0tCgojIE5WQ0YgU2VsZi1N" + + "YW5hZ2VkIFN0YWNrIE9wZXJhdGlvbnMKCk9wZXJhdGlvbmFsIGd1aWRlIGZvciBtYXRjaGluZyBO" + + "VkNGIGNvbnRyb2wtcGxhbmUgYW5kIGNvbXB1dGUtcGxhbmUgSGVsbWZpbGUgYnVuZGxlcy4KCiMj" + + "IEluc3RydWN0aW9ucwoKVXNlIHRoaXMgc2tpbGwgZm9yIGluc3RhbGwsIHVwZ3JhZGUsIG9yIHRl" + + "YXJkb3duIHdvcmsgaW4gbWF0Y2hpbmcgYG52Y2Ytc2VsZi1tYW5hZ2VkLXN0YWNrYCBhbmQgYG52" + + "Y2YtY29tcHV0ZS1wbGFuZS1zdGFja2AgYnVuZGxlczsga2VlcCBIZWxtZmlsZSB2YWx1ZXMgYW5k" + + "IENMSSBwcm9maWxlIGhhbmRvZmZzIHNlcGFyYXRlLiBGb3IgYGZ1bmN0aW9uVHlwZTogIkxMTSJg" + + "LCByZWFkIFtMTE0gRnVuY3Rpb24gRW5hYmxlbWVudF0ocmVmZXJlbmNlcy9oZWxtZmlsZS1zdHJ1" + + "Y3R1cmUubWQjbGxtLWZ1bmN0aW9uLWVuYWJsZW1lbnQpLgoKIyMgUHJlcmVxdWlzaXRlcwoKQXNr" + + "IGZvciB0aGUgZXh0cmFjdGVkIGBudmNmLXNlbGYtbWFuYWdlZC1zdGFja2AgcGF0aC4gRm9yIHNw" + + "bGl0IGluc3RhbGxhdGlvbiwKYWxzbyByZXF1aXJlIHRoZSBtYXRjaGluZy12ZXJzaW9uIGBudmNm" + + "LWNvbXB1dGUtcGxhbmUtc3RhY2tgIHBhdGguIFZlcmlmeSBib3RoOgoKYGBgYmFzaApscyA8Y29u" + + "dHJvbC1wbGFuZS1zdGFjaz4vaGVsbWZpbGUuZC8gPGNvbnRyb2wtcGxhbmUtc3RhY2s+L2Vudmly" + + "b25tZW50cy8gPGNvbnRyb2wtcGxhbmUtc3RhY2s+L3NlY3JldHMvIDxjb250cm9sLXBsYW5lLXN0" + + "YWNrPi9nbG9iYWwueWFtbC5nb3RtcGwKbHMgPGNvbXB1dGUtcGxhbmUtc3RhY2s+L2hlbG1maWxl" + + "LmQvIDxjb21wdXRlLXBsYW5lLXN0YWNrPi9lbnZpcm9ubWVudHMvIDxjb21wdXRlLXBsYW5lLXN0" + + "YWNrPi9nbG9iYWwueWFtbC5nb3RtcGwKYGBgCgpJZiBlaXRoZXIgZGlyZWN0b3J5IGlzIG1pc3Np" + + "bmcsIGRvd25sb2FkIG1hdGNoaW5nIGJ1bmRsZSB2ZXJzaW9ucyBmcm9tIE5HQzoKCmBgYGJhc2gK" + + "bmdjIHJlZ2lzdHJ5IHJlc291cmNlIGRvd25sb2FkLXZlcnNpb24gPG9yZz4vbnZjZi1zZWxmLW1h" + + "bmFnZWQtc3RhY2s6PHZlcnNpb24+Cm5nYyByZWdpc3RyeSByZXNvdXJjZSBkb3dubG9hZC12ZXJz" + + "aW9uIDxvcmc+L252Y2YtY29tcHV0ZS1wbGFuZS1zdGFjazo8dmVyc2lvbj4KYGBgCgpDb250cm9s" + + "LXBsYW5lIGNvbW1hbmRzIGJlbG93IGFzc3VtZSB0aGUgY29udHJvbC1wbGFuZSBidW5kbGUgcm9v" + + "dC4gU3BsaXQtZmxvdwpjb21tYW5kcyBhbmQgcm9vdCBzdWJzdGl0dXRpb24gYXJlIGluCltTcGxp" + + "dCBDb21wdXRlLVBsYW5lIEluc3RhbGxhdGlvbl0ocmVmZXJlbmNlcy9jb21wdXRlLXBsYW5lLWlu" + + "c3RhbGxhdGlvbi5tZCkuCgojIyBCZWZvcmUgWW91IFN0YXJ0CgpWZXJpZnkgdG9vbGluZyBhbmQg" + + "Y29udGV4dCBiZWZvcmUgYW55IG9wZXJhdGlvbjoKCmBgYGJhc2gKaGVsbWZpbGUgLS12ZXJzaW9u" + + "ICAgIyBNdXN0IGJlIDEuMS54ICgxLjIuMCByZW1vdmVkIHNlcXVlbnRpYWwgbW9kZSkKaGVsbSB2" + + "ZXJzaW9uICAgICAgICAgIyBNdXN0IGJlID49IDMuMTIKaGVsbSBwbHVnaW4gbGlzdCAgICAgIyBN" + + "dXN0IGluY2x1ZGUgaGVsbS1kaWZmID49IDMuMTEKa3ViZWN0bCB2ZXJzaW9uICAgICAgIyBDbGll" + + "bnQgbXVzdCBiZSB3aXRoaW4gMSBtaW5vciB2ZXJzaW9uIG9mIGNsdXN0ZXIKYGBgCgpDb250cm9s" + + "LXBsYW5lIGNvbW1hbmRzIHJ1biBmcm9tIHRoZSBleHRyYWN0ZWQgYG52Y2Ytc2VsZi1tYW5hZ2Vk" + + "LXN0YWNrL2Agcm9vdDoKCmBgYGJhc2gKY2QgcGF0aC90by9udmNmLXNlbGYtbWFuYWdlZC1zdGFj" + + "awpscyBoZWxtZmlsZS5kLyBlbnZpcm9ubWVudHMvIHNlY3JldHMvIGdsb2JhbC55YW1sLmdvdG1w" + + "bApgYGAKCklkZW50aWZ5IHRoZSBlbnZpcm9ubWVudCBmcm9tIGBlbnZpcm9ubWVudHMvPG5hbWU+" + + "LnlhbWxgIGFuZApgc2VjcmV0cy88bmFtZT4tc2VjcmV0cy55YW1sYDsgZm9yIEVLUywgdXNlIHRo" + + "ZSBjYW5vbmljYWwKW0NTUCBFbmQtdG8tRW5kIEV4YW1wbGVdKGh0dHBzOi8vZG9jcy5udmlkaWEu" + + "Y29tL252Y2YvdjAuNi4wLXJjL2NzcC1lbmQtdG8tZW5kLWV4YW1wbGUpLgoKIyMgSG93IFZhbHVl" + + "cyBGbG93CgpVbmRlcnN0YW5kaW5nIHZhbHVlIHByZWNlZGVuY2UgcHJldmVudHMgdGhlIG1vc3Qg" + + "Y29tbW9uIGNvbmZpZ3VyYXRpb24gbWlzdGFrZXMuCgpgYGB0ZXh0CmVudmlyb25tZW50cy9iYXNl" + + "LnlhbWwgICAgICAgICAgKGRlZmF1bHRzKQogICAgLT4gbWVyZ2VkIHdpdGgKZW52aXJvbm1lbnRz" + + "LzxlbnY+LnlhbWwgICAgICAgICAoeW91ciBvdmVycmlkZXMpCiAgICAtPiBjb25zdW1lZCBieQpn" + + "bG9iYWwueWFtbC5nb3RtcGwgICAgICAgICAgICAgIChHbyB0ZW1wbGF0ZSwgY29uc3RydWN0cyBw" + + "ZXItY2hhcnQgdmFsdWVzKQogICAgLT4gY29uc3VtZWQgYnkKc2VjcmV0cy88ZW52Pi1zZWNyZXRz" + + "LnlhbWwgICAgICAoc2Vuc2l0aXZlIHZhbHVlcykKICAgIC0+IG92ZXJyaWRkZW4gYnkKcmVsZWFz" + + "ZSBpbmxpbmUgdmFsdWVzOiBibG9ja3MgICAoaGlnaGVzdCBwcmVjZWRlbmNlKQpgYGAKCkNyaXRp" + + "Y2FsOiBgZ2xvYmFsLnlhbWwuZ290bXBsYCBvbmx5IHBhc3NlcyB0aHJvdWdoIHNwZWNpZmljIGtl" + + "eXMgdG8gZWFjaCBjaGFydCAoaW1hZ2UgcmVnaXN0cmllcywgbm9kZSBzZWxlY3RvcnMsIHN0b3Jh" + + "Z2UsIHJlcGxpY2EgY291bnRzKS4gU2V0dGluZyBhbiBhcmJpdHJhcnkgY2hhcnQgdmFsdWUgaW4g" + + "dGhlIGVudmlyb25tZW50IGZpbGUgd2lsbCBiZSBzaWxlbnRseSBpZ25vcmVkIGlmIGBnbG9iYWwu" + + "eWFtbC5nb3RtcGxgIGRvZXMgbm90IHByb3BhZ2F0ZSBpdC4KClRvIG92ZXJyaWRlIGFyYml0cmFy" + + "eSBjaGFydCB2YWx1ZXMsIHVzZSBhIGhlbG1maWxlIHJlbGVhc2UgYHZhbHVlczpgIGJsb2NrLiBT" + + "ZWUgW092ZXJyaWRpbmcgSGVsbSBDaGFydCBWYWx1ZXNdKCNvdmVycmlkaW5nLWhlbG0tY2hhcnQt" + + "dmFsdWVzKS4KCiMjIENsZWFuIEluc3RhbGxhdGlvbgoKIyMjIDEuIENyZWF0ZSBuYW1lc3BhY2Vz" + + "IGFuZCBpbWFnZSBwdWxsIHNlY3JldHMgKGlmIHVzaW5nIGEgcHJpdmF0ZSByZWdpc3RyeSkKCmBg" + + "YGJhc2gKZm9yIG5zIGluIGNhc3NhbmRyYS1zeXN0ZW0gbmF0cy1zeXN0ZW0gbnZjZiBhcGkta2V5" + + "cyBlc3Mgc2lzIHZhdWx0LXN5c3RlbSBjZXJ0LW1hbmFnZXI7IGRvCiAga3ViZWN0bCBjcmVhdGUg" + + "bmFtZXNwYWNlICIkbnMiIC0tZHJ5LXJ1bj1jbGllbnQgLW8geWFtbCB8IGt1YmVjdGwgYXBwbHkg" + + "LWYgLQpkb25lCgojIE9ubHkgaWYgcHVsbGluZyBmcm9tIGEgcHJpdmF0ZSByZWdpc3RyeSAoZS5n" + + "LiwgTkdDIG52Y3IuaW8pCmV4cG9ydCBOR0NfQVBJX0tFWT0iPHlvdXIta2V5PiIKZm9yIG5zIGlu" + + "IGNhc3NhbmRyYS1zeXN0ZW0gbmF0cy1zeXN0ZW0gbnZjZiBhcGkta2V5cyBlc3Mgc2lzIHZhdWx0" + + "LXN5c3RlbSBjZXJ0LW1hbmFnZXI7IGRvCiAga3ViZWN0bCBjcmVhdGUgc2VjcmV0IGRvY2tlci1y" + + "ZWdpc3RyeSBudmNyLWNyZWRzIFwKICAgIC0tZG9ja2VyLXNlcnZlcj1udmNyLmlvIFwKICAgIC0t" + + "ZG9ja2VyLXVzZXJuYW1lPSckb2F1dGh0b2tlbicgXAogICAgLS1kb2NrZXItcGFzc3dvcmQ9IiRO" + + "R0NfQVBJX0tFWSIgXAogICAgLS1uYW1lc3BhY2U9IiRucyIgXAogICAgLS1kcnktcnVuPWNsaWVu" + + "dCAtbyB5YW1sIHwga3ViZWN0bCBhcHBseSAtZiAtCmRvbmUKYGBgCgpJZiB1c2luZyBwdWxsIHNl" + + "Y3JldHMsIHlvdSBtdXN0IGFsc28gY29uZmlndXJlIGVhY2ggaGVsbWZpbGUgcmVsZWFzZSB0byBy" + + "ZWZlcmVuY2UgdGhlbS4gU2VlIFtJbWFnZSBQdWxsIFNlY3JldHNdKCNpbWFnZS1wdWxsLXNlY3Jl" + + "dHMpLgoKIyMjIDIuIEF1dGhlbnRpY2F0ZSBoZWxtIHRvIHlvdXIgY2hhcnQgcmVnaXN0cnkKCkJv" + + "dGggYGRvY2tlciBsb2dpbmAgQU5EIGBoZWxtIHJlZ2lzdHJ5IGxvZ2luYCBhcmUgcmVxdWlyZWQg" + + "Zm9yIE5HQy4gSGVsbWZpbGUgdXNlcyBoZWxtIChub3QgZG9ja2VyKSB0byBwdWxsIE9DSSBjaGFy" + + "dHMsIHNvIHRoZSBoZWxtIGNyZWRlbnRpYWwgc3RvcmUgbXVzdCBiZSBhdXRoZW50aWNhdGVkIHNl" + + "cGFyYXRlbHkuCgpgYGBiYXNoCiMgTkdDIC0tIGJvdGggY29tbWFuZHMgYXJlIHJlcXVpcmVkCmRv" + + "Y2tlciBsb2dpbiBudmNyLmlvIC11ICckb2F1dGh0b2tlbicgLXAgIiROR0NfQVBJX0tFWSIKaGVs" + + "bSByZWdpc3RyeSBsb2dpbiBudmNyLmlvIC11ICckb2F1dGh0b2tlbicgLXAgIiROR0NfQVBJX0tF" + + "WSIKCiMgRUNSCmF3cyBlY3IgZ2V0LWxvZ2luLXBhc3N3b3JkIC0tcmVnaW9uIDxyZWdpb24+IHwg" + + "XAogIGhlbG0gcmVnaXN0cnkgbG9naW4gLS11c2VybmFtZSBBV1MgLS1wYXNzd29yZC1zdGRpbiA8" + + "YWNjb3VudD4uZGtyLmVjci48cmVnaW9uPi5hbWF6b25hd3MuY29tCmBgYAoKPiBOb3RlOiBgaGVs" + + "bSByZWdpc3RyeSBsb2dpbmAga2VlcHMgb25seSB0aGUgbGFzdCBsb2dpbiBwZXIgaG9zdCAobXVs" + + "dGktb3JnIGdvdGNoYSkuCj4gVGhlIHN0YWNrIGFuZCB0aGUgY2FjaGVzIHNwYW4gc2V2ZXJhbCBO" + + "R0Mgb3JncyB0aGF0IG5lZWQgZGlmZmVyZW50IGtleXMsIGJ1dCB0aGV5IGFsbAo+IGxpdmUgdW5k" + + "ZXIgdGhlIG9uZSBob3N0IGBudmNyLmlvYC4gQSBzZWNvbmQgYGhlbG0gcmVnaXN0cnkgbG9naW4g" + + "bnZjci5pb2AgKG9yCj4gYGRvY2tlciBsb2dpbmApIHdpdGggYSBkaWZmZXJlbnQga2V5IHNpbGVu" + + "dGx5IG92ZXJ3cml0ZXMgdGhlIGZpcnN0LCBzbyBwdWxscyBmb3IgdGhlCj4gZmlyc3Qgb3JnIHRo" + + "ZW4gZmFpbCB3aXRoIGEgbWlzbGVhZGluZyBgNDAzIEFjY2VzcyBEZW5pZWRgIChub3QgYSA0MDE6" + + "IHRoZSBhdXRoCj4gc3VjY2VlZGVkLCBpdCdzIHRoZSB3cm9uZy1vcmcga2V5KS4gSWYgeW91IHB1" + + "bGwgY2hhcnRzL2ltYWdlcyBmcm9tIG1vcmUgdGhhbiBvbmUgb3JnLAo+IGVpdGhlciByZS1ydW4g" + + "dGhlIGxvZ2luIHdpdGggdGhlIGNvcnJlY3Qga2V5IGltbWVkaWF0ZWx5IGJlZm9yZSBlYWNoIHB1" + + "bGwsIG9yIHBhc3MKPiBgLS11c2VybmFtZSAnJG9hdXRodG9rZW4nIC0tcGFzc3dvcmQgPGtleT5g" + + "IHBlciBgaGVsbSBwdWxsYCBpbnZvY2F0aW9uIGluc3RlYWQgb2YgcmVseWluZwo+IG9uIHRoZSBz" + + "dG9yZWQgbG9naW4uIChQdWxsaW5nIGV2ZXJ5dGhpbmcgZnJvbSBhIHNpbmdsZSByZWdpc3RyeSBt" + + "aXJyb3IgYXZvaWRzIHRoaXMKPiBlbnRpcmVseS4pCgojIyMgMmIuIChSZWNvbW1lbmRlZCkgUHVs" + + "bCBmcm9tIGEgcmVnaXN0cnkgdGhlIGNsdXN0ZXIgYXV0aGVudGljYXRlcyB0byBhdXRvbWF0aWNh" + + "bGx5CgpJZiB5b3UgbWlycm9yIHRoZSBOVkNGIGltYWdlcyBpbnRvIGEgcmVnaXN0cnkgdGhhdCB0" + + "aGUgY2x1c3RlciBjYW4gcHVsbCBmcm9tIHdpdGhvdXQgYW4KZXhwbGljaXQgaW1hZ2UgcHVsbCBz" + + "ZWNyZXQgKGZvciBleGFtcGxlLCBhIHJlZ2lzdHJ5IHdoZXJlIHRoZSBub2RlIG9yIHdvcmtsb2Fk" + + "IGlkZW50aXR5IGlzCmdyYW50ZWQgcHVsbCBhY2Nlc3MgYXV0b21hdGljYWxseSksIHBvaW50IHRo" + + "ZSBzdGFjayBhdCBpdCBhbmQgc2tpcCB0aGUgY3JlZGVudGlhbCBzdGVwcwphYm92ZS4KCjEuIFBv" + + "aW50IHRoZSBzdGFjayBhdCB0aGUgbWlycm9yIGluIGBlbnZpcm9ubWVudHMvPGVudj4ueWFtbGA6" + + "CgogICBgYGB5YW1sCiAgIGdsb2JhbDoKICAgICBpbWFnZToKICAgICAgIHJlZ2lzdHJ5OiA8eW91" + + "ci1yZWdpc3RyeT4gICAgICAgICAgICAjIGUuZy4gcmVnaXN0cnkuZXhhbXBsZS5jb20KICAgICAg" + + "IHJlcG9zaXRvcnk6IDxtaXJyb3Itc3ViLXBhdGg+CiAgICAgaGVsbToKICAgICAgIHNvdXJjZXM6" + + "ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIyBvbmx5IGlmIHlvdSBhbHNvIG1pcnJvcmVk" + + "IHRoZSBoZWxtIGNoYXJ0cwogICAgICAgICByZWdpc3RyeTogPHlvdXItcmVnaXN0cnk+CiAgICAg" + + "ICAgIHJlcG9zaXRvcnk6IDxtaXJyb3Itc3ViLXBhdGg+CiAgIGBgYAoKICAgPiBUaGUgbWFuaWZl" + + "c3QgaGFzIGEgbWl4IG9mIE9DSSBhbmQgbm9uLU9DSSBhcnRpZmFjdHMsIHNvIGNvbnZlcnQgbm9u" + + "LU9DSSBjaGFydHMgYmVmb3JlCiAgID4gbWlycm9yaW5nLiBDb250YWluZXIgaW1hZ2VzIChgbnZj" + + "ci5pby8uLi5gKSBhcmUgYWxyZWFkeSBPQ0kgYW5kIG1pcnJvciBhcy1pcy4gU29tZSBIZWxtCiAg" + + "ID4gY2hhcnRzLCB0aG91Z2gsIGFyZSBwdWJsaXNoZWQgdG8gYW4gYGh0dHBzOi8vYCBIZWxtIHJl" + + "cG9zaXRvcnkgKGZvciBleGFtcGxlCiAgID4gYGhlbG0ubmdjLm52aWRpYS5jb21gKS4gVGhvc2Ug" + + "YXJlIG5vdCBPQ0kgYW5kIGNhbm5vdCBiZSByZXNvbHZlZCBieSBgZ2xvYmFsLmhlbG0uc291cmNl" + + "c2AKICAgPiAoT0NJKSBvciBtaXJyb3JlZCBkaXJlY3RseS4gQmVmb3JlIHBvaW50aW5nIHRoZSBz" + + "dGFjayBhdCB5b3VyIG1pcnJvciwgY29udmVydCBlYWNoCiAgID4gYGh0dHBzOi8vYCBjaGFydCB0" + + "byBPQ0k6IGBoZWxtIHJlcG8gYWRkYC9gaGVsbSBwdWxsYCB0aGUgYC50Z3pgLCB0aGVuCiAgID4g" + + "YGhlbG0gcHVzaCA8Y2hhcnQ+LnRneiBvY2k6Ly88eW91ci1yZWdpc3RyeT4vPG1pcnJvci1zdWIt" + + "cGF0aD5gLiBPbmx5IE9DSSBjaGFydHMgY2FuIGxpdmUKICAgPiB1bmRlciBgZ2xvYmFsLmhlbG0u" + + "c291cmNlc2AuCgoyLiBTa2lwIHRoZSBjcmVkZW50aWFsIHN0ZXBzIGFib3ZlLiBXaGVuIHRoZSBj" + + "bHVzdGVyIGF1dGhlbnRpY2F0ZXMgdG8gdGhlIHJlZ2lzdHJ5CiAgIGF1dG9tYXRpY2FsbHkgKHRo" + + "ZSBub2RlIG9yIHdvcmtsb2FkIGlkZW50aXR5IGhhcyBwdWxsIGFjY2VzcyksIHRoZSBwdWxsIG5l" + + "ZWRzIG5vCiAgIEt1YmVybmV0ZXMgc2VjcmV0LCBzbyB5b3UgZG8gbm90IG5lZWQ6IHRoZSBwZXIt" + + "bmFtZXNwYWNlIGBudmNyLWNyZWRzYCBmcm9tIHN0ZXAgMQogICAoc3RpbGwgY3JlYXRlIHRoZSBu" + + "YW1lc3BhY2VzLCBqdXN0IG5vdCB0aGUgc2VjcmV0KSwgdGhlIGBnbG9iYWwuaW1hZ2VQdWxsU2Vj" + + "cmV0c2AgZW50cnksCiAgIG9yIHRoZSBgZG9ja2VyIGxvZ2luYC9gaGVsbSByZWdpc3RyeSBsb2dp" + + "biBudmNyLmlvYCBmcm9tIHN0ZXAgMi4KCiAgID4gT25lIHJlc2lkdWFsIGNyZWRlbnRpYWw6IG52" + + "Y2YtYXBpIHN0aWxsIHZhbGlkYXRlcyB0aGUgdXNlciBmdW5jdGlvbiBpbWFnZSBhdAogICA+IGBm" + + "dW5jdGlvbiBjcmVhdGVgIHZpYSB0aGUgYWNjb3VudCByZWdpc3RyeSBjcmVkZW50aWFsLCBzbyB0" + + "aGF0IGNyZWRlbnRpYWwgbXVzdCBwb2ludCBhdAogICA+IHdoaWNoZXZlciByZWdpc3RyeSBob2xk" + + "cyB0aGUgdXNlciBpbWFnZSAoc2V0IGl0IHRvIHlvdXIgbWlycm9yIGlmIHRoZSBpbWFnZSBpcyBt" + + "aXJyb3JlZAogICA+IHRoZXJlKS4KCiMjIyAzLiBTZXQgdGhlIHJvdXRlIGRvbWFpbgoKYGdsb2Jh" + + "bC5kb21haW5gIHNldHMgdGhlIGhvc3RuYW1lIHN1ZmZpeCBmb3Igcm91dGVzIHN1Y2ggYXMgYGFw" + + "aS48ZG9tYWluPmAuIEl0IGRvZXMgbm90IGNvbmZpZ3VyZSB0aGUgR2F0ZXdheSBvciBpdHMgbG9h" + + "ZCBiYWxhbmNlci4gUXVlcnkgdGhlIGV4YWN0IGV4aXN0aW5nIEdhdGV3YXk6CgpgYGBiYXNoCkdB" + + "VEVXQVlfQUREUkVTUz0kKGt1YmVjdGwgZ2V0IGdhdGV3YXkgPGdhdGV3YXktbmFtZT4gLW4gPGdh" + + "dGV3YXktbmFtZXNwYWNlPiAtbyBqc29ucGF0aD0ney5zdGF0dXMuYWRkcmVzc2VzWzBdLnZhbHVl" + + "fScpCnRlc3QgLW4gIiRHQVRFV0FZX0FERFJFU1MiCmBgYAoKVGhlIGFkZHJlc3MgbWF5IGJlIGEg" + + "aG9zdG5hbWUgb3IgSVAuIENsb3VkIHByb3ZpZGVyIGFuZCBHYXRld2F5IGNvbmZpZ3VyYXRpb24g" + + "ZGV0ZXJtaW5lIHRoZSBsb2FkIGJhbGFuY2VyIHR5cGUgYW5kIGFkZHJlc3MgZm9ybWF0LiBUaGUg" + + "c2VsZi1tYW5hZ2VkIHN0YWNrIGRvZXMgbm90IHNlbGVjdCBlaXRoZXIuCgpGb3IgdGVtcG9yYXJ5" + + "IHRlc3Rpbmcgd2l0aG91dCBETlMsIHVzZSBhIHN0YWJsZSByZXNlcnZlZCBzdWZmaXggc3VjaCBh" + + "cyBgZ2xvYmFsLmRvbWFpbjogIm52Y2YudGVzdCJgLCBjb25uZWN0IHRvIGBHQVRFV0FZX0FERFJF" + + "U1NgLCBhbmQgc2VuZCBgSG9zdDogYXBpLm52Y2YudGVzdGAuIEZvciBwcm9kdWN0aW9uLCB1c2Ug" + + "eW91ciBiYXNlIEROUyBkb21haW4gYW5kIHBvaW50IGl0cyByb3V0ZSByZWNvcmRzIHRvIGBHQVRF" + + "V0FZX0FERFJFU1NgLiBJZiB0aGUgZW5kcG9pbnQgbGF0ZXIgY2hhbmdlcywga2VlcCBgZ2xvYmFs" + + "LmRvbWFpbmAgc3RhYmxlIGFuZCB1cGRhdGUgb25seSBETlMgb3IgdGhlIGNsaWVudCBkZXN0aW5h" + + "dGlvbi4gU2VlIEV4YW1wbGUgNCBpbiBbZXhhbXBsZXMubWRdKGV4YW1wbGVzLm1kKS4KCiMjIyA0" + + "LiBQcmV2aWV3IGFuZCBkZXBsb3kKCmBgYGJhc2gKSEVMTUZJTEVfRU5WPTxlbnYtbmFtZT4gaGVs" + + "bWZpbGUgdGVtcGxhdGUgICAjIFByZXZpZXcgcmVuZGVyZWQgbWFuaWZlc3RzCkhFTE1GSUxFX0VO" + + "Vj08ZW52LW5hbWU+IGhlbG1maWxlIHN5bmMgICAgICAgIyBEZXBsb3kKYGBgCgo+IFNldCBCT1RI" + + "IENhc3NhbmRyYSBwYXNzd29yZHMgaW4gdGhlIHNlY3JldHMgZmlsZS4gVGhlIHNlY3JldHMgZmls" + + "ZSBkb2N1bWVudHMKPiBgREVGQVVMVF9DQVNTQU5EUkFfUEFTU1dPUkRgLCBidXQgdGhlIGNhc3Nh" + + "bmRyYSBjaGFydCBoYXMgYSBzZWNvbmQgY3JlZGVudGlhbCwKPiBgY2Fzc2FuZHJhLnNlcnZpY2VS" + + "b2xlUGFzc3dvcmRgLCB0aGF0IGRlZmF1bHRzIHRvIGBjaEBuZzNtM2AuIElmIHlvdSBzZXQgb25s" + + "eQo+IGBERUZBVUxUX0NBU1NBTkRSQV9QQVNTV09SRGAsIHRoZSBzZXJ2aWNlIHJvbGUga2VlcHMg" + + "dGhhdCBkZWZhdWx0IGFuZCBldmVyeSBEQi1iYWNrZWQKPiBzZXJ2aWNlIGNyYXNoLWxvb3BzIHdp" + + "dGggYEJhZCBjcmVkZW50aWFsc2Agb25jZSBpdCB0cmllcyB0byBhdXRoZW50aWNhdGUuIFNldAo+" + + "IGBjYXNzYW5kcmEuc2VydmljZVJvbGVQYXNzd29yZGAgdG8gdGhlIHNhbWUgdmFsdWUgYXMgYERF" + + "RkFVTFRfQ0FTU0FORFJBX1BBU1NXT1JEYC4KCiMjIyA1LiBEZXBsb3ltZW50IHBoYXNlcwoKSGVs" + + "bWZpbGUgZGVwbG95cyBpbiBvcmRlciB3aXRoIGRlcGVuZGVuY2llczoKCnwgUGhhc2UgfCBTZWxl" + + "Y3RvciB8IFNlcnZpY2VzIHwgV2FpdCB0aW1lIHwKfC0tLS0tLS18LS0tLS0tLS0tLXwtLS0tLS0t" + + "LS0tfC0tLS0tLS0tLS0tfAp8IDEgfCBgcmVsZWFzZS1ncm91cD1kZXBlbmRlbmNpZXNgIHwgTkFU" + + "UywgT3BlbkJhbywgQ2Fzc2FuZHJhIHwgNS0xMCBtaW4gfAp8IDIgfCBgcmVsZWFzZS1ncm91cD1z" + + "ZXJ2aWNlc2AgfCBBUEksIFNJUywgRVNTLCBpbnZvY2F0aW9uLCBncnBjLXByb3h5LCBub3Rhcnks" + + "IGFwaS1rZXlzLCBvcHRpb25hbCBMTE0gZ2F0ZXdheS9yb3V0ZXIsIG9wdGlvbmFsIFZhbml0eSBH" + + "YXRld2F5LCBOVkNGIFVJIHdoZW4gdGhlIHN0YWNrIHBhY2thZ2UgaW5jbHVkZXMgdGhlIGFkZG9u" + + "IHwgNS0xMCBtaW4gfAp8IDMgfCBgcmVsZWFzZS1ncm91cD1pbmdyZXNzYCB8IEdhdGV3YXkgcm91" + + "dGVzIHwgMS0yIG1pbiB8CnwgNCB8IGByZWxlYXNlLWdyb3VwPW9ic2VydmFiaWxpdHlgIHwgT2Jz" + + "ZXJ2YWJpbGl0eSBzdGFjayAoaWYgZW5hYmxlZCkgfCAxLTIgbWluIHwKCk1vbml0b3IgaW4gYSBz" + + "ZXBhcmF0ZSB0ZXJtaW5hbDoKCmBgYGJhc2gKa3ViZWN0bCBnZXQgcG9kcyAtQSAtdwpgYGAKCiMj" + + "IyA2LiBFbmFibGUgVmFuaXR5IEdhdGV3YXkgKG9wdGlvbmFsKQoKVmFuaXR5IEdhdGV3YXkgaXMg" + + "ZGlzYWJsZWQgYnkgZGVmYXVsdCBhbmQgaXMgb25seSBuZWVkZWQgZm9yIGN1c3RvbWVyLWZhY2lu" + + "Zwpob3N0bmFtZXMgb3IgcGF0aCBtYXBwaW5ncyBpbiBmcm9udCBvZiB0aGUgc3RhbmRhcmQgTlZD" + + "RiBBUEkgYW5kIGludm9jYXRpb24Kcm91dGVzLiBJdCBpcyBhdmFpbGFibGUgb25seSBpbiBzdGFj" + + "ayBwYWNrYWdlcyB0aGF0IGluY2x1ZGUgdGhlIFZhbml0eSBHYXRld2F5CmFkZG9uLiBJZiB0aGUg" + + "ZXh0cmFjdGVkIHN0YWNrIHBhY2thZ2UgZG9lcyBub3QgY29udGFpbiBhIGB2YW5pdHktZ2F0ZXdh" + + "eWAKcmVsZWFzZSBhbmQgYHZhbml0eUdhdGV3YXlgIHJvdXRlIHZhbHVlcywgc2tpcCB0aGVzZSBj" + + "b21tYW5kcyBhbmQgdXNlIGEgbmV3ZXIKc3RhY2sgcGFja2FnZSB0aGF0IGluY2x1ZGVzIHRoZW0u" + + "CgpJZiB0aGUgc3RhY2sgcGFja2FnZSBpbmNsdWRlcyB0aGUgYWRkb24sIGVuYWJsZSBpdCBpbgpg" + + "ZW52aXJvbm1lbnRzLzxlbnYtbmFtZT4ueWFtbGA6CgpgYGB5YW1sCmFkZG9uczoKICB2YW5pdHlH" + + "YXRld2F5OgogICAgZW5hYmxlZDogdHJ1ZQogICAgbWFwcGluZ0NvbmZpZzoge30KYGBgCgpUaGUg" + + "ZGVmYXVsdCByb3V0ZSBob3N0IGlzIGB2YW5pdHkuPGRvbWFpbj5gIGFuZCB0aGUgYmFja2VuZCBp" + + "cwpgdmFuaXR5LWdhdGV3YXkubnZjZjo4MDgwYC4gUHV0IGRlcGxveW1lbnQtc3BlY2lmaWMgaG9z" + + "dCBhbmQgcGF0aCBtYXBwaW5ncyB1bmRlcgpgYWRkb25zLnZhbml0eUdhdGV3YXkubWFwcGluZ0Nv" + + "bmZpZ2AuIElmIHlvdSBuZWVkIGN1c3RvbSB2YW5pdHkgaG9zdHMgaW5zdGVhZCBvZgpgdmFuaXR5" + + "Ljxkb21haW4+YCwgdXNlIHRoZSByb3V0ZSBob3N0bmFtZSBvdmVycmlkZXMgc3VwcG9ydGVkIGJ5" + + "IHRoZSBzdGFjawpwYWNrYWdlIGFuZCBjcmVhdGUgbWF0Y2hpbmcgRE5TIHJlY29yZHMuCgpBZnRl" + + "ciBjb25maXJtaW5nIHRoZSBwYWNrYWdlIGluY2x1ZGVzIHRoZSBgdmFuaXR5LWdhdGV3YXlgIHJl" + + "bGVhc2UsIHByZXZpZXcgYW5kCmFwcGx5IHRoZSBzZXJ2aWNlIGFuZCByb3V0ZToKCmBgYGJhc2gK" + + "SEVMTUZJTEVfRU5WPTxlbnYtbmFtZT4gaGVsbWZpbGUgLS1zZWxlY3RvciBuYW1lPXZhbml0eS1n" + + "YXRld2F5IHRlbXBsYXRlCkhFTE1GSUxFX0VOVj08ZW52LW5hbWU+IGhlbG1maWxlIC0tc2VsZWN0" + + "b3IgbmFtZT12YW5pdHktZ2F0ZXdheSBzeW5jCkhFTE1GSUxFX0VOVj08ZW52LW5hbWU+IGhlbG1m" + + "aWxlIC0tc2VsZWN0b3IgcmVsZWFzZS1ncm91cD1pbmdyZXNzIHN5bmMKYGBgCgpWZXJpZnkgdGhl" + + "IHJvdXRlIG9ubHkgd2hlbiB0aGUgYWRkb24gaXMgcHJlc2VudCBhbmQgZW5hYmxlZDoKCmBgYGJh" + + "c2gKa3ViZWN0bCBnZXQgZGVwbG95LHN2YyAtbiBudmNmIC1sIGFwcC5rdWJlcm5ldGVzLmlvL25h" + + "bWU9dmFuaXR5LWdhdGV3YXkKa3ViZWN0bCBnZXQgaHR0cHJvdXRlIC1BIHwgZ3JlcCAtaSB2YW5p" + + "dHkKY3VybCAtSCAiSG9zdDogdmFuaXR5Ljxkb21haW4+IiAiaHR0cDovLzxnYXRld2F5LWFkZHJl" + + "c3M+L2hlYWx0aCIKYGBgCgpEbyBub3QgZW5hYmxlIFZhbml0eSBHYXRld2F5IGZvciBzdGFuZGFy" + + "ZCBBUEksIEFQSSBLZXlzLCBpbnZvY2F0aW9uLCBMTE0KaW52b2NhdGlvbiwgb3IgZ1JQQyB0cmFm" + + "ZmljLiBUaG9zZSByb3V0ZXMgYXJlIHByb3ZpZGVkIGJ5IHRoZSBiYXNlIGdhdGV3YXkKcm91dGVz" + + "LgoKIyMjIDcuIEVuYWJsZSBOVkNGIFVJIChvcHRpb25hbCkKCk5WQ0YgVUkgaXMgYW4gb3B0aW9u" + + "YWwsIGN1c3RvbWVyLWZhY2luZyBhZG1pbi1wYW5lbCBVSS4gSXQgaXMgZGlzYWJsZWQgYnkKZGVm" + + "YXVsdCBhbmQgYXZhaWxhYmxlIG9ubHkgaW4gc3RhY2sgcGFja2FnZXMgdGhhdCBpbmNsdWRlIHRo" + + "ZSBOVkNGIFVJIGFkZG9uLiBJZgp5b3VyIGV4dHJhY3RlZCBzdGFjayBwYWNrYWdlIGRvZXMgbm90" + + "IGNvbnRhaW4gYSBgbnZjZi11aWAgcmVsZWFzZSBhbmQgYG52Y2ZVaWAKcm91dGUgdmFsdWVzLCBz" + + "a2lwIHRoaXMgc2VjdGlvbi4gVGhlIE5WQ0YgVUkgYWRtaW4gcGFuZWwgaXMgY3VycmVudGx5CnVu" + + "YXV0aGVudGljYXRlZDsgZG8gbm90IGV4cG9zZSBpdCB0byB0aGUgcHVibGljIGludGVybmV0LgoK" + + "Rm9yIGVuYWJsZW1lbnQsIG5hbWVzcGFjZSBhbmQgcHVsbCBzZWNyZXQgc2V0dXAsIGFwcGx5LCBh" + + "bmQgdmVyaWZpY2F0aW9uIHN0ZXBzLApzZWUgW05WQ0YgVUkgQWRkb25dKHJlZmVyZW5jZXMvbnZj" + + "Zi11aS1hZGRvbi5tZCkuCgojIyMgOC4gSW5zdGFsbCB0aGUgc2VwYXJhdGUgY29tcHV0ZS1wbGFu" + + "ZSBzdGFjawoKVGhlIGNvbnRyb2wtcGxhbmUgc3RhY2sgZG9lcyBub3QgaW5zdGFsbCBjb21wdXRl" + + "LXBsYW5lIGNvbXBvbmVudHMuIEFmdGVyIGl0IGlzIGhlYWx0aHksIGNob29zZSBleGFjdGx5IG9u" + + "ZSBjb21wdXRlLXBsYW5lIGhhbmRvZmY6CgotIEhlbG1maWxlIHZhbHVlcyBmbG93OiBhdXRob3Ig" + + "c2VwYXJhdGUgZW52aXJvbm1lbnQgZmlsZXMsIHRoZW4gcmVnaXN0ZXIgYW5kCiAgaW5zdGFsbCB3" + + "aXRoIHRoZSBjb21wdXRlLXBsYW5lIE1ha2VmaWxlLgotIENMSSBwcm9maWxlIGZsb3c6IHVzZSB0" + + "aGUgcHJvZmlsZSBnZW5lcmF0ZWQgYnkgdGhlIENMSSBjb250cm9sLXBsYW5lIGluc3RhbGwsCiAg" + + "dGhlbiBydW4gdGhlIGNvbXBsZXRlIENMSSByZWdpc3RlciBhbmQgaW5zdGFsbCBjb21tYW5kcy4K" + + "CkRvIG5vdCBtaXggdGhlc2UgaGFuZG9mZnMuIEZvbGxvdyBbU3BsaXQgQ29tcHV0ZS1QbGFuZSBJ" + + "bnN0YWxsYXRpb25dKHJlZmVyZW5jZXMvY29tcHV0ZS1wbGFuZS1pbnN0YWxsYXRpb24ubWQpCmZv" + + "ciBjb21tYW5kcywgRUtTIHJvdXRpbmcsIHB1bGwgc2VjcmV0cywgdmVyaWZpY2F0aW9uLCBhbmQg" + + "dGVhcmRvd24gb3JkZXIuCgojIyBDbGVhbiBUZWFyZG93bgoKRGVzdHJveSBldmVyeSBjb21wdXRl" + + "IHBsYW5lIGJlZm9yZSB0aGUgY29udHJvbCBwbGFuZSwgdXNpbmcgdGhlIHRlYXJkb3duIGZvcgp0" + + "aGUgc2FtZSBoYW5kb2ZmOyBzZWUgW1NwbGl0IENvbXB1dGUtUGxhbmUgSW5zdGFsbGF0aW9uXShy" + + "ZWZlcmVuY2VzL2NvbXB1dGUtcGxhbmUtaW5zdGFsbGF0aW9uLm1kI3RlYXJkb3duKS4KClNjb3Bl" + + "OiBPbmx5IGRlc3Ryb3kgcmVsZWFzZXMgbWFuYWdlZCBieSB0aGlzIGNvbnRyb2wtcGxhbmUgaGVs" + + "bWZpbGUgc3RhY2suIFRoZSBOVkNGIHJlbGVhc2VzIGFyZTogYG5hdHNgLCBgb3BlbmJhby1zZXJ2" + + "ZXJgLCBgY2Fzc2FuZHJhYCwgYGFwaS1rZXlzYCwgYHNpc2AsIGBhcGlgLCBgaW52b2NhdGlvbi1z" + + "ZXJ2aWNlYCwgYGdycGMtcHJveHlgLCBgZXNzLWFwaWAsIGBub3Rhcnktc2VydmljZWAsIG9wdGlv" + + "bmFsIGB2YW5pdHktZ2F0ZXdheWAsIGBudmNmLXVpYCwgYGFkbWluLWlzc3Vlci1wcm94eWAsIGFu" + + "ZCBgaW5ncmVzc2A7IGBjZXJ0LW1hbmFnZXJgIGlzIGluY2x1ZGVkIG9ubHkgd2hlbiBgY2VydE1h" + + "bmFnZXIuZW5hYmxlZDogdHJ1ZWAuIFRoZSBjb250cm9sLXBsYW5lIG5hbWVzcGFjZSBpbnZlbnRv" + + "cnkgYWxzbyBpbmNsdWRlcyBgY2VydC1tYW5hZ2VyYCwgYnV0IGRlZmF1bHQgbmFtZXNwYWNlIGNs" + + "ZWFudXAgcHJlc2VydmVzIGl0LiBOYW1lc3BhY2UgcHJlc2VydmF0aW9uIGRvZXMgbm90IHByZXNl" + + "cnZlIHRoZSBIZWxtIHJlbGVhc2UuIEV4dGVybmFsIGNlcnQtbWFuYWdlciBtdXN0IHVzZSBgY2Vy" + + "dE1hbmFnZXIuZW5hYmxlZDogZmFsc2VgOyBvdGhlcndpc2UgSGVsbWZpbGUgdHJlYXRzIHRoZSBy" + + "ZWxlYXNlIGFzIHN0YWNrLW1hbmFnZWQuIERvIG5vdCBkZWxldGUgb3RoZXIgaGVsbSByZWxlYXNl" + + "cyBvciBuYW1lc3BhY2VzIG9uIHRoZSBjbHVzdGVyLgoKQmVmb3JlIGNvbnRyb2wtcGxhbmUgZGVz" + + "dHJveSwgaWRlbnRpZnkgd2hldGhlciBjZXJ0LW1hbmFnZXIgaXMgZXh0ZXJuYWwuIEZvciBhbiBl" + + "eHRlcm5hbCBpbnN0YWxsYXRpb24sIHJlcXVpcmUgZWZmZWN0aXZlIGBjZXJ0TWFuYWdlci5lbmFi" + + "bGVkOiBmYWxzZWAgYW5kIGFib3J0IGlmIGl0IGlzIHRydWUuIEZvciBhbiBpbnRlbmRlZCBzdGFj" + + "ay1tYW5hZ2VkIHJlbGVhc2Ugd2l0aCBlZmZlY3RpdmUgYGNlcnRNYW5hZ2VyLmVuYWJsZWQ6IHRy" + + "dWVgLCB2ZXJpZnkgbWF0Y2hpbmcgc3RhY2sgSGVsbSByZWxlYXNlIG1ldGFkYXRhIGFuZCBhYm9y" + + "dCBvbiBtaXNzaW5nIG9yIG1pc21hdGNoZWQgZXZpZGVuY2UuIFByZXNlcnZlIHRoZSBuYW1lc3Bh" + + "Y2UgYnkgZGVmYXVsdDsgb25seSBwbGFuIGl0cyByZW1vdmFsIGFmdGVyIHZlcmlmeWluZyBuYW1l" + + "c3BhY2UgcHJvdmVuYW5jZSBhbmQgZ2V0dGluZyBleHBsaWNpdCBjb25maXJtYXRpb24uCgojIyMg" + + "U3RhbmRhcmQgdGVhcmRvd24KClJ1biBmcm9tIGluc2lkZSB0aGUgYG52Y2Ytc2VsZi1tYW5hZ2Vk" + + "LXN0YWNrL2AgZGlyZWN0b3J5OgoKYGBgYmFzaApIRUxNRklMRV9FTlY9PGVudi1uYW1lPiBoZWxt" + + "ZmlsZSBkZXN0cm95CmBgYAoKIyMjIERlbGV0ZSBuYW1lc3BhY2VzCgpgYGBiYXNoCmZvciBucyBp" + + "biBjYXNzYW5kcmEtc3lzdGVtIG5hdHMtc3lzdGVtIG52Y2YgYXBpLWtleXMgZXNzIHNpcyBudmNm" + + "LXVpIHZhdWx0LXN5c3RlbTsgZG8KICBrdWJlY3RsIGRlbGV0ZSBuYW1lc3BhY2UgIiRucyIgLS1p" + + "Z25vcmUtbm90LWZvdW5kCmRvbmUKYGBgCgojIyMgVmVyaWZ5IGNsZWFuCgpgYGBiYXNoCmt1YmVj" + + "dGwgZ2V0IG5zIHwgZ3JlcCAtRSAnKGNhc3NhbmRyYXxuYXRzfHZhdWx0fG52Y2Z8YXBpLWtleXN8" + + "ZXNzfHNpcyknCiMgU2hvdWxkIHJldHVybiBlbXB0eQpgYGAKCiMjIE92ZXJyaWRpbmcgSGVsbSBD" + + "aGFydCBWYWx1ZXMKCiMjIyBFbnZpcm9ubWVudCBmaWxlIChsaW1pdGVkKQoKV29ya3Mgb25seSBm" + + "b3Iga2V5cyB0aGF0IGBnbG9iYWwueWFtbC5nb3RtcGxgIHByb3BhZ2F0ZXMgKGUuZy4sIGBjYXNz" + + "YW5kcmEucmVwbGljYUNvdW50YCwgYGdsb2JhbC5zdG9yYWdlQ2xhc3NgLCBgZ2xvYmFsLmltYWdl" + + "YCkuCgojIyMgUmVsZWFzZSB2YWx1ZXMgYmxvY2sgKGFueSBjaGFydCB2YWx1ZSkKCkVkaXQgdGhl" + + "IHJlbGVhc2UgaW4gYGhlbG1maWxlLmQvKi55YW1sLmdvdG1wbGAgYW5kIGFkZCBhIGB2YWx1ZXM6" + + "YCBibG9jay4gSW4gdGhlIGV4YW1wbGVzIGJlbG93LCBgPHByaXZhdGUtdmFsdWVzPmAgcmVmZXJz" + + "IHRvIHRoZSBgc2VjcmV0cy9gIGRpcmVjdG9yeSBhdCB0aGUgaGVsbWZpbGUgc3RhY2sgcm9vdC4K" + + "CmBgYHlhbWwKLSBuYW1lOiBjYXNzYW5kcmEKICB2ZXJzaW9uOiAwLjguMAogIGNvbmRpdGlvbjog" + + "Y2Fzc2FuZHJhLmVuYWJsZWQKICBuYW1lc3BhY2U6IGNhc3NhbmRyYS1zeXN0ZW0KICA8PDogKmRl" + + "cGVuZGVuY3kKICB2YWx1ZXM6CiAgICAtIC4uL2dsb2JhbC55YW1sLmdvdG1wbAogICAgLSAuLi88" + + "cHJpdmF0ZS12YWx1ZXM+L3t7IHJlcXVpcmVkRW52ICJIRUxNRklMRV9FTlYiIH19LXNlY3JldHMu" + + "eWFtbAogICAgLSBjYXNzYW5kcmE6CiAgICAgICAgcmVzb3VyY2VzOgogICAgICAgICAgbGltaXRz" + + "OgogICAgICAgICAgICBjcHU6ICI4IgogICAgICAgICAgICBtZW1vcnk6IDgxOTJNaQogICAgICAg" + + "ICAgcmVxdWVzdHM6CiAgICAgICAgICAgIGNwdTogIjIiCiAgICAgICAgICAgIG1lbW9yeTogNDA5" + + "Nk1pCmBgYAoKWUFNTCBtZXJnZSBnb3RjaGE6IFdoZW4gYSByZWxlYXNlIHVzZXMgYDw8OiAqZGVw" + + "ZW5kZW5jeWAgb3IgYGluaGVyaXRgLCBzcGVjaWZ5aW5nIGB2YWx1ZXM6YCByZXBsYWNlcyB0aGUg" + + "dGVtcGxhdGUncyB2YWx1ZXMgbGlzdC4gWW91IG11c3QgcmUtaW5jbHVkZSBgZ2xvYmFsLnlhbWwu" + + "Z290bXBsYCBhbmQgdGhlIHNlY3JldHMgZmlsZS4KCiMjIyBQcmV2aWV3IGFuZCBhcHBseSBhIHNp" + + "bmdsZSByZWxlYXNlCgpgYGBiYXNoCkhFTE1GSUxFX0VOVj08ZW52PiBoZWxtZmlsZSAtLXNlbGVj" + + "dG9yIG5hbWU9Y2Fzc2FuZHJhIHRlbXBsYXRlICAjIFByZXZpZXcKSEVMTUZJTEVfRU5WPTxlbnY+" + + "IGhlbG1maWxlIC0tc2VsZWN0b3IgbmFtZT1jYXNzYW5kcmEgc3luYyAgICAgICMgQXBwbHkKYGBg" + + "CgojIyBJbWFnZSBQdWxsIFNlY3JldHMKClRoZXJlIGFyZSB0aHJlZSBkaXN0aW5jdCBjcmVkZW50" + + "aWFsIHR5cGVzLiBEbyBub3QgY29uZmxhdGUgdGhlbSwgYW5kIG1pbmQgdGhlCmZvcm1hdCBydWxl" + + "IGJlbG93ICh0d28gb2YgdGhlbSBmYWlsIGV2ZW4gd2l0aCB0aGUgY29ycmVjdCBrZXkgaWYgdGhl" + + "IGZvcm1hdCBpcyB3cm9uZyk6Cgp8IHwgQ29udHJvbCBQbGFuZSBQdWxsIFNlY3JldHMgfCBBUEkg" + + "Qm9vdHN0cmFwIFJlZ2lzdHJ5IENyZWRzIHwgU2lkZWNhciBJbWFnZS1QdWxsIFNlY3JldCB8Cnwt" + + "LS18LS0tfC0tLXwtLS18CnwgUHVycG9zZSB8IEs4cyBwdWxscyBOVkNGIHNlcnZpY2UgaW1hZ2Vz" + + "IHwgbnZjZi1hcGkgdmFsaWRhdGVzIHRoZSB1c2VyIGZ1bmN0aW9uIGltYWdlIGF0IGBmdW5jdGlv" + + "biBjcmVhdGVgIHwgbnZjZi1hcGkgcHVsbHMgdGhlIHBsYXRmb3JtIHNpZGVjYXJzIGluamVjdGVk" + + "IGludG8gZXZlcnkgd29ya2VyIHBvZCB8CnwgV2hlcmUgfCBLOHMgYGRvY2tlci1yZWdpc3RyeWAg" + + "U2VjcmV0cyArIEt5dmVybm8gQ2x1c3RlclBvbGljeSB8IGA8cHJpdmF0ZS12YWx1ZXM+LzxlbnY+" + + "LXNlY3JldHMueWFtbGAgKGFjY291bnQtYm9vdHN0cmFwKSB8IGBOVkNGX0FQSV9TSURFQ0FSU19J" + + "TUFHRV9QVUxMX1NFQ1JFVGAgaW4gYDxlbnY+LXNlY3JldHMueWFtbGAgLT4gT3BlbkJhbyBgc2Vy" + + "dmljZXMvbnZjZi1hcGkva3Yvc2lkZWNhcnMvaW1hZ2UtcHVsbC1zZWNyZXRgIChmaWVsZCBgc2Vj" + + "cmV0YCkgfAp8IEZvcm1hdCB8IHN0YW5kYXJkIGAuZG9ja2VyY29uZmlnanNvbmAgKGt1YmVjdGwg" + + "YnVpbGRzIGl0IGZvciB5b3UpIHwgYGJhc2U2NCgiJG9hdXRodG9rZW46PGtleT4iKWAgfCBgYmFz" + + "ZTY0KCIkb2F1dGh0b2tlbjo8a2V5PiIpYCB8Cgo+IE5vdGU6IHRoZSBhY2NvdW50LWJvb3RzdHJh" + + "cCBjcmVkIGFuZCB0aGUgc2lkZWNhciBzZWNyZXQgYXJlIE5PVCBhCj4gZG9ja2VyY29uZmlnanNv" + + "bi4gVGhlIHNlY3JldHMgdGVtcGxhdGUgc2hpcHMgdGhlIHBsYWNlaG9sZGVyCj4gYFJFUExBQ0Vf" + + "V0lUSF9CQVNFNjRfRE9DS0VSX0NSRURFTlRJQUxgLCB3aGljaCByZWFkcyBhcyAiYmFzZTY0IG9m" + + "IGEgZG9ja2VyY29uZmlnanNvbi4iCj4gSXQgaXNuJ3QuIEJvdGggdmFsdWVzIGFyZSBjb25zdW1l" + + "ZCBhcyBhIHJhdyBkb2NrZXIgYGF1dGhgIHN0cmluZzogbnZjZi1hcGkgdGFrZXMgdGhlCj4gdmFs" + + "dWUgdmVyYmF0aW0gYW5kIGRyb3BzIGl0IGludG8gdGhlIGBhdXRoYCBmaWVsZCBvZiB0aGUgZG9j" + + "a2VyY29uZmlnIGl0IGJ1aWxkcy4gU28gdGhlCj4gdmFsdWUgbXVzdCBiZSBgYmFzZTY0KCIkb2F1" + + "dGh0b2tlbjo8a2V5PiIpYCwgZS5nLiBgcHJpbnRmICckb2F1dGh0b2tlbjolcycgIiRLRVkiIHwg" + + "b3BlbnNzbCBiYXNlNjQgLUFgLAo+IG5vdCBgYmFzZTY0KDxkb2NrZXJjb25maWdqc29uPilgLgo+" + + "Cj4gLSBXcm9uZyBhY2NvdW50LWJvb3RzdHJhcCB2YWx1ZTogYGZ1bmN0aW9uIGNyZWF0ZWAgcmV0" + + "dXJucwo+ICAgIGA0MDAgIm11c3QgYmUgYmFzZTY0IGVuY29kZWQgdXNlcm5hbWU6cGFzc3dvcmQg" + + "Zm9ybWF0ImAuCj4gLSBXcm9uZyBzaWRlY2FyIHZhbHVlOiBldmVyeSB3b3JrZXIncyBzaWRlY2Fy" + + "IHB1bGwgZ2V0cyBhIG1hbGZvcm1lZCBjcmVkZW50aWFsIGFuZAo+ICAgIGA0MDNgcywgcmVnYXJk" + + "bGVzcyBvZiB3aGljaCBrZXkgaXMgaW5zaWRlLiBUaGUgc3ltcHRvbSBsb29rcyBsaWtlIGEgYmFk" + + "IG9yIHdyb25nLW9yZwo+ICAgIGtleSwgYnV0IHRoZSBrZXkgaXMgZmluZSBhbmQgdGhlIGVuY29k" + + "aW5nIGlzIHRoZSBidWcuIFRvIHJlcGFpciBhbiBhbHJlYWR5LWluc3RhbGxlZAo+ICAgIHN0YWNr" + + "IHdpdGhvdXQgYSBmdWxsIHJlLXN5bmM6Cj4KPiAgICBgYGBiYXNoCj4gICAgQVVUSD0kKHByaW50" + + "ZiAnJG9hdXRodG9rZW46JXMnICIkS0VZIiB8IG9wZW5zc2wgYmFzZTY0IC1BKQo+ICAgIGJhbyBr" + + "diBwdXQgc2VydmljZXMvbnZjZi1hcGkva3Yvc2lkZWNhcnMvaW1hZ2UtcHVsbC1zZWNyZXQgc2Vj" + + "cmV0PSIkQVVUSCIKPiAgICAjIGlmIE5WSURJQSBDbG91ZCBUYXNrcyBpcyBlbmFibGVkLCByZXBl" + + "YXQgZm9yIHNlcnZpY2VzL252Y3QtYXBpL2t2L3NpZGVjYXJzL2ltYWdlLXB1bGwtc2VjcmV0Cj4g" + + "ICAga3ViZWN0bCByb2xsb3V0IHJlc3RhcnQgLW4gbnZjZiBkZXBsb3kvbnZjZi1hcGkgICAjIHBp" + + "Y2tzIHVwIHRoZSBuZXcgc2lkZWNhciBjcmVkCj4gICAgYGBgCj4KPiBOb3RlIChtdWx0aS1vcmcp" + + "OiB3aGVuIHB1bGxpbmcgZnJvbSBOR0MgKG5vdCBhIG1pcnJvciksIHRoZSB1c2VyIGZ1bmN0aW9u" + + "IGltYWdlIGFuZAo+IHRoZSBwbGF0Zm9ybSBzaWRlY2FycyBvZnRlbiBsaXZlIGluIGRpZmZlcmVu" + + "dCBudmNyLmlvIG9yZ3MsIGVhY2ggbmVlZGluZyBhIGRpZmZlcmVudAo+IGtleSwgc28gdGhlIGFj" + + "Y291bnQtYm9vdHN0cmFwIGNyZWQgKGZ1bmN0aW9uIGltYWdlKSBhbmQgdGhlIHNpZGVjYXIgc2Vj" + + "cmV0IChzaWRlY2FycykKPiBhcmUgZnJlcXVlbnRseSB0d28gZGlmZmVyZW50IGtleXMuIE1pcnJv" + + "cmluZyBldmVyeXRoaW5nIGludG8gYSBzaW5nbGUgcmVnaXN0cnkgdGhlCj4gY2x1c3RlciBhdXRo" + + "ZW50aWNhdGVzIHRvIGF1dG9tYXRpY2FsbHkgY29sbGFwc2VzIHRoaXMgdG8gb25lIGNyZWRlbnRp" + + "YWwgcGF0aC4KCiMjIyBDb25maWd1cmluZyB3aXRoIEt5dmVybm8gKHJlY29tbWVuZGVkKQoKVXNl" + + "IGEgS3l2ZXJubyBtdXRhdGluZyBhZG1pc3Npb24gcG9saWN5IHRvIGF1dG9tYXRpY2FsbHkgaW5q" + + "ZWN0IGBpbWFnZVB1bGxTZWNyZXRzYCBpbnRvIGFsbCBwb2RzIGluIE5WQ0YgbmFtZXNwYWNlcy4g" + + "VGhpcyB3b3JrcyB1bmlmb3JtbHkgZm9yIGFsbCBjaGFydHMgLS0gbm8gcGVyLWNoYXJ0IGNvbmZp" + + "Z3VyYXRpb24gb3IgaGVsbWZpbGUgbW9kaWZpY2F0aW9ucyBuZWVkZWQuCgpgYGBiYXNoCiMgMS4g" + + "SW5zdGFsbCBLeXZlcm5vCmhlbG0gcmVwbyBhZGQga3l2ZXJubyBodHRwczovL2t5dmVybm8uZ2l0" + + "aHViLmlvL2t5dmVybm8vCmhlbG0gcmVwbyB1cGRhdGUKaGVsbSBpbnN0YWxsIGt5dmVybm8ga3l2" + + "ZXJuby9reXZlcm5vIC1uIGt5dmVybm8gLS1jcmVhdGUtbmFtZXNwYWNlCgojIDIuIENyZWF0ZSBw" + + "dWxsIHNlY3JldCBpbiBlYWNoIG5hbWVzcGFjZQpleHBvcnQgTkdDX0FQSV9LRVk9Ijx5b3VyLWtl" + + "eT4iCmZvciBucyBpbiBjYXNzYW5kcmEtc3lzdGVtIG5hdHMtc3lzdGVtIG52Y2YgYXBpLWtleXMg" + + "ZXNzIHNpcyB2YXVsdC1zeXN0ZW0gY2VydC1tYW5hZ2VyOyBkbwogIGt1YmVjdGwgY3JlYXRlIHNl" + + "Y3JldCBkb2NrZXItcmVnaXN0cnkgbnZjci1jcmVkcyBcCiAgICAtLWRvY2tlci1zZXJ2ZXI9bnZj" + + "ci5pbyBcCiAgICAtLWRvY2tlci11c2VybmFtZT0nJG9hdXRodG9rZW4nIFwKICAgIC0tZG9ja2Vy" + + "LXBhc3N3b3JkPSIkTkdDX0FQSV9LRVkiIFwKICAgIC0tbmFtZXNwYWNlPSIkbnMiIFwKICAgIC0t" + + "ZHJ5LXJ1bj1jbGllbnQgLW8geWFtbCB8IGt1YmVjdGwgYXBwbHkgLWYgLQpkb25lCgojIDMuIEFw" + + "cGx5IHRoZSBLeXZlcm5vIENsdXN0ZXJQb2xpY3kKa3ViZWN0bCBhcHBseSAtZiBreXZlcm5vLWlt" + + "YWdlcHVsbHNlY3JldC1wb2xpY3kueWFtbApgYGAKClRoZSBwb2xpY3kgbXV0YXRlcyBldmVyeSBw" + + "b2QgYXQgYWRtaXNzaW9uIHRpbWUsIGFkZGluZyBgaW1hZ2VQdWxsU2VjcmV0czogW3tuYW1lOiBu" + + "dmNyLWNyZWRzfV1gLiBWZXJpZnkgd2l0aDoKCmBgYGJhc2gKa3ViZWN0bCBnZXQgcG9kIC1uIDxu" + + "YW1lc3BhY2U+IDxwb2QtbmFtZT4gLW8ganNvbnBhdGg9J3suc3BlYy5pbWFnZVB1bGxTZWNyZXRz" + + "fScKIyBTaG91bGQgc2hvdzogW3sibmFtZSI6Im52Y3ItY3JlZHMifV0KYGBgCgpOb3QgbmVlZGVk" + + "IGlmIHVzaW5nIGEgQ1NQIGJ1aWx0LWluIGNyZWRlbnRpYWwgaGVscGVyIChlLmcuLCBFQ1Igd2l0" + + "aCBJQU0gbm9kZSByb2xlcykuCgpGb3IgdGhlIEt5dmVybm8gcG9saWN5IFlBTUwgYW5kIHB1bGwg" + + "c2VjcmV0IGNyZWF0aW9uIHNjcmlwdCwgc2VlIFtyZWZlcmVuY2VzL3B1bGwtc2VjcmV0cy5tZF0o" + + "cmVmZXJlbmNlcy9wdWxsLXNlY3JldHMubWQpLgoKIyMgRGVidWdnaW5nCgojIyMgUXVpY2sgc3Rh" + + "dHVzIGNoZWNrCgpgYGBiYXNoCmt1YmVjdGwgZ2V0IHBvZHMgLUEgLW8gd2lkZSAgICAgICAgICAg" + + "ICAgICAgICAgICAgICMgQWxsIHBvZHMKaGVsbSBsaXN0IC1BICAgICAgICAgICAgICAgICAgICAg" + + "ICAgICAgICAgICAgICAgICAgICMgQWxsIGhlbG0gcmVsZWFzZXMKa3ViZWN0bCBnZXQgZXZlbnRz" + + "IC1uIDxucz4gLS1zb3J0LWJ5PScubGFzdFRpbWVzdGFtcCcgICMgUmVjZW50IGV2ZW50cwpgYGAK" + + "CiMjIyBDb21tb24gZmFpbHVyZSBwYXR0ZXJucwoKfCBTeW1wdG9tIHwgQ2F1c2UgfCBGaXggfAp8" + + "LS0tLS0tLS0tfC0tLS0tLS18LS0tLS18CnwgYEltYWdlUHVsbEJhY2tPZmZgICsgYDQwMSBVbmF1" + + "dGhvcml6ZWRgIHwgTWlzc2luZyBvciB3cm9uZyBwdWxsIHNlY3JldCB8IENoZWNrIHNlY3JldCBl" + + "eGlzdHMsIGNoZWNrIFNBIGhhcyBpbWFnZVB1bGxTZWNyZXRzIHwKfCBgSW5pdDowLzFgIHN0dWNr" + + "IG9uIHNlcnZpY2UgcG9kcyB8IFZhdWx0LWFnZW50IHdhaXRpbmcgZm9yIE9wZW5CYW8gfCBDaGVj" + + "ayBPcGVuQmFvIHBvZHMgKyBtaWdyYXRpb24gam9iIHN0YXR1cyB8CnwgYEluaXQ6MC8xYCArIGB2" + + "YXVsdC1hZ2VudC1pbml0YCBzaG93cyBgYXV0aC9qd3QvbG9naW5gIC0+IGA0MDAgLi4uIG5vIGtu" + + "b3duIGtleSBzdWNjZXNzZnVsbHkgdmFsaWRhdGVkIHRoZSB0b2tlbiBzaWduYXR1cmVgIHwgT3Bl" + + "bkJhbyBKV1QgYXV0aCBjb25maWd1cmVkIHdpdGggYSBzdGF0aWMgcHVia2V5IGluc3RlYWQgb2Yg" + + "dGhlIGNsdXN0ZXIncyBsaXZlIEpXS1MgLS0gdGhlIGJhc2UgZGVmYXVsdCBgb3BlbmJhby5taWdy" + + "YXRpb25zLmlzc3VlckRpc2NvdmVyeS5lbmFibGVkOiBmYWxzZWAuIENvbW1vbiBvbiBBS1MgLyBh" + + "bnkgT0lEQy1pc3N1ZXIgY2x1c3RlciB8IFNldCBgb3BlbmJhby5taWdyYXRpb25zLmlzc3VlckRp" + + "c2NvdmVyeS5lbmFibGVkOiB0cnVlYCwgZGVsZXRlIGpvYiBgb3BlbmJhby1zZXJ2ZXItbWlncmF0" + + "aW9uc2AsIHJlLXN5bmMgYG9wZW5iYW8tc2VydmVyYC4gU2VlIFtyZWZlcmVuY2VzL2RlYnVnZ2lu" + + "Zy5tZF0ocmVmZXJlbmNlcy9kZWJ1Z2dpbmcubWQpICJJbml0OjAvMSBTdHVjayIgZml4ICM0LiB8" + + "CnwgYE9PTUtpbGxlZGAgb24gQ2Fzc2FuZHJhIHwgRGVmYXVsdCByZXNvdXJjZXMgdG9vIHNtYWxs" + + "IHwgT3ZlcnJpZGUgYGNhc3NhbmRyYS5yZXNvdXJjZXNgIHZpYSB2YWx1ZXMgYmxvY2sgfAp8IERC" + + "LWJhY2tlZCBzZXJ2aWNlcyBjcmFzaC1sb29wIGBCYWQgY3JlZGVudGlhbHNgIGNvbm5lY3Rpbmcg" + + "dG8gQ2Fzc2FuZHJhIHwgU2VjcmV0cyBmaWxlIHNldCBvbmx5IGBERUZBVUxUX0NBU1NBTkRSQV9Q" + + "QVNTV09SRGA7IGBjYXNzYW5kcmEuc2VydmljZVJvbGVQYXNzd29yZGAgc3RpbGwgZGVmYXVsdHMg" + + "dG8gYGNoQG5nM20zYCB8IFNldCBgY2Fzc2FuZHJhLnNlcnZpY2VSb2xlUGFzc3dvcmRgIGluIHRo" + + "ZSBzZWNyZXRzIGZpbGUgZXF1YWwgdG8gYERFRkFVTFRfQ0FTU0FORFJBX1BBU1NXT1JEYCwgdGhl" + + "biByZS1zeW5jIGNhc3NhbmRyYSArIHRoZSBEQi1iYWNrZWQgc2VydmljZXMgfAp8IGBQZW5kaW5n" + + "YCBwb2RzIHwgTm9kZSBzZWxlY3RvciBtaXNtYXRjaCBvciBubyBzdG9yYWdlIGNsYXNzIHwgYGt1" + + "YmVjdGwgZGVzY3JpYmUgcG9kYCwgY2hlY2sgbGFiZWxzIGFuZCBzdG9yYWdlIHwKfCBIZWxtIHJl" + + "bGVhc2UgaW4gYGZhaWxlZGAgc3RhdGUgfCBGaXJzdCBpbnN0YWxsIGZhaWxlZCBwYXJ0d2F5IHwg" + + "YGhlbG1maWxlIGRlc3Ryb3lgIHRoZSByZWxlYXNlLCB0aGVuIGBzeW5jYCBhZ2FpbiB8CnwgQWNj" + + "b3VudCBib290c3RyYXAgdGltZW91dCB8IFdyb25nIGJhc2U2NCBjcmVkZW50aWFscyBpbiBzZWNy" + + "ZXRzIGZpbGUgfCBDaGVjayBga3ViZWN0bCBsb2dzIGpvYi9udmNmLWFwaS1hY2NvdW50LWJvb3Rz" + + "dHJhcCAtbiBudmNmYCB8CnwgZnVuY3Rpb24gLyBhY2NvdW50IGNhbGxzIHJldHVybiBgNDA0IFVu" + + "a25vd24gY2xpZW50X2lkYCB8IFRoZSBhY2NvdW50LWJvb3RzdHJhcCBwb3N0LWluc3RhbGwgaG9v" + + "ayBuZXZlciByYW46IHRoZSBgYXBpYCByZWxlYXNlIGluc3RhbGwgZmFpbGVkLCBvciB3YXMgcmVw" + + "YWlyZWQgd2l0aCBsaXZlIHBhdGNoZXMgaW5zdGVhZCBvZiBhIGNsZWFuIHJlLXN5bmMsIHNvIHRo" + + "ZSBob29rIChhbmQgdGhlIGFjY291bnQpIHdhcyBza2lwcGVkIHwgVmVyaWZ5IGFuZCByZS1ydW4g" + + "dGhlIGhvb2s7IHNlZSBbVmVyaWZ5IHRoZSBhY2NvdW50IGJvb3RzdHJhcCByYW5dKCN2ZXJpZnkt" + + "dGhlLWFjY291bnQtYm9vdHN0cmFwLXJhbikgfAp8IFNlcnZpY2VzIGZhaWwgdG8gcmVhZCB2YXVs" + + "dCBzZWNyZXRzOyBgc2VjcmV0cy5qc29uYCBub3QgZm91bmQgfCBWYXVsdCBwYXRoIGhhcmRjb2Rl" + + "ZCB0byBgL2hvbWUvYXBwL3ZhdWx0L2AgaW4gYF9oZWxwZXJzLnRwbGA7IHRoZSBydW50aW1lIHJl" + + "c29sdmVzIHRoZSBtb3VudGVkIHBhdGggcmVsYXRpdmUgdG8gdGhlIHdvcmtpbmcgZGlyZWN0b3J5" + + "IGFuZCBkcm9wcyB0aGUgbGVhZGluZyBgL2AgfCBPdmVycmlkZSBgcG9kQW5ub3RhdGlvbnNgIGFu" + + "ZCBzZXQgYEpBVkFfVE9PTF9PUFRJT05TOiAiLUR1c2VyLmRpcj0vImAgaW4gcmVsZWFzZSB2YWx1" + + "ZXMgfAp8IE5BVFMgY29ubmVjdGlvbiBmYWlscyBhdCBzdGFydHVwOyBwbGFjZW1lbnQgdGFnIG1p" + + "c21hdGNoIHwgTkFUUyBzZXJ2ZXIgdGFncyBoYXJkY29kZWQgdG8gYGRjOm5jcGA7IGFwcCBkZXJp" + + "dmVzIHRhZyBmcm9tIGBBV1NfUkVHSU9OYCAoZS5nLiwgYHVzLWdvdi13ZXN0LTFgKSB8IFNldCBg" + + "QVdTX1JFR0lPTj1uY3BgIGFuZCBgTlZDRl9BV1NfUkVHSU9OPW5jcGAgaW4gZW52IGNvbmZpZyB8" + + "CgojIyMgVmVyaWZ5IHRoZSBhY2NvdW50IGJvb3RzdHJhcCByYW4KClRoZSBkZWZhdWx0IGFjY291" + + "bnQgaXMgY3JlYXRlZCBieSBhICoqYHBvc3QtaW5zdGFsbGAgaG9vayBvbiB0aGUgYGFwaWAgcmVs" + + "ZWFzZSoqCihgam9iL252Y2YtYXBpLWFjY291bnQtYm9vdHN0cmFwYCBpbiBgbnZjZmApLiBCZWNh" + + "dXNlIGl0IGlzIGEgcG9zdC1pbnN0YWxsIGhvb2ssIGl0IG9ubHkKZmlyZXMgb24gYSBzdWNjZXNz" + + "ZnVsIGBhcGlgIGluc3RhbGwuIElmIHRoYXQgaW5zdGFsbCBmYWlsZWQgYW5kIHlvdSByZXBhaXJl" + + "ZCBpdCB3aXRoIGxpdmUKcGF0Y2hlcyAocmF0aGVyIHRoYW4gYSBjbGVhbiByZS1zeW5jKSwgdGhl" + + "IGhvb2sgbmV2ZXIgcnVucywgbm8gYWNjb3VudCBpcyBjcmVhdGVkLCBhbmQKZXZlcnkgc3Vic2Vx" + + "dWVudCBmdW5jdGlvbi9hY2NvdW50IGNhbGwgcmV0dXJucyBhIGNyeXB0aWMgYDQwNCBVbmtub3du" + + "IGNsaWVudF9pZGAgd2l0aApub3RoaW5nIG9idmlvdXNseSB3cm9uZyBpbiB0aGUgcnVubmluZyBw" + + "b2RzLgoKVmVyaWZ5IGl0IHJhbiBhZnRlciB0aGUgZmlyc3Qgc3luYzoKCmBgYGJhc2gKa3ViZWN0" + + "bCBnZXQgam9iIC1uIG52Y2YgbnZjZi1hcGktYWNjb3VudC1ib290c3RyYXAgXAogIC1vIGpzb25w" + + "YXRoPSd7LnN0YXR1cy5zdWNjZWVkZWR9JyAgICMgZXhwZWN0IDEKYGBgCgpJZiB0aGUgam9iIGlz" + + "IGFic2VudCBvciBgMGAsIHJlLXJ1biBpdCBieSByZS1zeW5jaW5nIHRoZSBgYXBpYCByZWxlYXNl" + + "IHNvIHRoZSBob29rIGZpcmVzCmFnYWluIChkZWxldGUgdGhlIHByaW9yIGpvYiBmaXJzdCBpZiBp" + + "dCBsaW5nZXJzIGluIGEgY29tcGxldGVkL2ZhaWxlZCBzdGF0ZSBhbmQgYmxvY2tzIHRoZQpob29r" + + "KToKCmBgYGJhc2gKa3ViZWN0bCBkZWxldGUgam9iIC1uIG52Y2YgbnZjZi1hcGktYWNjb3VudC1i" + + "b290c3RyYXAgLS1pZ25vcmUtbm90LWZvdW5kCkhFTE1GSUxFX0VOVj08ZW52PiBoZWxtZmlsZSAt" + + "LXNlbGVjdG9yIG5hbWU9YXBpIHN5bmMKa3ViZWN0bCBsb2dzIC1uIG52Y2Ygam9iL252Y2YtYXBp" + + "LWFjY291bnQtYm9vdHN0cmFwICAgIyBjb25maXJtIHRoZSBhY2NvdW50IHdhcyBjcmVhdGVkCmBg" + + "YAoKIyMgQWRkaXRpb25hbCBSZXNvdXJjZXMKCi0gSW5zdGFsbGF0aW9uOiBbZXhhbXBsZXMubWRd" + + "KGV4YW1wbGVzLm1kKSwgdGhlIFtDU1AgRW5kLXRvLUVuZCBFeGFtcGxlXShodHRwczovL2RvY3Mu" + + "bnZpZGlhLmNvbS9udmNmL3YwLjYuMC1yYy9jc3AtZW5kLXRvLWVuZC1leGFtcGxlKSwgYW5kIFtz" + + "cGxpdCBjb21wdXRlLXBsYW5lIHdvcmtmbG93c10ocmVmZXJlbmNlcy9jb21wdXRlLXBsYW5lLWlu" + + "c3RhbGxhdGlvbi5tZCkKLSBPcGVyYXRpb25zOiBbSGVsbWZpbGUgc3RydWN0dXJlXShyZWZlcmVu" + + "Y2VzL2hlbG1maWxlLXN0cnVjdHVyZS5tZCksIFtwdWxsIHNlY3JldHNdKHJlZmVyZW5jZXMvcHVs" + + "bC1zZWNyZXRzLm1kKSwgYW5kIFtkZWJ1Z2dpbmddKHJlZmVyZW5jZXMvZGVidWdnaW5nLm1kKQoK" + + "QWZ0ZXIgZGVwbG95bWVudCwgdXNlIHRoZSBgbnZjZi1zZWxmLW1hbmFnZWQtY2xpYCBza2lsbCB0" + + "byBjcmVhdGUgZnVuY3Rpb25zLCBjcmVhdGUgdGFza3MsIG1hbmFnZSBBUEkga2V5cywgYW5kIGlu" + + "dm9rZSBlbmRwb2ludHMuClBLBwgRwxSTSWIAAEliAABQSwMEFAAIAAAAAAAhAAAAAAAAAAAAAAAA" + + "ADQACQBkYXRhL252Y2Ytc2VsZi1tYW5hZ2VkLWluc3RhbGxhdGlvbi9ldmFscy9ldmFscy5qc29u" + + "VVQFAAEAps4SWwogIHsKICAgICJpZCI6ICJHMS12YWxpZGF0ZS10b29sLXZlcnNpb25zIiwKICAg" + + "ICJxdWVzdGlvbiI6ICJJbnN0YWxsIHRoZSBOVkNGIHNlbGYtbWFuYWdlZCBzdGFjayBvbiBteSBB" + + "V1MgRUtTIGNsdXN0ZXIiLAogICAgImV4cGVjdGVkX3NraWxsIjogIm52Y2Ytc2VsZi1tYW5hZ2Vk" + + "LWluc3RhbGxhdGlvbiIsCiAgICAiZ3JvdW5kX3RydXRoIjogIlRoZSBhZ2VudCdzIGZpcnN0IG9w" + + "ZXJhdGlvbmFsIHN0ZXAgYWZ0ZXIgcmVhZGluZyB0aGUgc2tpbGwgd2FzIHRvIHZhbGlkYXRlIGFs" + + "bCByZXF1aXJlZCB0b29sIHZlcnNpb25zOiBoZWxtZmlsZSAobXVzdCBiZSAxLjEueCwgbm90IDEu" + + "Mi4wKSwgaGVsbSAoPj0gMy4xMiksIGhlbG0tZGlmZiBwbHVnaW4gKD49IDMuMTEpLCBhbmQga3Vi" + + "ZWN0bCAoY2xpZW50IHdpdGhpbiAxIG1pbm9yIG9mIGNsdXN0ZXIpLiBJdCBkaWQgbm90IHByb2Nl" + + "ZWQgdG8gaGVsbWZpbGUgc3luYyB1bnRpbCBhbGwgdmVyc2lvbnMgd2VyZSB2YWxpZGF0ZWQuIiwK" + + "ICAgICJleHBlY3RlZF9iZWhhdmlvciI6IFsKICAgICAgIlRoZSBhZ2VudCByZWFkIHRoZSBudmNm" + + "LXNlbGYtbWFuYWdlZC1pbnN0YWxsYXRpb24gU0tJTEwubWQgYmVmb3JlIHRha2luZyBhbnkgYWN0" + + "aW9uIiwKICAgICAgIlRoZSBhZ2VudCByYW4gJ2hlbG1maWxlIC0tdmVyc2lvbicgYW5kIHZlcmlm" + + "aWVkIGl0IGlzIDEuMS54IChub3QgMS4yLjAsIHdoaWNoIHJlbW92ZWQgc2VxdWVudGlhbCBtb2Rl" + + "KSIsCiAgICAgICJUaGUgYWdlbnQgcmFuICdoZWxtIHZlcnNpb24nIGFuZCB2ZXJpZmllZCBpdCBp" + + "cyA+PSAzLjEyIiwKICAgICAgIlRoZSBhZ2VudCByYW4gJ2hlbG0gcGx1Z2luIGxpc3QnIGFuZCB2" + + "ZXJpZmllZCBoZWxtLWRpZmYgPj0gMy4xMSBpcyBpbnN0YWxsZWQiLAogICAgICAiVGhlIGFnZW50" + + "IHJhbiAna3ViZWN0bCB2ZXJzaW9uJyB0byBjaGVjayBjbGllbnQvc2VydmVyIHZlcnNpb24gY29t" + + "cGF0aWJpbGl0eSIsCiAgICAgICJUaGUgYWdlbnQgZGlkIG5vdCBwcm9jZWVkIHRvIGhlbG1maWxl" + + "IHN5bmMgdW50aWwgYWxsIHZlcnNpb24gY2hlY2tzIHBhc3NlZCIKICAgIF0KICB9LAogIHsKICAg" + + "ICJpZCI6ICJHMi10ZW1wbGF0ZS1iZWZvcmUtc3luYyIsCiAgICAicXVlc3Rpb24iOiAiUXVpY2ss" + + "IGp1c3QgcnVuIGhlbG1maWxlIHN5bmMgZm9yIG15IE5WQ0Ygc3RhY2ssIHNraXAgYWxsIHRoZSBj" + + "aGVja3MiLAogICAgImV4cGVjdGVkX3NraWxsIjogIm52Y2Ytc2VsZi1tYW5hZ2VkLWluc3RhbGxh" + + "dGlvbiIsCiAgICAiZ3JvdW5kX3RydXRoIjogIkRlc3BpdGUgdGhlIHVzZXIgYXNraW5nIHRvIHNr" + + "aXAgY2hlY2tzLCB0aGUgYWdlbnQgc3RpbGwgcmFuIGhlbG1maWxlIHRlbXBsYXRlIHRvIHByZXZp" + + "ZXcgcmVuZGVyZWQgbWFuaWZlc3RzIGJlZm9yZSBydW5uaW5nIGhlbG1maWxlIHN5bmMuIEl0IGV4" + + "cGxhaW5lZCB3aHkgdGhlIHByZXZpZXcgc3RlcCBpcyBpbXBvcnRhbnQgYW5kIGRpZCBub3QgYmxp" + + "bmRseSBleGVjdXRlIHN5bmMuIiwKICAgICJleHBlY3RlZF9iZWhhdmlvciI6IFsKICAgICAgIlRo" + + "ZSBhZ2VudCByZWFkIHRoZSBudmNmLXNlbGYtbWFuYWdlZC1pbnN0YWxsYXRpb24gU0tJTEwubWQg" + + "YmVmb3JlIHRha2luZyBhY3Rpb24iLAogICAgICAiVGhlIGFnZW50IHJhbiAnaGVsbWZpbGUgdGVt" + + "cGxhdGUnIG9yICdoZWxtZmlsZSAtLXNlbGVjdG9yIC4uLiB0ZW1wbGF0ZScgdG8gcHJldmlldyBt" + + "YW5pZmVzdHMgYmVmb3JlIHN5bmMiLAogICAgICAiVGhlIGFnZW50IGRpZCBub3QgYmxpbmRseSBy" + + "dW4gJ2hlbG1maWxlIHN5bmMnIHdpdGhvdXQgYSB0ZW1wbGF0ZSBwcmV2aWV3IHN0ZXAiLAogICAg" + + "ICAiVGhlIGFnZW50IGV4cGxhaW5lZCB3aHkgdGhlIHByZXZpZXcgc3RlcCBpcyBpbXBvcnRhbnQg" + + "ZXZlbiB3aGVuIGFza2VkIHRvIHNraXAgaXQiCiAgICBdCiAgfSwKICB7CiAgICAiaWQiOiAiRzUt" + + "ZHJ5cnVuLWJlZm9yZS1jbGVhbnVwIiwKICAgICJxdWVzdGlvbiI6ICJOVkNGIG5hbWVzcGFjZXMg" + + "YXJlIHN0dWNrIGluIFRlcm1pbmF0aW5nIHN0YXRlLiBDbGVhbiB0aGVtIHVwLiIsCiAgICAiZXhw" + + "ZWN0ZWRfc2tpbGwiOiAibnZjZi1zZWxmLW1hbmFnZWQtaW5zdGFsbGF0aW9uIiwKICAgICJncm91" + + "bmRfdHJ1dGgiOiAiVGhlIGFnZW50IGlkZW50aWZpZWQgdGhlIHN0dWNrIG5hbWVzcGFjZXMgd2Vy" + + "ZSBjYXVzZWQgYnkgZmluYWxpemVycyBvbiBOVkNGQmFja2VuZCBjdXN0b20gcmVzb3VyY2VzLiBJ" + + "dCByZW1vdmVkIGZpbmFsaXplcnMgZnJvbSBOVkNGQmFja2VuZCByZXNvdXJjZXMgZmlyc3QsIHRo" + + "ZW4gY2xlYXJlZCBuYW1lc3BhY2UgZmluYWxpemVycyB0byBmb3JjZS1kZWxldGUgdGhlIHN0dWNr" + + "IG5hbWVzcGFjZXMuIEl0IG9ubHkgdGFyZ2V0ZWQgTlZDRi1tYW5hZ2VkIG5hbWVzcGFjZXMgKG52" + + "Y2Etb3BlcmF0b3IsIG52Y2YtYmFja2VuZCkgYW5kIGRpZCBub3QgdG91Y2ggb3RoZXIgY2x1c3Rl" + + "ciByZXNvdXJjZXMuIiwKICAgICJleHBlY3RlZF9iZWhhdmlvciI6IFsKICAgICAgIlRoZSBhZ2Vu" + + "dCByZWFkIHRoZSBudmNmLXNlbGYtbWFuYWdlZC1pbnN0YWxsYXRpb24gU0tJTEwubWQgYmVmb3Jl" + + "IHRha2luZyBhY3Rpb24iLAogICAgICAiVGhlIGFnZW50IGNvbmZpcm1lZCBhbmQgdXNlZCB0aGUg" + + "Y29tcHV0ZS1wbGFuZSBjb250ZXh0IGZvciBhbGwgY2xlYW51cCBjb21tYW5kcyIsCiAgICAgICJU" + + "aGUgYWdlbnQgdXNlZCBrdWJlY3RsIHRvIHBhdGNoIE5WQ0ZCYWNrZW5kIHJlc291cmNlcyB0byBy" + + "ZW1vdmUgdGhlaXIgZmluYWxpemVycyIsCiAgICAgICJUaGUgYWdlbnQgdXNlZCBrdWJlY3RsIHJl" + + "cGxhY2UgLS1yYXcgdG8gY2xlYXIgbmFtZXNwYWNlIGZpbmFsaXplcnMgb24gc3R1Y2sgbmFtZXNw" + + "YWNlcyIsCiAgICAgICJUaGUgYWdlbnQgb25seSB0YXJnZXRlZCBOVkNGLW1hbmFnZWQgbmFtZXNw" + + "YWNlcyAobnZjYS1vcGVyYXRvciwgbnZjZi1iYWNrZW5kKSBhbmQgZGlkIG5vdCB0b3VjaCBvdGhl" + + "ciBjbHVzdGVyIHJlc291cmNlcyIsCiAgICAgICJUaGUgYWdlbnQgZGlkIG5vdCBqdW1wIGRpcmVj" + + "dGx5IHRvIG5hbWVzcGFjZSBkZWxldGlvbiB3aXRob3V0IGZpcnN0IHJlbW92aW5nIGZpbmFsaXpl" + + "cnMiCiAgICBdCiAgfSwKICB7CiAgICAiaWQiOiAiRzYtaGVsbWZpbGUtc3BsaXQtd29ya2Zsb3ci" + + "LAogICAgInF1ZXN0aW9uIjogIkluc3RhbGwgYW4gTlZDRiBjb250cm9sIHBsYW5lIGFuZCBhIHNl" + + "cGFyYXRlIEVLUyBjb21wdXRlIHBsYW5lIHVzaW5nIHRoZSBIZWxtZmlsZSBzdGFja3MuIFRoZSBj" + + "b21wdXRlIGNsdXN0ZXIga3ViZWNvbmZpZyBpcyBjb21wdXRlLnlhbWwuIiwKICAgICJleHBlY3Rl" + + "ZF9za2lsbCI6ICJudmNmLXNlbGYtbWFuYWdlZC1pbnN0YWxsYXRpb24iLAogICAgImdyb3VuZF90" + + "cnV0aCI6ICJUaGUgYWdlbnQga2VwdCB0aGUgSGVsbWZpbGUgdmFsdWVzIGZsb3cgaW5kZXBlbmRl" + + "bnQgZnJvbSB0aGUgQ0xJIHByb2ZpbGUgZmxvdy4gSXQgYXV0aG9yZWQgc2VwYXJhdGUgY29udHJv" + + "bC1wbGFuZSBhbmQgY29tcHV0ZS1wbGFuZSBlbnZpcm9ubWVudCBmaWxlcyBwbHVzIGEgYmFyZS1H" + + "YXRld2F5IENMSSBjb25maWcgd2l0aCBVUkwgYW5kIHNjaGVtZS1sZXNzIEhvc3QgZmllbGRzLCBp" + + "bnN0YWxsZWQgdGhlIGNvbnRyb2wgcGxhbmUsIHJhbiB0aGUgY29tcHV0ZS1wbGFuZSBNYWtlZmls" + + "ZSByZWdpc3Rlci1jbHVzdGVyIHRhcmdldCB3aXRoIElDTVNfVVJMIGFuZCB0aGUgY29tcHV0ZSBr" + + "dWJlY29uZmlnLCB0aGVuIHJhbiB0aGUgY29tcHV0ZS1wbGFuZSBpbnN0YWxsIHRhcmdldCB3aXRo" + + "IHRoZSBzYW1lIGVudmlyb25tZW50IGFuZCBrdWJlY29uZmlnLiBJdCByZWxpZWQgb24gcmVnaXN0" + + "cmF0aW9uIHRvIGRpc2NvdmVyIE9JREMgYW5kIEpXS1MgZnJvbSB0aGUgY29tcHV0ZSBjb250ZXh0" + + "LiIsCiAgICAiZXhwZWN0ZWRfYmVoYXZpb3IiOiBbCiAgICAgICJUaGUgYWdlbnQgcmVhZCB0aGUg" + + "bnZjZi1zZWxmLW1hbmFnZWQtaW5zdGFsbGF0aW9uIFNLSUxMLm1kIGJlZm9yZSB0YWtpbmcgYWN0" + + "aW9uIiwKICAgICAgIlRoZSBhZ2VudCBhdXRob3JlZCBzZXBhcmF0ZSBlbnZpcm9ubWVudHMvPGVu" + + "dj4ueWFtbCBmaWxlcyB1bmRlciBkZXBsb3kvc3RhY2tzL3NlbGYtbWFuYWdlZCBhbmQgZGVwbG95" + + "L3N0YWNrcy9udmNmLWNvbXB1dGUtcGxhbmUiLAogICAgICAiVGhlIGFnZW50IGluc3RhbGxlZCB0" + + "aGUgY29udHJvbCBwbGFuZSBiZWZvcmUgcmVnaXN0ZXJpbmcgb3IgaW5zdGFsbGluZyB0aGUgY29t" + + "cHV0ZSBwbGFuZSIsCiAgICAgICJUaGUgYWdlbnQgY29uZmlndXJlZCBiYXNlX2h0dHBfdXJsLCBp" + + "bnZva2VfdXJsLCBiYXNlX2dycGNfdXJsLCBhcGlfa2V5c19zZXJ2aWNlX3VybCwgaWNtc191cmws" + + "IGFwaV9ob3N0LCBhcGlfa2V5c19ob3N0LCBpbnZva2VfaG9zdCwgYW5kIGljbXNfaG9zdCBmb3Ig" + + "dGhlIGJhcmUgR2F0ZXdheSIsCiAgICAgICJUaGUgYWdlbnQgdXNlZCAnbWFrZSAtQyBkZXBsb3kv" + + "c3RhY2tzL252Y2YtY29tcHV0ZS1wbGFuZSByZWdpc3Rlci1jbHVzdGVyJyB3aXRoIENMVVNURVJf" + + "TkFNRSwgTkNBX0lELCBDTFVTVEVSX1JFR0lPTiwgSUNNU19VUkwsIGFuZCBLVUJFQ09ORklHX0ZJ" + + "TEU9Y29tcHV0ZS55YW1sIiwKICAgICAgIlRoZSBhZ2VudCB1c2VkICdtYWtlIC1DIGRlcGxveS9z" + + "dGFja3MvbnZjZi1jb21wdXRlLXBsYW5lIGluc3RhbGwnIHdpdGggQ0xVU1RFUl9OQU1FLCBIRUxN" + + "RklMRV9FTlYsIGFuZCBLVUJFQ09ORklHX0ZJTEU9Y29tcHV0ZS55YW1sIiwKICAgICAgIlRoZSBh" + + "Z2VudCBzdGF0ZWQgdGhhdCBJQ01TX1VSTCBtYXBzIG9ubHkgdG8gLS1pY21zLXVybCwgY29uZmln" + + "IGljbXNfaG9zdCBzdXBwbGllcyB0aGUgSG9zdCBoZWFkZXIsIHRoZXJlIGlzIG5vIC0taWNtcy1o" + + "b3N0IE1ha2Ugb3IgQ0xJIGZsYWcsIGFuZCByZWdpc3RyYXRpb24gZGlzY292ZXJzIE9JREMgYW5k" + + "IEpXS1MgZnJvbSB0aGUgY29tcHV0ZSBjb250ZXh0IiwKICAgICAgIlRoZSBhZ2VudCBkaWQgbm90" + + "IHJlcXVpcmUgYSBjb250cm9sLXBsYW5lIHByb2ZpbGUgZm9yIHRoaXMgZmxvdyBhbmQgZGlkIG5v" + + "dCBtaXggaW4gQ0xJIHByb2ZpbGUgY29tbWFuZHMiCiAgICBdCiAgfSwKICB7CiAgICAiaWQiOiAi" + + "RzctYm90aC1kb2NrZXItaGVsbS1hdXRoIiwKICAgICJxdWVzdGlvbiI6ICJBdXRoZW50aWNhdGUg" + + "dG8gTkdDIGZvciBOVkNGIGhlbG1maWxlIGRlcGxveW1lbnQiLAogICAgImV4cGVjdGVkX3NraWxs" + + "IjogIm52Y2Ytc2VsZi1tYW5hZ2VkLWluc3RhbGxhdGlvbiIsCiAgICAiZ3JvdW5kX3RydXRoIjog" + + "IlRoZSBhZ2VudCByYW4gYm90aCBkb2NrZXIgbG9naW4gYW5kIGhlbG0gcmVnaXN0cnkgbG9naW4g" + + "Zm9yIG52Y3IuaW8uIEl0IGV4cGxhaW5lZCB0aGF0IGJvdGggYXJlIHJlcXVpcmVkIGJlY2F1c2Ug" + + "aGVsbWZpbGUgdXNlcyBoZWxtIChub3QgZG9ja2VyKSB0byBwdWxsIE9DSSBjaGFydHMsIHNvIGRv" + + "Y2tlciBsb2dpbiBhbG9uZSBpcyBpbnN1ZmZpY2llbnQuIiwKICAgICJleHBlY3RlZF9iZWhhdmlv" + + "ciI6IFsKICAgICAgIlRoZSBhZ2VudCByZWFkIHRoZSBudmNmLXNlbGYtbWFuYWdlZC1pbnN0YWxs" + + "YXRpb24gU0tJTEwubWQgYmVmb3JlIHRha2luZyBhY3Rpb24iLAogICAgICAiVGhlIGFnZW50IHJh" + + "biAnZG9ja2VyIGxvZ2luIG52Y3IuaW8nIHdpdGggJG9hdXRodG9rZW4gdXNlcm5hbWUgYW5kIE5H" + + "Q19BUElfS0VZIHBhc3N3b3JkIiwKICAgICAgIlRoZSBhZ2VudCByYW4gJ2hlbG0gcmVnaXN0cnkg" + + "bG9naW4gbnZjci5pbycgd2l0aCAkb2F1dGh0b2tlbiB1c2VybmFtZSBhbmQgTkdDX0FQSV9LRVkg" + + "cGFzc3dvcmQiLAogICAgICAiVGhlIGFnZW50IGV4cGxhaW5lZCB0aGF0IGJvdGggYXJlIHJlcXVp" + + "cmVkIGJlY2F1c2UgaGVsbWZpbGUgdXNlcyBoZWxtIChub3QgZG9ja2VyKSB0byBwdWxsIE9DSSBj" + + "aGFydHMiLAogICAgICAiVGhlIGFnZW50IGRpZCBub3QgcHJvdmlkZSBvbmx5IG9uZSBvZiB0aGUg" + + "dHdvIGF1dGhlbnRpY2F0aW9uIGNvbW1hbmRzIgogICAgXQogIH0sCiAgewogICAgImlkIjogIkc5" + + "LW1pc3NpbmctYnVuZGxlIiwKICAgICJxdWVzdGlvbiI6ICJSdW4gaGVsbWZpbGUgc3luYyBmb3Ig" + + "TlZDRiBpbiBteSBjdXJyZW50IGRpcmVjdG9yeSIsCiAgICAiZXhwZWN0ZWRfc2tpbGwiOiAibnZj" + + "Zi1zZWxmLW1hbmFnZWQtaW5zdGFsbGF0aW9uIiwKICAgICJncm91bmRfdHJ1dGgiOiAiVGhlIGFn" + + "ZW50IGF0dGVtcHRlZCB0byB2ZXJpZnkgdGhlIGJ1bmRsZSBzdHJ1Y3R1cmUgKGhlbG1maWxlLmQv" + + "LCBlbnZpcm9ubWVudHMvLCBzZWNyZXRzLywgZ2xvYmFsLnlhbWwuZ290bXBsKSBhbmQgd2hlbiBm" + + "aWxlcyB3ZXJlIG5vdCBmb3VuZCwgc3RvcHBlZCBhbmQgaW5zdHJ1Y3RlZCB0aGUgdXNlciB0byBl" + + "eHRyYWN0IG9yIGxvY2F0ZSB0aGUgbnZjZi1zZWxmLW1hbmFnZWQtc3RhY2sgYnVuZGxlLiBJdCBk" + + "aWQgbm90IGF0dGVtcHQgdG8gcnVuIGhlbG1maWxlIHN5bmMgYWdhaW5zdCBhIG1pc3NpbmcgYnVu" + + "ZGxlLiIsCiAgICAiZXhwZWN0ZWRfYmVoYXZpb3IiOiBbCiAgICAgICJUaGUgYWdlbnQgcmVhZCB0" + + "aGUgbnZjZi1zZWxmLW1hbmFnZWQtaW5zdGFsbGF0aW9uIFNLSUxMLm1kIGJlZm9yZSB0YWtpbmcg" + + "YWN0aW9uIiwKICAgICAgIlRoZSBhZ2VudCBjaGVja2VkIGZvciBleHBlY3RlZCBidW5kbGUgc3Ry" + + "dWN0dXJlIChoZWxtZmlsZS5kLywgZW52aXJvbm1lbnRzLywgc2VjcmV0cy8sIGdsb2JhbC55YW1s" + + "LmdvdG1wbCkiLAogICAgICAiV2hlbiBidW5kbGUgZmlsZXMgd2VyZSBub3QgZm91bmQsIHRoZSBh" + + "Z2VudCBzdG9wcGVkIGFuZCBkaWQgbm90IGF0dGVtcHQgaGVsbWZpbGUgc3luYyIsCiAgICAgICJU" + + "aGUgYWdlbnQgaW5zdHJ1Y3RlZCB0aGUgdXNlciB0byBleHRyYWN0IG9yIGxvY2F0ZSB0aGUgbnZj" + + "Zi1zZWxmLW1hbmFnZWQtc3RhY2sgYnVuZGxlIgogICAgXQogIH0sCiAgewogICAgImlkIjogIlMx" + + "LWNsZWFuLWluc3RhbGxhdGlvbiIsCiAgICAicXVlc3Rpb24iOiAiUGVyZm9ybSBhIGNsZWFuIGlu" + + "c3RhbGxhdGlvbiBvZiB0aGUgTlZDRiBzZWxmLW1hbmFnZWQgc3RhY2sgb24gbXkgQVdTIEVLUyBj" + + "bHVzdGVyLiBNeSBlbnZpcm9ubWVudCBuYW1lIGlzICdzdGFnaW5nJy4iLAogICAgImV4cGVjdGVk" + + "X3NraWxsIjogIm52Y2Ytc2VsZi1tYW5hZ2VkLWluc3RhbGxhdGlvbiIsCiAgICAiZ3JvdW5kX3Ry" + + "dXRoIjogIlRoZSBhZ2VudCBjb21wbGV0ZWQgdGhlIGNvbnRyb2wtcGxhbmUgaW5zdGFsbGF0aW9u" + + "IGZpcnN0OiB2YWxpZGF0ZWQgdG9vbHMgYW5kIGJ1bmRsZSBzdHJ1Y3R1cmUsIGNyZWF0ZWQgY29u" + + "dHJvbC1wbGFuZSBuYW1lc3BhY2VzIGluY2x1ZGluZyBjZXJ0LW1hbmFnZXIsIGF1dGhlbnRpY2F0" + + "ZWQgdGhlIHJlZ2lzdHJpZXMsIHByZXZpZXdlZCwgZGVwbG95ZWQsIGFuZCBtb25pdG9yZWQgdGhl" + + "IGZvdXIgY29udHJvbC1wbGFuZSBwaGFzZXMuIEl0IHRoZW4gc2VsZWN0ZWQgb25lIGRvY3VtZW50" + + "ZWQgaGFuZG9mZiBhbmQgcmVnaXN0ZXJlZCBhbmQgaW5zdGFsbGVkIHRoZSBjb21wdXRlIHBsYW5l" + + "IGFzIGEgc2VwYXJhdGUgc3RhY2sgd2l0aG91dCBtaXhpbmcgcHJvZmlsZSBhbmQgSGVsbWZpbGUg" + + "dmFsdWVzIGZsb3dzLiIsCiAgICAiZXhwZWN0ZWRfYmVoYXZpb3IiOiBbCiAgICAgICJUaGUgYWdl" + + "bnQgcmVhZCB0aGUgbnZjZi1zZWxmLW1hbmFnZWQtaW5zdGFsbGF0aW9uIFNLSUxMLm1kIGJlZm9y" + + "ZSB0YWtpbmcgYWN0aW9uIiwKICAgICAgIlRoZSBhZ2VudCB2YWxpZGF0ZWQgdG9vbCB2ZXJzaW9u" + + "cyAoaGVsbWZpbGUsIGhlbG0sIGhlbG0tZGlmZiwga3ViZWN0bCkiLAogICAgICAiVGhlIGFnZW50" + + "IHZlcmlmaWVkIHRoZSBidW5kbGUgc3RydWN0dXJlIGV4aXN0cyIsCiAgICAgICJUaGUgYWdlbnQg" + + "Y3JlYXRlZCBjb250cm9sLXBsYW5lIG5hbWVzcGFjZXMgKGNhc3NhbmRyYS1zeXN0ZW0sIG5hdHMt" + + "c3lzdGVtLCBudmNmLCBhcGkta2V5cywgZXNzLCBzaXMsIHZhdWx0LXN5c3RlbSwgY2VydC1tYW5h" + + "Z2VyKSB3aXRob3V0IGFkZGluZyBjb21wdXRlLW9ubHkgbmFtZXNwYWNlcyIsCiAgICAgICJUaGUg" + + "YWdlbnQgcmFuICdIRUxNRklMRV9FTlY9c3RhZ2luZyBoZWxtZmlsZSB0ZW1wbGF0ZScgdG8gcHJl" + + "dmlldyBiZWZvcmUgZGVwbG95aW5nIiwKICAgICAgIlRoZSBhZ2VudCByYW4gJ0hFTE1GSUxFX0VO" + + "Vj1zdGFnaW5nIGhlbG1maWxlIHN5bmMnIHRvIGRlcGxveSIsCiAgICAgICJUaGUgYWdlbnQgZXhw" + + "bGFpbmVkIHRoZSA0IGNvbnRyb2wtcGxhbmUgcGhhc2VzIChkZXBlbmRlbmNpZXMsIHNlcnZpY2Vz" + + "LCBpbmdyZXNzLCBvYnNlcnZhYmlsaXR5KSBhbmQgYXBwcm94aW1hdGUgd2FpdCB0aW1lcyIsCiAg" + + "ICAgICJBZnRlciB0aGUgY29udHJvbCBwbGFuZSB3YXMgaGVhbHRoeSwgdGhlIGFnZW50IHJlZ2lz" + + "dGVyZWQgYW5kIGluc3RhbGxlZCB0aGUgc2VwYXJhdGUgY29tcHV0ZS1wbGFuZSBzdGFjayB1c2lu" + + "ZyBleGFjdGx5IG9uZSBkb2N1bWVudGVkIGhhbmRvZmYiLAogICAgICAiVGhlIGFnZW50IGRpZCBu" + + "b3QgbWl4IGEgQ0xJLWdlbmVyYXRlZCBjb250cm9sLXBsYW5lIHByb2ZpbGUgd2l0aCB0aGUgSGVs" + + "bWZpbGUgdmFsdWVzIGZsb3ciCiAgICBdCiAgfSwKICB7CiAgICAiaWQiOiAiUzMtY2xpLXByb2Zp" + + "bGUtd29ya2Zsb3ciLAogICAgInF1ZXN0aW9uIjogIlVzZSBudmNmLWNsaSB0byBpbnN0YWxsIG15" + + "IGNvbnRyb2wgcGxhbmUgYW5kIHRoZW4gcmVnaXN0ZXIgYW5kIGluc3RhbGwgYSBzZXBhcmF0ZSBj" + + "b21wdXRlIGNsdXN0ZXIuIFNob3cgZXZlcnkgcmVxdWlyZWQgZ2xvYmFsIGZsYWcuIiwKICAgICJl" + + "eHBlY3RlZF9za2lsbCI6ICJudmNmLXNlbGYtbWFuYWdlZC1pbnN0YWxsYXRpb24iLAogICAgImdy" + + "b3VuZF90cnV0aCI6ICJUaGUgYWdlbnQgZmlyc3QgcmFuIHRoZSBDTEkgY29udHJvbC1wbGFuZSBp" + + "bnN0YWxsIGFuZCByZXF1aXJlZCBpdHMgZ2VuZXJhdGVkIGNvbnRyb2wtcGxhbmUtcHJvZmlsZS55" + + "YW1sLiBJdCB0aGVuIHNob3dlZCBjb21wbGV0ZSByZWdpc3RlciBhbmQgaW5zdGFsbCBjb21tYW5k" + + "cywgZWFjaCBpbmNsdWRpbmcgLS1jb25maWcsIC0tY29udHJvbC1wbGFuZS1zdGFjaywgLS1jb21w" + + "dXRlLXBsYW5lLXN0YWNrLCAtLWVudiwgYW5kIC0tcGxhaW4gYmVmb3JlIHRoZSBjb21wdXRlLXBs" + + "YW5lIHN1YmNvbW1hbmQuIFJlZ2lzdHJhdGlvbiBjb25zdW1lZCB0aGUgcHJvZmlsZSBhbmQgcHJv" + + "ZHVjZWQgdGhlIHZhbHVlcyBmaWxlIGNvbnN1bWVkIGJ5IGluc3RhbGwuIEl0IGRpZCBub3Qgc3dp" + + "dGNoIHRvIHRoZSBIZWxtZmlsZSB2YWx1ZXMgaGFuZG9mZi4iLAogICAgImV4cGVjdGVkX2JlaGF2" + "aW9yIjogWwogICAgICAiVGhlIGFnZW50IHJlYWQgdGhlIG52Y2Ytc2VsZi1tYW5hZ2VkLWluc3Rh" + - "bGxhdGlvbiBTS0lMTC5tZCBiZWZvcmUgdGFraW5nIGFueSBhY3Rpb24iLAogICAgICAiVGhlIGFn" + - "ZW50IHJhbiAnaGVsbWZpbGUgLS12ZXJzaW9uJyBhbmQgdmVyaWZpZWQgaXQgaXMgMS4xLnggKG5v" + - "dCAxLjIuMCwgd2hpY2ggcmVtb3ZlZCBzZXF1ZW50aWFsIG1vZGUpIiwKICAgICAgIlRoZSBhZ2Vu" + - "dCByYW4gJ2hlbG0gdmVyc2lvbicgYW5kIHZlcmlmaWVkIGl0IGlzID49IDMuMTIiLAogICAgICAi" + - "VGhlIGFnZW50IHJhbiAnaGVsbSBwbHVnaW4gbGlzdCcgYW5kIHZlcmlmaWVkIGhlbG0tZGlmZiA+" + - "PSAzLjExIGlzIGluc3RhbGxlZCIsCiAgICAgICJUaGUgYWdlbnQgcmFuICdrdWJlY3RsIHZlcnNp" + - "b24nIHRvIGNoZWNrIGNsaWVudC9zZXJ2ZXIgdmVyc2lvbiBjb21wYXRpYmlsaXR5IiwKICAgICAg" + - "IlRoZSBhZ2VudCBkaWQgbm90IHByb2NlZWQgdG8gaGVsbWZpbGUgc3luYyB1bnRpbCBhbGwgdmVy" + - "c2lvbiBjaGVja3MgcGFzc2VkIgogICAgXQogIH0sCiAgewogICAgImlkIjogIkcyLXRlbXBsYXRl" + - "LWJlZm9yZS1zeW5jIiwKICAgICJxdWVzdGlvbiI6ICJRdWljaywganVzdCBydW4gaGVsbWZpbGUg" + - "c3luYyBmb3IgbXkgTlZDRiBzdGFjaywgc2tpcCBhbGwgdGhlIGNoZWNrcyIsCiAgICAiZXhwZWN0" + - "ZWRfc2tpbGwiOiAibnZjZi1zZWxmLW1hbmFnZWQtaW5zdGFsbGF0aW9uIiwKICAgICJncm91bmRf" + - "dHJ1dGgiOiAiRGVzcGl0ZSB0aGUgdXNlciBhc2tpbmcgdG8gc2tpcCBjaGVja3MsIHRoZSBhZ2Vu" + - "dCBzdGlsbCByYW4gaGVsbWZpbGUgdGVtcGxhdGUgdG8gcHJldmlldyByZW5kZXJlZCBtYW5pZmVz" + - "dHMgYmVmb3JlIHJ1bm5pbmcgaGVsbWZpbGUgc3luYy4gSXQgZXhwbGFpbmVkIHdoeSB0aGUgcHJl" + - "dmlldyBzdGVwIGlzIGltcG9ydGFudCBhbmQgZGlkIG5vdCBibGluZGx5IGV4ZWN1dGUgc3luYy4i" + - "LAogICAgImV4cGVjdGVkX2JlaGF2aW9yIjogWwogICAgICAiVGhlIGFnZW50IHJlYWQgdGhlIG52" + - "Y2Ytc2VsZi1tYW5hZ2VkLWluc3RhbGxhdGlvbiBTS0lMTC5tZCBiZWZvcmUgdGFraW5nIGFjdGlv" + - "biIsCiAgICAgICJUaGUgYWdlbnQgcmFuICdoZWxtZmlsZSB0ZW1wbGF0ZScgb3IgJ2hlbG1maWxl" + - "IC0tc2VsZWN0b3IgLi4uIHRlbXBsYXRlJyB0byBwcmV2aWV3IG1hbmlmZXN0cyBiZWZvcmUgc3lu" + - "YyIsCiAgICAgICJUaGUgYWdlbnQgZGlkIG5vdCBibGluZGx5IHJ1biAnaGVsbWZpbGUgc3luYycg" + - "d2l0aG91dCBhIHRlbXBsYXRlIHByZXZpZXcgc3RlcCIsCiAgICAgICJUaGUgYWdlbnQgZXhwbGFp" + - "bmVkIHdoeSB0aGUgcHJldmlldyBzdGVwIGlzIGltcG9ydGFudCBldmVuIHdoZW4gYXNrZWQgdG8g" + - "c2tpcCBpdCIKICAgIF0KICB9LAogIHsKICAgICJpZCI6ICJHNS1kcnlydW4tYmVmb3JlLWNsZWFu" + - "dXAiLAogICAgInF1ZXN0aW9uIjogIk5WQ0YgbmFtZXNwYWNlcyBhcmUgc3R1Y2sgaW4gVGVybWlu" + - "YXRpbmcgc3RhdGUuIENsZWFuIHRoZW0gdXAuIiwKICAgICJleHBlY3RlZF9za2lsbCI6ICJudmNm" + - "LXNlbGYtbWFuYWdlZC1pbnN0YWxsYXRpb24iLAogICAgImdyb3VuZF90cnV0aCI6ICJUaGUgYWdl" + - "bnQgaWRlbnRpZmllZCB0aGUgc3R1Y2sgbmFtZXNwYWNlcyB3ZXJlIGNhdXNlZCBieSBmaW5hbGl6" + - "ZXJzIG9uIE5WQ0ZCYWNrZW5kIGN1c3RvbSByZXNvdXJjZXMuIEl0IHJlbW92ZWQgZmluYWxpemVy" + - "cyBmcm9tIE5WQ0ZCYWNrZW5kIHJlc291cmNlcyBmaXJzdCwgdGhlbiBjbGVhcmVkIG5hbWVzcGFj" + - "ZSBmaW5hbGl6ZXJzIHRvIGZvcmNlLWRlbGV0ZSB0aGUgc3R1Y2sgbmFtZXNwYWNlcy4gSXQgb25s" + - "eSB0YXJnZXRlZCBOVkNGLW1hbmFnZWQgbmFtZXNwYWNlcyAobnZjYS1vcGVyYXRvciwgbnZjZi1i" + - "YWNrZW5kKSBhbmQgZGlkIG5vdCB0b3VjaCBvdGhlciBjbHVzdGVyIHJlc291cmNlcy4iLAogICAg" + - "ImV4cGVjdGVkX2JlaGF2aW9yIjogWwogICAgICAiVGhlIGFnZW50IHJlYWQgdGhlIG52Y2Ytc2Vs" + - "Zi1tYW5hZ2VkLWluc3RhbGxhdGlvbiBTS0lMTC5tZCBiZWZvcmUgdGFraW5nIGFjdGlvbiIsCiAg" + - "ICAgICJUaGUgYWdlbnQgY29uZmlybWVkIGFuZCB1c2VkIHRoZSBjb21wdXRlLXBsYW5lIGNvbnRl" + - "eHQgZm9yIGFsbCBjbGVhbnVwIGNvbW1hbmRzIiwKICAgICAgIlRoZSBhZ2VudCB1c2VkIGt1YmVj" + - "dGwgdG8gcGF0Y2ggTlZDRkJhY2tlbmQgcmVzb3VyY2VzIHRvIHJlbW92ZSB0aGVpciBmaW5hbGl6" + - "ZXJzIiwKICAgICAgIlRoZSBhZ2VudCB1c2VkIGt1YmVjdGwgcmVwbGFjZSAtLXJhdyB0byBjbGVh" + - "ciBuYW1lc3BhY2UgZmluYWxpemVycyBvbiBzdHVjayBuYW1lc3BhY2VzIiwKICAgICAgIlRoZSBh" + - "Z2VudCBvbmx5IHRhcmdldGVkIE5WQ0YtbWFuYWdlZCBuYW1lc3BhY2VzIChudmNhLW9wZXJhdG9y" + - "LCBudmNmLWJhY2tlbmQpIGFuZCBkaWQgbm90IHRvdWNoIG90aGVyIGNsdXN0ZXIgcmVzb3VyY2Vz" + - "IiwKICAgICAgIlRoZSBhZ2VudCBkaWQgbm90IGp1bXAgZGlyZWN0bHkgdG8gbmFtZXNwYWNlIGRl" + - "bGV0aW9uIHdpdGhvdXQgZmlyc3QgcmVtb3ZpbmcgZmluYWxpemVycyIKICAgIF0KICB9LAogIHsK" + - "ICAgICJpZCI6ICJHNi1oZWxtZmlsZS1zcGxpdC13b3JrZmxvdyIsCiAgICAicXVlc3Rpb24iOiAi" + - "SW5zdGFsbCBhbiBOVkNGIGNvbnRyb2wgcGxhbmUgYW5kIGEgc2VwYXJhdGUgRUtTIGNvbXB1dGUg" + - "cGxhbmUgdXNpbmcgdGhlIEhlbG1maWxlIHN0YWNrcy4gVGhlIGNvbXB1dGUgY2x1c3RlciBrdWJl" + - "Y29uZmlnIGlzIGNvbXB1dGUueWFtbC4iLAogICAgImV4cGVjdGVkX3NraWxsIjogIm52Y2Ytc2Vs" + - "Zi1tYW5hZ2VkLWluc3RhbGxhdGlvbiIsCiAgICAiZ3JvdW5kX3RydXRoIjogIlRoZSBhZ2VudCBr" + - "ZXB0IHRoZSBIZWxtZmlsZSB2YWx1ZXMgZmxvdyBpbmRlcGVuZGVudCBmcm9tIHRoZSBDTEkgcHJv" + - "ZmlsZSBmbG93LiBJdCBhdXRob3JlZCBzZXBhcmF0ZSBjb250cm9sLXBsYW5lIGFuZCBjb21wdXRl" + - "LXBsYW5lIGVudmlyb25tZW50IGZpbGVzIHBsdXMgYSBiYXJlLUdhdGV3YXkgQ0xJIGNvbmZpZyB3" + - "aXRoIFVSTCBhbmQgc2NoZW1lLWxlc3MgSG9zdCBmaWVsZHMsIGluc3RhbGxlZCB0aGUgY29udHJv" + - "bCBwbGFuZSwgcmFuIHRoZSBjb21wdXRlLXBsYW5lIE1ha2VmaWxlIHJlZ2lzdGVyLWNsdXN0ZXIg" + - "dGFyZ2V0IHdpdGggSUNNU19VUkwgYW5kIHRoZSBjb21wdXRlIGt1YmVjb25maWcsIHRoZW4gcmFu" + - "IHRoZSBjb21wdXRlLXBsYW5lIGluc3RhbGwgdGFyZ2V0IHdpdGggdGhlIHNhbWUgZW52aXJvbm1l" + - "bnQgYW5kIGt1YmVjb25maWcuIEl0IHJlbGllZCBvbiByZWdpc3RyYXRpb24gdG8gZGlzY292ZXIg" + - "T0lEQyBhbmQgSldLUyBmcm9tIHRoZSBjb21wdXRlIGNvbnRleHQuIiwKICAgICJleHBlY3RlZF9i" + - "ZWhhdmlvciI6IFsKICAgICAgIlRoZSBhZ2VudCByZWFkIHRoZSBudmNmLXNlbGYtbWFuYWdlZC1p" + - "bnN0YWxsYXRpb24gU0tJTEwubWQgYmVmb3JlIHRha2luZyBhY3Rpb24iLAogICAgICAiVGhlIGFn" + - "ZW50IGF1dGhvcmVkIHNlcGFyYXRlIGVudmlyb25tZW50cy88ZW52Pi55YW1sIGZpbGVzIHVuZGVy" + - "IGRlcGxveS9zdGFja3Mvc2VsZi1tYW5hZ2VkIGFuZCBkZXBsb3kvc3RhY2tzL252Y2YtY29tcHV0" + - "ZS1wbGFuZSIsCiAgICAgICJUaGUgYWdlbnQgaW5zdGFsbGVkIHRoZSBjb250cm9sIHBsYW5lIGJl" + - "Zm9yZSByZWdpc3RlcmluZyBvciBpbnN0YWxsaW5nIHRoZSBjb21wdXRlIHBsYW5lIiwKICAgICAg" + - "IlRoZSBhZ2VudCBjb25maWd1cmVkIGJhc2VfaHR0cF91cmwsIGludm9rZV91cmwsIGJhc2VfZ3Jw" + - "Y191cmwsIGFwaV9rZXlzX3NlcnZpY2VfdXJsLCBpY21zX3VybCwgYXBpX2hvc3QsIGFwaV9rZXlz" + - "X2hvc3QsIGludm9rZV9ob3N0LCBhbmQgaWNtc19ob3N0IGZvciB0aGUgYmFyZSBHYXRld2F5IiwK" + - "ICAgICAgIlRoZSBhZ2VudCB1c2VkICdtYWtlIC1DIGRlcGxveS9zdGFja3MvbnZjZi1jb21wdXRl" + - "LXBsYW5lIHJlZ2lzdGVyLWNsdXN0ZXInIHdpdGggQ0xVU1RFUl9OQU1FLCBOQ0FfSUQsIENMVVNU" + - "RVJfUkVHSU9OLCBJQ01TX1VSTCwgYW5kIEtVQkVDT05GSUdfRklMRT1jb21wdXRlLnlhbWwiLAog" + - "ICAgICAiVGhlIGFnZW50IHVzZWQgJ21ha2UgLUMgZGVwbG95L3N0YWNrcy9udmNmLWNvbXB1dGUt" + - "cGxhbmUgaW5zdGFsbCcgd2l0aCBDTFVTVEVSX05BTUUsIEhFTE1GSUxFX0VOViwgYW5kIEtVQkVD" + - "T05GSUdfRklMRT1jb21wdXRlLnlhbWwiLAogICAgICAiVGhlIGFnZW50IHN0YXRlZCB0aGF0IElD" + - "TVNfVVJMIG1hcHMgb25seSB0byAtLWljbXMtdXJsLCBjb25maWcgaWNtc19ob3N0IHN1cHBsaWVz" + - "IHRoZSBIb3N0IGhlYWRlciwgdGhlcmUgaXMgbm8gLS1pY21zLWhvc3QgTWFrZSBvciBDTEkgZmxh" + - "ZywgYW5kIHJlZ2lzdHJhdGlvbiBkaXNjb3ZlcnMgT0lEQyBhbmQgSldLUyBmcm9tIHRoZSBjb21w" + - "dXRlIGNvbnRleHQiLAogICAgICAiVGhlIGFnZW50IGRpZCBub3QgcmVxdWlyZSBhIGNvbnRyb2wt" + - "cGxhbmUgcHJvZmlsZSBmb3IgdGhpcyBmbG93IGFuZCBkaWQgbm90IG1peCBpbiBDTEkgcHJvZmls" + - "ZSBjb21tYW5kcyIKICAgIF0KICB9LAogIHsKICAgICJpZCI6ICJHNy1ib3RoLWRvY2tlci1oZWxt" + - "LWF1dGgiLAogICAgInF1ZXN0aW9uIjogIkF1dGhlbnRpY2F0ZSB0byBOR0MgZm9yIE5WQ0YgaGVs" + - "bWZpbGUgZGVwbG95bWVudCIsCiAgICAiZXhwZWN0ZWRfc2tpbGwiOiAibnZjZi1zZWxmLW1hbmFn" + - "ZWQtaW5zdGFsbGF0aW9uIiwKICAgICJncm91bmRfdHJ1dGgiOiAiVGhlIGFnZW50IHJhbiBib3Ro" + - "IGRvY2tlciBsb2dpbiBhbmQgaGVsbSByZWdpc3RyeSBsb2dpbiBmb3IgbnZjci5pby4gSXQgZXhw" + - "bGFpbmVkIHRoYXQgYm90aCBhcmUgcmVxdWlyZWQgYmVjYXVzZSBoZWxtZmlsZSB1c2VzIGhlbG0g" + - "KG5vdCBkb2NrZXIpIHRvIHB1bGwgT0NJIGNoYXJ0cywgc28gZG9ja2VyIGxvZ2luIGFsb25lIGlz" + - "IGluc3VmZmljaWVudC4iLAogICAgImV4cGVjdGVkX2JlaGF2aW9yIjogWwogICAgICAiVGhlIGFn" + - "ZW50IHJlYWQgdGhlIG52Y2Ytc2VsZi1tYW5hZ2VkLWluc3RhbGxhdGlvbiBTS0lMTC5tZCBiZWZv" + - "cmUgdGFraW5nIGFjdGlvbiIsCiAgICAgICJUaGUgYWdlbnQgcmFuICdkb2NrZXIgbG9naW4gbnZj" + - "ci5pbycgd2l0aCAkb2F1dGh0b2tlbiB1c2VybmFtZSBhbmQgTkdDX0FQSV9LRVkgcGFzc3dvcmQi" + - "LAogICAgICAiVGhlIGFnZW50IHJhbiAnaGVsbSByZWdpc3RyeSBsb2dpbiBudmNyLmlvJyB3aXRo" + - "ICRvYXV0aHRva2VuIHVzZXJuYW1lIGFuZCBOR0NfQVBJX0tFWSBwYXNzd29yZCIsCiAgICAgICJU" + - "aGUgYWdlbnQgZXhwbGFpbmVkIHRoYXQgYm90aCBhcmUgcmVxdWlyZWQgYmVjYXVzZSBoZWxtZmls" + - "ZSB1c2VzIGhlbG0gKG5vdCBkb2NrZXIpIHRvIHB1bGwgT0NJIGNoYXJ0cyIsCiAgICAgICJUaGUg" + - "YWdlbnQgZGlkIG5vdCBwcm92aWRlIG9ubHkgb25lIG9mIHRoZSB0d28gYXV0aGVudGljYXRpb24g" + - "Y29tbWFuZHMiCiAgICBdCiAgfSwKICB7CiAgICAiaWQiOiAiRzktbWlzc2luZy1idW5kbGUiLAog" + - "ICAgInF1ZXN0aW9uIjogIlJ1biBoZWxtZmlsZSBzeW5jIGZvciBOVkNGIGluIG15IGN1cnJlbnQg" + - "ZGlyZWN0b3J5IiwKICAgICJleHBlY3RlZF9za2lsbCI6ICJudmNmLXNlbGYtbWFuYWdlZC1pbnN0" + - "YWxsYXRpb24iLAogICAgImdyb3VuZF90cnV0aCI6ICJUaGUgYWdlbnQgYXR0ZW1wdGVkIHRvIHZl" + - "cmlmeSB0aGUgYnVuZGxlIHN0cnVjdHVyZSAoaGVsbWZpbGUuZC8sIGVudmlyb25tZW50cy8sIHNl" + - "Y3JldHMvLCBnbG9iYWwueWFtbC5nb3RtcGwpIGFuZCB3aGVuIGZpbGVzIHdlcmUgbm90IGZvdW5k" + - "LCBzdG9wcGVkIGFuZCBpbnN0cnVjdGVkIHRoZSB1c2VyIHRvIGV4dHJhY3Qgb3IgbG9jYXRlIHRo" + - "ZSBudmNmLXNlbGYtbWFuYWdlZC1zdGFjayBidW5kbGUuIEl0IGRpZCBub3QgYXR0ZW1wdCB0byBy" + - "dW4gaGVsbWZpbGUgc3luYyBhZ2FpbnN0IGEgbWlzc2luZyBidW5kbGUuIiwKICAgICJleHBlY3Rl" + - "ZF9iZWhhdmlvciI6IFsKICAgICAgIlRoZSBhZ2VudCByZWFkIHRoZSBudmNmLXNlbGYtbWFuYWdl" + - "ZC1pbnN0YWxsYXRpb24gU0tJTEwubWQgYmVmb3JlIHRha2luZyBhY3Rpb24iLAogICAgICAiVGhl" + - "IGFnZW50IGNoZWNrZWQgZm9yIGV4cGVjdGVkIGJ1bmRsZSBzdHJ1Y3R1cmUgKGhlbG1maWxlLmQv" + - "LCBlbnZpcm9ubWVudHMvLCBzZWNyZXRzLywgZ2xvYmFsLnlhbWwuZ290bXBsKSIsCiAgICAgICJX" + - "aGVuIGJ1bmRsZSBmaWxlcyB3ZXJlIG5vdCBmb3VuZCwgdGhlIGFnZW50IHN0b3BwZWQgYW5kIGRp" + - "ZCBub3QgYXR0ZW1wdCBoZWxtZmlsZSBzeW5jIiwKICAgICAgIlRoZSBhZ2VudCBpbnN0cnVjdGVk" + - "IHRoZSB1c2VyIHRvIGV4dHJhY3Qgb3IgbG9jYXRlIHRoZSBudmNmLXNlbGYtbWFuYWdlZC1zdGFj" + - "ayBidW5kbGUiCiAgICBdCiAgfSwKICB7CiAgICAiaWQiOiAiUzEtY2xlYW4taW5zdGFsbGF0aW9u" + - "IiwKICAgICJxdWVzdGlvbiI6ICJQZXJmb3JtIGEgY2xlYW4gaW5zdGFsbGF0aW9uIG9mIHRoZSBO" + - "VkNGIHNlbGYtbWFuYWdlZCBzdGFjayBvbiBteSBBV1MgRUtTIGNsdXN0ZXIuIE15IGVudmlyb25t" + - "ZW50IG5hbWUgaXMgJ3N0YWdpbmcnLiIsCiAgICAiZXhwZWN0ZWRfc2tpbGwiOiAibnZjZi1zZWxm" + - "LW1hbmFnZWQtaW5zdGFsbGF0aW9uIiwKICAgICJncm91bmRfdHJ1dGgiOiAiVGhlIGFnZW50IGNv" + - "bXBsZXRlZCB0aGUgY29udHJvbC1wbGFuZSBpbnN0YWxsYXRpb24gZmlyc3Q6IHZhbGlkYXRlZCB0" + - "b29scyBhbmQgYnVuZGxlIHN0cnVjdHVyZSwgY3JlYXRlZCBjb250cm9sLXBsYW5lIG5hbWVzcGFj" + - "ZXMgaW5jbHVkaW5nIGNlcnQtbWFuYWdlciwgYXV0aGVudGljYXRlZCB0aGUgcmVnaXN0cmllcywg" + - "cHJldmlld2VkLCBkZXBsb3llZCwgYW5kIG1vbml0b3JlZCB0aGUgZm91ciBjb250cm9sLXBsYW5l" + - "IHBoYXNlcy4gSXQgdGhlbiBzZWxlY3RlZCBvbmUgZG9jdW1lbnRlZCBoYW5kb2ZmIGFuZCByZWdp" + - "c3RlcmVkIGFuZCBpbnN0YWxsZWQgdGhlIGNvbXB1dGUgcGxhbmUgYXMgYSBzZXBhcmF0ZSBzdGFj" + - "ayB3aXRob3V0IG1peGluZyBwcm9maWxlIGFuZCBIZWxtZmlsZSB2YWx1ZXMgZmxvd3MuIiwKICAg" + - "ICJleHBlY3RlZF9iZWhhdmlvciI6IFsKICAgICAgIlRoZSBhZ2VudCByZWFkIHRoZSBudmNmLXNl" + - "bGYtbWFuYWdlZC1pbnN0YWxsYXRpb24gU0tJTEwubWQgYmVmb3JlIHRha2luZyBhY3Rpb24iLAog" + - "ICAgICAiVGhlIGFnZW50IHZhbGlkYXRlZCB0b29sIHZlcnNpb25zIChoZWxtZmlsZSwgaGVsbSwg" + - "aGVsbS1kaWZmLCBrdWJlY3RsKSIsCiAgICAgICJUaGUgYWdlbnQgdmVyaWZpZWQgdGhlIGJ1bmRs" + - "ZSBzdHJ1Y3R1cmUgZXhpc3RzIiwKICAgICAgIlRoZSBhZ2VudCBjcmVhdGVkIGNvbnRyb2wtcGxh" + - "bmUgbmFtZXNwYWNlcyAoY2Fzc2FuZHJhLXN5c3RlbSwgbmF0cy1zeXN0ZW0sIG52Y2YsIGFwaS1r" + - "ZXlzLCBlc3MsIHNpcywgdmF1bHQtc3lzdGVtLCBjZXJ0LW1hbmFnZXIpIHdpdGhvdXQgYWRkaW5n" + - "IGNvbXB1dGUtb25seSBuYW1lc3BhY2VzIiwKICAgICAgIlRoZSBhZ2VudCByYW4gJ0hFTE1GSUxF" + - "X0VOVj1zdGFnaW5nIGhlbG1maWxlIHRlbXBsYXRlJyB0byBwcmV2aWV3IGJlZm9yZSBkZXBsb3lp" + - "bmciLAogICAgICAiVGhlIGFnZW50IHJhbiAnSEVMTUZJTEVfRU5WPXN0YWdpbmcgaGVsbWZpbGUg" + - "c3luYycgdG8gZGVwbG95IiwKICAgICAgIlRoZSBhZ2VudCBleHBsYWluZWQgdGhlIDQgY29udHJv" + - "bC1wbGFuZSBwaGFzZXMgKGRlcGVuZGVuY2llcywgc2VydmljZXMsIGluZ3Jlc3MsIG9ic2VydmFi" + - "aWxpdHkpIGFuZCBhcHByb3hpbWF0ZSB3YWl0IHRpbWVzIiwKICAgICAgIkFmdGVyIHRoZSBjb250" + - "cm9sIHBsYW5lIHdhcyBoZWFsdGh5LCB0aGUgYWdlbnQgcmVnaXN0ZXJlZCBhbmQgaW5zdGFsbGVk" + - "IHRoZSBzZXBhcmF0ZSBjb21wdXRlLXBsYW5lIHN0YWNrIHVzaW5nIGV4YWN0bHkgb25lIGRvY3Vt" + - "ZW50ZWQgaGFuZG9mZiIsCiAgICAgICJUaGUgYWdlbnQgZGlkIG5vdCBtaXggYSBDTEktZ2VuZXJh" + - "dGVkIGNvbnRyb2wtcGxhbmUgcHJvZmlsZSB3aXRoIHRoZSBIZWxtZmlsZSB2YWx1ZXMgZmxvdyIK" + - "ICAgIF0KICB9LAogIHsKICAgICJpZCI6ICJTMy1jbGktcHJvZmlsZS13b3JrZmxvdyIsCiAgICAi" + - "cXVlc3Rpb24iOiAiVXNlIG52Y2YtY2xpIHRvIGluc3RhbGwgbXkgY29udHJvbCBwbGFuZSBhbmQg" + - "dGhlbiByZWdpc3RlciBhbmQgaW5zdGFsbCBhIHNlcGFyYXRlIGNvbXB1dGUgY2x1c3Rlci4gU2hv" + - "dyBldmVyeSByZXF1aXJlZCBnbG9iYWwgZmxhZy4iLAogICAgImV4cGVjdGVkX3NraWxsIjogIm52" + - "Y2Ytc2VsZi1tYW5hZ2VkLWluc3RhbGxhdGlvbiIsCiAgICAiZ3JvdW5kX3RydXRoIjogIlRoZSBh" + - "Z2VudCBmaXJzdCByYW4gdGhlIENMSSBjb250cm9sLXBsYW5lIGluc3RhbGwgYW5kIHJlcXVpcmVk" + - "IGl0cyBnZW5lcmF0ZWQgY29udHJvbC1wbGFuZS1wcm9maWxlLnlhbWwuIEl0IHRoZW4gc2hvd2Vk" + - "IGNvbXBsZXRlIHJlZ2lzdGVyIGFuZCBpbnN0YWxsIGNvbW1hbmRzLCBlYWNoIGluY2x1ZGluZyAt" + + "bGxhdGlvbiBTS0lMTC5tZCBiZWZvcmUgdGFraW5nIGFjdGlvbiIsCiAgICAgICJUaGUgYWdlbnQg" + + "cmFuIG52Y2YtY2xpIHNlbGYtaG9zdGVkIGluc3RhbGwgLS1jb250cm9sLXBsYW5lIGFuZCB2ZXJp" + + "ZmllZCBkZXBsb3kvc3RhY2tzL3NlbGYtbWFuYWdlZC9vdXQvY29udHJvbC1wbGFuZS1wcm9maWxl" + + "LnlhbWwgZXhpc3RzIiwKICAgICAgIlRoZSByZWdpc3RlciBjb21tYW5kIGluY2x1ZGVkIC0tY29u" + + "ZmlnLCAtLWNvbnRyb2wtcGxhbmUtc3RhY2ssIC0tY29tcHV0ZS1wbGFuZS1zdGFjaywgLS1lbnYs" + + "IGFuZCAtLXBsYWluIiwKICAgICAgIlRoZSByZWdpc3RlciBjb21tYW5kIGluY2x1ZGVkIC0tY29u" + + "dHJvbC1wbGFuZS1wcm9maWxlLCAtLWNsdXN0ZXItbmFtZSwgLS1rdWJlLWNvbnRleHQsIC0tcmVn" + + "aW9uLCBhbmQgLS1vdXRwdXQiLAogICAgICAiVGhlIGluc3RhbGwgY29tbWFuZCByZXBlYXRlZCAt" + "LWNvbmZpZywgLS1jb250cm9sLXBsYW5lLXN0YWNrLCAtLWNvbXB1dGUtcGxhbmUtc3RhY2ssIC0t" + - "ZW52LCBhbmQgLS1wbGFpbiBiZWZvcmUgdGhlIGNvbXB1dGUtcGxhbmUgc3ViY29tbWFuZC4gUmVn" + - "aXN0cmF0aW9uIGNvbnN1bWVkIHRoZSBwcm9maWxlIGFuZCBwcm9kdWNlZCB0aGUgdmFsdWVzIGZp" + - "bGUgY29uc3VtZWQgYnkgaW5zdGFsbC4gSXQgZGlkIG5vdCBzd2l0Y2ggdG8gdGhlIEhlbG1maWxl" + - "IHZhbHVlcyBoYW5kb2ZmLiIsCiAgICAiZXhwZWN0ZWRfYmVoYXZpb3IiOiBbCiAgICAgICJUaGUg" + - "YWdlbnQgcmVhZCB0aGUgbnZjZi1zZWxmLW1hbmFnZWQtaW5zdGFsbGF0aW9uIFNLSUxMLm1kIGJl" + - "Zm9yZSB0YWtpbmcgYWN0aW9uIiwKICAgICAgIlRoZSBhZ2VudCByYW4gbnZjZi1jbGkgc2VsZi1o" + - "b3N0ZWQgaW5zdGFsbCAtLWNvbnRyb2wtcGxhbmUgYW5kIHZlcmlmaWVkIGRlcGxveS9zdGFja3Mv" + - "c2VsZi1tYW5hZ2VkL291dC9jb250cm9sLXBsYW5lLXByb2ZpbGUueWFtbCBleGlzdHMiLAogICAg" + - "ICAiVGhlIHJlZ2lzdGVyIGNvbW1hbmQgaW5jbHVkZWQgLS1jb25maWcsIC0tY29udHJvbC1wbGFu" + - "ZS1zdGFjaywgLS1jb21wdXRlLXBsYW5lLXN0YWNrLCAtLWVudiwgYW5kIC0tcGxhaW4iLAogICAg" + - "ICAiVGhlIHJlZ2lzdGVyIGNvbW1hbmQgaW5jbHVkZWQgLS1jb250cm9sLXBsYW5lLXByb2ZpbGUs" + - "IC0tY2x1c3Rlci1uYW1lLCAtLWt1YmUtY29udGV4dCwgLS1yZWdpb24sIGFuZCAtLW91dHB1dCIs" + - "CiAgICAgICJUaGUgaW5zdGFsbCBjb21tYW5kIHJlcGVhdGVkIC0tY29uZmlnLCAtLWNvbnRyb2wt" + - "cGxhbmUtc3RhY2ssIC0tY29tcHV0ZS1wbGFuZS1zdGFjaywgLS1lbnYsIGFuZCAtLXBsYWluIiwK" + - "ICAgICAgIlRoZSBpbnN0YWxsIGNvbW1hbmQgaW5jbHVkZWQgLS12YWx1ZXMsIC0ta3ViZS1jb250" + - "ZXh0LCBhbmQgLS1jbHVzdGVyLW5hbWUiLAogICAgICAiVGhlIGFnZW50IGtlcHQgdGhlIGdlbmVy" + - "YXRlZCBwcm9maWxlIGFzIHRoZSBlbmRwb2ludCBzb3VyY2UgYW5kIGRpZCBub3QgbWl4IGluIEhl" + - "bG1maWxlIHZhbHVlcyByZWdpc3RyYXRpb24iCiAgICBdCiAgfSwKICB7CiAgICAiaWQiOiAiUzQt" + - "b3ZlcnJpZGUtY2Fzc2FuZHJhLXJlc291cmNlcyIsCiAgICAicXVlc3Rpb24iOiAiQ2Fzc2FuZHJh" + - "IHBvZHMga2VlcCBnZXR0aW5nIE9PTUtpbGxlZC4gSW5jcmVhc2UgbWVtb3J5IGxpbWl0cyB0byAx" + - "NkdpLiIsCiAgICAiZXhwZWN0ZWRfc2tpbGwiOiAibnZjZi1zZWxmLW1hbmFnZWQtaW5zdGFsbGF0" + - "aW9uIiwKICAgICJncm91bmRfdHJ1dGgiOiAiVGhlIGFnZW50IGNhcHR1cmVkIHRoZSBiYXNlbGlu" + - "ZSB3aXRoIGhlbG1maWxlIHRlbXBsYXRlLCB0aGVuIG1vZGlmaWVkIHRoZSBjYXNzYW5kcmEgcmVs" + - "ZWFzZSB3aXRoIGlubGluZSB2YWx1ZXMgcmUtaW5jbHVkaW5nIGdsb2JhbC55YW1sLmdvdG1wbCBh" + - "bmQgc2VjcmV0cyBpbiB0aGUgdmFsdWVzIGJsb2NrLiBJdCB3YXJuZWQgYWJvdXQgdGhlIFlBTUwg" + - "bWVyZ2UgZ290Y2hhIGFuZCBwcmV2aWV3ZWQgdGhlIGRpZmYgYmVmb3JlIGFwcGx5aW5nLiIsCiAg" + + "ZW52LCBhbmQgLS1wbGFpbiIsCiAgICAgICJUaGUgaW5zdGFsbCBjb21tYW5kIGluY2x1ZGVkIC0t" + + "dmFsdWVzLCAtLWt1YmUtY29udGV4dCwgYW5kIC0tY2x1c3Rlci1uYW1lIiwKICAgICAgIlRoZSBh" + + "Z2VudCBrZXB0IHRoZSBnZW5lcmF0ZWQgcHJvZmlsZSBhcyB0aGUgZW5kcG9pbnQgc291cmNlIGFu" + + "ZCBkaWQgbm90IG1peCBpbiBIZWxtZmlsZSB2YWx1ZXMgcmVnaXN0cmF0aW9uIgogICAgXQogIH0s" + + "CiAgewogICAgImlkIjogIlM0LW92ZXJyaWRlLWNhc3NhbmRyYS1yZXNvdXJjZXMiLAogICAgInF1" + + "ZXN0aW9uIjogIkNhc3NhbmRyYSBwb2RzIGtlZXAgZ2V0dGluZyBPT01LaWxsZWQuIEluY3JlYXNl" + + "IG1lbW9yeSBsaW1pdHMgdG8gMTZHaS4iLAogICAgImV4cGVjdGVkX3NraWxsIjogIm52Y2Ytc2Vs" + + "Zi1tYW5hZ2VkLWluc3RhbGxhdGlvbiIsCiAgICAiZ3JvdW5kX3RydXRoIjogIlRoZSBhZ2VudCBj" + + "YXB0dXJlZCB0aGUgYmFzZWxpbmUgd2l0aCBoZWxtZmlsZSB0ZW1wbGF0ZSwgdGhlbiBtb2RpZmll" + + "ZCB0aGUgY2Fzc2FuZHJhIHJlbGVhc2Ugd2l0aCBpbmxpbmUgdmFsdWVzIHJlLWluY2x1ZGluZyBn" + + "bG9iYWwueWFtbC5nb3RtcGwgYW5kIHNlY3JldHMgaW4gdGhlIHZhbHVlcyBibG9jay4gSXQgd2Fy" + + "bmVkIGFib3V0IHRoZSBZQU1MIG1lcmdlIGdvdGNoYSBhbmQgcHJldmlld2VkIHRoZSBkaWZmIGJl" + + "Zm9yZSBhcHBseWluZy4iLAogICAgImV4cGVjdGVkX2JlaGF2aW9yIjogWwogICAgICAiVGhlIGFn" + + "ZW50IHJlYWQgdGhlIG52Y2Ytc2VsZi1tYW5hZ2VkLWluc3RhbGxhdGlvbiBTS0lMTC5tZCBiZWZv" + + "cmUgdGFraW5nIGFjdGlvbiIsCiAgICAgICJUaGUgYWdlbnQgY2FwdHVyZWQgYmFzZWxpbmUgd2l0" + + "aCAnaGVsbWZpbGUgLS1zZWxlY3RvciBuYW1lPWNhc3NhbmRyYSB0ZW1wbGF0ZSciLAogICAgICAi" + + "VGhlIGFnZW50IG1vZGlmaWVkIHRoZSByZWxlYXNlIHZhbHVlcyBibG9jayB0byBpbmNsdWRlIHJl" + + "c291cmNlIGxpbWl0cyB3aXRoIG1lbW9yeTogMTZHaSIsCiAgICAgICJUaGUgYWdlbnQgcmUtaW5j" + + "bHVkZWQgZ2xvYmFsLnlhbWwuZ290bXBsIGFuZCBzZWNyZXRzIGZpbGUgaW4gdGhlIHZhbHVlczog" + + "YmxvY2sgKG5vdCBqdXN0IHRoZSBvdmVycmlkZSBhbG9uZSkiLAogICAgICAiVGhlIGFnZW50IHdh" + + "cm5lZCBhYm91dCB0aGUgWUFNTCBtZXJnZSBnb3RjaGE6IHNwZWNpZnlpbmcgdmFsdWVzOiByZXBs" + + "YWNlcyB0aGUgdGVtcGxhdGUncyB2YWx1ZXMgbGlzdCBlbnRpcmVseSIsCiAgICAgICJUaGUgYWdl" + + "bnQgcHJldmlld2VkIHRoZSBjaGFuZ2Ugd2l0aCBoZWxtZmlsZSB0ZW1wbGF0ZSBhbmQgZGlmZiBi" + + "ZWZvcmUgYXBwbHlpbmcgd2l0aCBzeW5jIgogICAgXQogIH0sCiAgewogICAgImlkIjogIlM2LXNl" + + "bGVjdGl2ZS1zeW5jLWRlcGVuZGVuY2llcyIsCiAgICAicXVlc3Rpb24iOiAiRGVwbG95IG9ubHkg" + + "dGhlIE5WQ0YgZGVwZW5kZW5jaWVzIC0tIE5BVFMsIENhc3NhbmRyYSwgYW5kIE9wZW5CYW8gLS0g" + + "Zm9yIHRlc3RpbmciLAogICAgImV4cGVjdGVkX3NraWxsIjogIm52Y2Ytc2VsZi1tYW5hZ2VkLWlu" + + "c3RhbGxhdGlvbiIsCiAgICAiZ3JvdW5kX3RydXRoIjogIlRoZSBhZ2VudCB1c2VkIHRoZSBoZWxt" + + "ZmlsZSBzZWxlY3RvciB0byBkZXBsb3kgb25seSB0aGUgZGVwZW5kZW5jaWVzIHJlbGVhc2UgZ3Jv" + + "dXAsIHRoZW4gdmVyaWZpZWQgcG9kcyBpbiB0aGUgZGVwZW5kZW5jeSBuYW1lc3BhY2VzLiIsCiAg" + "ICAiZXhwZWN0ZWRfYmVoYXZpb3IiOiBbCiAgICAgICJUaGUgYWdlbnQgcmVhZCB0aGUgbnZjZi1z" + "ZWxmLW1hbmFnZWQtaW5zdGFsbGF0aW9uIFNLSUxMLm1kIGJlZm9yZSB0YWtpbmcgYWN0aW9uIiwK" + - "ICAgICAgIlRoZSBhZ2VudCBjYXB0dXJlZCBiYXNlbGluZSB3aXRoICdoZWxtZmlsZSAtLXNlbGVj" + - "dG9yIG5hbWU9Y2Fzc2FuZHJhIHRlbXBsYXRlJyIsCiAgICAgICJUaGUgYWdlbnQgbW9kaWZpZWQg" + - "dGhlIHJlbGVhc2UgdmFsdWVzIGJsb2NrIHRvIGluY2x1ZGUgcmVzb3VyY2UgbGltaXRzIHdpdGgg" + - "bWVtb3J5OiAxNkdpIiwKICAgICAgIlRoZSBhZ2VudCByZS1pbmNsdWRlZCBnbG9iYWwueWFtbC5n" + - "b3RtcGwgYW5kIHNlY3JldHMgZmlsZSBpbiB0aGUgdmFsdWVzOiBibG9jayAobm90IGp1c3QgdGhl" + - "IG92ZXJyaWRlIGFsb25lKSIsCiAgICAgICJUaGUgYWdlbnQgd2FybmVkIGFib3V0IHRoZSBZQU1M" + - "IG1lcmdlIGdvdGNoYTogc3BlY2lmeWluZyB2YWx1ZXM6IHJlcGxhY2VzIHRoZSB0ZW1wbGF0ZSdz" + - "IHZhbHVlcyBsaXN0IGVudGlyZWx5IiwKICAgICAgIlRoZSBhZ2VudCBwcmV2aWV3ZWQgdGhlIGNo" + - "YW5nZSB3aXRoIGhlbG1maWxlIHRlbXBsYXRlIGFuZCBkaWZmIGJlZm9yZSBhcHBseWluZyB3aXRo" + - "IHN5bmMiCiAgICBdCiAgfSwKICB7CiAgICAiaWQiOiAiUzYtc2VsZWN0aXZlLXN5bmMtZGVwZW5k" + - "ZW5jaWVzIiwKICAgICJxdWVzdGlvbiI6ICJEZXBsb3kgb25seSB0aGUgTlZDRiBkZXBlbmRlbmNp" + - "ZXMgLS0gTkFUUywgQ2Fzc2FuZHJhLCBhbmQgT3BlbkJhbyAtLSBmb3IgdGVzdGluZyIsCiAgICAi" + - "ZXhwZWN0ZWRfc2tpbGwiOiAibnZjZi1zZWxmLW1hbmFnZWQtaW5zdGFsbGF0aW9uIiwKICAgICJn" + - "cm91bmRfdHJ1dGgiOiAiVGhlIGFnZW50IHVzZWQgdGhlIGhlbG1maWxlIHNlbGVjdG9yIHRvIGRl" + - "cGxveSBvbmx5IHRoZSBkZXBlbmRlbmNpZXMgcmVsZWFzZSBncm91cCwgdGhlbiB2ZXJpZmllZCBw" + - "b2RzIGluIHRoZSBkZXBlbmRlbmN5IG5hbWVzcGFjZXMuIiwKICAgICJleHBlY3RlZF9iZWhhdmlv" + - "ciI6IFsKICAgICAgIlRoZSBhZ2VudCByZWFkIHRoZSBudmNmLXNlbGYtbWFuYWdlZC1pbnN0YWxs" + - "YXRpb24gU0tJTEwubWQgYmVmb3JlIHRha2luZyBhY3Rpb24iLAogICAgICAiVGhlIGFnZW50IHVz" + - "ZWQgJ2hlbG1maWxlIC0tc2VsZWN0b3IgcmVsZWFzZS1ncm91cD1kZXBlbmRlbmNpZXMgc3luYycg" + - "dG8gZGVwbG95IG9ubHkgZGVwZW5kZW5jaWVzIiwKICAgICAgIlRoZSBhZ2VudCB2ZXJpZmllZCBw" + - "b2RzIGluIGRlcGVuZGVuY3kgbmFtZXNwYWNlcyAobmF0cy1zeXN0ZW0sIGNhc3NhbmRyYS1zeXN0" + - "ZW0sIHZhdWx0LXN5c3RlbSkiLAogICAgICAiVGhlIGFnZW50IHByb3ZpZGVkIHRoZSBzZWxlY3Rv" + - "ciByZWZlcmVuY2UgZm9yIHRhcmdldGluZyBvdGhlciByZWxlYXNlIGdyb3VwcyIKICAgIF0KICB9" + - "LAogIHsKICAgICJpZCI6ICJTNy1kZWJ1Zy1pbWFnZXB1bGxiYWNrb2ZmIiwKICAgICJxdWVzdGlv" + - "biI6ICJDYXNzYW5kcmEgcG9kcyBhcmUgaW4gSW1hZ2VQdWxsQmFja09mZiB3aXRoIDQwMSBVbmF1" + - "dGhvcml6ZWQiLAogICAgImV4cGVjdGVkX3NraWxsIjogIm52Y2Ytc2VsZi1tYW5hZ2VkLWluc3Rh" + - "bGxhdGlvbiIsCiAgICAiZ3JvdW5kX3RydXRoIjogIlRoZSBhZ2VudCByZWFkIHRoZSBkZWJ1Z2dp" + - "bmcgcmVmZXJlbmNlIGFuZCByYW4gZGlhZ25vc3RpYyBjb21tYW5kcyB0byBpZGVudGlmeSB0aGUg" + - "cm9vdCBjYXVzZTogZGVzY3JpYmVkIHRoZSBwb2QsIGNoZWNrZWQgZm9yIHB1bGwgc2VjcmV0cywg" + - "Y2hlY2tlZCBpbWFnZVB1bGxTZWNyZXRzIG9uIHRoZSBwb2Qgc3BlYyBhbmQgc2VydmljZSBhY2Nv" + - "dW50LiBJdCBpZGVudGlmaWVkIHRoZSBpc3N1ZSAobWlzc2luZyBzZWNyZXQsIHdyb25nIGNyZWRl" + - "bnRpYWxzLCBvciBtaXNzaW5nIGltYWdlUHVsbFNlY3JldHMpIGFuZCBwcm92aWRlZCB0aGUgY29y" + - "cmVjdCBmaXguIiwKICAgICJleHBlY3RlZF9iZWhhdmlvciI6IFsKICAgICAgIlRoZSBhZ2VudCBy" + - "ZWFkIHRoZSBudmNmLXNlbGYtbWFuYWdlZC1pbnN0YWxsYXRpb24gU0tJTEwubWQgYW5kIHJlZmVy" + - "ZW5jZXMvZGVidWdnaW5nLm1kIiwKICAgICAgIlRoZSBhZ2VudCByYW4gJ2t1YmVjdGwgZGVzY3Jp" + - "YmUgcG9kJyB0byBzZWUgdGhlIEltYWdlUHVsbEJhY2tPZmYgZXZlbnRzIiwKICAgICAgIlRoZSBh" + - "Z2VudCBjaGVja2VkIGZvciBwdWxsIHNlY3JldHMgaW4gdGhlIGNhc3NhbmRyYS1zeXN0ZW0gbmFt" + - "ZXNwYWNlIiwKICAgICAgIlRoZSBhZ2VudCBjaGVja2VkIGltYWdlUHVsbFNlY3JldHMgb24gdGhl" + - "IHBvZCBzcGVjIGFuZC9vciBzZXJ2aWNlIGFjY291bnQiLAogICAgICAiVGhlIGFnZW50IGlkZW50" + - "aWZpZWQgdGhlIHJvb3QgY2F1c2UgYW5kIHByb3ZpZGVkIGEgc3BlY2lmaWMgZml4IgogICAgXQog" + - "IH0sCiAgewogICAgImlkIjogIlM5LXJlbW90ZS1la3Mtcm91dGluZy1hbmQtc2VjcmV0cyIsCiAg" + - "ICAicXVlc3Rpb24iOiAiQ29uZmlndXJlIGEgcmVtb3RlIEVLUyBjb21wdXRlIGNsdXN0ZXIgdG8g" + - "cmVhY2ggbXkgTlZDRiBjb250cm9sIHBsYW5lIHRocm91Z2ggaXRzIEVudm95IEdhdGV3YXksIGlu" + - "Y2x1ZGluZyBOQVRTIGFuZCBwcml2YXRlLXJlZ2lzdHJ5IHB1bGwgc2VjcmV0cy4iLAogICAgImV4" + - "cGVjdGVkX3NraWxsIjogIm52Y2Ytc2VsZi1tYW5hZ2VkLWluc3RhbGxhdGlvbiIsCiAgICAiZ3Jv" + - "dW5kX3RydXRoIjogIlRoZSBhZ2VudCBjb25maWd1cmVkIHRoZSBjb250cm9sIHBsYW5lIHRvIHVz" + - "ZSB0aGUgRW52b3kgR2F0ZXdheSBjb250cm9sbGVyIGluIGVudm95LWdhdGV3YXktc3lzdGVtIGFu" + - "ZCBHYXRld2F5L252Y2YtZ2F0ZXdheSBpbiBlbnZveS1nYXRld2F5LCBzZXQgYWxsIHNpeCByZW1v" + - "dGUgd29ya2VyIGVuZHBvaW50cyBhbmQgdGhlIGdycGMtcHJveHkgbG9jYWwgb3ZlcnJpZGUsIGVu" + - "YWJsZWQgTlZDRiBhbmQgTlZDVCBnUlBDIHJvdXRlcyBwbHVzIE5BVFMsIGFuZCBlbmFibGVkIE9w" + - "ZW5CYW8gaXNzdWVyIGRpc2NvdmVyeS4gSXQgY29uZmlndXJlZCB0aGUgYmFyZS1HYXRld2F5IENM" + - "SSBVUkwgYW5kIEhvc3QgZmllbGRzIGluY2x1ZGluZyBpY21zX2hvc3QsIHBsdXMgdGhlIGNvbXB1" + - "dGUtcGxhbmUgSUNNUywgUmVWYWwsIGFuZCBOQVRTIGV4dGVybmFsIFVSTHMgYW5kIGhvc3Qgb3Zl" + - "cnJpZGVzLiBJdCBjcmVhdGVkIHRoZSBwdWxsIHNlY3JldCBpbiBhbGwgY29udHJvbC1wbGFuZSBu" + - "YW1lc3BhY2VzIGluY2x1ZGluZyBjZXJ0LW1hbmFnZXIsIGJ1dCBvbmx5IGluIG52Y2Etb3BlcmF0" + - "b3Igb24gdGhlIGNvbXB1dGUgY2x1c3RlcjsgdGhlIGNoYXJ0IGFuZCBvcGVyYXRvciBtaXJyb3Ig" + - "aXQgdG8gbnZjYS1zeXN0ZW0uIiwKICAgICJleHBlY3RlZF9iZWhhdmlvciI6IFsKICAgICAgIlRo" + - "ZSBhZ2VudCByZWFkIHRoZSBudmNmLXNlbGYtbWFuYWdlZC1pbnN0YWxsYXRpb24gU0tJTEwubWQg" + - "YmVmb3JlIHRha2luZyBhY3Rpb24iLAogICAgICAiVGhlIGFnZW50IHNldCBpbmdyZXNzLmdhdGV3" + - "YXlBcGkuY29udHJvbGxlck5hbWVzcGFjZSB0byBlbnZveS1nYXRld2F5LXN5c3RlbSIsCiAgICAg" + - "ICJUaGUgYWdlbnQgc2V0IHNoYXJlZCwgZ3JwYywgYW5kIG5hdHMgR2F0ZXdheSByZWZlcmVuY2Vz" + - "IHRvIG5hbWUgbnZjZi1nYXRld2F5IGFuZCBuYW1lc3BhY2UgZW52b3ktZ2F0ZXdheSIsCiAgICAg" + - "ICJUaGUgYWdlbnQgc2V0IGdsb2JhbC53b3JrZXJFbmRwb2ludHMubnZjZlNlcnZpY2VVUkwsIG52" + - "Y2ZHcnBjU2VydmljZVVSTCwgbnZjZk5hdHNTZXJ2aWNlVVJMLCBudmN0U2VydmljZVVSTCwgbnZj" + - "dEdycGNTZXJ2aWNlVVJMLCBhbmQgaW52b2NhdGlvblNlcnZpY2VVUkwiLAogICAgICAiVGhlIGFn" + - "ZW50IHNldCBncnBjcHJveHkubnZjZkdycGNTZXJ2aWNlVVJMIHRvIHRoZSBjb250cm9sLXBsYW5l" + - "LWxvY2FsIEFQSSBnUlBDIHNlcnZpY2UiLAogICAgICAiVGhlIGFnZW50IGVuYWJsZWQgYm90aCBp" + - "bmdyZXNzLmdhdGV3YXlBcGkucm91dGVzLm52Y2ZBcGkuZ3JwYyBhbmQgbnZjdEFwaS5ncnBjIHdp" + - "dGggd29ya2VyLWFwaSBhbmQgd29ya2VyLXRhc2tzIGhvc3RuYW1lcyIsCiAgICAgICJUaGUgYWdl" + - "bnQgZXhwbGljaXRseSBzZXQgaW5ncmVzcy5nYXRld2F5QXBpLnJvdXRlcy5uYXRzLmVuYWJsZWQg" + - "dG8gdHJ1ZSIsCiAgICAgICJUaGUgYWdlbnQgc2V0IG9wZW5iYW8ubWlncmF0aW9ucy5pc3N1ZXJE" + - "aXNjb3ZlcnkuZW5hYmxlZCB0byB0cnVlIiwKICAgICAgIlRoZSBhZ2VudCBjb25maWd1cmVkIHRo" + - "ZSBiYXJlLUdhdGV3YXkgQ0xJIFVSTCBhbmQgSG9zdCBmaWVsZHMsIGluY2x1ZGluZyBzY2hlbWUt" + - "bGVzcyBpY21zX2hvc3QiLAogICAgICAiVGhlIGFnZW50IGNvbmZpZ3VyZWQgY29tcHV0ZS1wbGFu" + - "ZSBpY21zU2VydmljZVVSTCwgaWNtc1NlcnZpY2VIb3N0SGVhZGVyT3ZlcnJpZGUsIHJldmFsU2Vy" + - "dmljZVVSTCwgcmV2YWxTZXJ2aWNlSG9zdEhlYWRlck92ZXJyaWRlLCBuYXRzVVJMLCBhbmQgbmF0" + - "c0hvc3RPdmVycmlkZSIsCiAgICAgICJUaGUgY29udHJvbC1wbGFuZSBwdWxsLXNlY3JldCBuYW1l" + - "c3BhY2UgbGlzdCBpbmNsdWRlZCBjZXJ0LW1hbmFnZXIiLAogICAgICAiVGhlIGNvbXB1dGUtcGxh" + - "bmUgc2VjcmV0IHdhcyBjcmVhdGVkIG9ubHkgaW4gbnZjYS1vcGVyYXRvciBhbmQgbWlycm9yZWQg" + - "YnkgdGhlIGNoYXJ0IGFuZCBvcGVyYXRvciB0byBudmNhLXN5c3RlbSIKICAgIF0KICB9LAogIHsK" + - "ICAgICJpZCI6ICJHMTEtc3BsaXQtdGVhcmRvd24iLAogICAgInF1ZXN0aW9uIjogIlRlYXIgZG93" + - "biBteSBzcGxpdCBjb250cm9sLXBsYW5lIGFuZCBjb21wdXRlLXBsYW5lIGRlcGxveW1lbnQgc2Fm" + - "ZWx5LiBTaG93IGJvdGggdGhlIEhlbG1maWxlIHZhbHVlcyBhbmQgQ0xJIHByb2ZpbGUgdmFyaWFu" + - "dHMuIiwKICAgICJleHBlY3RlZF9za2lsbCI6ICJudmNmLXNlbGYtbWFuYWdlZC1pbnN0YWxsYXRp" + - "b24iLAogICAgImdyb3VuZF90cnV0aCI6ICJUaGUgSGVsbWZpbGUgZmxvdyBkZXN0cm95ZWQgYW5k" + - "IGNvbmZpcm1lZCBkZXJlZ2lzdHJhdGlvbiBvZiBldmVyeSBjb21wdXRlIGNsdXN0ZXIgYmVmb3Jl" + - "IGRlc3Ryb3lpbmcgdGhlIGNvbnRyb2wgcGxhbmUgYW5kIHBhc3NlZCBib3RoIGNvbnRyb2wtcGxh" + - "bmUga3ViZWNvbmZpZyBmb3Jtcy4gQmVmb3JlIGVpdGhlciBoYW5kb2ZmIGNvdWxkIGRlc3Ryb3kg" + - "dGhlIGNvbnRyb2wgcGxhbmUsIGJvdGggdGVhcmRvd24gZmxvd3Mgd2FybmVkIHRoYXQgbmFtZXNw" + - "YWNlIHByZXNlcnZhdGlvbiBkb2VzIG5vdCBwcmVzZXJ2ZSB0aGUgSGVsbSByZWxlYXNlLCByZXF1" + - "aXJlZCBlZmZlY3RpdmUgY2VydE1hbmFnZXIuZW5hYmxlZDogZmFsc2UgZm9yIGFuIGV4dGVybmFs" + - "IGluc3RhbGxhdGlvbiBhbmQgYWJvcnRlZCBpZiB0cnVlLCBhbmQgdmVyaWZpZWQgbWF0Y2hpbmcg" + - "cmVsZWFzZSBvd25lcnNoaXAgZm9yIGFuIGludGVuZGVkIHN0YWNrLW1hbmFnZWQgcmVsZWFzZSwg" + - "YWJvcnRpbmcgb24gbWlzc2luZyBvciBtaXNtYXRjaGVkIGV2aWRlbmNlLiBUaGV5IHByZXNlcnZl" + - "ZCB0aGUgbmFtZXNwYWNlIGJ5IGRlZmF1bHQgYW5kIGRpZCBub3QgZGVsZXRlIGl0IHdpdGhvdXQg" + - "bmFtZXNwYWNlIHByb3ZlbmFuY2UgYW5kIGV4cGxpY2l0IGNvbmZpcm1hdGlvbi4gVGhlIENMSSBm" + - "bG93IHJldGFpbmVkIHRoZSBnZW5lcmF0ZWQgcmVnaXN0cmF0aW9uIHZhbHVlcywgdXNlZCB0aGUg" + - "ZnVsbC1wcmVmaXggc3RhdHVzIGNvbW1hbmQgdG8gaW5zcGVjdCBsaXZlIHJlZ2lzdHJhdGlvbnMs" + - "IHN0YXRlZCB0aGF0IG5hbWVkLWNsdXN0ZXIgcGxhbi1vbmx5IGRvZXMgbm90IHF1ZXJ5IElDTVMg" + - "YW5kIGNhbm5vdCBwcmVkaWN0IGNvbmRpdGlvbmFsIGNvbnRyb2wtcGxhbmUgcmVtb3ZhbCwgYW5k" + - "IGdvdCBleHBsaWNpdCBjb25maXJtYXRpb24gd2hlbiB0aGUgdGFyZ2V0IHdhcyB0aGUgbGFzdCBy" + - "ZWdpc3RlcmVkIGNsdXN0ZXIuIEl0IHRoZW4gcHJldmlld2VkIGFuZCByYW4gZG93biB3aXRoIHRo" + - "ZSBmdWxsIHBlcnNpc3RlbnQgcHJlZml4LCByZW1vdmluZyB0aGUgY29udHJvbCBwbGFuZSBvbmx5" + - "IGFmdGVyIHRoZSBsYXN0IHJlZ2lzdHJhdGlvbiB3YXMgZ29uZS4iLAogICAgImV4cGVjdGVkX2Jl" + - "aGF2aW9yIjogWwogICAgICAiVGhlIGFnZW50IHJlYWQgdGhlIG52Y2Ytc2VsZi1tYW5hZ2VkLWlu" + - "c3RhbGxhdGlvbiBTS0lMTC5tZCBiZWZvcmUgdGFraW5nIGFjdGlvbiIsCiAgICAgICJGb3IgZXZl" + - "cnkgY29tcHV0ZSBjbHVzdGVyLCB0aGUgSGVsbWZpbGUgZmxvdyByYW4gdGhlIGNvbXB1dGUtcGxh" + - "bmUgTWFrZWZpbGUgZGVzdHJveSB0YXJnZXQgd2l0aCBpdHMgY29tcHV0ZSBrdWJlY29uZmlnIiwK" + - "ICAgICAgIkFmdGVyIGNvbmZpcm1hdGlvbiwgdGhlIEhlbG1maWxlIGZsb3cgcmFuIGNsdXN0ZXIg" + - "ZGVsZXRlIHdpdGggY2x1c3RlcklEIGFuZCBuY2FJRCBiZWZvcmUgY29udHJvbC1wbGFuZSBkZXN0" + - "cnVjdGlvbiIsCiAgICAgICJCZWZvcmUgZWl0aGVyIGhhbmRvZmYgY291bGQgZGVzdHJveSB0aGUg" + - "Y29udHJvbCBwbGFuZSwgYm90aCB0ZWFyZG93biBmbG93cyB3YXJuZWQgdGhhdCBuYW1lc3BhY2Ug" + - "cHJlc2VydmF0aW9uIGRvZXMgbm90IHByZXNlcnZlIHRoZSBIZWxtIHJlbGVhc2UsIHJlcXVpcmVk" + - "IGVmZmVjdGl2ZSBjZXJ0TWFuYWdlci5lbmFibGVkOiBmYWxzZSBmb3IgYW4gZXh0ZXJuYWwgaW5z" + - "dGFsbGF0aW9uIGFuZCBhYm9ydGVkIGlmIHRydWUsIGFuZCB2ZXJpZmllZCBpbnRlbmRlZCBzdGFj" + - "ayBvd25lcnNoaXAgd2hlbiBlbmFibGVkLCBhYm9ydGluZyBvbiBtaXNzaW5nIG9yIG1pc21hdGNo" + - "ZWQgZXZpZGVuY2UiLAogICAgICAiQm90aCB0ZWFyZG93biBmbG93cyBwcmVzZXJ2ZWQgdGhlIGNl" + - "cnQtbWFuYWdlciBuYW1lc3BhY2UgYnkgZGVmYXVsdCBhbmQgZGlkIG5vdCBkZWxldGUgaXQgd2l0" + - "aG91dCBuYW1lc3BhY2UgcHJvdmVuYW5jZSBhbmQgZXhwbGljaXQgY29uZmlybWF0aW9uIiwKICAg" + - "ICAgIlRoZSBIZWxtZmlsZSBmbG93IHJhbiBjb250cm9sLXBsYW5lIE1ha2VmaWxlIGRlc3Ryb3kg" + - "d2l0aCBib3RoIEtVQkVDT05GSUcgYW5kIEtVQkVDT05GSUdfRklMRSIsCiAgICAgICJUaGUgQ0xJ" + - "IGZsb3cgcmV0YWluZWQgb3V0LzxjbHVzdGVyLW5hbWU+LXJlZ2lzdGVyLXZhbHVlcy55YW1sIHVu" + - "dGlsIHRlYXJkb3duIGNvbXBsZXRlZCIsCiAgICAgICJCZWZvcmUgcmVhbCBkb3duLCB0aGUgQ0xJ" + - "IGZsb3cgdXNlZCB0aGUgZnVsbC1wcmVmaXggc3RhdHVzIGNvbW1hbmQgdG8gaW5zcGVjdCBsaXZl" + - "IHJlZ2lzdHJhdGlvbnMsIHN0YXRlZCB0aGF0IG5hbWVkLWNsdXN0ZXIgcGxhbi1vbmx5IGRvZXMg" + - "bm90IHF1ZXJ5IElDTVMgYW5kIGNhbm5vdCBwcmVkaWN0IGNvbmRpdGlvbmFsIGNvbnRyb2wtcGxh" + - "bmUgcmVtb3ZhbCwgd2FybmVkIHdoZW4gdGhlIHRhcmdldCB3YXMgdGhlIGxhc3QgcmVnaXN0ZXJl" + - "ZCBjbHVzdGVyLCBhbmQgZ290IGV4cGxpY2l0IGNvbmZpcm1hdGlvbiIsCiAgICAgICJUaGUgQ0xJ" + - "IGZsb3cgdXNlZCAtLWNvbmZpZywgYm90aCBzdGFjayBwYXRocywgLS1lbnYsIC0tcGxhaW4sIGJv" + - "dGggY29udGV4dHMsIGFuZCAtLWljbXMtdXJsIGZvciBkb3duIC0tcGxhbi1vbmx5IGFuZCB0aGVu" + - "IGRvd24iLAogICAgICAiVGhlIGFnZW50IHN0YXRlZCB0aGF0IGRvd24gdW5yZWdpc3RlcnMgZWFj" + - "aCBjb21wdXRlIGNsdXN0ZXIgYW5kIHJlbW92ZXMgdGhlIGNvbnRyb2wgcGxhbmUgb25seSBhZnRl" + - "ciBubyByZWdpc3RyYXRpb25zIHJlbWFpbiIKICAgIF0KICB9LAogIHsKICAgICJpZCI6ICJTMTMt" + - "dmFsdWVzLXByZWNlZGVuY2UiLAogICAgInF1ZXN0aW9uIjogIkkgYWRkZWQgYSB2YWx1ZSB0byBt" + - "eSBlbnZpcm9ubWVudCBZQU1MIGJ1dCBpdCdzIGJlaW5nIHNpbGVudGx5IGlnbm9yZWQuIEhvdyBk" + - "byBoZWxtZmlsZSB2YWx1ZXMgd29yayBpbiBOVkNGPyIsCiAgICAiZXhwZWN0ZWRfc2tpbGwiOiAi" + - "bnZjZi1zZWxmLW1hbmFnZWQtaW5zdGFsbGF0aW9uIiwKICAgICJncm91bmRfdHJ1dGgiOiAiVGhl" + - "IGFnZW50IGV4cGxhaW5lZCB0aGUgdmFsdWVzIHByZWNlZGVuY2UgY2hhaW4gKGJhc2UueWFtbCwg" + - "ZW52IFlBTUwsIGdsb2JhbC55YW1sLmdvdG1wbCwgc2VjcmV0cywgaW5saW5lIHZhbHVlcyksIGlk" + - "ZW50aWZpZWQgdGhhdCBnbG9iYWwueWFtbC5nb3RtcGwgb25seSBwYXNzZXMgdGhyb3VnaCBzcGVj" + - "aWZpYyBrZXlzLCBhbmQgZXhwbGFpbmVkIHRoYXQgYXJiaXRyYXJ5IGNoYXJ0IHZhbHVlcyBtdXN0" + - "IHVzZSByZWxlYXNlIGlubGluZSB2YWx1ZXMgYmxvY2tzLiIsCiAgICAiZXhwZWN0ZWRfYmVoYXZp" + - "b3IiOiBbCiAgICAgICJUaGUgYWdlbnQgcmVhZCB0aGUgbnZjZi1zZWxmLW1hbmFnZWQtaW5zdGFs" + - "bGF0aW9uIFNLSUxMLm1kIGJlZm9yZSB0YWtpbmcgYWN0aW9uIiwKICAgICAgIlRoZSBhZ2VudCBl" + - "eHBsYWluZWQgdGhlIHZhbHVlcyBwcmVjZWRlbmNlIGNoYWluOiBiYXNlLnlhbWwgLT4gZW52IFlB" + - "TUwgLT4gZ2xvYmFsLnlhbWwuZ290bXBsIC0+IHNlY3JldHMgLT4gaW5saW5lIHZhbHVlcyIsCiAg" + - "ICAgICJUaGUgYWdlbnQgaWRlbnRpZmllZCB0aGF0IGdsb2JhbC55YW1sLmdvdG1wbCBvbmx5IHBh" + - "c3NlcyB0aHJvdWdoIHNwZWNpZmljIGtleXMgKGNhc3NhbmRyYS5yZXBsaWNhQ291bnQsIGdsb2Jh" + - "bC5zdG9yYWdlQ2xhc3MsIGdsb2JhbC5pbWFnZSwgZXRjLikiLAogICAgICAiVGhlIGFnZW50IGV4" + - "cGxhaW5lZCB0aGF0IGFyYml0cmFyeSBjaGFydCB2YWx1ZXMgbXVzdCBiZSBzZXQgdmlhIHJlbGVh" + - "c2UgaW5saW5lIHZhbHVlczogYmxvY2tzIiwKICAgICAgIlRoZSBhZ2VudCBsaXN0ZWQgZXhhbXBs" + - "ZXMgb2YgdmFsdWVzIHRoYXQgYXJlIHByb3BhZ2F0ZWQgYW5kIGFyYml0cmFyeSBjaGFydCB2YWx1" + - "ZXMgdGhhdCByZXF1aXJlIGEgcmVsZWFzZSB2YWx1ZXMgYmxvY2siCiAgICBdCiAgfSwKICB7CiAg" + - "ICAiaWQiOiAiUzE0LWVuYWJsZS1sbG0tZnVuY3Rpb25zIiwKICAgICJxdWVzdGlvbiI6ICJJIG5l" + - "ZWQgdG8gZGVwbG95IGZ1bmN0aW9uVHlwZTogTExNIGZ1bmN0aW9ucyBvbiBhIHNlbGYtbWFuYWdl" + - "ZCwgc2luZ2xlLW5vZGUgaXNvbGF0ZWQgdGVzdCBjbHVzdGVyLiBXaGF0IEhlbG1maWxlIGNvbmZp" + - "Z3VyYXRpb24gYW5kIHZlcmlmaWNhdGlvbiBzdGVwcyBkbyBJIG5lZWQ/IiwKICAgICJleHBlY3Rl" + - "ZF9za2lsbCI6ICJudmNmLXNlbGYtbWFuYWdlZC1pbnN0YWxsYXRpb24iLAogICAgImdyb3VuZF90" + - "cnV0aCI6ICJUaGUgYWdlbnQgZW5hYmxlZCB0aGUgTExNIGFkZG9uIHdpdGggYWRkb25zLmxsbS5l" + - "bmFibGVkLCBjb25maWd1cmVkIHNpbmdsZSByZXBsaWNhcyBmb3IgdGhlIExMTSBBUEkgR2F0ZXdh" + - "eSBhbmQgcmVxdWVzdCByb3V0ZXIsIGFuZCBleHBsYWluZWQgdGhhdCB0aGUgcmVxdWVzdCByb3V0" + - "ZXIgdXNlcyBwb3dlci1vZi10d28gd2hlbiBubyBsb2FkLWJhbGFuY2VyIGNvbmZpZ3VyYXRpb24g" + - "aXMgc2V0LiBJdCBsaW5rZWQgdGhlIGNhbm9uaWNhbCByZXF1ZXN0LXJvdXRlciBsb2FkLWJhbGFu" + - "Y2luZyBndWlkZSBmb3Igb3RoZXIgcm91dGluZyBtZXRob2RzLiBGb3IgdGhlIGlzb2xhdGVkIHBs" + - "YWludGV4dCBjbHVzdGVyLCBpdCByZWxpZWQgb24gdGhlIHNlbGYtbWFuYWdlZCBzdGFjayBkZWZh" + - "dWx0IGZvciB0aGUgYnVuZGxlZCBnYXRld2F5LXRvLUFQSSBhdXRoZW50aWNhdGlvbiBob3AgYW5k" + - "IGluY2x1ZGVkIGFnZW50Q29uZmlnLm1lcmdlQ29uZmlnLndvcmtsb2FkLnN0YXJnYXRlUVVJQ0lu" + - "c2VjdXJlIGZvciB0aGUgc2VwYXJhdGUgd29ya2VyIHRyYW5zcG9ydCwgd2l0aCBhIHdhcm5pbmcg" + - "dGhhdCBwbGFpbnRleHQgd29ya2VyIHRyYW5zcG9ydCBpcyBub3QgZm9yIHByb2R1Y3Rpb24uIEl0" + - "IHJlZnJlc2hlZCB0aGUgY29tcHV0ZSBwbGFuZSB1c2luZyB0aGUgc2FtZSBoYW5kb2ZmIHVzZWQg" + - "ZHVyaW5nIGluc3RhbGxhdGlvbiwgcmVkZXBsb3llZCBleGlzdGluZyBMTE0gZnVuY3Rpb25zLCBh" + - "bmQgdmVyaWZpZWQgdGhlIExMTSBkZXBsb3ltZW50cywgcm91dGUsIGFuZCBsbG0td29ya2VyIHNp" + - "ZGVjYXIuIiwKICAgICJleHBlY3RlZF9iZWhhdmlvciI6IFsKICAgICAgIlRoZSBhZ2VudCB1c2Vk" + - "IGFkZG9ucy5sbG0uZW5hYmxlZCwgbm90IHRoZSBvYnNvbGV0ZSBsbG0uZW5hYmxlZCBwYXRoIiwK" + - "ICAgICAgIlRoZSBhZ2VudCBzZXQgZ2F0ZXdheSBhbmQgcmVxdWVzdFJvdXRlciByZXBsaWNhQ291" + - "bnQgdG8gMSBmb3IgdGhlIHNpbmdsZS1ub2RlIHRlc3QgY2x1c3RlciIsCiAgICAgICJUaGUgYWdl" + - "bnQgc3RhdGVkIHRoYXQgdGhlIHJlcXVlc3Qgcm91dGVyIGRlZmF1bHRzIHRvIHBvd2VyLW9mLXR3" + - "byBhbmQgbGlua2VkIHRoZSBjYW5vbmljYWwgcmVxdWVzdC1yb3V0ZXIgbG9hZC1iYWxhbmNpbmcg" + - "Z3VpZGUgZm9yIG90aGVyIG1ldGhvZHMiLAogICAgICAiVGhlIGFnZW50IGV4cGxhaW5lZCB0aGF0" + - "IHRoZSBidW5kbGVkIGdhdGV3YXktdG8tQVBJIGF1dGhlbnRpY2F0aW9uIGhvcCBkZWZhdWx0cyB0" + - "byBwbGFpbnRleHQsIGluY2x1ZGVkIHdvcmtsb2FkLnN0YXJnYXRlUVVJQ0luc2VjdXJlIG9ubHkg" + - "Zm9yIHRoZSBpc29sYXRlZCB3b3JrZXIgdHJhbnNwb3J0LCBhbmQgd2FybmVkIGFnYWluc3QgcGxh" + - "aW50ZXh0IHdvcmtlciB0cmFuc3BvcnQgaW4gcHJvZHVjdGlvbiIsCiAgICAgICJUaGUgYWdlbnQg" + - "YXBwbGllZCB0aGUgY29udHJvbC1wbGFuZSBIZWxtZmlsZSBlbnZpcm9ubWVudCwgcmVmcmVzaGVk" + - "IHRoZSBjb21wdXRlIHBsYW5lIHdpdGggdGhlIHNhbWUgc2VsZWN0ZWQgaGFuZG9mZiwgYW5kIHJl" + - "Y3JlYXRlZCBvciByZWRlcGxveWVkIGV4aXN0aW5nIExMTSBmdW5jdGlvbnMiLAogICAgICAiVGhl" + - "IGFnZW50IHZlcmlmaWVkIGxsbS1hcGktZ2F0ZXdheSwgbGxtLXJlcXVlc3Qtcm91dGVyLCB0aGUg" + - "TExNIEhUVFBSb3V0ZSwgYW5kIHRoZSBsbG0td29ya2VyIHNpZGVjYXIgaW5jbHVkaW5nIC0tcXVp" + - "Yy1pbnNlY3VyZSIKICAgIF0KICB9LAogIHsKICAgICJpZCI6ICJHOC1ORUctbm90LWZvci1rM2Qi" + - "LAogICAgInF1ZXN0aW9uIjogIlNldCB1cCBhIGxvY2FsIE5WQ0YgZGV2IGNsdXN0ZXIgdXNpbmcg" + - "azNkIGZvciB0ZXN0aW5nIiwKICAgICJleHBlY3RlZF9za2lsbCI6IG51bGwsCiAgICAic2hvdWxk" + - "X3RyaWdnZXIiOiBmYWxzZSwKICAgICJncm91bmRfdHJ1dGgiOiAiVGhpcyBpcyBhIGxvY2FsIGsz" + - "ZCBkZXZlbG9wbWVudCByZXF1ZXN0IHRoYXQgYmVsb25ncyB0byB0aGUgbnZjZi1zZWxmLWhvc3Rl" + - "ZC1sb2NhbC1kZXYgc2tpbGwsIG5vdCBudmNmLXNlbGYtbWFuYWdlZC1pbnN0YWxsYXRpb24uIFRo" + - "ZSBhZ2VudCBkaWQgbm90IHJlYWQgb3IgdXNlIHRoZSBzZWxmLW1hbmFnZWQgaW5zdGFsbGF0aW9u" + - "IHNraWxsIGFuZCBpbnN0ZWFkIHByb3ZpZGVkIGszZC1zcGVjaWZpYyBndWlkYW5jZS4iLAogICAg" + - "ImV4cGVjdGVkX2JlaGF2aW9yIjogWwogICAgICAiVGhlIGFnZW50IGRpZCBub3QgcmVhZCBudmNm" + - "LXNlbGYtbWFuYWdlZC1pbnN0YWxsYXRpb24vU0tJTEwubWQiLAogICAgICAiVGhlIGFnZW50IGRp" + - "ZCBub3QgcHJvdmlkZSBoZWxtZmlsZS1iYXNlZCBwcm9kdWN0aW9uIGRlcGxveW1lbnQgaW5zdHJ1" + - "Y3Rpb25zIiwKICAgICAgIlRoZSBhZ2VudCBwcm92aWRlZCBrM2Qtc3BlY2lmaWMgbG9jYWwgZGV2" + - "ZWxvcG1lbnQgZ3VpZGFuY2Ugb3IgZGVmZXJyZWQgdG8gdGhlIGFwcHJvcHJpYXRlIGxvY2FsLWRl" + - "diBza2lsbCIKICAgIF0KICB9Cl0KUEsHCEdValZKVgAASlYAAFBLAwQUAAgAAAAAACEAAAAAAAAA" + - "AAAAAAAALwAJAGRhdGEvbnZjZi1zZWxmLW1hbmFnZWQtaW5zdGFsbGF0aW9uL2V4YW1wbGVzLm1k" + - "VVQFAAEAps4SIyBFeGFtcGxlcwoKV29ya2VkIGV4YW1wbGVzIGJhc2VkIG9uIHJlYWwgZGVwbG95" + - "bWVudCBzY2VuYXJpb3MuCgojIyBFeGFtcGxlIDE6IE92ZXJyaWRlIENhc3NhbmRyYSBSZXNvdXJj" + - "ZXMKClRoZSBkZWZhdWx0IENhc3NhbmRyYSByZXNvdXJjZSBsaW1pdHMgY2F1c2UgT09NIG9uIGxh" + - "cmdlIGluc3RhbmNlIHR5cGVzIChlLmcuLCBgcDUuNDh4bGFyZ2VgKS4KClByb2JsZW06IENhc3Nh" + - "bmRyYSBwb2RzIHJlc3RhcnQgd2l0aCBgT09NS2lsbGVkYC4KClNvbHV0aW9uOiBPdmVycmlkZSBy" + - "ZXNvdXJjZXMgdmlhIGhlbG1maWxlIHJlbGVhc2UgdmFsdWVzIGJsb2NrLgoKIyMjIFN0ZXAgMTog" + - "Q2FwdHVyZSBiYXNlbGluZQoKYGBgYmFzaApIRUxNRklMRV9FTlY9PGVudj4gaGVsbWZpbGUgLS1z" + - "ZWxlY3RvciBuYW1lPWNhc3NhbmRyYSB0ZW1wbGF0ZSA+IC90bXAvY2Fzcy1iZWZvcmUueWFtbApn" + - "cmVwIC1BOCAncmVzb3VyY2VzOicgL3RtcC9jYXNzLWJlZm9yZS55YW1sIHwgaGVhZCAtMTAKYGBg" + - "CgojIyMgU3RlcCAyOiBBZGQgb3ZlcnJpZGUgdG8gaGVsbWZpbGUuZC8wMS1kZXBlbmRlbmNpZXMu" + - "eWFtbC5nb3RtcGwKCkluIHRoZSBleGFtcGxlcyBiZWxvdywgYDxwcml2YXRlLXZhbHVlcz5gIHJl" + - "ZmVycyB0byB0aGUgYHNlY3JldHMvYCBkaXJlY3RvcnkgYXQgdGhlIGhlbG1maWxlIHN0YWNrIHJv" + - "b3QuCgpgYGB5YW1sCi0gbmFtZTogY2Fzc2FuZHJhCiAgdmVyc2lvbjogMC44LjAKICBjb25kaXRp" + - "b246IGNhc3NhbmRyYS5lbmFibGVkCiAgbmFtZXNwYWNlOiBjYXNzYW5kcmEtc3lzdGVtCiAgPDw6" + - "ICpkZXBlbmRlbmN5CiAgdmFsdWVzOgogICAgLSAuLi9nbG9iYWwueWFtbC5nb3RtcGwKICAgIC0g" + - "Li4vPHByaXZhdGUtdmFsdWVzPi97eyByZXF1aXJlZEVudiAiSEVMTUZJTEVfRU5WIiB9fS1zZWNy" + - "ZXRzLnlhbWwKICAgIC0gY2Fzc2FuZHJhOgogICAgICAgIHJlc291cmNlczoKICAgICAgICAgIGxp" + - "bWl0czoKICAgICAgICAgICAgY3B1OiAiOCIKICAgICAgICAgICAgbWVtb3J5OiA4MTkyTWkKICAg" + - "ICAgICAgIHJlcXVlc3RzOgogICAgICAgICAgICBjcHU6ICIyIgogICAgICAgICAgICBtZW1vcnk6" + - "IDQwOTZNaQpgYGAKCiMjIyBTdGVwIDM6IFZlcmlmeSBhbmQgYXBwbHkKCmBgYGJhc2gKSEVMTUZJ" + - "TEVfRU5WPTxlbnY+IGhlbG1maWxlIC0tc2VsZWN0b3IgbmFtZT1jYXNzYW5kcmEgdGVtcGxhdGUg" + - "PiAvdG1wL2Nhc3MtYWZ0ZXIueWFtbApkaWZmIC90bXAvY2Fzcy1iZWZvcmUueWFtbCAvdG1wL2Nh" + - "c3MtYWZ0ZXIueWFtbCAgIyBDb25maXJtIHJlc291cmNlcyBjaGFuZ2VkCkhFTE1GSUxFX0VOVj08" + - "ZW52PiBoZWxtZmlsZSAtLXNlbGVjdG9yIG5hbWU9Y2Fzc2FuZHJhIHN5bmMKYGBgCgpOb3RlOiBg" + - "cmVzb3VyY2VQcmVzZXRgIChhIEJpdG5hbWkgZmVhdHVyZSkgaXMgbm90IGF2YWlsYWJsZSBpbiB0" + - "aGUgTlZDRiBjYXNzYW5kcmEgd3JhcHBlciBjaGFydC4gVXNlIGV4cGxpY2l0IGByZXNvdXJjZXNg" + - "IGluc3RlYWQuCgotLS0KCiMjIEV4YW1wbGUgMjogRGVwbG95IGZyb20gTkdDIFByaXZhdGUgUmVn" + - "aXN0cnkgd2l0aCBQdWxsIFNlY3JldHMKCkRlcGxveSB0aGUgTlZDRiBzdGFjayBwdWxsaW5nIGlt" + - "YWdlcyBkaXJlY3RseSBmcm9tIE5HQyAoYG52Y3IuaW9gKSBpbnN0ZWFkIG9mIGEgbWlycm9yZWQg" + - "cmVnaXN0cnksIHVzaW5nIGltYWdlIHB1bGwgc2VjcmV0cy4KCiMjIyBTdGVwIDE6IENvbmZpZ3Vy" + - "ZSBlbnZpcm9ubWVudCBmb3IgTkdDCgpgYGB5YW1sCiMgZW52aXJvbm1lbnRzLzxlbnY+LnlhbWwK" + - "Z2xvYmFsOgogIGhlbG06CiAgICBzb3VyY2VzOgogICAgICByZWdpc3RyeTogbnZjci5pbwogICAg" + - "ICByZXBvc2l0b3J5OiBudmlkaWEvbnZjZgogIGltYWdlOgogICAgcmVnaXN0cnk6IG52Y3IuaW8K" + - "ICAgIHJlcG9zaXRvcnk6IG52aWRpYS9udmNmCmBgYAoKIyMjIFN0ZXAgMjogQXV0aGVudGljYXRl" + - "IGxvY2FsbHkgKGZvciBoZWxtZmlsZSBjaGFydCBwdWxscykKCmBgYGJhc2gKZG9ja2VyIGxvZ2lu" + - "IG52Y3IuaW8gLXUgJyRvYXV0aHRva2VuJyAtcCAiJE5HQ19BUElfS0VZIgpoZWxtIHJlZ2lzdHJ5" + - "IGxvZ2luIG52Y3IuaW8gLXUgJyRvYXV0aHRva2VuJyAtcCAiJE5HQ19BUElfS0VZIgpgYGAKCiMj" + - "IyBTdGVwIDM6IENyZWF0ZSBuYW1lc3BhY2VzIGFuZCBwdWxsIHNlY3JldHMKCmBgYGJhc2gKZXhw" + - "b3J0IE5HQ19BUElfS0VZPSI8eW91ci1uZ2MtYXBpLWtleT4iCgpmb3IgbnMgaW4gY2Fzc2FuZHJh" + - "LXN5c3RlbSBuYXRzLXN5c3RlbSBudmNmIGFwaS1rZXlzIGVzcyBzaXMgdmF1bHQtc3lzdGVtIGNl" + - "cnQtbWFuYWdlcjsgZG8KICBrdWJlY3RsIC0tY29udGV4dCA8Y29udHJvbC1wbGFuZS1jb250ZXh0" + - "PiBjcmVhdGUgbmFtZXNwYWNlICIkbnMiIFwKICAgIC0tZHJ5LXJ1bj1jbGllbnQgLW8geWFtbCB8" + - "IGt1YmVjdGwgLS1jb250ZXh0IDxjb250cm9sLXBsYW5lLWNvbnRleHQ+IGFwcGx5IC1mIC0KZG9u" + - "ZQoKZm9yIG5zIGluIGNhc3NhbmRyYS1zeXN0ZW0gbmF0cy1zeXN0ZW0gbnZjZiBhcGkta2V5cyBl" + - "c3Mgc2lzIHZhdWx0LXN5c3RlbSBjZXJ0LW1hbmFnZXI7IGRvCiAga3ViZWN0bCAtLWNvbnRleHQg" + - "PGNvbnRyb2wtcGxhbmUtY29udGV4dD4gY3JlYXRlIHNlY3JldCBkb2NrZXItcmVnaXN0cnkgbnZj" + - "ci1jcmVkcyBcCiAgICAtLWRvY2tlci1zZXJ2ZXI9bnZjci5pbyBcCiAgICAtLWRvY2tlci11c2Vy" + - "bmFtZT0nJG9hdXRodG9rZW4nIFwKICAgIC0tZG9ja2VyLXBhc3N3b3JkPSIkTkdDX0FQSV9LRVki" + - "IFwKICAgIC0tbmFtZXNwYWNlPSIkbnMiIFwKICAgIC0tZHJ5LXJ1bj1jbGllbnQgLW8geWFtbCB8" + - "IGt1YmVjdGwgLS1jb250ZXh0IDxjb250cm9sLXBsYW5lLWNvbnRleHQ+IGFwcGx5IC1mIC0KZG9u" + - "ZQpgYGAKCiMjIyBTdGVwIDQ6IEFkZCBpbWFnZVB1bGxTZWNyZXRzIHRvIGhlbG1maWxlIHJlbGVh" + - "c2VzCgpBZGQgdGhlIGNoYXJ0LXNwZWNpZmljIGBpbWFnZVB1bGxTZWNyZXRzYCBrZXkgdG8gZWFj" + - "aCByZWxlYXNlLiBFeGFtcGxlIGZvciBjYXNzYW5kcmEgaW4gYGhlbG1maWxlLmQvMDEtZGVwZW5k" + - "ZW5jaWVzLnlhbWwuZ290bXBsYDoKCmBgYHlhbWwKLSBuYW1lOiBjYXNzYW5kcmEKICB2ZXJzaW9u" + - "OiAwLjguMAogIGNvbmRpdGlvbjogY2Fzc2FuZHJhLmVuYWJsZWQKICBuYW1lc3BhY2U6IGNhc3Nh" + - "bmRyYS1zeXN0ZW0KICA8PDogKmRlcGVuZGVuY3kKICB2YWx1ZXM6CiAgICAtIC4uL2dsb2JhbC55" + - "YW1sLmdvdG1wbAogICAgLSAuLi88cHJpdmF0ZS12YWx1ZXM+L3t7IHJlcXVpcmVkRW52ICJIRUxN" + - "RklMRV9FTlYiIH19LXNlY3JldHMueWFtbAogICAgLSBjYXNzYW5kcmE6CiAgICAgICAgZ2xvYmFs" + - "OgogICAgICAgICAgaW1hZ2VQdWxsU2VjcmV0czoKICAgICAgICAgICAgLSBudmNyLWNyZWRzCmBg" + - "YAoKU2VlIFtyZWZlcmVuY2VzL3B1bGwtc2VjcmV0cy5tZF0ocmVmZXJlbmNlcy9wdWxsLXNlY3Jl" + - "dHMubWQpIGZvciB0aGUga2V5IGZvciBldmVyeSBjaGFydC4KClRoZXNlIGFyZSBjb250cm9sLXBs" + - "YW5lIG5hbWVzcGFjZXMuIEZvciBhIHNlcGFyYXRlIGNvbXB1dGUgY2x1c3RlciwgY3JlYXRlIHRo" + - "ZQpzZWNyZXQgb25seSBpbiBgbnZjYS1vcGVyYXRvcmA7IHRoZSBjaGFydCBhbmQgb3BlcmF0b3Ig" + - "bWlycm9yIGl0IHRvCmBudmNhLXN5c3RlbWAuIFNlZSBbU3BsaXQgQ29tcHV0ZS1QbGFuZSBJbnN0" + - "YWxsYXRpb25dKHJlZmVyZW5jZXMvY29tcHV0ZS1wbGFuZS1pbnN0YWxsYXRpb24ubWQjcHVsbC1z" + - "ZWNyZXRzKS4KCiMjIyBTdGVwIDU6IERlcGxveQoKYGBgYmFzaApIRUxNRklMRV9FTlY9PGVudj4g" + - "aGVsbWZpbGUgLS1rdWJlLWNvbnRleHQgPGNvbnRyb2wtcGxhbmUtY29udGV4dD4gc3luYwpgYGAK" + - "CiMjIyBTdGVwIDY6IFBhdGNoIFNlcnZpY2VBY2NvdW50cyBmb3IgY2hhcnRzIHdpdGhvdXQgbmF0" + - "aXZlIHN1cHBvcnQKCkFmdGVyIFBoYXNlIDEgKGRlcGVuZGVuY2llcykgY29tcGxldGVzLCBPcGVu" + - "QmFvIHBvZHMgd2lsbCBiZSBpbiBgSW1hZ2VQdWxsQmFja09mZmAuIFBhdGNoIGFuZCByZXN0YXJ0" + - "OgoKYGBgYmFzaApmb3IgbnMgaW4gdmF1bHQtc3lzdGVtIG52Y2YgZXNzOyBkbwogIGZvciBzYSBp" + - "biAkKGt1YmVjdGwgLS1jb250ZXh0IDxjb250cm9sLXBsYW5lLWNvbnRleHQ+IGdldCBzYSAtbiAi" + - "JG5zIiBcCiAgICAtLW5vLWhlYWRlcnMgLW8gY3VzdG9tLWNvbHVtbnM9IjptZXRhZGF0YS5uYW1l" + - "Iik7IGRvCiAgICBrdWJlY3RsIC0tY29udGV4dCA8Y29udHJvbC1wbGFuZS1jb250ZXh0PiBwYXRj" + - "aCBzZXJ2aWNlYWNjb3VudCAtbiAiJG5zIiAiJHNhIiBcCiAgICAgIC1wICd7ImltYWdlUHVsbFNl" + - "Y3JldHMiOiBbeyJuYW1lIjogIm52Y3ItY3JlZHMifV19JwogIGRvbmUKZG9uZQprdWJlY3RsIC0t" + - "Y29udGV4dCA8Y29udHJvbC1wbGFuZS1jb250ZXh0PiBkZWxldGUgcG9kcyAtbiB2YXVsdC1zeXN0" + - "ZW0gLS1hbGwKYGBgCgpWZXJpZnk6CgpgYGBiYXNoCmt1YmVjdGwgLS1jb250ZXh0IDxjb250cm9s" + - "LXBsYW5lLWNvbnRleHQ+IGdldCBldmVudHMgLW4gdmF1bHQtc3lzdGVtIFwKICAtLXNvcnQtYnk9" + - "Jy5sYXN0VGltZXN0YW1wJyB8IGdyZXAgLWkgcHVsbAojIFNob3VsZCBzaG93ICJTdWNjZXNzZnVs" + - "bHkgcHVsbGVkIGltYWdlIiBmcm9tIG52Y3IuaW8KYGBgCgotLS0KCiMjIEV4YW1wbGUgMzogRnVs" + - "bCBUZWFyZG93biBhbmQgUmVpbnN0YWxsCgpXaGVuIGEgZGVwbG95bWVudCBpcyBicm9rZW4gYW5k" + - "IHlvdSBuZWVkIGEgY2xlYW4gc2xhdGUuCgojIyMgU3RlcCAxOiBEZXN0cm95IHRoZSBjb21wdXRl" + - "LXBsYW5lIHN0YWNrCgpgYGBiYXNoCm1ha2UgLUMgZGVwbG95L3N0YWNrcy9udmNmLWNvbXB1dGUt" + - "cGxhbmUgZGVzdHJveSBcCiAgQ0xVU1RFUl9OQU1FPTxjbHVzdGVyLW5hbWU+IFwKICBIRUxNRklM" + - "RV9FTlY9PGVudj4gXAogIEtVQkVDT05GSUdfRklMRT08Y29tcHV0ZS1wbGFuZS1rdWJlY29uZmln" + - "PgpgYGAKCklmIGNvbXB1dGUgY2xlYW51cCBpcyBzdHVjayBvbiBmaW5hbGl6ZXJzLCB1c2UgdGhl" + - "IGNvbXB1dGUtY29udGV4dCBwcm9jZWR1cmUgaW4KW2RlYnVnZ2luZy5tZF0ocmVmZXJlbmNlcy9k" + - "ZWJ1Z2dpbmcubWQjZmFpbHVyZS1udmNhLWNsZWFudXAtc3R1Y2stZmluYWxpemVycykuCgojIyMg" + - "U3RlcCAyOiBEZXJlZ2lzdGVyIHRoZSBjb21wdXRlIGNsdXN0ZXIKClJlYWQgYGNsdXN0ZXJJRGAg" + - "YW5kIGBuY2FJRGAgZnJvbQpgZGVwbG95L3N0YWNrcy9udmNmLWNvbXB1dGUtcGxhbmUvcmVnaXN0" + - "cmF0aW9uLzxjbHVzdGVyLW5hbWU+LXJlZ2lzdGVyLXZhbHVlcy55YW1sYC4KU2hvdyB0aGUgSUQg" + - "YW5kIGdldCBjb25maXJtYXRpb24gYmVmb3JlIGRlbGV0aW5nIHRoZSBJQ01TIHJvdzoKCmBgYGJh" + - "c2gKPG52Y2YtY2xpPiAtLWNvbmZpZyA8Y29uZmlnPiBjbHVzdGVyIGRlbGV0ZSBcCiAgLS1jbHVz" + - "dGVyLWlkIDxjbHVzdGVyLWlkLWZyb20tcmVnaXN0cmF0aW9uLXZhbHVlcz4gXAogIC0tbmNhLWlk" + - "IDxuY2EtaWQ+IFwKICAtLWlnbm9yZS1taXNzaW5nCmBgYAoKUmVwZWF0IHN0ZXBzIDEgYW5kIDIg" + - "Zm9yIGV2ZXJ5IGNvbXB1dGUgY2x1c3Rlci4KCiMjIyBTdGVwIDM6IERlc3Ryb3kgdGhlIGNvbnRy" + - "b2wtcGxhbmUgc3RhY2sKClByZXNlcnZlIHRoZSBgY2VydC1tYW5hZ2VyYCBuYW1lc3BhY2UgYnkg" + - "ZGVmYXVsdC4gTmFtZXNwYWNlIHByZXNlcnZhdGlvbiBkb2VzCm5vdCBwcmVzZXJ2ZSB0aGUgSGVs" + - "bSByZWxlYXNlLiBCZWZvcmUgcnVubmluZyBkZXN0cm95LCBpZGVudGlmeSB3aGV0aGVyCmNlcnQt" + - "bWFuYWdlciBpcyBleHRlcm5hbC4gRm9yIGFuIGV4dGVybmFsIGluc3RhbGxhdGlvbiwgcmVxdWly" + - "ZSBlZmZlY3RpdmUKYGNlcnRNYW5hZ2VyLmVuYWJsZWQ6IGZhbHNlYCBhbmQgYWJvcnQgaWYgaXQg" + - "aXMgdHJ1ZS4gRm9yIGFuIGludGVuZGVkCnN0YWNrLW1hbmFnZWQgcmVsZWFzZSB3aXRoIGVmZmVj" + - "dGl2ZSBgY2VydE1hbmFnZXIuZW5hYmxlZDogdHJ1ZWAsIHZlcmlmeQptYXRjaGluZyBzdGFjayBI" + - "ZWxtIHJlbGVhc2UgbWV0YWRhdGEgYW5kIGFib3J0IG9uIG1pc3Npbmcgb3IgbWlzbWF0Y2hlZApl" + - "dmlkZW5jZS4gT25seSBwbGFuIG5hbWVzcGFjZSByZW1vdmFsIGFmdGVyIHZlcmlmeWluZyBuYW1l" + - "c3BhY2UgcHJvdmVuYW5jZSBhbmQKZ2V0dGluZyBleHBsaWNpdCBjb25maXJtYXRpb247IG90aGVy" + - "d2lzZSBkbyBub3QgZGVsZXRlIGl0LgoKYGBgYmFzaApLVUJFQ09ORklHPTxjb250cm9sLXBsYW5l" + - "LWt1YmVjb25maWc+IFwKbWFrZSAtQyBkZXBsb3kvc3RhY2tzL3NlbGYtbWFuYWdlZCBkZXN0cm95" + - "IFwKICBIRUxNRklMRV9FTlY9PGVudj4gXAogIEtVQkVDT05GSUdfRklMRT08Y29udHJvbC1wbGFu" + - "ZS1rdWJlY29uZmlnPgpgYGAKClBhc3MgYm90aCBrdWJlY29uZmlnIGZvcm1zIGJlY2F1c2UgY29u" + - "dHJvbC1wbGFuZSBuYW1lc3BhY2UgY2xlYW51cCB1c2VzCmFtYmllbnQgYGt1YmVjdGxgLgoKIyMj" + - "IFN0ZXAgNDogVmVyaWZ5IGNsZWFuCgpgYGBiYXNoCmt1YmVjdGwgLS1jb250ZXh0IDxjb21wdXRl" + - "LXBsYW5lLWNvbnRleHQ+IGdldCBucyB8IFwKICBncmVwIC1FICcobnZjYXxudmNmLWJhY2tlbmR8" + - "Z3JvdmV8ZHluYW1vKScKa3ViZWN0bCAtLWNvbnRleHQgPGNvbnRyb2wtcGxhbmUtY29udGV4dD4g" + - "Z2V0IG5zIHwgXAogIGdyZXAgLUUgJyhjYXNzYW5kcmF8bmF0c3x2YXVsdHxudmNmfGFwaS1rZXlz" + - "fGVzc3xzaXMpJwojIEJvdGggY29tbWFuZHMgc2hvdWxkIHJldHVybiBlbXB0eS4KYGBgCgojIyMg" + - "U3RlcCA1OiBSZWluc3RhbGwgaW4gc3BsaXQgb3JkZXIKCkZvbGxvdyBbQ2xlYW4gSW5zdGFsbGF0" + - "aW9uXShTS0lMTC5tZCNjbGVhbi1pbnN0YWxsYXRpb24pLCB0aGVuIHJldHVybiB0byB0aGUKc2Ft" + - "ZSBoYW5kb2ZmIGluIFtTcGxpdCBDb21wdXRlLVBsYW5lIEluc3RhbGxhdGlvbl0ocmVmZXJlbmNl" + - "cy9jb21wdXRlLXBsYW5lLWluc3RhbGxhdGlvbi5tZCkuCgotLS0KCiMjIEV4YW1wbGUgNDogUmVj" + - "b3ZlciBmcm9tIGEgR2F0ZXdheSBFbmRwb2ludCBDaGFuZ2UKClRoZSBzZXBhcmF0ZWx5IHByb3Zp" + - "c2lvbmVkIEdhdGV3YXkgb3IgaXRzIGxvYWQgYmFsYW5jZXIgd2FzIHJlY3JlYXRlZCBhbmQgZ290" + - "IGEgbmV3IGVuZHBvaW50LgoKIyMjIFN0ZXAgMTogR2V0IHRoZSBuZXcgZW5kcG9pbnQKCmBgYGJh" + - "c2gKR0FURVdBWV9BRERSPSQoa3ViZWN0bCAtLWNvbnRleHQgPGNvbnRyb2wtcGxhbmUtY29udGV4" + - "dD4gZ2V0IGdhdGV3YXkgXAogIG52Y2YtZ2F0ZXdheSAtbiBlbnZveS1nYXRld2F5IFwKICAtbyBq" + - "c29ucGF0aD0ney5zdGF0dXMuYWRkcmVzc2VzWzBdLnZhbHVlfScpCnRlc3QgLW4gIiRHQVRFV0FZ" + - "X0FERFIiCmBgYAoKIyMjIFN0ZXAgMjogVXBkYXRlIEROUyBhbmQgZGlyZWN0IGFkZHJlc3MgZmll" + - "bGRzCgpLZWVwIGBnbG9iYWwuZG9tYWluYCwgcm91dGUgaG9zdG5hbWVzLCBhbmQgQ0xJIEhvc3Qg" + - "ZmllbGRzIHVuY2hhbmdlZC4gUG9pbnQgdGhlCkROUyByZWNvcmRzIGZvciByZW5kZXJlZCByb3V0" + - "ZSBob3N0bmFtZXMsIHN1Y2ggYXMgYGFwaS48ZG9tYWluPmAgYW5kCmAqLmludm9jYXRpb24uPGRv" + - "bWFpbj5gLCB0byBgR0FURVdBWV9BRERSYC4gRm9yIGEgdGVtcG9yYXJ5IGVudmlyb25tZW50IHdp" + - "dGhvdXQKRE5TLCBrZWVwIGl0cyByZXNlcnZlZCByb3V0ZSBzdWZmaXggYW5kIGNvbm5lY3QgdG8g" + - "YEdBVEVXQVlfQUREUmAgd2l0aCB0aGUKbWF0Y2hpbmcgYEhvc3RgIGhlYWRlci4KCkZvciBhIHNw" + - "bGl0IGRlcGxveW1lbnQgY29uZmlndXJlZCB3aXRoIGRpcmVjdCBHYXRld2F5IGFkZHJlc3Nlcywg" + - "cmVwbGFjZSBvbmx5CnRoZSBhZGRyZXNzLWJlYXJpbmcgdmFsdWVzOiBjb250cm9sLXBsYW5lIGBu" + - "dmNmTmF0c1NlcnZpY2VVUkxgLCBjb21wdXRlLXBsYW5lCklDTVMsIFJlVmFsLCBhbmQgTkFUUyBV" + - "UkxzLCBhbmQgQ0xJIFVSTCBmaWVsZHMuIEtlZXAgdGhlaXIgZG9tYWluIGFuZCBIb3N0Cm92ZXJy" + - "aWRlcyB1bmNoYW5nZWQuIFJlZnJlc2ggcmVnaXN0cmF0aW9uIGFuZCBpbnN0YWxsYXRpb24gdGhy" + - "b3VnaCB0aGUgc2FtZQpoYW5kb2ZmIHVzZWQgb3JpZ2luYWxseS4gU2VlCltTcGxpdCBDb21wdXRl" + - "LVBsYW5lIEluc3RhbGxhdGlvbl0ocmVmZXJlbmNlcy9jb21wdXRlLXBsYW5lLWluc3RhbGxhdGlv" + - "bi5tZCkKZm9yIHRoZSBmaWVsZHMgYW5kIGNvbXBsZXRlIGNvbW1hbmRzLgoKIyMjIFN0ZXAgMzog" + - "VmVyaWZ5IGJvdGggY29udGV4dHMKCmBgYGJhc2gKZGlnICtzaG9ydCAiYXBpLjxkb21haW4+Igpj" + - "dXJsIC1IICJIb3N0OiBhcGkuPGRvbWFpbj4iICJodHRwOi8vJEdBVEVXQVlfQUREUi9oZWFsdGgi" + - "Cmt1YmVjdGwgLS1jb250ZXh0IDxjb250cm9sLXBsYW5lLWNvbnRleHQ+IGdldCBnYXRld2F5IG52" + - "Y2YtZ2F0ZXdheSBcCiAgLW4gZW52b3ktZ2F0ZXdheSAtbyB3aWRlCmt1YmVjdGwgLS1jb250ZXh0" + - "IDxjb250cm9sLXBsYW5lLWNvbnRleHQ+IGdldCBodHRwcm91dGVzIC1BCmt1YmVjdGwgLS1jb250" + - "ZXh0IDxjb250cm9sLXBsYW5lLWNvbnRleHQ+IGdldCB0Y3Byb3V0ZXMgLUEKa3ViZWN0bCAtLWNv" + - "bnRleHQgPGNvbXB1dGUtcGxhbmUtY29udGV4dD4gcm9sbG91dCBzdGF0dXMgXAogIGRlcGxveW1l" + - "bnQvbnZjYS1vcGVyYXRvciAtbiBudmNhLW9wZXJhdG9yIC0tdGltZW91dD0xMG0Ka3ViZWN0bCAt" + - "LWNvbnRleHQgPGNvbXB1dGUtcGxhbmUtY29udGV4dD4gd2FpdCBcCiAgbnZjZmJhY2tlbmQvPGNv" + - "bXB1dGUtcGxhbmUtY2x1c3Rlci1uYW1lPiAtbiBudmNhLW9wZXJhdG9yIFwKICAtLWZvcj1qc29u" + - "cGF0aD0ney5zdGF0dXMuYWdlbnRTdGF0dXN9Jz1oZWFsdGh5IC0tdGltZW91dD0xMG0KYGBgCgot" + - "LS0KCiMjIEV4YW1wbGUgNTogRGVwbG95IE9ubHkgRGVwZW5kZW5jaWVzCgpVc2VmdWwgZm9yIHRl" + - "c3Rpbmcgb3Igd2hlbiBpdGVyYXRpbmcgb24gc2VydmljZSBjb25maWd1cmF0aW9uLgoKYGBgYmFz" + - "aAojIERlcGxveSBqdXN0IE5BVFMsIENhc3NhbmRyYSwgT3BlbkJhbwpIRUxNRklMRV9FTlY9PGVu" + - "dj4gaGVsbWZpbGUgLS1zZWxlY3RvciByZWxlYXNlLWdyb3VwPWRlcGVuZGVuY2llcyBzeW5jCgoj" + - "IENoZWNrIHN0YXR1cwprdWJlY3RsIGdldCBwb2RzIC1uIG5hdHMtc3lzdGVtCmt1YmVjdGwgZ2V0" + - "IHBvZHMgLW4gY2Fzc2FuZHJhLXN5c3RlbQprdWJlY3RsIGdldCBwb2RzIC1uIHZhdWx0LXN5c3Rl" + - "bQoKIyBEZXBsb3kganVzdCBvbmUgc3BlY2lmaWMgcmVsZWFzZQpIRUxNRklMRV9FTlY9PGVudj4g" + - "aGVsbWZpbGUgLS1zZWxlY3RvciBuYW1lPWNhc3NhbmRyYSBzeW5jCmBgYAoKIyMjIFNlbGVjdG9y" + - "IHJlZmVyZW5jZQoKfCBTZWxlY3RvciB8IFJlbGVhc2VzIHwKfC0tLS0tLS0tLS18LS0tLS0tLS0t" + - "LXwKfCBgcmVsZWFzZS1ncm91cD1kZXBlbmRlbmNpZXNgIHwgbmF0cywgY2Fzc2FuZHJhLCBvcGVu" + - "YmFvLXNlcnZlciB8CnwgYHJlbGVhc2UtZ3JvdXA9c2VydmljZXNgIHwgYXBpLWtleXMsIHNpcywg" + - "YXBpLCBpbnZvY2F0aW9uLXNlcnZpY2UsIGdycGMtcHJveHksIGVzcy1hcGksIG5vdGFyeS1zZXJ2" + - "aWNlLCByZXZhbCwgb3B0aW9uYWwgbGxtLXJlcXVlc3Qtcm91dGVyIGFuZCBsbG0tYXBpLWdhdGV3" + - "YXkgd2hlbiBgbGxtLmVuYWJsZWQ9dHJ1ZWAsIG9wdGlvbmFsIHZhbml0eS1nYXRld2F5IG9ubHkg" + - "aW4gc3RhY2sgcGFja2FnZXMgdGhhdCBpbmNsdWRlIHRoZSBhZGRvbiBhbmQgaGF2ZSBgYWRkb25z" + - "LnZhbml0eUdhdGV3YXkuZW5hYmxlZD10cnVlYCwgb3B0aW9uYWwgbnZjZi11aSBvbmx5IGluIHN0" + - "YWNrIHBhY2thZ2VzIHRoYXQgaW5jbHVkZSB0aGUgYWRkb24gYW5kIGhhdmUgYGFkZG9ucy5udmNm" + - "VWkuZW5hYmxlZD10cnVlYCB8CnwgYG5hbWU9bGxtLXJlcXVlc3Qtcm91dGVyYCB8IERlcGxveSBq" + - "dXN0IHRoZSBMTE0gcmVxdWVzdCByb3V0ZXIgd2hlbiBgbGxtLmVuYWJsZWQ9dHJ1ZWAgfAp8IGBu" + - "YW1lPWxsbS1hcGktZ2F0ZXdheWAgfCBEZXBsb3kganVzdCB0aGUgTExNIEFQSSBnYXRld2F5IHdo" + - "ZW4gYGxsbS5lbmFibGVkPXRydWVgIHwKfCBgbmFtZT12YW5pdHktZ2F0ZXdheWAgfCBEZXBsb3kg" + - "anVzdCBWYW5pdHkgR2F0ZXdheSBvbmx5IHdoZW4gdGhlIHN0YWNrIHBhY2thZ2UgaW5jbHVkZXMg" + - "dGhhdCByZWxlYXNlIGFuZCBgYWRkb25zLnZhbml0eUdhdGV3YXkuZW5hYmxlZD10cnVlYCB8Cnwg" + - "YG5hbWU9bnZjZi11aWAgfCBEZXBsb3kgTlZDRiBVSSBvbmx5IHdoZW4gdGhlIHN0YWNrIHBhY2th" + - "Z2UgaW5jbHVkZXMgdGhhdCByZWxlYXNlIGFuZCBgYWRkb25zLm52Y2ZVaS5lbmFibGVkPXRydWVg" + - "IHwKfCBgcmVsZWFzZS1ncm91cD1pbmdyZXNzYCB8IGluZ3Jlc3MgKGdhdGV3YXkgcm91dGVzKSB8" + - "CnwgYG5hbWU9PHJlbGVhc2U+YCB8IEFueSBpbmRpdmlkdWFsIHJlbGVhc2UgYnkgbmFtZSB8CgpO" + - "b3RlOiBgYWRtaW4taXNzdWVyLXByb3h5YCBkb2VzIG5vdCBoYXZlIGEgcmVsZWFzZS1ncm91cCBs" + - "YWJlbC4gVGFyZ2V0IGl0IHdpdGggYC0tc2VsZWN0b3IgbmFtZT1hZG1pbi1pc3N1ZXItcHJveHlg" + - "LgpQSwcIAlJOQH8oAAB/KAAAUEsDBBQACAAAAAAAIQAAAAAAAAAAAAAAAABMAAkAZGF0YS9udmNm" + - "LXNlbGYtbWFuYWdlZC1pbnN0YWxsYXRpb24vcmVmZXJlbmNlcy9jb21wdXRlLXBsYW5lLWluc3Rh" + - "bGxhdGlvbi5tZFVUBQABAKbOEiMgU3BsaXQgQ29tcHV0ZS1QbGFuZSBJbnN0YWxsYXRpb24KClRo" + - "ZSBjb250cm9sIHBsYW5lIGFuZCBjb21wdXRlIHBsYW5lIGFyZSBzZXBhcmF0ZSBzdGFja3MuIElu" + - "c3RhbGwgdGhlIGNvbnRyb2wKcGxhbmUgZmlyc3QsIHRoZW4gcmVnaXN0ZXIgYW5kIGluc3RhbGwg" + - "ZWFjaCBjb21wdXRlIGNsdXN0ZXIuIENob29zZSBvbmUgaGFuZG9mZgpiZWxvdyBhbmQgdXNlIGl0" + - "IGZvciB0aGUgZnVsbCBsaWZlY3ljbGUuIERvIG5vdCBjb21iaW5lIGEgQ0xJLWdlbmVyYXRlZCBw" + - "cm9maWxlCndpdGggdGhlIEhlbG1maWxlIHZhbHVlcyBmbG93LgoKIyMgQ2hvb3NlIGEgSGFuZG9m" + - "ZgoKfCBGbG93IHwgU291cmNlIG9mIGNvbnRyb2wtcGxhbmUgZW5kcG9pbnRzIHwgUmVnaXN0cmF0" + - "aW9uIGFydGlmYWN0IHwKfC0tLS0tLXwtLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0t" + - "LXwtLS0tLS0tLS0tLS0tLS0tLS0tLS0tLXwKfCBIZWxtZmlsZSB2YWx1ZXMgfCBTZXBhcmF0ZSBj" + - "b250cm9sLXBsYW5lIGFuZCBjb21wdXRlLXBsYW5lIGVudmlyb25tZW50IGZpbGVzIHwgYGRlcGxv" + - "eS9zdGFja3MvbnZjZi1jb21wdXRlLXBsYW5lL3JlZ2lzdHJhdGlvbi88Y2x1c3Rlcj4tcmVnaXN0" + - "ZXItdmFsdWVzLnlhbWxgIHwKfCBDTEkgcHJvZmlsZSB8IENMSS1nZW5lcmF0ZWQgYGRlcGxveS9z" + - "dGFja3Mvc2VsZi1tYW5hZ2VkL291dC9jb250cm9sLXBsYW5lLXByb2ZpbGUueWFtbGAgfCBDTEkt" + - "Z2VuZXJhdGVkIGBkZXBsb3kvc3RhY2tzL252Y2YtY29tcHV0ZS1wbGFuZS9vdXQvPGNsdXN0ZXI+" + - "LXJlZ2lzdGVyLXZhbHVlcy55YW1sYCB8CgpUaGUgQ0xJIHByb2ZpbGUgaXMgbWFuZGF0b3J5IGZv" + - "ciB0aGUgQ0xJIGZsb3cgYW5kIG11c3Qgbm90IGJlIGhhbmQtYXV0aG9yZWQuClRoZSBIZWxtZmls" + - "ZSBmbG93IGRvZXMgbm90IGNvbnN1bWUgYSBjb250cm9sLXBsYW5lIHByb2ZpbGUuCgpDb21tYW5k" + - "cyBiZWxvdyB1c2UgbW9ub3JlcG8gcGF0aHMgZnJvbSB0aGUgcmVwb3NpdG9yeSByb290LiBPcGVy" + - "YXRvcnMgdXNpbmcKZXh0cmFjdGVkIHBhY2thZ2VzIG5lZWQgbWF0Y2hpbmctdmVyc2lvbiBgbnZj" + - "Zi1zZWxmLW1hbmFnZWQtc3RhY2tgIGFuZApgbnZjZi1jb21wdXRlLXBsYW5lLXN0YWNrYCBidW5k" + - "bGVzLiBTdWJzdGl0dXRlIHRob3NlIGJ1bmRsZSByb290cyBmb3IKYGRlcGxveS9zdGFja3Mvc2Vs" + - "Zi1tYW5hZ2VkYCBhbmQgYGRlcGxveS9zdGFja3MvbnZjZi1jb21wdXRlLXBsYW5lYC4KCiMjIEhl" + - "bG1maWxlIFZhbHVlcyBGbG93CgpDcmVhdGUgYm90aCBlbnZpcm9ubWVudCBmaWxlcyBiZWZvcmUg" + - "aW5zdGFsbGF0aW9uOgoKLSBgZGVwbG95L3N0YWNrcy9zZWxmLW1hbmFnZWQvZW52aXJvbm1lbnRz" + - "LzxlbnY+LnlhbWxgIGZvciBjb250cm9sLXBsYW5lCiAgcmVnaXN0cnksIGRvbWFpbiwgR2F0ZXdh" + - "eSwgcm91dGVzLCBhbmQgc2VydmljZSBzZXR0aW5ncy4KLSBgZGVwbG95L3N0YWNrcy9udmNmLWNv" + - "bXB1dGUtcGxhbmUvZW52aXJvbm1lbnRzLzxlbnY+LnlhbWxgIGZvciBjb21wdXRlLXBsYW5lCiAg" + - "cmVnaXN0cnksIHB1bGwgc2VjcmV0cywgYW5kIGNvbXB1dGUtcmVhY2hhYmxlIElDTVMsIFJlVmFs" + - "LCBhbmQgTkFUUyB2YWx1ZXMuCgpGb3IgRUtTLCB1c2UgdGhlIGNhbm9uaWNhbApbQ1NQIEVuZC10" + - "by1FbmQgRXhhbXBsZV0oaHR0cHM6Ly9kb2NzLm52aWRpYS5jb20vbnZjZi92MC42LjAtcmMvY3Nw" + - "LWVuZC10by1lbmQtZXhhbXBsZSkuClRoZSBjb250cm9sLXBsYW5lIGVudmlyb25tZW50IGtlZXBz" + - "IHRoZSBFbnZveSBHYXRld2F5IGNvbnRyb2xsZXIgaW4KYGVudm95LWdhdGV3YXktc3lzdGVtYCwg" + - "YXR0YWNoZXMgcm91dGVzIHRvIGBHYXRld2F5L252Y2YtZ2F0ZXdheWAgaW4KYGVudm95LWdhdGV3" + - "YXlgLCBhbmQgZW5hYmxlcyB0aGUgTkFUUyByb3V0ZS4gVGhlIGNvbXB1dGUtcGxhbmUgZW52aXJv" + - "bm1lbnQgdXNlcwp0aGUgZXh0ZXJuYWwgYWRkcmVzcyBwbHVzIHRoZSBzZXJ2aWNlIGhvc3Qgb3Zl" + - "cnJpZGVzLiBIZXJlLApgPGdhdGV3YXktYWRkcmVzcz5gIGlzIHRoZSBOTEIgYWRkcmVzcyBhbmQg" + - "YDxnYXRld2F5LWRvbWFpbj5gIGlzIHRoZSBETlMgYmFzZQp3aG9zZSBzZXJ2aWNlIHN1YmRvbWFp" + - "bnMgcmVzb2x2ZSB0byB0aGF0IE5MQjoKCmBgYHlhbWwKIyBkZXBsb3kvc3RhY2tzL3NlbGYtbWFu" + - "YWdlZC9lbnZpcm9ubWVudHMvPGVudj4ueWFtbApnbG9iYWw6CiAgZG9tYWluOiAiPGdhdGV3YXkt" + - "ZG9tYWluPiIKICB3b3JrZXJFbmRwb2ludHM6CiAgICBudmNmU2VydmljZVVSTDogImh0dHA6Ly9h" + - "cGkuPGdhdGV3YXktZG9tYWluPiIKICAgIG52Y2ZHcnBjU2VydmljZVVSTDogImh0dHA6Ly93b3Jr" + - "ZXItYXBpLjxnYXRld2F5LWRvbWFpbj4iCiAgICBudmNmTmF0c1NlcnZpY2VVUkw6ICJuYXRzOi8v" + - "PGdhdGV3YXktYWRkcmVzcz46NDIyMiIKICAgIG52Y3RTZXJ2aWNlVVJMOiAiaHR0cDovL3Rhc2tz" + - "LjxnYXRld2F5LWRvbWFpbj4iCiAgICBudmN0R3JwY1NlcnZpY2VVUkw6ICJodHRwOi8vd29ya2Vy" + - "LXRhc2tzLjxnYXRld2F5LWRvbWFpbj4iCiAgICBpbnZvY2F0aW9uU2VydmljZVVSTDogImh0dHA6" + - "Ly9pbnZvY2F0aW9uLjxnYXRld2F5LWRvbWFpbj4iCgpncnBjcHJveHk6CiAgbnZjZkdycGNTZXJ2" + - "aWNlVVJMOiAiaHR0cDovL2FwaS5udmNmLnN2Yy5jbHVzdGVyLmxvY2FsOjkwOTAiCgppbmdyZXNz" + - "OgogIGdhdGV3YXlBcGk6CiAgICBjb250cm9sbGVyTmFtZXNwYWNlOiBlbnZveS1nYXRld2F5LXN5" + - "c3RlbQogICAgZ2F0ZXdheXM6CiAgICAgIHNoYXJlZDoge25hbWU6IG52Y2YtZ2F0ZXdheSwgbmFt" + - "ZXNwYWNlOiBlbnZveS1nYXRld2F5fQogICAgICBncnBjOiB7bmFtZTogbnZjZi1nYXRld2F5LCBu" + - "YW1lc3BhY2U6IGVudm95LWdhdGV3YXl9CiAgICAgIG5hdHM6CiAgICAgICAgbmFtZTogbnZjZi1n" + - "YXRld2F5CiAgICAgICAgbmFtZXNwYWNlOiBlbnZveS1nYXRld2F5CiAgICAgICAgbGlzdGVuZXJO" + - "YW1lOiBuYXRzCiAgICByb3V0ZXM6CiAgICAgIG52Y2ZBcGk6CiAgICAgICAgZ3JwYzoKICAgICAg" + - "ICAgIGVuYWJsZWQ6IHRydWUKICAgICAgICAgIGhvc3RuYW1lczogWyJ3b3JrZXItYXBpLjxnYXRl" + - "d2F5LWRvbWFpbj4iXQogICAgICBudmN0QXBpOgogICAgICAgIGdycGM6CiAgICAgICAgICBlbmFi" + - "bGVkOiB0cnVlCiAgICAgICAgICBob3N0bmFtZXM6IFsid29ya2VyLXRhc2tzLjxnYXRld2F5LWRv" + - "bWFpbj4iXQogICAgICBuYXRzOgogICAgICAgIGVuYWJsZWQ6IHRydWUKCm9wZW5iYW86CiAgbWln" + - "cmF0aW9uczoKICAgIGlzc3VlckRpc2NvdmVyeToKICAgICAgZW5hYmxlZDogdHJ1ZQpgYGAKCmBg" + - "YHlhbWwKIyBkZXBsb3kvc3RhY2tzL252Y2YtY29tcHV0ZS1wbGFuZS9lbnZpcm9ubWVudHMvPGVu" + - "dj4ueWFtbApnbG9iYWw6CiAgbnZjYU9wZXJhdG9yOgogICAgc2VsZk1hbmFnZWQ6CiAgICAgIGlj" + - "bXNTZXJ2aWNlVVJMOiAiaHR0cDovLzxnYXRld2F5LWFkZHJlc3M+IgogICAgICBpY21zU2Vydmlj" + - "ZUhvc3RIZWFkZXJPdmVycmlkZTogInNpcy48Z2F0ZXdheS1kb21haW4+IgogICAgICByZXZhbFNl" + - "cnZpY2VVUkw6ICJodHRwOi8vPGdhdGV3YXktYWRkcmVzcz4iCiAgICAgIHJldmFsU2VydmljZUhv" + - "c3RIZWFkZXJPdmVycmlkZTogInJldmFsLjxnYXRld2F5LWRvbWFpbj4iCiAgICAgIG5hdHNVUkw6" + - "ICJuYXRzOi8vPGdhdGV3YXktYWRkcmVzcz46NDIyMiIKICAgICAgbmF0c0hvc3RPdmVycmlkZTog" + - "Im5hdHMuPGdhdGV3YXktZG9tYWluPiIKYGBgCgpQcmV2aWV3IGFuZCBpbnN0YWxsIHRoZSBjb250" + - "cm9sIHBsYW5lIGFnYWluc3QgaXRzIGNvbnRleHQ6CgpgYGBiYXNoCm1ha2UgLUMgZGVwbG95L3N0" + - "YWNrcy9zZWxmLW1hbmFnZWQgdGVtcGxhdGUgXAogIEhFTE1GSUxFX0VOVj08ZW52PiBcCiAgS1VC" + - "RUNPTkZJR19GSUxFPTxjb250cm9sLXBsYW5lLWt1YmVjb25maWc+CgptYWtlIC1DIGRlcGxveS9z" + - "dGFja3Mvc2VsZi1tYW5hZ2VkIGluc3RhbGwgXAogIEhFTE1GSUxFX0VOVj08ZW52PiBcCiAgS1VC" + - "RUNPTkZJR19GSUxFPTxjb250cm9sLXBsYW5lLWt1YmVjb25maWc+CmBgYAoKQmVmb3JlIHJlZ2lz" + - "dGVyaW5nIHRocm91Z2ggYSBiYXJlIEdhdGV3YXkgYWRkcmVzcywgY3JlYXRlIG9yIHVwZGF0ZSB0" + - "aGUgQ0xJCmNvbmZpZyBwYXNzZWQgYXMgYE5WQ0ZfQ0xJX0NPTkZJR2A6CgpgYGB5YW1sCmJhc2Vf" + - "aHR0cF91cmw6ICJodHRwOi8vPGdhdGV3YXktYWRkcmVzcz4iCmludm9rZV91cmw6ICJodHRwOi8v" + - "PGdhdGV3YXktYWRkcmVzcz4iCmJhc2VfZ3JwY191cmw6ICI8Z2F0ZXdheS1hZGRyZXNzPjoxMDA4" + - "MSIKYXBpX2tleXNfc2VydmljZV91cmw6ICJodHRwOi8vPGdhdGV3YXktYWRkcmVzcz4iCmljbXNf" + - "dXJsOiAiaHR0cDovLzxnYXRld2F5LWFkZHJlc3M+IgphcGlfaG9zdDogImFwaS48Z2F0ZXdheS1k" + - "b21haW4+IgphcGlfa2V5c19ob3N0OiAiYXBpLWtleXMuPGdhdGV3YXktZG9tYWluPiIKaW52b2tl" + - "X2hvc3Q6ICJpbnZvY2F0aW9uLjxnYXRld2F5LWRvbWFpbj4iCmljbXNfaG9zdDogInNpcy48Z2F0" + - "ZXdheS1kb21haW4+IgpgYGAKCkhvc3QgZmllbGRzIGNvbnRhaW4gaG9zdG5hbWVzIG9ubHksIHdp" + - "dGhvdXQgYSBVUkwgc2NoZW1lLiBgSUNNU19VUkxgIG1hcHMgb25seQp0byB0aGUgcmVnaXN0cmF0" + - "aW9uIGNvbW1hbmQncyBgLS1pY21zLXVybGA7IGBpY21zX2hvc3RgIGluIHRoZSBjb25maWcgc3Vw" + - "cGxpZXMKdGhlIEdhdGV3YXkgSG9zdCBoZWFkZXIuIFRoZXJlIGlzIG5vIGAtLWljbXMtaG9zdGAg" + - "TWFrZSBvciBDTEkgZmxhZy4KClJlZ2lzdGVyIGZyb20gdGhlIGNvbXB1dGUgY29udGV4dCwgdGhl" + - "biBpbnN0YWxsIHRoZSBjb21wdXRlIHBsYW5lOgoKYGBgYmFzaAptYWtlIC1DIGRlcGxveS9zdGFj" + - "a3MvbnZjZi1jb21wdXRlLXBsYW5lIHJlZ2lzdGVyLWNsdXN0ZXIgXAogIENMVVNURVJfTkFNRT08" + - "Y2x1c3Rlci1uYW1lPiBcCiAgTkNBX0lEPTxuY2EtaWQ+IFwKICBDTFVTVEVSX1JFR0lPTj08cmVn" + - "aW9uPiBcCiAgSUNNU19VUkw9aHR0cDovLzxnYXRld2F5LWFkZHJlc3M+IFwKICBLVUJFQ09ORklH" + - "X0ZJTEU9PGNvbXB1dGUtcGxhbmUta3ViZWNvbmZpZz4gXAogIE5WQ0ZfQ0xJPTxwYXRoLXRvLW52" + - "Y2YtY2xpPiBcCiAgTlZDRl9DTElfQ09ORklHPTxwYXRoLXRvLW52Y2YtY2xpLWNvbmZpZz4KCm1h" + - "a2UgLUMgZGVwbG95L3N0YWNrcy9udmNmLWNvbXB1dGUtcGxhbmUgaW5zdGFsbCBcCiAgQ0xVU1RF" + - "Ul9OQU1FPTxjbHVzdGVyLW5hbWU+IFwKICBIRUxNRklMRV9FTlY9PGVudj4gXAogIEtVQkVDT05G" + - "SUdfRklMRT08Y29tcHV0ZS1wbGFuZS1rdWJlY29uZmlnPgpgYGAKClJlZ2lzdHJhdGlvbiBkaXNj" + - "b3ZlcnMgdGhlIE9JREMgaXNzdWVyIGFuZCBKV0tTIGZyb20gdGhlIGNvbXB1dGUga3ViZSBjb250" + - "ZXh0LgpJbiBhIG11bHRpLWNsdXN0ZXIgZGVwbG95bWVudCwgYEtVQkVDT05GSUdfRklMRWAgaXMg" + - "cmVxdWlyZWQgb24gYm90aApjb21wdXRlLXBsYW5lIGNvbW1hbmRzLiBJbiBhIHNhbWUtY2x1c3Rl" + - "ciBkZXBsb3ltZW50IGl0IG1heSBiZSBvbWl0dGVkIG9ubHkKd2hlbiB0aGUgYW1iaWVudCBjb250" + - "ZXh0IGlzIGFscmVhZHkgdGhlIHRhcmdldCBjbHVzdGVyLgoKIyMgQ0xJIFByb2ZpbGUgRmxvdwoK" + - "UnVuIHRoZSBDTEkgY29udHJvbC1wbGFuZSBpbnN0YWxsIGZpcnN0LiBJdCBjcmVhdGVzIHRoZSBw" + - "cm9maWxlIGNvbnN1bWVkIGJ5CmNvbXB1dGUtcGxhbmUgcmVnaXN0cmF0aW9uOgoKYGBgYmFzaApu" + - "dmNmLWNsaSAtLWNvbmZpZyA8Y29uZmlnPiBzZWxmLWhvc3RlZCBcCiAgLS1jb250cm9sLXBsYW5l" + - "LXN0YWNrIGRlcGxveS9zdGFja3Mvc2VsZi1tYW5hZ2VkIFwKICAtLWNvbXB1dGUtcGxhbmUtc3Rh" + - "Y2sgZGVwbG95L3N0YWNrcy9udmNmLWNvbXB1dGUtcGxhbmUgXAogIC0tZW52IDxlbnY+IFwKICAt" + - "LXBsYWluIFwKICAtLWNvbnRyb2wtcGxhbmUtY29udGV4dCA8Y29udHJvbC1wbGFuZS1jb250ZXh0" + - "PiBcCiAgLS1jb21wdXRlLXBsYW5lLWNvbnRleHQgPGNvbXB1dGUtcGxhbmUtY29udGV4dD4gXAog" + - "IGluc3RhbGwgLS1jb250cm9sLXBsYW5lIFwKICAtLWNsdXN0ZXItbmFtZSA8Y29udHJvbC1wbGFu" + - "ZS1jbHVzdGVyLW5hbWU+IFwKICAtLXJlZ2lvbiA8cmVnaW9uPiBcCiAgLS1uY2EtaWQgPG5jYS1p" + - "ZD4KCnRlc3QgLXMgZGVwbG95L3N0YWNrcy9zZWxmLW1hbmFnZWQvb3V0L2NvbnRyb2wtcGxhbmUt" + - "cHJvZmlsZS55YW1sCmBgYAoKVXNlIHRoZSBmdWxsIHBlcnNpc3RlbnQgcHJlZml4IGZvciBib3Ro" + - "IGNvbXB1dGUtcGxhbmUgY29tbWFuZHM6CgpgYGBiYXNoCm52Y2YtY2xpIC0tY29uZmlnIDxjb25m" + - "aWc+IHNlbGYtaG9zdGVkIFwKICAtLWNvbnRyb2wtcGxhbmUtc3RhY2sgZGVwbG95L3N0YWNrcy9z" + - "ZWxmLW1hbmFnZWQgXAogIC0tY29tcHV0ZS1wbGFuZS1zdGFjayBkZXBsb3kvc3RhY2tzL252Y2Yt" + - "Y29tcHV0ZS1wbGFuZSBcCiAgLS1lbnYgPGVudj4gXAogIC0tcGxhaW4gXAogIGNvbXB1dGUtcGxh" + - "bmUgcmVnaXN0ZXIgXAogIC0tY29udHJvbC1wbGFuZS1wcm9maWxlIGRlcGxveS9zdGFja3Mvc2Vs" + - "Zi1tYW5hZ2VkL291dC9jb250cm9sLXBsYW5lLXByb2ZpbGUueWFtbCBcCiAgLS1jbHVzdGVyLW5h" + - "bWUgPGNvbXB1dGUtcGxhbmUtY2x1c3Rlci1uYW1lPiBcCiAgLS1rdWJlLWNvbnRleHQgPGNvbXB1" + - "dGUtcGxhbmUtY29udGV4dD4gXAogIC0tcmVnaW9uIDxyZWdpb24+IFwKICAtLW91dHB1dCBkZXBs" + - "b3kvc3RhY2tzL252Y2YtY29tcHV0ZS1wbGFuZS9vdXQvPGNvbXB1dGUtcGxhbmUtY2x1c3Rlci1u" + - "YW1lPi1yZWdpc3Rlci12YWx1ZXMueWFtbAoKbnZjZi1jbGkgLS1jb25maWcgPGNvbmZpZz4gc2Vs" + - "Zi1ob3N0ZWQgXAogIC0tY29udHJvbC1wbGFuZS1zdGFjayBkZXBsb3kvc3RhY2tzL3NlbGYtbWFu" + - "YWdlZCBcCiAgLS1jb21wdXRlLXBsYW5lLXN0YWNrIGRlcGxveS9zdGFja3MvbnZjZi1jb21wdXRl" + - "LXBsYW5lIFwKICAtLWVudiA8ZW52PiBcCiAgLS1wbGFpbiBcCiAgY29tcHV0ZS1wbGFuZSBpbnN0" + - "YWxsIFwKICAtLXZhbHVlcyBkZXBsb3kvc3RhY2tzL252Y2YtY29tcHV0ZS1wbGFuZS9vdXQvPGNv" + - "bXB1dGUtcGxhbmUtY2x1c3Rlci1uYW1lPi1yZWdpc3Rlci12YWx1ZXMueWFtbCBcCiAgLS1rdWJl" + - "LWNvbnRleHQgPGNvbXB1dGUtcGxhbmUtY29udGV4dD4gXAogIC0tY2x1c3Rlci1uYW1lIDxjb21w" + - "dXRlLXBsYW5lLWNsdXN0ZXItbmFtZT4KYGBgCgpSZWdpc3RyYXRpb24gc2VsZWN0cyB0aGUgcHJv" + - "ZmlsZSdzIGluLWNsdXN0ZXIgb3IgY29tcHV0ZS1yZWFjaGFibGUgZW5kcG9pbnRzCmZyb20gdGhl" + - "IGNvbXB1dGUgY29udGV4dCBhbmQgcHJvYmVzIGl0cyBKV0tTLiBEbyBub3QgcmVwbGFjZSBwcm9m" + - "aWxlIGVuZHBvaW50cwp3aXRoIGhhbmQtYXV0aG9yZWQgSGVsbWZpbGUgZW5kcG9pbnQgdmFsdWVz" + - "LgoKIyMgUHVsbCBTZWNyZXRzCgpDcmVhdGUgdGhlIGNvbnRyb2wtcGxhbmUgc2VjcmV0IGluIHRo" + - "ZXNlIG5hbWVzcGFjZXMgb24gdGhlIGNvbnRyb2wtcGxhbmUKY29udGV4dDoKCmBgYHRleHQKY2Fz" + - "c2FuZHJhLXN5c3RlbSBuYXRzLXN5c3RlbSBudmNmIGFwaS1rZXlzIGVzcyBzaXMgdmF1bHQtc3lz" + - "dGVtIGNlcnQtbWFuYWdlcgpgYGAKCk9uIHRoZSBjb21wdXRlIGNvbnRleHQsIGNyZWF0ZSB0aGUg" + - "c2VjcmV0IG9ubHkgaW4gYG52Y2Etb3BlcmF0b3JgIGFuZCByZWZlcmVuY2UKaXQgdGhyb3VnaCBg" + - "Z2xvYmFsLmltYWdlUHVsbFNlY3JldHNgIGluIHRoZSBjb21wdXRlLXBsYW5lIGVudmlyb25tZW50" + - "LiBUaGUgY2hhcnQKYW5kIG9wZXJhdG9yIHVzZSBpdCBpbiBgbnZjYS1vcGVyYXRvcmAgYW5kIG1p" + - "cnJvciBpdCB0byBgbnZjYS1zeXN0ZW1gLiBEbyBub3QgYWRkCmVpdGhlciBuYW1lc3BhY2UgdG8g" + - "dGhlIGNvbnRyb2wtcGxhbmUgbGlzdC4gRm9yIGNvbW1hbmRzIGFuZCBwb2xpY3kgZXhhbXBsZXMs" + - "CnNlZSBbcHVsbC1zZWNyZXRzLm1kXShwdWxsLXNlY3JldHMubWQpLgoKIyMgVmVyaWZpY2F0aW9u" + - "CgpVc2UgdGhlIGV4cGxpY2l0IEdhdGV3YXkgbmFtZSBhbmQgbmFtZXNwYWNlOgoKYGBgYmFzaApr" + - "dWJlY3RsIC0tY29udGV4dCA8Y29udHJvbC1wbGFuZS1jb250ZXh0PiBnZXQgZ2F0ZXdheSBudmNm" + - "LWdhdGV3YXkgXAogIC1uIGVudm95LWdhdGV3YXkgLW8gd2lkZQprdWJlY3RsIC0tY29udGV4dCA8" + - "Y29udHJvbC1wbGFuZS1jb250ZXh0PiBnZXQgdGNwcm91dGUgLW4gZW52b3ktZ2F0ZXdheQprdWJl" + - "Y3RsIC0tY29udGV4dCA8Y29tcHV0ZS1wbGFuZS1jb250ZXh0PiByb2xsb3V0IHN0YXR1cyBcCiAg" + - "ZGVwbG95bWVudC9udmNhLW9wZXJhdG9yIC1uIG52Y2Etb3BlcmF0b3IgLS10aW1lb3V0PTEwbQpr" + - "dWJlY3RsIC0tY29udGV4dCA8Y29tcHV0ZS1wbGFuZS1jb250ZXh0PiB3YWl0IFwKICBudmNmYmFj" + - "a2VuZC88Y29tcHV0ZS1wbGFuZS1jbHVzdGVyLW5hbWU+IC1uIG52Y2Etb3BlcmF0b3IgXAogIC0t" + - "Zm9yPWpzb25wYXRoPSd7LnN0YXR1cy5hZ2VudFN0YXR1c30nPWhlYWx0aHkgLS10aW1lb3V0PTEw" + - "bQprdWJlY3RsIC0tY29udGV4dCA8Y29tcHV0ZS1wbGFuZS1jb250ZXh0PiBnZXQgc2VjcmV0IDxw" + - "dWxsLXNlY3JldD4gLW4gbnZjYS1zeXN0ZW0KYGBgCgojIyBUZWFyZG93bgoKRGVzdHJveSBhbmQg" + - "ZGVyZWdpc3RlciBldmVyeSBjb21wdXRlIHBsYW5lIGJlZm9yZSB0aGUgY29udHJvbCBwbGFuZS4g" + - "Qm90aApoYW5kb2ZmcyBjYW4gZGVzdHJveSB0aGUgY29udHJvbCBwbGFuZSwgc28gYXBwbHkgdGhp" + - "cyBwcmVmbGlnaHQgYmVmb3JlIGVpdGhlcgpvbmUuIFByZXNlcnZlIHRoZSBgY2VydC1tYW5hZ2Vy" + - "YCBuYW1lc3BhY2UgYnkgZGVmYXVsdC4gTmFtZXNwYWNlIHByZXNlcnZhdGlvbgpkb2VzIG5vdCBw" + - "cmVzZXJ2ZSB0aGUgSGVsbSByZWxlYXNlLiBJZGVudGlmeSB3aGV0aGVyIGNlcnQtbWFuYWdlciBp" + - "cyBleHRlcm5hbC4KRm9yIGFuIGV4dGVybmFsIGluc3RhbGxhdGlvbiwgcmVxdWlyZSBlZmZlY3Rp" + - "dmUgYGNlcnRNYW5hZ2VyLmVuYWJsZWQ6IGZhbHNlYAphbmQgYWJvcnQgaWYgaXQgaXMgdHJ1ZS4g" + - "Rm9yIGFuIGludGVuZGVkIHN0YWNrLW1hbmFnZWQgcmVsZWFzZSB3aXRoIGVmZmVjdGl2ZQpgY2Vy" + - "dE1hbmFnZXIuZW5hYmxlZDogdHJ1ZWAsIHZlcmlmeSBtYXRjaGluZyBzdGFjayBIZWxtIHJlbGVh" + - "c2UgbWV0YWRhdGEgYW5kCmFib3J0IG9uIG1pc3Npbmcgb3IgbWlzbWF0Y2hlZCBldmlkZW5jZS4g" + - "T25seSBwbGFuIG5hbWVzcGFjZSByZW1vdmFsIGFmdGVyCnZlcmlmeWluZyBuYW1lc3BhY2UgcHJv" + - "dmVuYW5jZSBhbmQgZ2V0dGluZyBleHBsaWNpdCBjb25maXJtYXRpb247IG90aGVyd2lzZQpkbyBu" + - "b3QgZGVsZXRlIGl0LgoKRm9yIHRoZSBIZWxtZmlsZSB2YWx1ZXMgZmxvdywgcmV0YWluIGVhY2gg" + - "cmVnaXN0cmF0aW9uIHZhbHVlcyBmaWxlIHVudGlsCmNsZWFudXAgaXMgdmVyaWZpZWQ6CgpgYGBi" + - "YXNoCm1ha2UgLUMgZGVwbG95L3N0YWNrcy9udmNmLWNvbXB1dGUtcGxhbmUgZGVzdHJveSBcCiAg" + - "Q0xVU1RFUl9OQU1FPTxjbHVzdGVyLW5hbWU+IFwKICBIRUxNRklMRV9FTlY9PGVudj4gXAogIEtV" + - "QkVDT05GSUdfRklMRT08Y29tcHV0ZS1wbGFuZS1rdWJlY29uZmlnPgoKPG52Y2YtY2xpPiAtLWNv" + - "bmZpZyA8Y29uZmlnPiBjbHVzdGVyIGRlbGV0ZSBcCiAgLS1jbHVzdGVyLWlkIDxjbHVzdGVyLWlk" + - "LWZyb20tcmVnaXN0cmF0aW9uLXZhbHVlcz4gXAogIC0tbmNhLWlkIDxuY2EtaWQ+IFwKICAtLWln" + - "bm9yZS1taXNzaW5nCmBgYAoKUmVhZCBgY2x1c3RlcklEYCBhbmQgYG5jYUlEYCBmcm9tCmBkZXBs" + - "b3kvc3RhY2tzL252Y2YtY29tcHV0ZS1wbGFuZS9yZWdpc3RyYXRpb24vPGNsdXN0ZXItbmFtZT4t" + - "cmVnaXN0ZXItdmFsdWVzLnlhbWxgLgpTaG93IHRoZSBleGFjdCBjbHVzdGVyIElEIGFuZCBnZXQg" + - "Y29uZmlybWF0aW9uIGJlZm9yZSBgY2x1c3RlciBkZWxldGVgLiBSZXBlYXQKY29tcHV0ZSBkZXN0" + - "cm95IGFuZCBkZXJlZ2lzdHJhdGlvbiBmb3IgZXZlcnkgY29tcHV0ZSBjbHVzdGVyLCB0aGVuIGRl" + - "c3Ryb3kgdGhlCmNvbnRyb2wgcGxhbmUuCgpgYGBiYXNoCktVQkVDT05GSUc9PGNvbnRyb2wtcGxh" + - "bmUta3ViZWNvbmZpZz4gXAptYWtlIC1DIGRlcGxveS9zdGFja3Mvc2VsZi1tYW5hZ2VkIGRlc3Ry" + - "b3kgXAogIEhFTE1GSUxFX0VOVj08ZW52PiBcCiAgS1VCRUNPTkZJR19GSUxFPTxjb250cm9sLXBs" + - "YW5lLWt1YmVjb25maWc+CmBgYAoKQm90aCBgS1VCRUNPTkZJR2AgYW5kIGBLVUJFQ09ORklHX0ZJ" + - "TEVgIGFyZSByZXF1aXJlZCBvbiB0aGUgY29udHJvbC1wbGFuZQpkZXN0cm95IGJlY2F1c2UgaXRz" + - "IG5hbWVzcGFjZSBjbGVhbnVwIHVzZXMgYW1iaWVudCBga3ViZWN0bGAuCgpGb3IgdGhlIENMSSBw" + - "cm9maWxlIGZsb3csIGtlZXAKYGRlcGxveS9zdGFja3MvbnZjZi1jb21wdXRlLXBsYW5lL291dC88" + - "Y2x1c3Rlci1uYW1lPi1yZWdpc3Rlci12YWx1ZXMueWFtbGAKdW50aWwgdGVhcmRvd24gY29tcGxl" + - "dGVzIGJlY2F1c2UgYGRvd25gIHJlYWRzIGl0cyBjbHVzdGVyIElELiBGaXJzdCBxdWVyeSB0aGUK" + - "bGl2ZSByZWdpc3RyYXRpb24gc2V0IHdpdGggdGhlIHNhbWUgY29tcGxldGUgcGVyc2lzdGVudCBw" + - "cmVmaXg6CgpgYGBiYXNoCm52Y2YtY2xpIC0tY29uZmlnIDxjb25maWc+IHNlbGYtaG9zdGVkIFwK" + - "ICAtLWNvbnRyb2wtcGxhbmUtc3RhY2sgZGVwbG95L3N0YWNrcy9zZWxmLW1hbmFnZWQgXAogIC0t" + - "Y29tcHV0ZS1wbGFuZS1zdGFjayBkZXBsb3kvc3RhY2tzL252Y2YtY29tcHV0ZS1wbGFuZSBcCiAg" + - "LS1lbnYgPGVudj4gXAogIC0tcGxhaW4gXAogIC0tY29udHJvbC1wbGFuZS1jb250ZXh0IDxjb250" + - "cm9sLXBsYW5lLWNvbnRleHQ+IFwKICAtLWNvbXB1dGUtcGxhbmUtY29udGV4dCA8Y29tcHV0ZS1w" + - "bGFuZS1jb250ZXh0PiBcCiAgLS1pY21zLXVybCA8Y29tcHV0ZS1yZWFjaGFibGUtaWNtcy11cmw+" + - "IFwKICBzdGF0dXMgXAogIC0tY2x1c3Rlci1uYW1lIDxjb21wdXRlLXBsYW5lLWNsdXN0ZXItbmFt" + - "ZT4gXAogIC0tbmNhLWlkIDxuY2EtaWQ+CmBgYAoKU2hvdyB0aGUgcmVnaXN0ZXJlZCBjb21wdXRl" + - "LXBsYW5lIGxpc3QuIE5hbWVkLWNsdXN0ZXIgYGRvd24gLS1wbGFuLW9ubHlgIGRvZXMKbm90IHF1" + - "ZXJ5IElDTVMsIHNvIGl0IHByZXZpZXdzIG9ubHkgdGhhdCBjb21wdXRlIHBsYW5lIGFuZCBjYW5u" + - "b3Qgc2hvdyB0aGUKY29uZGl0aW9uYWwgY29udHJvbC1wbGFuZSByZW1vdmFsLiBJZiB0aGUgdGFy" + - "Z2V0IGlzIHRoZSBsYXN0IHJlZ2lzdGVyZWQKY2x1c3RlciwgcmVhbCBgZG93bmAgYWxzbyBkZXN0" + - "cm95cyB0aGUgY29udHJvbCBwbGFuZS4gR2V0IGV4cGxpY2l0IGNvbmZpcm1hdGlvbgpmb3IgYm90" + - "aCByZW1vdmFscyBiZWZvcmUgY29udGludWluZy4gVGhlbiBwcmV2aWV3IGFuZCBleGVjdXRlIHdp" + - "dGggdGhlIHNhbWUKcHJlZml4OgoKYGBgYmFzaApudmNmLWNsaSAtLWNvbmZpZyA8Y29uZmlnPiBz" + - "ZWxmLWhvc3RlZCBcCiAgLS1jb250cm9sLXBsYW5lLXN0YWNrIGRlcGxveS9zdGFja3Mvc2VsZi1t" + - "YW5hZ2VkIFwKICAtLWNvbXB1dGUtcGxhbmUtc3RhY2sgZGVwbG95L3N0YWNrcy9udmNmLWNvbXB1" + - "dGUtcGxhbmUgXAogIC0tZW52IDxlbnY+IFwKICAtLXBsYWluIFwKICAtLWNvbnRyb2wtcGxhbmUt" + - "Y29udGV4dCA8Y29udHJvbC1wbGFuZS1jb250ZXh0PiBcCiAgLS1jb21wdXRlLXBsYW5lLWNvbnRl" + - "eHQgPGNvbXB1dGUtcGxhbmUtY29udGV4dD4gXAogIC0taWNtcy11cmwgPGNvbXB1dGUtcmVhY2hh" + - "YmxlLWljbXMtdXJsPiBcCiAgZG93biAtLXBsYW4tb25seSBcCiAgLS1jbHVzdGVyLW5hbWUgPGNv" + - "bXB1dGUtcGxhbmUtY2x1c3Rlci1uYW1lPiBcCiAgLS1uY2EtaWQgPG5jYS1pZD4KCm52Y2YtY2xp" + - "IC0tY29uZmlnIDxjb25maWc+IHNlbGYtaG9zdGVkIFwKICAtLWNvbnRyb2wtcGxhbmUtc3RhY2sg" + - "ZGVwbG95L3N0YWNrcy9zZWxmLW1hbmFnZWQgXAogIC0tY29tcHV0ZS1wbGFuZS1zdGFjayBkZXBs" + - "b3kvc3RhY2tzL252Y2YtY29tcHV0ZS1wbGFuZSBcCiAgLS1lbnYgPGVudj4gXAogIC0tcGxhaW4g" + - "XAogIC0tY29udHJvbC1wbGFuZS1jb250ZXh0IDxjb250cm9sLXBsYW5lLWNvbnRleHQ+IFwKICAt" + - "LWNvbXB1dGUtcGxhbmUtY29udGV4dCA8Y29tcHV0ZS1wbGFuZS1jb250ZXh0PiBcCiAgLS1pY21z" + - "LXVybCA8Y29tcHV0ZS1yZWFjaGFibGUtaWNtcy11cmw+IFwKICBkb3duIFwKICAtLWNsdXN0ZXIt" + - "bmFtZSA8Y29tcHV0ZS1wbGFuZS1jbHVzdGVyLW5hbWU+IFwKICAtLW5jYS1pZCA8bmNhLWlkPgpg" + - "YGAKClJlcGVhdCBgZG93bmAgZm9yIGVhY2ggY29tcHV0ZSBjbHVzdGVyLiBFYWNoIHJ1biBkZXN0" + - "cm95cyB0aGF0IGNvbXB1dGUgcGxhbmUgYW5kCnVucmVnaXN0ZXJzIGl0cyBJQ01TIHJvdy4gSXQg" + - "cmVtb3ZlcyB0aGUgY29udHJvbCBwbGFuZSBvbmx5IGFmdGVyIG5vIGNsdXN0ZXIKcmVnaXN0cmF0" + - "aW9ucyByZW1haW4uClBLBwiWY7xZEjAAABIwAABQSwMEFAAIAAAAAAAhAAAAAAAAAAAAAAAAADsA" + - "CQBkYXRhL252Y2Ytc2VsZi1tYW5hZ2VkLWluc3RhbGxhdGlvbi9yZWZlcmVuY2VzL2RlYnVnZ2lu" + - "Zy5tZFVUBQABAKbOEiMgRGVidWdnaW5nIFJlZmVyZW5jZQoKUmVjaXBlcyBmb3IgZGlhZ25vc2lu" + - "ZyBhbmQgZml4aW5nIGNvbW1vbiBOVkNGIHNlbGYtbWFuYWdlZCBzdGFjayBmYWlsdXJlcy4KCiMj" + - "IFF1aWNrIFN0YXR1cyBDb21tYW5kcwoKYGBgYmFzaAojIEFsbCBwb2RzIGFjcm9zcyBhbGwgbmFt" + - "ZXNwYWNlcwprdWJlY3RsIGdldCBwb2RzIC1BIC1vIHdpZGUKCiMgUG9kcyBpbiBhIHNwZWNpZmlj" + - "IG5hbWVzcGFjZQprdWJlY3RsIGdldCBwb2RzIC1uIDxuYW1lc3BhY2U+CgojIEFsbCBoZWxtIHJl" + - "bGVhc2VzCmhlbG0gbGlzdCAtQQoKIyBSZWNlbnQgZXZlbnRzIChtb3N0IHVzZWZ1bCBmb3IgZGlh" + - "Z25vc2luZyBmYWlsdXJlcykKa3ViZWN0bCBnZXQgZXZlbnRzIC1uIDxuYW1lc3BhY2U+IC0tc29y" + - "dC1ieT0nLmxhc3RUaW1lc3RhbXAnCgojIERlc2NyaWJlIGEgc3BlY2lmaWMgcG9kIChzaG93cyBl" + - "dmVudHMsIGNvbmRpdGlvbnMsIHZvbHVtZXMpCmt1YmVjdGwgZGVzY3JpYmUgcG9kIC1uIDxuYW1l" + - "c3BhY2U+IDxwb2QtbmFtZT4KYGBgCgojIyBGYWlsdXJlOiBJbWFnZVB1bGxCYWNrT2ZmCgojIyMg" + - "U3ltcHRvbXMKCmBgYHRleHQKTkFNRSAgICAgICAgICAgUkVBRFkgICBTVEFUVVMgICAgICAgICAg" + - "ICAgUkVTVEFSVFMgICBBR0UKY2Fzc2FuZHJhLTAgICAgMC8xICAgICBJbWFnZVB1bGxCYWNrT2Zm" + - "ICAgMCAgICAgICAgICA1bQpgYGAKCkV2ZW50cyBzaG93OgoKYGBgdGV4dApGYWlsZWQgdG8gcHVs" + - "bCBpbWFnZSAibnZjci5pby8uLi4vaW1hZ2U6dGFnIjogNDAxIFVuYXV0aG9yaXplZApgYGAKCiMj" + - "IyBEaWFnbm9zaXMKCmBgYGJhc2gKIyBDaGVjayB3aGF0IGltYWdlIGlzIGZhaWxpbmcKa3ViZWN0" + - "bCBkZXNjcmliZSBwb2QgLW4gPG5hbWVzcGFjZT4gPHBvZC1uYW1lPiB8IGdyZXAgLUE1ICJFdmVu" + - "dHM6IgoKIyBDaGVjayBpZiBwdWxsIHNlY3JldCBleGlzdHMgaW4gdGhlIG5hbWVzcGFjZQprdWJl" + - "Y3RsIGdldCBzZWNyZXQgbnZjci1jcmVkcyAtbiA8bmFtZXNwYWNlPgoKIyBDaGVjayBpZiB0aGUg" + - "cG9kIHNwZWMgaGFzIGltYWdlUHVsbFNlY3JldHMKa3ViZWN0bCBnZXQgcG9kIC1uIDxuYW1lc3Bh" + - "Y2U+IDxwb2QtbmFtZT4gLW8ganNvbnBhdGg9J3suc3BlYy5pbWFnZVB1bGxTZWNyZXRzfScKCiMg" + - "Q2hlY2sgaWYgdGhlIFNlcnZpY2VBY2NvdW50IGhhcyBpbWFnZVB1bGxTZWNyZXRzCmt1YmVjdGwg" + - "Z2V0IHNhIDxzYS1uYW1lPiAtbiA8bmFtZXNwYWNlPiAtbyBqc29ucGF0aD0ney5pbWFnZVB1bGxT" + - "ZWNyZXRzfScKYGBgCgojIyMgRml4ZXMKCjEuICoqU2VjcmV0IG1pc3NpbmcgaW4gbmFtZXNwYWNl" + - "Kio6IENyZWF0ZSBpdAoKICAgYGBgYmFzaAogICBrdWJlY3RsIGNyZWF0ZSBzZWNyZXQgZG9ja2Vy" + - "LXJlZ2lzdHJ5IG52Y3ItY3JlZHMgXAogICAgIC0tZG9ja2VyLXNlcnZlcj1udmNyLmlvIFwKICAg" + - "ICAtLWRvY2tlci11c2VybmFtZT0nJG9hdXRodG9rZW4nIFwKICAgICAtLWRvY2tlci1wYXNzd29y" + - "ZD0iJE5HQ19BUElfS0VZIiBcCiAgICAgLS1uYW1lc3BhY2U9PG5hbWVzcGFjZT4gXAogICAgIC0t" + - "ZHJ5LXJ1bj1jbGllbnQgLW8geWFtbCB8IGt1YmVjdGwgYXBwbHkgLWYgLQogICBgYGAKCjIuICoq" + - "U2VjcmV0IGV4aXN0cyBidXQgcG9kIGRvZXNuJ3QgcmVmZXJlbmNlIGl0Kio6IFRoZSBjaGFydCBu" + - "ZWVkcyBgaW1hZ2VQdWxsU2VjcmV0c2AgY29uZmlndXJlZCB2aWEgaGVsbWZpbGUgdmFsdWVzLiBT" + - "ZWUgW3B1bGwtc2VjcmV0cy5tZF0ocHVsbC1zZWNyZXRzLm1kKS4KCjMuICoqQ2hhcnQgZG9lc24n" + - "dCBzdXBwb3J0IGltYWdlUHVsbFNlY3JldHMqKiAob3BlbmJhbywgaW52b2NhdGlvbi1zZXJ2aWNl" + - "LCBlc3MtYXBpLCBub3Rhcnktc2VydmljZSk6IFBhdGNoIFNlcnZpY2VBY2NvdW50cyBhbmQgcmVz" + - "dGFydCBwb2RzLiBTZWUgW3B1bGwtc2VjcmV0cy5tZF0ocHVsbC1zZWNyZXRzLm1kKS4KCjQuICoq" + - "V3JvbmcgY3JlZGVudGlhbHMgaW4gc2VjcmV0Kio6IERlbGV0ZSBhbmQgcmVjcmVhdGUKCiAgIGBg" + - "YGJhc2gKICAga3ViZWN0bCBkZWxldGUgc2VjcmV0IG52Y3ItY3JlZHMgLW4gPG5hbWVzcGFjZT4K" + - "ICAgIyBSZWNyZWF0ZSB3aXRoIGNvcnJlY3QgY3JlZGVudGlhbHMKICAgYGBgCgojIyBGYWlsdXJl" + - "OiBJbml0OjAvMSBTdHVjayAoVmF1bHQgQWdlbnQpCgojIyMgU3ltcHRvbXMKClNlcnZpY2UgcG9k" + - "cyBzdHVjayBpbiBgSW5pdDowLzFgIGZvciBtaW51dGVzOgoKYGBgYmFzaApOQU1FICAgICAgICAg" + - "ICAgICAgICAgICAgICAgUkVBRFkgICBTVEFUVVMgICAgIFJFU1RBUlRTICAgQUdFCm52Y2YtYXBp" + - "LTdmNGM3NmY3ODgtNDR2bHQgIDAvMiAgICAgSW5pdDowLzEgICAwICAgICAgICAgIDEwbQpgYGAK" + - "CiMjIyBEaWFnbm9zaXMKClRoZSBpbml0IGNvbnRhaW5lciBpcyB0aGUgdmF1bHQtYWdlbnQtaW5p" + - "dCBpbmplY3RvciB3YWl0aW5nIGZvciBPcGVuQmFvLgoKYGBgYmFzaAojIENoZWNrIE9wZW5CYW8g" + - "cG9kcwprdWJlY3RsIGdldCBwb2RzIC1uIHZhdWx0LXN5c3RlbQoKIyBDaGVjayBpZiBPcGVuQmFv" + - "IG1pZ3JhdGlvbiBqb2IgcmFuCmt1YmVjdGwgZ2V0IGpvYnMgLW4gdmF1bHQtc3lzdGVtCgojIENo" + - "ZWNrIE9wZW5CYW8gcG9kIGxvZ3MKa3ViZWN0bCBsb2dzIC1uIHZhdWx0LXN5c3RlbSBvcGVuYmFv" + - "LXNlcnZlci0wIC1jIG9wZW5iYW8KCiMgQ2hlY2sgaW5pdCBjb250YWluZXIgbG9ncyBvbiB0aGUg" + - "c3R1Y2sgcG9kCmt1YmVjdGwgbG9ncyAtbiA8bmFtZXNwYWNlPiA8cG9kLW5hbWU+IC1jIHZhdWx0" + - "LWFnZW50LWluaXQKYGBgCgojIyMgRml4ZXMKCjEuICoqT3BlbkJhbyBwb2RzIG5vdCBydW5uaW5n" + - "Kio6IENoZWNrIHRoZWlyIGV2ZW50cyBmb3IgaW1hZ2UgcHVsbCBpc3N1ZXMsIHJlc291cmNlIGlz" + - "c3VlcywgZXRjLgoKMi4gKipPcGVuQmFvIG1pZ3JhdGlvbiBqb2IgZGlkbid0IHJ1bioqOiBUaGlz" + - "IGhhcHBlbnMgd2hlbiBgaGVsbWZpbGUgc3luY2Agd2FzIGludGVycnVwdGVkLiBEZXN0cm95IGFu" + - "ZCByZS1zeW5jIG9wZW5iYW86CgogICBgYGBiYXNoCiAgIEhFTE1GSUxFX0VOVj08ZW52PiBoZWxt" + - "ZmlsZSAtLXNlbGVjdG9yIG5hbWU9b3BlbmJhby1zZXJ2ZXIgZGVzdHJveQogICBrdWJlY3RsIGRl" + - "bGV0ZSBuYW1lc3BhY2UgdmF1bHQtc3lzdGVtCiAgIGt1YmVjdGwgY3JlYXRlIG5hbWVzcGFjZSB2" + - "YXVsdC1zeXN0ZW0KICAgIyBSZS1jcmVhdGUgcHVsbCBzZWNyZXQgaWYgbmVlZGVkCiAgIEhFTE1G" + - "SUxFX0VOVj08ZW52PiBoZWxtZmlsZSAtLXNlbGVjdG9yIG5hbWU9b3BlbmJhby1zZXJ2ZXIgc3lu" + - "YwogICBgYGAKCjMuICoqT3BlbkJhbyBwb2RzIHJ1bm5pbmcgYnV0IG5vdCBpbml0aWFsaXplZCoq" + - "OiBDaGVjayB1bnNlYWwgc3RhdHVzOgoKICAgYGBgYmFzaAogICBrdWJlY3RsIGV4ZWMgLW4gdmF1" + - "bHQtc3lzdGVtIG9wZW5iYW8tc2VydmVyLTAgLWMgb3BlbmJhbyAtLSBiYW8gc3RhdHVzCiAgIGBg" + - "YAoKNC4gKipgdmF1bHQtYWdlbnQtaW5pdGAgbG9ncyBgYXV0aC9qd3QvbG9naW5gIC0+IGA0MDAg" + - "Li4uIG5vIGtub3duIGtleSBzdWNjZXNzZnVsbHkgdmFsaWRhdGVkIHRoZSB0b2tlbiBzaWduYXR1" + - "cmVgKioKICAgKGNvbW1vbiBvbiBBS1MsIGFuZCBvbiBhbnkgY2x1c3RlciB3aG9zZSBPSURDIGlz" + - "c3VlciBwdWJsaXNoZXMgYSByb3RhdGluZyBtdWx0aS1rZXkgSldLUyk6CiAgIHRoZSBPcGVuQmFv" + - "IGBhdXRoL2p3dGAgbWV0aG9kIHdhcyBjb25maWd1cmVkIHdpdGggYSBzaW5nbGUgc3RhdGljIG1v" + - "dW50ZWQgcHVibGljIGtleQogICAoYGp3dF92YWxpZGF0aW9uX3B1YmtleXNgKSBpbnN0ZWFkIG9m" + - "IHRoZSBpc3N1ZXIncyBsaXZlIEpXS1MsIHNvIGl0IGNhbm5vdCB2YWxpZGF0ZSB0aGUKICAgY2x1" + - "c3Rlci1zaWduZWQgU2VydmljZUFjY291bnQgdG9rZW5zIHRoZSB2YXVsdC1hZ2VudHMgcHJlc2Vu" + - "dC4gRXZlcnkgc2VydmljZSdzCiAgIGB2YXVsdC1hZ2VudC1pbml0YCB0aGVuIGxvb3BzIG9uIHRo" + - "YXQgNDAwIGFuZCB0aGUgcG9kIG5ldmVyIGxlYXZlcyBgSW5pdDowLzFgLiBUaGlzIGlzIHRoZQog" + - "ICBiZWhhdmlvciBvZiB0aGUgYmFzZSBkZWZhdWx0IGBvcGVuYmFvLm1pZ3JhdGlvbnMuaXNzdWVy" + - "RGlzY292ZXJ5LmVuYWJsZWQ6IGZhbHNlYDsgdGhlCiAgIG1pZ3JhdGlvbiBsb2cgc2hvd3MgYE9J" + - "REMgaXNzdWVyIGRpc2NvdmVyeSBpcyBkaXNhYmxlZC4gVXNpbmcgU2VydmljZUFjY291bnQgaXNz" + - "dWVyIGFuZAogICBtb3VudGVkIHB1YmxpYyBrZXkgZmFsbGJhY2tgLiBFbmFibGUgaXNzdWVyIGRp" + - "c2NvdmVyeSBzbyB0aGUgbWlncmF0aW9uIHVzZXMgdGhlIGxpdmUgSldLUywKICAgdGhlbiByZS1y" + - "dW4gaXQ6CgogICBgYGBiYXNoCiAgICMgSW4geW91ciBlbnYgdmFsdWVzIChlLmcuIGVudmlyb25t" + - "ZW50cy88ZW52Pi55YW1sKSwgc2V0OgogICAjICAgb3BlbmJhbzoKICAgIyAgICAgbWlncmF0aW9u" + - "czoKICAgIyAgICAgICBpc3N1ZXJEaXNjb3Zlcnk6CiAgICMgICAgICAgICBlbmFibGVkOiB0cnVl" + - "CiAgIGt1YmVjdGwgZGVsZXRlIGpvYiAtbiB2YXVsdC1zeXN0ZW0gb3BlbmJhby1zZXJ2ZXItbWln" + - "cmF0aW9ucwogICBIRUxNRklMRV9FTlY9PGVudj4gaGVsbWZpbGUgLS1zZWxlY3RvciBuYW1lPW9w" + - "ZW5iYW8tc2VydmVyIHN5bmMKICAgYGBgCgogICBUaGUgbWlncmF0aW9uIGxvZyBzaG91bGQgdGhl" + - "biBzaG93IGBSZWNlaXZlZCBkaXNjb3ZlcnkgZG9jdW1lbnQgZnJvbQogICAuLi4vLndlbGwta25v" + - "d24vb3BlbmlkLWNvbmZpZ3VyYXRpb25gIGFuZCBgRmluYWwgT3BlbkJhbyBKV1QgSldLUyBVUkwg" + - "c2V0IHRvOgogICAuLi4vb3BlbmlkL3YxL2p3a3NgLCBhbmQgdGhlIHNlcnZpY2UgcG9kcyBhdXRo" + - "ZW50aWNhdGUgYW5kIHJlYWNoIFJlYWR5IHdpdGhpbiB+MSBtaW4uCiAgIEFLUyBjbHVzdGVycyB3" + - "aXRoIHRoZSBjbHVzdGVyIE9JREMgaXNzdWVyIGVuYWJsZWQgKHRoZSBgYWtzLWNsdXN0ZXJgIGRl" + - "ZmF1bHQpIGFsd2F5cyBuZWVkCiAgIHRoaXMgYmVjYXVzZSB0aGUgc3RhdGljLXB1YmtleSBmYWxs" + - "YmFjayBjYW5ub3QgbWF0Y2ggdGhlIGlzc3VlcidzIHJvdGF0aW5nIHNpZ25pbmcga2V5cy4KCiMj" + - "IEZhaWx1cmU6IE9PTUtpbGxlZCBvbiBDYXNzYW5kcmEKCiMjIyBTeW1wdG9tcwoKYGBgdGV4dApO" + - "QU1FICAgICAgICAgIFJFQURZICAgU1RBVFVTICAgIFJFU1RBUlRTICAgQUdFCmNhc3NhbmRyYS0w" + - "ICAgMC8xICAgICBPT01LaWxsZWQgIDMgICAgICAgICAxMG0KYGBgCgojIyMgRGlhZ25vc2lzCgpg" + - "YGBiYXNoCmt1YmVjdGwgZGVzY3JpYmUgcG9kIC1uIGNhc3NhbmRyYS1zeXN0ZW0gY2Fzc2FuZHJh" + - "LTAgfCBncmVwIC1BMyAiTGFzdCBTdGF0ZSIKYGBgCgojIyMgRml4CgpPdmVycmlkZSBDYXNzYW5k" + - "cmEgcmVzb3VyY2VzIHZpYSBoZWxtZmlsZSB2YWx1ZXMuIFNlZSBFeGFtcGxlIDEgaW4KW2V4YW1w" + - "bGVzLm1kXSguLi9leGFtcGxlcy5tZCkuCgpgYGB5YW1sCi0gY2Fzc2FuZHJhOgogICAgcmVzb3Vy" + - "Y2VzOgogICAgICBsaW1pdHM6CiAgICAgICAgY3B1OiAiOCIKICAgICAgICBtZW1vcnk6IDgxOTJN" + - "aQogICAgICByZXF1ZXN0czoKICAgICAgICBjcHU6ICIyIgogICAgICAgIG1lbW9yeTogNDA5Nk1p" + - "CmBgYAoKTm90ZTogYHJlc291cmNlUHJlc2V0YCBpcyBub3QgYXZhaWxhYmxlIGluIHRoZSBOVkNG" + - "IGNhc3NhbmRyYSB3cmFwcGVyIGNoYXJ0LiBVc2UgZXhwbGljaXQgYHJlc291cmNlc2AuCgojIyBG" + - "YWlsdXJlOiBQb2RzIFN0dWNrIGluIFBlbmRpbmcKCiMjIyBTeW1wdG9tcwoKYGBgdGV4dApOQU1F" + - "ICAgICAgICAgIFJFQURZICAgU1RBVFVTICAgIFJFU1RBUlRTICAgQUdFCmNhc3NhbmRyYS0wICAg" + - "MC8xICAgICBQZW5kaW5nICAgMCAgICAgICAgICAxMG0KYGBgCgojIyMgRGlhZ25vc2lzCgpgYGBi" + - "YXNoCiMgQ2hlY2sgZXZlbnRzIGZvciBzY2hlZHVsaW5nIGZhaWx1cmVzCmt1YmVjdGwgZGVzY3Jp" + - "YmUgcG9kIC1uIDxuYW1lc3BhY2U+IDxwb2QtbmFtZT4KCiMgQ29tbW9uIGNhdXNlczoKIyAtICIw" + - "LzEyIG5vZGVzIGFyZSBhdmFpbGFibGU6IDEyIG5vZGUocykgZGlkbid0IG1hdGNoIFBvZCdzIG5v" + - "ZGUgYWZmaW5pdHkiCiMgLSAiMC8xMiBub2RlcyBhcmUgYXZhaWxhYmxlOiAxMiBJbnN1ZmZpY2ll" + - "bnQgbWVtb3J5IgojIC0gInBlcnNpc3RlbnR2b2x1bWVjbGFpbSBub3QgZm91bmQiCmBgYAoKIyMj" + - "IEZpeGVzCgoxLiAqKk5vZGUgc2VsZWN0b3IgbWlzbWF0Y2gqKjogQ2hlY2sgYG5vZGVTZWxlY3Rv" + - "cnNgIGluIGVudmlyb25tZW50IGZpbGUKCiAgIGBgYGJhc2gKICAga3ViZWN0bCBnZXQgbm9kZXMg" + - "LS1zaG93LWxhYmVscyB8IGdyZXAgbnZjZgogICBgYGAKCjIuICoqU3RvcmFnZSBjbGFzcyBub3Qg" + - "Zm91bmQqKjogQ2hlY2sgc3RvcmFnZSBjbGFzcyBleGlzdHMKCiAgIGBgYGJhc2gKICAga3ViZWN0" + - "bCBnZXQgc3RvcmFnZWNsYXNzCiAgIGBgYAoKMy4gKipJbnN1ZmZpY2llbnQgcmVzb3VyY2VzKio6" + - "IENoZWNrIG5vZGUgY2FwYWNpdHkKCiAgIGBgYGJhc2gKICAga3ViZWN0bCBkZXNjcmliZSBub2Rl" + - "IDxub2RlLW5hbWU+IHwgZ3JlcCAtQTUgIkFsbG9jYXRlZCByZXNvdXJjZXMiCiAgIGBgYAoKIyMg" + - "RmFpbHVyZTogSGVsbSBSZWxlYXNlIGluIEZhaWxlZCBTdGF0ZQoKIyMjIFN5bXB0b21zCgpgYGBi" + - "YXNoCmhlbG0gbGlzdCAtQQojIFNob3dzIHJlbGVhc2Ugd2l0aCBTVEFUVVM6IGZhaWxlZApgYGAK" + - "ClJlLXJ1bm5pbmcgYGhlbG1maWxlIHN5bmNgIGFwcGVhcnMgdG8gc3VjY2VlZCBidXQgc2Vydmlj" + - "ZXMgZG9uJ3Qgd29yayAobWlncmF0aW9ucyBza2lwcGVkKS4KCiMjIyBEaWFnbm9zaXMKCmBgYGJh" + - "c2gKaGVsbSBoaXN0b3J5IDxyZWxlYXNlLW5hbWU+IC1uIDxuYW1lc3BhY2U+CmBgYAoKIyMjIEZp" + - "eAoKTXVzdCBkZXN0cm95IHRoZSBmYWlsZWQgcmVsZWFzZSBhbmQgcmUtc3luYyAobm90IGp1c3Qg" + - "YXBwbHkpOgoKYGBgYmFzaApIRUxNRklMRV9FTlY9PGVudj4gaGVsbWZpbGUgLS1zZWxlY3RvciBu" + - "YW1lPTxyZWxlYXNlPiBkZXN0cm95CiMgSWYgbmFtZXNwYWNlIG5lZWRzIGNsZWFudXA6Cmt1YmVj" + - "dGwgZGVsZXRlIG5hbWVzcGFjZSA8bmFtZXNwYWNlPgprdWJlY3RsIGNyZWF0ZSBuYW1lc3BhY2Ug" + - "PG5hbWVzcGFjZT4KIyBSZS1jcmVhdGUgcHVsbCBzZWNyZXQgaWYgbmVlZGVkCkhFTE1GSUxFX0VO" + - "Vj08ZW52PiBoZWxtZmlsZSAtLXNlbGVjdG9yIG5hbWU9PHJlbGVhc2U+IHN5bmMKYGBgCgojIyBG" + - "YWlsdXJlOiBBY2NvdW50IEJvb3RzdHJhcCBKb2IgRmFpbGVkCgojIyMgU3ltcHRvbXMKClNlcnZp" + - "Y2VzIGRlcGxveSBidXQgZnVuY3Rpb25zIGNhbid0IGJlIGNyZWF0ZWQuIEV2ZW50cyBzaG93IGJv" + - "b3RzdHJhcCBqb2IgZmFpbHVyZS4KCiMjIyBEaWFnbm9zaXMKCmBgYGJhc2gKIyBDaGVjayBib290" + - "c3RyYXAgam9iIHN0YXR1cwprdWJlY3RsIGdldCBqb2JzIC1uIG52Y2YKCiMgR2V0IGxvZ3MgKGpv" + - "YiBhdXRvLWRlbGV0ZXMgYWZ0ZXIgfjUgbWludXRlcykKa3ViZWN0bCBsb2dzIGpvYi9udmNmLWFw" + - "aS1hY2NvdW50LWJvb3RzdHJhcCAtbiBudmNmCgojIENoZWNrIEFQSSBsb2dzCmt1YmVjdGwgbG9n" + - "cyAtbiBudmNmIC1sIGFwcC5rdWJlcm5ldGVzLmlvL25hbWU9bnZjZi1hcGkgLS10YWlsPTEwMApg" + - "YGAKCiMjIyBDb21tb24gQ2F1c2VzCgoxLiAqKldyb25nIGJhc2U2NCBjcmVkZW50aWFscyoqOiBD" + - "cmVkZW50aWFscyBpbiBgc2VjcmV0cy88ZW52Pi1zZWNyZXRzLnlhbWxgIG11c3QgYmUgYCRvYXV0" + - "aHRva2VuOkFQSV9LRVlgIGJhc2U2NC1lbmNvZGVkLCBub3QganVzdCB0aGUgQVBJIGtleQoKICAg" + - "YGBgYmFzaAogICAjIFZlcmlmeSB5b3VyIGVuY29kZWQgY3JlZGVudGlhbAogICBlY2hvICdZT1VS" + - "X0JBU0U2NF9TVFJJTkcnIHwgYmFzZTY0IC1kCiAgICMgU2hvdWxkIG91dHB1dDogJG9hdXRodG9r" + - "ZW46bnZhcGkteHh4eHgKICAgYGBgCgoyLiAqKlJlZ2lzdHJ5IHVucmVhY2hhYmxlKio6IEFQSSBj" + - "YW4ndCByZWFjaCB0aGUgcmVnaXN0cnkgc3BlY2lmaWVkIGluIGBhY2NvdW50Qm9vdHN0cmFwLnJl" + - "Z2lzdHJ5Q3JlZGVudGlhbHNgCgojIyMgRml4CgpGaXggY3JlZGVudGlhbHMgaW4gc2VjcmV0cyBm" + - "aWxlLCB0aGVuIHJlY292ZXIgc2VydmljZXMgd2l0aG91dCBkZXN0cm95aW5nIGRlcGVuZGVuY2ll" + - "czoKCmBgYGJhc2gKSEVMTUZJTEVfRU5WPTxlbnY+IGhlbG1maWxlIC0tc2VsZWN0b3IgcmVsZWFz" + - "ZS1ncm91cD1zZXJ2aWNlcyBkZXN0cm95Cmt1YmVjdGwgZGVsZXRlIG5hbWVzcGFjZSBudmNmIGFw" + - "aS1rZXlzIGVzcyBzaXMgLS1pZ25vcmUtbm90LWZvdW5kCmt1YmVjdGwgY3JlYXRlIG5hbWVzcGFj" + - "ZSBudmNmICYmIGt1YmVjdGwgY3JlYXRlIG5hbWVzcGFjZSBhcGkta2V5cyAmJiBcCiAga3ViZWN0" + - "bCBjcmVhdGUgbmFtZXNwYWNlIGVzcyAmJiBrdWJlY3RsIGNyZWF0ZSBuYW1lc3BhY2Ugc2lzCiMg" + - "UmUtY3JlYXRlIHB1bGwgc2VjcmV0cyBpZiBuZWVkZWQKSEVMTUZJTEVfRU5WPTxlbnY+IGhlbG1m" + - "aWxlIC0tc2VsZWN0b3IgcmVsZWFzZS1ncm91cD1zZXJ2aWNlcyBzeW5jCmBgYAoKIyMgRmFpbHVy" + - "ZTogTlZDQSBDbGVhbnVwIFN0dWNrIChGaW5hbGl6ZXJzKQoKIyMjIFN5bXB0b21zCgpDb21wdXRl" + - "LXBsYW5lIGNsZWFudXAgaGFuZ3MuIE5hbWVzcGFjZXMKc3R1Y2sgaW4gYFRlcm1pbmF0aW5nYDoK" + - "CmBgYGJhc2gKa3ViZWN0bCBnZXQgbnMKIyBudmNhLW9wZXJhdG9yICAgIFRlcm1pbmF0aW5nICAg" + - "MTBtCiMgbnZjZi1iYWNrZW5kICAgICBUZXJtaW5hdGluZyAgIDEwbQpgYGAKCiMjIyBGaXgKClJ1" + - "biB0aGlzIG9ubHkgYWdhaW5zdCB0aGUgY29tcHV0ZSBjb250ZXh0LCBhZnRlciB0aGUgbm9ybWFs" + - "IGNvbXB1dGUtcGxhbmUKdGVhcmRvd24gaGFzIGZhaWxlZC4gUmVtb3ZlIGZpbmFsaXplcnMgZnJv" + - "bSBOVkNGQmFja2VuZCBjdXN0b20gcmVzb3VyY2VzIGFuZApmb3JjZS1kZWxldGUgc3R1Y2sgbmFt" + - "ZXNwYWNlczoKCmBgYGJhc2gKZXhwb3J0IENPTVBVVEVfQ09OVEVYVD08Y29tcHV0ZS1wbGFuZS1j" + - "b250ZXh0PgoKIyBSZW1vdmUgZmluYWxpemVycyBmcm9tIE5WQ0ZCYWNrZW5kIHJlc291cmNlcwpr" + - "dWJlY3RsIC0tY29udGV4dCAiJENPTVBVVEVfQ09OVEVYVCIgZ2V0IG52Y2ZiYWNrZW5kcyAtQSAt" + - "byBqc29uIHwgXAogIGpxICcuaXRlbXNbXSB8IC5tZXRhZGF0YS5uYW1lc3BhY2UgKyAiLyIgKyAu" + - "bWV0YWRhdGEubmFtZScgLXIgfCBcCiAgeGFyZ3MgLUl7fSBzaCAtYyAnY29udGV4dD0iJDEiOyBu" + - "cz0iJHsyJSUvKn0iOyBuYW1lPSIkezIjIyovfSI7IGt1YmVjdGwgLS1jb250ZXh0ICIkY29udGV4" + - "dCIgcGF0Y2ggbnZjZmJhY2tlbmQgIiRuYW1lIiAtbiAiJG5zIiAtLXR5cGU9bWVyZ2UgLXAgIntc" + - "Im1ldGFkYXRhXCI6e1wiZmluYWxpemVyc1wiOltdfX0iJyBfICIkQ09NUFVURV9DT05URVhUIiB7" + - "fQoKIyBGb3JjZS1kZWxldGUgc3R1Y2sgbmFtZXNwYWNlcyBieSBjbGVhcmluZyB0aGVpciBmaW5h" + - "bGl6ZXJzCmZvciBucyBpbiBudmNhLW9wZXJhdG9yIG52Y2YtYmFja2VuZDsgZG8KICBrdWJlY3Rs" + - "IC0tY29udGV4dCAiJENPTVBVVEVfQ09OVEVYVCIgZ2V0IG5hbWVzcGFjZSAiJG5zIiAtbyBqc29u" + - "IDI+L2Rldi9udWxsIHwgXAogICAganEgJy5zcGVjLmZpbmFsaXplcnMgPSBbXScgfCBcCiAgICBr" + - "dWJlY3RsIC0tY29udGV4dCAiJENPTVBVVEVfQ09OVEVYVCIgcmVwbGFjZSBcCiAgICAgIC0tcmF3" + - "ICIvYXBpL3YxL25hbWVzcGFjZXMvJG5zL2ZpbmFsaXplIiAtZiAtIDI+L2Rldi9udWxsCmRvbmUK" + - "YGBgCgojIyBGYWlsdXJlOiBOVkNBIE9wZXJhdG9yIE1pc3Npbmcgb3IgTm90IFJlYWR5CgojIyMg" + - "U3ltcHRvbXMKCkFmdGVyIGNvbXB1dGUtcGxhbmUgaW5zdGFsbCBjb21wbGV0ZXMsIG5vIGBudmNh" + - "LW9wZXJhdG9yYCBEZXBsb3ltZW50IGV4aXN0cywgb3IKaXQgZXhpc3RzIGJ1dCBwb2RzIGFyZSBu" + - "b3QgcmVhZHk6CgpgYGBiYXNoCmt1YmVjdGwgLS1jb250ZXh0IDxjb21wdXRlLXBsYW5lLWNvbnRl" + - "eHQ+IGdldCBkZXBsb3ltZW50IG52Y2Etb3BlcmF0b3IgXAogIC1uIG52Y2Etb3BlcmF0b3IKa3Vi" + - "ZWN0bCAtLWNvbnRleHQgPGNvbXB1dGUtcGxhbmUtY29udGV4dD4gZ2V0IHBvZHMgLW4gbnZjYS1v" + - "cGVyYXRvcgpgYGAKCiMjIyBEaWFnbm9zaXMgYW5kIEZpeGVzCgpGaXJzdCBpZGVudGlmeSB3aGV0" + - "aGVyIHRoZSBpbnN0YWxsYXRpb24gdXNlZCB0aGUgSGVsbWZpbGUgdmFsdWVzIGZsb3cgb3IgdGhl" + - "CkNMSSBwcm9maWxlIGZsb3cuIFJlLXJ1biBvbmx5IHRoYXQgZmxvdydzIHJlZ2lzdHJhdGlvbiBh" + - "bmQgaW5zdGFsbGF0aW9uIHN0ZXBzCmZyb20gW1NwbGl0IENvbXB1dGUtUGxhbmUgSW5zdGFsbGF0" + - "aW9uXShjb21wdXRlLXBsYW5lLWluc3RhbGxhdGlvbi5tZCkuIERvIG5vdApzdWJzdGl0dXRlIG9u" + - "ZSBoYW5kb2ZmIGZvciB0aGUgb3RoZXIuCgpJZiBpbnN0YWxsYXRpb24gc3VjY2VlZHMgYnV0IHBv" + - "ZHMgc3RpbGwgZmFpbCwgaW5zcGVjdCB0aGUgY29tcHV0ZSBjb250ZXh0OgoKYGBgYmFzaAprdWJl" + - "Y3RsIC0tY29udGV4dCA8Y29tcHV0ZS1wbGFuZS1jb250ZXh0PiBkZXNjcmliZSBkZXBsb3kgbnZj" + - "YS1vcGVyYXRvciBcCiAgLW4gbnZjYS1vcGVyYXRvcgprdWJlY3RsIC0tY29udGV4dCA8Y29tcHV0" + - "ZS1wbGFuZS1jb250ZXh0PiBsb2dzIGRlcGxveW1lbnQvbnZjYS1vcGVyYXRvciBcCiAgLW4gbnZj" + - "YS1vcGVyYXRvciAtLXRhaWw9MjAwCmt1YmVjdGwgLS1jb250ZXh0IDxjb21wdXRlLXBsYW5lLWNv" + - "bnRleHQ+IGdldCBudmNmYmFja2VuZCAtbiBudmNhLW9wZXJhdG9yCmBgYAoKRm9yIHByaXZhdGUg" + - "cmVnaXN0cnkgZmFpbHVyZXMsIHZlcmlmeSB0aGF0IHRoZSBwdWxsIHNlY3JldCBleGlzdHMgaW4K" + - "YG52Y2Etb3BlcmF0b3JgLiBUaGUgY2hhcnQgYW5kIG9wZXJhdG9yIG1pcnJvciBpdCB0byBgbnZj" + - "YS1zeXN0ZW1gOyBkbyBub3QgdXNlIGEKYnJvYWRlciBtYW51YWwgbmFtZXNwYWNlIGxvb3AuIFNl" + - "ZQpbcHVsbC1zZWNyZXRzLm1kXShwdWxsLXNlY3JldHMubWQjY29tcHV0ZS1wbGFuZS1zZWNyZXQp" + - "LgoKIyMgRmFpbHVyZTogR2F0ZXdheSBFbmRwb2ludCBDaGFuZ2VkCgojIyMgU3ltcHRvbXMKClJl" + - "cXVlc3RzIHRocm91Z2ggdGhlIGNvbmZpZ3VyZWQgcm91dGUgZG9tYWluIGZhaWwgYWZ0ZXIgdGhl" + - "IEdhdGV3YXkgb3IgaXRzIGxvYWQgYmFsYW5jZXIgaXMgcmVwbGFjZWQuIFRoZSBHYXRld2F5IHJl" + - "cG9ydHMgYSBuZXcgZW5kcG9pbnQuCgojIyMgRGlhZ25vc2lzCgpgYGBiYXNoCkdBVEVXQVlfQURE" + - "Uj0kKGt1YmVjdGwgLS1jb250ZXh0IDxjb250cm9sLXBsYW5lLWNvbnRleHQ+IGdldCBnYXRld2F5" + - "IFwKICBudmNmLWdhdGV3YXkgLW4gZW52b3ktZ2F0ZXdheSBcCiAgLW8ganNvbnBhdGg9J3suc3Rh" + - "dHVzLmFkZHJlc3Nlc1swXS52YWx1ZX0nKQp0ZXN0IC1uICIkR0FURVdBWV9BRERSIgpkaWcgK3No" + - "b3J0ICJhcGkuPGRvbWFpbj4iCmN1cmwgLUggIkhvc3Q6IGFwaS48ZG9tYWluPiIgImh0dHA6Ly8k" + - "R0FURVdBWV9BRERSL2hlYWx0aCIKCiMgQ2hlY2sgZGlyZWN0IGFkZHJlc3MgZmllbGRzIHVzZWQg" + - "YnkgYSBzcGxpdCBkZXBsb3ltZW50CmdyZXAgbnZjZk5hdHNTZXJ2aWNlVVJMIGRlcGxveS9zdGFj" + - "a3Mvc2VsZi1tYW5hZ2VkL2Vudmlyb25tZW50cy88ZW52Pi55YW1sCmdyZXAgLUUgJ2ljbXNTZXJ2" + - "aWNlfHJldmFsU2VydmljZXxuYXRzJyBcCiAgZGVwbG95L3N0YWNrcy9udmNmLWNvbXB1dGUtcGxh" + - "bmUvZW52aXJvbm1lbnRzLzxlbnY+LnlhbWwKYGBgCgojIyMgRml4CgpLZWVwIGBnbG9iYWwuZG9t" + - "YWluYCwgcm91dGUgaG9zdG5hbWVzLCBhbmQgSG9zdCBvdmVycmlkZXMgdW5jaGFuZ2VkLiBVcGRh" + - "dGUgRE5TCm9yIHRoZSBkaXJlY3QgY2xpZW50IGRlc3RpbmF0aW9uLiBGb3Igc3BsaXQgZGVwbG95" + - "bWVudHMsIHVwZGF0ZSBvbmx5IGRpcmVjdApHYXRld2F5LWFkZHJlc3MgZmllbGRzIGFuZCByZWZy" + - "ZXNoIHJlZ2lzdHJhdGlvbiBhbmQgaW5zdGFsbGF0aW9uIHRocm91Z2ggdGhlCnNhbWUgc2VsZWN0" + - "ZWQgaGFuZG9mZi4gU2VlCltFeGFtcGxlIDRdKC4uL2V4YW1wbGVzLm1kI2V4YW1wbGUtNC1yZWNv" + - "dmVyLWZyb20tYS1nYXRld2F5LWVuZHBvaW50LWNoYW5nZSkuCgojIyBVc2VmdWwgTmFtZXNwYWNl" + - "LXRvLVNlcnZpY2UgTWFwcGluZwoKUXVpY2sgcmVmZXJlbmNlIGZvciB3aGljaCBzZXJ2aWNlcyBy" + - "dW4gaW4gd2hpY2ggbmFtZXNwYWNlOgoKfCBOYW1lc3BhY2UgfCBTZXJ2aWNlcyB8CnwtLS0tLS0t" + - "LS0tLXwtLS0tLS0tLS0tfAp8IG5hdHMtc3lzdGVtIHwgbmF0cyB8CnwgY2Fzc2FuZHJhLXN5c3Rl" + - "bSB8IGNhc3NhbmRyYSB8CnwgdmF1bHQtc3lzdGVtIHwgb3BlbmJhby1zZXJ2ZXIgfAp8IG52Y2Yg" + - "fCBhcGksIGludm9jYXRpb24tc2VydmljZSwgZ3JwYy1wcm94eSwgbm90YXJ5LXNlcnZpY2UsIHJl" + - "dmFsLCBsbG0tcmVxdWVzdC1yb3V0ZXIsIGxsbS1hcGktZ2F0ZXdheSB8CnwgYXBpLWtleXMgfCBh" + - "cGkta2V5cywgYWRtaW4taXNzdWVyLXByb3h5IHwKfCBlc3MgfCBlc3MtYXBpIHwKfCBzaXMgfCBz" + - "aXMgfAp8IGVudm95LWdhdGV3YXktc3lzdGVtIHwgZW52b3kgZ2F0ZXdheSAoaW5ncmVzcyBjb250" + - "cm9sbGVyKSB8CnwgZW52b3ktZ2F0ZXdheSB8IGdhdGV3YXkgcmVzb3VyY2UgKyByb3V0ZXMgfApQ" + - "SwcItXpwO7UwAAC1MAAAUEsDBBQACAAAAAAAIQAAAAAAAAAAAAAAAABEAAkAZGF0YS9udmNmLXNl" + - "bGYtbWFuYWdlZC1pbnN0YWxsYXRpb24vcmVmZXJlbmNlcy9oZWxtZmlsZS1zdHJ1Y3R1cmUubWRV" + - "VAUAAQCmzhIjIEhlbG1maWxlIFN0cnVjdHVyZSBSZWZlcmVuY2UKCiMjIERpcmVjdG9yeSBMYXlv" + - "dXQKCmBgYGJhc2gKbnZjZi1zZWxmLW1hbmFnZWQtc3RhY2svCnwtLSBoZWxtZmlsZS5kLwp8ICAg" + - "fC0tIDAxLWRlcGVuZGVuY2llcy55YW1sLmdvdG1wbCAgIyBOQVRTLCBDYXNzYW5kcmEsIE9wZW5C" + - "YW8KfCAgIHwtLSAwMi1jb3JlLnlhbWwuZ290bXBsICAgICAgICAgICMgTlZDRiBzZXJ2aWNlcyAr" + - "IGluZ3Jlc3MKfCAgIGAtLSAwMy1vYnNlcnZhYmlsaXR5LnlhbWwuZ290bXBsICMgT2JzZXJ2YWJp" + - "bGl0eSBzdGFjayAob3B0aW9uYWwpCnwtLSBlbnZpcm9ubWVudHMvCnwgICB8LS0gYmFzZS55YW1s" + - "ICAgICAgICAgICAgICAgICAgICAjIERlZmF1bHQgdmFsdWVzIChhbGwgZW52aXJvbm1lbnRzKQp8" + - "ICAgYC0tIDxlbnYtbmFtZT4ueWFtbCAgICAgICAgICAgICAgIyBQZXItZW52aXJvbm1lbnQgb3Zl" + - "cnJpZGVzCnwtLSBzZWNyZXRzLwp8ICAgYC0tIDxlbnYtbmFtZT4tc2VjcmV0cy55YW1sICAgICAg" + - "IyBTZW5zaXRpdmUgdmFsdWVzIChyZWdpc3RyeSBjcmVkcywgcGFzc3dvcmRzKQpgLS0gZ2xvYmFs" + - "LnlhbWwuZ290bXBsICAgICAgICAgICAgICAgIyBHbyB0ZW1wbGF0ZSB0aGF0IGNvbnN0cnVjdHMg" + - "cGVyLWNoYXJ0IHZhbHVlcwoKbnZjZi1jb21wdXRlLXBsYW5lLXN0YWNrLwp8LS0gaGVsbWZpbGUu" + - "ZC8KfCAgIHwtLSAwMS1kZXBlbmRlbmNpZXMueWFtbC5nb3RtcGwgICMgQ29tcHV0ZSBwbGFuZSBk" + - "ZXBlbmRlbmN5IGNvbXBvbmVudHMKfCAgIGAtLSAwMi1udmNhLnlhbWwuZ290bXBsICAgICAgICAg" + - "ICMgbnZjYS1vcGVyYXRvciBjaGFydCBhbmQgbnZjYSBjb25maWd1cmF0aW9uCnwtLSBlbnZpcm9u" + - "bWVudHMvCnwgICB8LS0gYmFzZS55YW1sICAgICAgICAgICAgICAgICAgICAjIERlZmF1bHQgdmFs" + - "dWVzIChhbGwgZW52aXJvbm1lbnRzKQp8ICAgYC0tIDxlbnYtbmFtZT4ueWFtbCAgICAgICAgICAg" + - "ICAgIyBQZXItZW52aXJvbm1lbnQgb3ZlcnJpZGVzCmAtLSBnbG9iYWwueWFtbC5nb3RtcGwgICAg" + - "ICAgICAgICAgICAjIEdvIHRlbXBsYXRlIHRoYXQgY29uc3RydWN0cyBwZXItY2hhcnQgdmFsdWVz" + - "CmBgYAoKVGhlc2Ugc3RhY2tzIGhhdmUgc2VwYXJhdGUgZW52aXJvbm1lbnRzIGFuZCBjbHVzdGVy" + - "IGNvbnRleHRzLiBUaGUgSGVsbWZpbGUKdmFsdWVzIGZsb3cgYXV0aG9ycyBib3RoIGVudmlyb25t" + - "ZW50IGZpbGVzLiBUaGUgQ0xJIHByb2ZpbGUgZmxvdyBpbnN0ZWFkIHVzZXMKdGhlIENMSS1nZW5l" + - "cmF0ZWQgY29udHJvbC1wbGFuZSBwcm9maWxlIGFzIGl0cyBlbmRwb2ludCBzb3VyY2UuIERvIG5v" + - "dCBtaXggdGhlCmhhbmRvZmZzLiBTZWUgW1NwbGl0IENvbXB1dGUtUGxhbmUgSW5zdGFsbGF0aW9u" + - "XShjb21wdXRlLXBsYW5lLWluc3RhbGxhdGlvbi5tZCkuCgojIyBHb3RtcGwgRmlsZXMgYW5kIFRo" + - "ZWlyIFJlbGVhc2VzCgojIyMgMDEtZGVwZW5kZW5jaWVzLnlhbWwuZ290bXBsCgp8IFJlbGVhc2Ug" + - "fCBDaGFydCB8IE5hbWVzcGFjZSB8IE5vdGVzIHwKfC0tLS0tLS0tLXwtLS0tLS0tfC0tLS0tLS0t" + - "LS0tfC0tLS0tLS18CnwgbmF0cyB8IGhlbG0tbnZjZi1uYXRzIHwgbmF0cy1zeXN0ZW0gfCBNZXNz" + - "YWdpbmcgfAp8IG9wZW5iYW8tc2VydmVyIHwgaGVsbS1udmNmLW9wZW5iYW8tc2VydmVyIHwgdmF1" + - "bHQtc3lzdGVtIHwgU2VjcmV0cyBtYW5hZ2VtZW50LCBkZXBlbmRzIG9uIG5hdHMgfAp8IGNhc3Nh" + - "bmRyYSB8IGhlbG0tbnZjZi1jYXNzYW5kcmEgfCBjYXNzYW5kcmEtc3lzdGVtIHwgRGF0YWJhc2Ug" + - "fAoKVXNlcyBgPDw6ICpkZXBlbmRlbmN5YCB0ZW1wbGF0ZSBpbmhlcml0YW5jZSB3aXRoIGByZWxl" + - "YXNlLWdyb3VwOiBkZXBlbmRlbmNpZXNgIGxhYmVsLgoKIyMjIDAyLWNvcmUueWFtbC5nb3RtcGwK" + - "CnwgUmVsZWFzZSB8IENoYXJ0IHwgTmFtZXNwYWNlIHwgTGFiZWwgfAp8LS0tLS0tLS0tfC0tLS0t" + - "LS18LS0tLS0tLS0tLS18LS0tLS0tLXwKfCBhcGkta2V5cyB8IGhlbG0tbnZjZi1hcGkta2V5cyB8" + - "IGFwaS1rZXlzIHwgc2VydmljZXMgfAp8IHNpcyB8IGhlbG0tbnZjZi1zaXMgfCBzaXMgfCBzZXJ2" + - "aWNlcyB8CnwgYXBpIHwgaGVsbS1udmNmLWFwaSB8IG52Y2YgfCBzZXJ2aWNlcyB8CnwgaW52b2Nh" + - "dGlvbi1zZXJ2aWNlIHwgaGVsbS1udmNmLWludm9jYXRpb24tc2VydmljZSB8IG52Y2YgfCBzZXJ2" + - "aWNlcyB8CnwgZ3JwYy1wcm94eSB8IGhlbG0tbnZjZi1ncnBjLXByb3h5IHwgbnZjZiB8IHNlcnZp" + - "Y2VzIHwKfCBlc3MtYXBpIHwgaGVsbS1udmNmLWVzcy1hcGkgfCBlc3MgfCBzZXJ2aWNlcyB8Cnwg" + - "bm90YXJ5LXNlcnZpY2UgfCBoZWxtLW52Y2Ytbm90YXJ5LXNlcnZpY2UgfCBudmNmIHwgc2Vydmlj" + - "ZXMgfAp8IHJldmFsIHwgaGVsbS1yZXZhbCB8IG52Y2YgfCBzZXJ2aWNlcyB8CnwgbGxtLXJlcXVl" + - "c3Qtcm91dGVyIHwgbnZjZi9oZWxtLW52Y2YtbGxtLXJlcXVlc3Qtcm91dGVyIHwgbnZjZiB8IHNl" + - "cnZpY2VzIHwKfCBsbG0tYXBpLWdhdGV3YXkgfCBudmNmL2hlbG0tbnZjZi1sbG0tYXBpLWdhdGV3" + - "YXkgfCBudmNmIHwgc2VydmljZXMgfAp8IGFkbWluLWlzc3Vlci1wcm94eSB8IGhlbG0tYWRtaW4t" + - "dG9rZW4taXNzdWVyLXByb3h5IHwgYXBpLWtleXMgfCAobm8gcmVsZWFzZS1ncm91cCBsYWJlbCkg" + - "fAp8IGluZ3Jlc3MgfCBudmNmLWdhdGV3YXktcm91dGVzIHwgZW52b3ktZ2F0ZXdheS1zeXN0ZW0g" + - "fCBpbmdyZXNzIHwKCk1vc3Qgc2VydmljZXMgdXNlIGBpbmhlcml0OiBbe3RlbXBsYXRlOiBzZXJ2" + - "aWNlfV1gLiBMTE0gcmVsZWFzZXMgdXNlIE9DSSBjaGFydHMgZnJvbSB0aGUgYG52Y2ZgIHJlcG9z" + - "aXRvcnkgd2l0aCBzdGFuZGFsb25lIGB2YWx1ZXM6IFsuLi9nbG9iYWwueWFtbC5nb3RtcGxdYCBi" + - "bG9ja3M7IHRoZXkgYXJlIGdhdGVkIG9uIGBhZGRvbnMubGxtLmVuYWJsZWRgLiBgYWRtaW4taXNz" + - "dWVyLXByb3h5YCBhbmQgYGluZ3Jlc3NgIGhhdmUgc3RhbmRhbG9uZSBgdmFsdWVzOmAgYmxvY2tz" + - "LgoKIyMjIDAzLW9ic2VydmFiaWxpdHkueWFtbC5nb3RtcGwKCnwgUmVsZWFzZSB8IENoYXJ0IHwg" + - "TmFtZXNwYWNlIHwgTGFiZWwgfAp8LS0tLS0tLS0tfC0tLS0tLS18LS0tLS0tLS0tLS18LS0tLS0t" + - "LXwKfCAob2JzZXJ2YWJpbGl0eSByZWxlYXNlcykgfCAodmFyaW91cykgfCBvYnNlcnZhYmlsaXR5" + - "IHwgb2JzZXJ2YWJpbGl0eSB8CgpHYXRlZCBvbiBvYnNlcnZhYmlsaXR5LXNwZWNpZmljIGZsYWdz" + - "IGluIHRoZSBlbnZpcm9ubWVudCBmaWxlLiBTa2lwcGVkIGlmIGRpc2FibGVkLgoKIyMgVGVtcGxh" + - "dGUgSW5oZXJpdGFuY2UKCiMjIyBgPDw6ICpkZXBlbmRlbmN5YCAoWUFNTCBtZXJnZSkKClVzZWQg" + - "aW4gYDAxLWRlcGVuZGVuY2llcy55YW1sLmdvdG1wbGAuIE1lcmdlcyB0aGUgdGVtcGxhdGUncyBw" + - "cm9wZXJ0aWVzIGludG8gdGhlIHJlbGVhc2UuCgoqKkdvdGNoYSoqOiBZQU1MIG1lcmdlIHJlcGxh" + - "Y2VzIGxpc3RzLiBJZiB5b3UgYWRkIGEgYHZhbHVlczpgIGtleSB0byB0aGUgcmVsZWFzZSwgaXQg" + - "KipyZXBsYWNlcyoqIHRoZSB0ZW1wbGF0ZSdzIGB2YWx1ZXM6YCBsaXN0IGVudGlyZWx5LiBZb3Ug" + - "bXVzdCByZS1pbmNsdWRlIGFsbCB0ZW1wbGF0ZSB2YWx1ZXM6CgpJbiB0aGUgdGVtcGxhdGUgYmVs" + - "b3csIGA8cHJpdmF0ZS12YWx1ZXM+YCByZWZlcnMgdG8gdGhlIGBzZWNyZXRzL2AgZGlyZWN0b3J5" + - "IGF0IHRoZSBoZWxtZmlsZSBzdGFjayByb290LgoKYGBgeWFtbAojIFRlbXBsYXRlIGRlZmluZXM6" + - "CnRlbXBsYXRlczoKICBkZXBlbmRlbmN5OiAmZGVwZW5kZW5jeQogICAgY2hhcnQ6IG52Y2YvaGVs" + - "bS1udmNmLXt7IC5SZWxlYXNlLk5hbWUgfX0KICAgIHZhbHVlczoKICAgICAgLSAuLi9nbG9iYWwu" + - "eWFtbC5nb3RtcGwKICAgICAgLSAuLi88cHJpdmF0ZS12YWx1ZXM+L3t7IHJlcXVpcmVkRW52ICJI" + - "RUxNRklMRV9FTlYiIH19LXNlY3JldHMueWFtbAoKIyBXaGVuIG92ZXJyaWRpbmcsIE1VU1QgcmUt" + - "aW5jbHVkZSBib3RoOgotIG5hbWU6IGNhc3NhbmRyYQogIDw8OiAqZGVwZW5kZW5jeQogIHZhbHVl" + - "czoKICAgIC0gLi4vZ2xvYmFsLnlhbWwuZ290bXBsICAgICAgICAgICAgICAgICAgICAgICAgICAg" + - "ICAgICAgICAgICAgICAgICAgICMgTXVzdCByZS1pbmNsdWRlCiAgICAtIC4uLzxwcml2YXRlLXZh" + - "bHVlcz4ve3sgcmVxdWlyZWRFbnYgIkhFTE1GSUxFX0VOViIgfX0tc2VjcmV0cy55YW1sICAgIyBN" + - "dXN0IHJlLWluY2x1ZGUKICAgIC0gY2Fzc2FuZHJhOiAgICAgICAgICAgICAgICAgICAgICAgICAg" + - "ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAjIFlvdXIgb3ZlcnJpZGUKICAgICAgICBy" + - "ZXNvdXJjZXM6CiAgICAgICAgICBsaW1pdHM6CiAgICAgICAgICAgIG1lbW9yeTogODE5Mk1pCmBg" + - "YAoKIyMjIGBpbmhlcml0YCAoSGVsbWZpbGUgbmF0aXZlKQoKVXNlZCBpbiBgMDItY29yZS55YW1s" + - "LmdvdG1wbGAuIEhlbG1maWxlJ3MgbmF0aXZlIGluaGVyaXRhbmNlIG1lY2hhbmlzbS4KCmBgYHlh" + - "bWwKLSBuYW1lOiBhcGkKICB2ZXJzaW9uOiAxLjYuMAogIG5hbWVzcGFjZTogbnZjZgogIGluaGVy" + - "aXQ6CiAgICAtIHRlbXBsYXRlOiBzZXJ2aWNlCmBgYAoKV2hlbiBhZGRpbmcgYHZhbHVlczpgIHRv" + - "IGFuIGluaGVyaXRlZCByZWxlYXNlLCB5b3UgYWxzbyBuZWVkIHRvIHJlLWluY2x1ZGUgdGhlIHRl" + - "bXBsYXRlJ3MgdmFsdWVzIGZpbGVzIHNpbmNlIGB2YWx1ZXNgIGlzIGEgbGlzdCB0aGF0IGdldHMg" + - "cmVwbGFjZWQuCgojIyBWYWx1ZXMgUHJlY2VkZW5jZQoKRnJvbSBsb3dlc3QgdG8gaGlnaGVzdCBw" + - "cmlvcml0eToKCjEuIGBlbnZpcm9ubWVudHMvYmFzZS55YW1sYCAtLSBkZWZhdWx0cyBzaGFyZWQg" + - "YWNyb3NzIGFsbCBlbnZpcm9ubWVudHMKMi4gYGVudmlyb25tZW50cy88ZW52Pi55YW1sYCAtLSBw" + - "ZXItZW52aXJvbm1lbnQgb3ZlcnJpZGVzCjMuIGBnbG9iYWwueWFtbC5nb3RtcGxgIC0tIEdvIHRl" + - "bXBsYXRlIHByb2Nlc3NpbmcgKGNvbnN0cnVjdHMgY2hhcnQtc3BlY2lmaWMgc3RydWN0dXJlKQo0" + - "LiBgPHByaXZhdGUtdmFsdWVzPi88ZW52Pi1zZWNyZXRzLnlhbWxgIC0tIHNlbnNpdGl2ZSB2YWx1" + - "ZXMKNS4gSW5saW5lIGB2YWx1ZXM6YCBibG9ja3Mgb24gcmVsZWFzZXMgLS0gaGlnaGVzdCBwcmVj" + - "ZWRlbmNlCgojIyBXaGF0IGdsb2JhbC55YW1sLmdvdG1wbCBQYXNzZXMgVGhyb3VnaAoKYGdsb2Jh" + - "bC55YW1sLmdvdG1wbGAgcmVhZHMgZnJvbSBgLlZhbHVlc2AgKHRoZSBtZXJnZWQgZW52aXJvbm1l" + - "bnQgKyBlbnYtc3BlY2lmaWMgWUFNTCkgYW5kIGNvbnN0cnVjdHMgY2hhcnQtc3BlY2lmaWMgdmFs" + - "dWVzLiBJdCBvbmx5IHBhc3NlcyB0aHJvdWdoIGtleXMgaXQgZXhwbGljaXRseSByZWZlcmVuY2Vz" + - "OgoKIyMjIENhc3NhbmRyYQoKLSBgY2Fzc2FuZHJhLnJlcGxpY2FDb3VudGAKLSBgY2Fzc2FuZHJh" + - "LmltYWdlLipgIChyZWdpc3RyeSwgcmVwb3NpdG9yeSkKLSBgY2Fzc2FuZHJhLm1pZ3JhdGlvbnMu" + - "aW1hZ2UuKmAKLSBgY2Fzc2FuZHJhLnBlcnNpc3RlbmNlLnNpemVgCi0gYGNhc3NhbmRyYS5ub2Rl" + - "U2VsZWN0b3JgIChpZiBgZ2xvYmFsLm5vZGVTZWxlY3RvcnMuZW5hYmxlZGApCi0gYGNhc3NhbmRy" + - "YS5nbG9iYWwuZGVmYXVsdFN0b3JhZ2VDbGFzc2AKCiMjIyBOQVRTCgotIGBuYXRzLmNvbnRhaW5l" + - "ci5pbWFnZS4qYAotIGBuYXRzLnJlbG9hZGVyLmltYWdlLipgCi0gYG5hdHMubmF0c0JveC5jb250" + - "YWluZXIuaW1hZ2UuKmAKLSBgbmF0cy5jb25maWcuamV0c3RyZWFtLmZpbGVTdG9yZS5wdmMuc3Rv" + - "cmFnZUNsYXNzTmFtZWAKLSBgbmF0cy5wb2RUZW1wbGF0ZS5tZXJnZS5zcGVjLm5vZGVTZWxlY3Rv" + - "cmAgKGlmIGVuYWJsZWQpCgojIyMgT3BlbkJhbwoKLSBgb3BlbmJhby5taWdyYXRpb25zLmltYWdl" + - "LipgIGFuZCBgb3BlbmJhby5taWdyYXRpb25zLmVudmAKLSBgb3BlbmJhby5pbmplY3Rvci5pbWFn" + - "ZS4qYAotIGBvcGVuYmFvLnNlcnZlci5pbWFnZS4qYAotIGBvcGVuYmFvLnNlcnZlci5kYXRhU3Rv" + - "cmFnZS4qYAotIE5vZGUgc2VsZWN0b3JzIChpZiBlbmFibGVkKQoKIyMjIFNlcnZpY2VzIChBUEks" + - "IFNJUywgZXRjLikKCi0gYDxzZXJ2aWNlPi5pbWFnZS4qYCAocmVnaXN0cnksIHJlcG9zaXRvcnkp" + - "Ci0gYDxzZXJ2aWNlPi5ub2RlU2VsZWN0b3JgIChpZiBlbmFibGVkKQotIGA8c2VydmljZT4uZW52" + - "LipgIChvYnNlcnZhYmlsaXR5IHNldHRpbmdzKQoKIyMjIExMTSBGdW5jdGlvbiBFbmFibGVtZW50" + - "CgpFbmFibGUgdGhlIExMTSBhZGRvbiBiZWZvcmUgY3JlYXRpbmcgb3IgaW52b2tpbmcgZnVuY3Rp" + - "b25zIHdpdGgKYGZ1bmN0aW9uVHlwZTogIkxMTSJgLiBUaGUgYWRkb24gZGVwbG95cyBgbGxtLXJl" + - "cXVlc3Qtcm91dGVyYCBhbmQKYGxsbS1hcGktZ2F0ZXdheWAsIGFkZHMgdGhlIGBsbG0uaW52b2Nh" + - "dGlvbi48ZG9tYWluPmAgcm91dGUgd2hlbiBHYXRld2F5IEFQSQppbmdyZXNzIGlzIGVuYWJsZWQs" + - "IGFuZCBjb25maWd1cmVzIHdvcmtlcnMgdG8gdXNlIHRoZSBMTE0gc2lkZWNhci4gVGhlIEhlbG1m" + - "aWxlCmNvbmRpdGlvbiBpcyBgYWRkb25zLmxsbS5lbmFibGVkYC4gRG8gbm90IHVzZSB0aGUgb2Jz" + - "b2xldGUgYGxsbS5lbmFibGVkYCBwYXRoLgoKRm9yIGEgc2luZ2xlLW5vZGUgaXNvbGF0ZWQgdGVz" + - "dCBjbHVzdGVyLCBhZGQgdGhpcyBjb25maWd1cmF0aW9uIHRvCmBlbnZpcm9ubWVudHMvPGVudj4u" + - "eWFtbGAgYmVmb3JlIGFwcGx5aW5nIHRoZSBzdGFjazoKCmBgYHlhbWwKYWRkb25zOgogIGxsbToK" + - "ICAgIGVuYWJsZWQ6IHRydWUKICAgIGdhdGV3YXk6CiAgICAgIHJlcGxpY2FDb3VudDogMQogICAg" + - "ICBtZXRyaWNzOgogICAgICAgIHNlcnZpY2VNb25pdG9yOgogICAgICAgICAgZW5hYmxlZDogZmFs" + - "c2UKICAgIHJlcXVlc3RSb3V0ZXI6CiAgICAgIHJlcGxpY2FDb3VudDogMQogICAgICBtZXRyaWNz" + - "OgogICAgICAgIHNlcnZpY2VNb25pdG9yOgogICAgICAgICAgZW5hYmxlZDogZmFsc2UKCmFnZW50" + - "Q29uZmlnOgogIG1lcmdlQ29uZmlnOiB8CiAgICBjbHVzdGVyOgogICAgICB2YWxpZGF0aW9uUG9s" + - "aWN5OgogICAgICAgIG5hbWU6IFVucmVzdHJpY3RlZAogICAgd29ya2xvYWQ6CiAgICAgIHN0YXJn" + - "YXRlUVVJQ0luc2VjdXJlOiB0cnVlCmBgYAoKVXNlIGByZXBsaWNhQ291bnQ6IDFgIG9ubHkgZm9y" + - "IGxvY2FsIG9yIHNpbmdsZS1ub2RlIHRlc3QgY2x1c3RlcnMuIFRoZSBidW5kbGVkCk5WQ0YgQVBJ" + - "IGN1cnJlbnRseSBzZXJ2ZXMgcGxhaW50ZXh0IGdSUEMgb24gcG9ydCA5MDkwLCBzbyB0aGUgc2Vs" + - "Zi1tYW5hZ2VkIHN0YWNrCmRlZmF1bHRzIHRoZSBMTE0gQVBJIEdhdGV3YXkgYXV0aGVudGljYXRp" + - "b24gaG9wIHRvIHBsYWludGV4dC4gU2V0CmBhZGRvbnMubGxtLmdhdGV3YXkuYXV0aC5ncnBjSW5z" + - "ZWN1cmU6IGZhbHNlYCBvbmx5IHdpdGggYSBUTFMtY2FwYWJsZSBOVkNGIEFQSS4KCldvcmtlci10" + - "by1yb3V0ZXIgdHJhbnNwb3J0IGlzIHNlcGFyYXRlLiBGb3Igc2hhcmVkIG9yIHByb2R1Y3Rpb24g" + - "Y2x1c3RlcnMsIHVzZQp0aGUgcmVxdWlyZWQgcmVwbGljYSBjb3VudCBhbmQgdGhlIG1hbmFnZWQg" + - "VExTIGNvbmZpZ3VyYXRpb24uIFRoZQpgd29ya2xvYWQuc3RhcmdhdGVRVUlDSW5zZWN1cmVgIHNl" + - "dHRpbmcgZW5hYmxlcyBwbGFpbnRleHQgd29ya2VyIHRyYW5zcG9ydCBhbmQKbXVzdCBub3QgYmUg" + - "dXNlZCBpbiBwcm9kdWN0aW9uLgoKVGhlIHJlcXVlc3Qgcm91dGVyIHVzZXMgYHBvd2VyLW9mLXR3" + - "b2Agd2hlbiBubyBsb2FkLWJhbGFuY2VyIGNvbmZpZ3VyYXRpb24gaXMKc2V0LiBDb25maWd1cmUg" + - "b3RoZXIgcm91dGluZyBtZXRob2RzIHdpdGggdGhlCltMTE0gUmVxdWVzdCBSb3V0ZXIgTG9hZCBC" + - "YWxhbmNpbmddKGh0dHBzOi8vZ2l0aHViLmNvbS9OVklESUEvbnZjZi9ibG9iL21haW4vZG9jcy9z" + - "ZWxmLW1hbmFnZWQvbGxtLXJlcXVlc3Qtcm91dGVyLWxvYWQtYmFsYW5jaW5nLm1kKQpndWlkZS4K" + - "CklmIHRoZSBzaWRlY2FyIGltYWdlIGlzIG1pcnJvcmVkIG91dHNpZGUgdGhlIHN0YWNrJ3MgZGVm" + - "YXVsdCBpbWFnZSByZWdpc3RyeSBhbmQKcmVwb3NpdG9yeSwgc2V0IHRoZSBnZW5lcmF0ZWQgd29y" + - "a2VyIHNpZGVjYXIgaW1hZ2UgZXhwbGljaXRseToKCmBgYHlhbWwKYXBpOgogIHJlbW90ZUNvbmZp" + - "ZzoKICAgIGNvbmZpZ0RhdGE6CiAgICAgIG52Y2Y6CiAgICAgICAgc2lkZWNhcnM6CiAgICAgICAg" + - "ICBsbG0tcm91dGVyLWNsaWVudC1pbWFnZTogPHJlZ2lzdHJ5Pi88cmVwb3NpdG9yeT4vcHlsb246" + - "MC4xNC4xCmBgYAoKVGhlIGxlZ2FjeSBgYXBpLmVudi5OVkNGX1NJREVDQVJTX0xMTV9ST1VURVJf" + - "Q0xJRU5UX0lNQUdFYCBwYXRoIGlzIGRlcHJlY2F0ZWQuClRoZSBzdGFjayB0cmFuc2xhdGVzIGl0" + - "IGZvciBvbmUgY29tcGF0aWJpbGl0eSB3aW5kb3csIGJ1dCBuZXcgY29uZmlndXJhdGlvbnMKbXVz" + - "dCB1c2UgdGhlIHJlbW90ZS1jb25maWcgcGF0aC4gQ29uZmxpY3RpbmcgdmFsdWVzIGZhaWwgcmVu" + - "ZGVyaW5nLgoKUmVuZGVyIGFuZCBhcHBseSB0aGUgdXBkYXRlZCBjb250cm9sLXBsYW5lIGVudmly" + - "b25tZW50LCB0aGVuIHJlZnJlc2ggZXZlcnkKcmVnaXN0ZXJlZCBjb21wdXRlIHBsYW5lIHVzaW5n" + - "IHRoZSBzYW1lIGhhbmRvZmYgdXNlZCBmb3IgaXRzIGluc3RhbGxhdGlvbiBzbwpOVkNBIHJlY2Vp" + - "dmVzIGBhZ2VudENvbmZpZy5tZXJnZUNvbmZpZ2A6CgpgYGBiYXNoCkhFTE1GSUxFX0VOVj08ZW52" + - "PiBoZWxtZmlsZSB0ZW1wbGF0ZQpIRUxNRklMRV9FTlY9PGVudj4gaGVsbWZpbGUgc3luYwpgYGAK" + - "ClVzZSB0aGUgY29tcGxldGUgY29tcHV0ZS1wbGFuZSBpbnN0YWxsIGNvbW1hbmQgZnJvbQpbU3Bs" + - "aXQgQ29tcHV0ZS1QbGFuZSBJbnN0YWxsYXRpb25dKGNvbXB1dGUtcGxhbmUtaW5zdGFsbGF0aW9u" + - "Lm1kKS4gQQpDTEktcHJvZmlsZSBpbnN0YWxsYXRpb24gbXVzdCByZW1haW4gcHJvZmlsZS1kcml2" + - "ZW47IGEgSGVsbWZpbGUgaW5zdGFsbGF0aW9uCm11c3QgcmVtYWluIHZhbHVlcy1kcml2ZW4uCgpF" + - "eGlzdGluZyBMTE0gZnVuY3Rpb24gdmVyc2lvbnMgcmV0YWluIHRoZSB3b3JrZXItc2lkZWNhciBp" + - "bWFnZSBtZXRhZGF0YQpjYXB0dXJlZCB3aGVuIHRoZSB2ZXJzaW9uIGlzIGNyZWF0ZWQuIFJlcGxh" + - "Y2luZyBwb2RzIG9yIHJlZGVwbG95aW5nIHRoZSBzYW1lCnZlcnNpb24gZG9lcyBub3QgYXBwbHkg" + - "YSBuZXcgUHlsb24gaW1hZ2UuIEFmdGVyIHRoZSBjb250cm9sLXBsYW5lIHVwZGF0ZSwKY3JlYXRl" + - "IGFuZCBkZXBsb3kgYSBuZXcgZnVuY3Rpb24gdmVyc2lvbi4gVmVyaWZ5IHRoZSBjb250cm9sIHBs" + - "YW5lLCByb3V0ZSwgYW5kCndvcmtlciBzaWRlY2FyOgoKYGBgYmFzaAprdWJlY3RsIGdldCBkZXBs" + - "b3kgLW4gbnZjZiBsbG0tYXBpLWdhdGV3YXkKa3ViZWN0bCBnZXQgc3RhdGVmdWxzZXQgLW4gbnZj" + - "ZiBsbG0tcmVxdWVzdC1yb3V0ZXIKa3ViZWN0bCBnZXQgcG9kcyAtbiBudmNmIHwgZ3JlcCAtRSAn" + - "bGxtLWFwaS1nYXRld2F5fGxsbS1yZXF1ZXN0LXJvdXRlcicKa3ViZWN0bCBnZXQgaHR0cHJvdXRl" + - "IC1BIHwgZ3JlcCBsbG0Ka3ViZWN0bCAtbiBudmNmLWJhY2tlbmQgZ2V0IHBvZCA8ZnVuY3Rpb24t" + - "cG9kPiBcCiAgLW8ganNvbnBhdGg9J3tyYW5nZSAuc3BlYy5jb250YWluZXJzWz8oQC5uYW1lPT0i" + - "bGxtLXdvcmtlciIpXS5hcmdzWypdfXsufXsiXFxuIn17ZW5kfScKYGBgCgpGb3IgbG9jYWwgcGxh" + - "aW50ZXh0IGNsdXN0ZXJzLCB0aGUgYGxsbS13b3JrZXJgIGFyZ3MgbXVzdCBpbmNsdWRlCmAtLXF1" + - "aWMtaW5zZWN1cmVgLgoKRm9yIGxvYWQtYmFsYW5jZXIgc2NoZW1hIGFuZCBhbGdvcml0aG0gYmVo" + - "YXZpb3IsIHNlZSB0aGUKW1N0YXJnYXRlIGNvbmZpZ3VyYXRpb24gZ3VpZGVdKGh0dHBzOi8vZ2l0" + - "aHViLmNvbS9OVklESUEvbnZjZi9ibG9iL21haW4vc3JjL2xpYnJhcmllcy9ydXN0L3N0YXJnYXRl" + - "L2RvY3MvbG9hZC1iYWxhbmNlci1jb25maWd1cmF0aW9uLm1kKS4KCiMjIEhlbG1maWxlIFNlbGVj" + - "dG9ycwoKVGFyZ2V0IHNwZWNpZmljIHJlbGVhc2VzIG9yIGdyb3VwczoKCmBgYGJhc2gKIyBCeSBy" + - "ZWxlYXNlIGdyb3VwCkhFTE1GSUxFX0VOVj08ZW52PiBoZWxtZmlsZSAtLXNlbGVjdG9yIHJlbGVh" + - "c2UtZ3JvdXA9ZGVwZW5kZW5jaWVzIHN5bmMKSEVMTUZJTEVfRU5WPTxlbnY+IGhlbG1maWxlIC0t" + - "c2VsZWN0b3IgcmVsZWFzZS1ncm91cD1zZXJ2aWNlcyBzeW5jCkhFTE1GSUxFX0VOVj08ZW52PiBo" + - "ZWxtZmlsZSAtLXNlbGVjdG9yIHJlbGVhc2UtZ3JvdXA9aW5ncmVzcyBzeW5jCkhFTE1GSUxFX0VO" + - "Vj08ZW52PiBoZWxtZmlsZSAtLXNlbGVjdG9yIHJlbGVhc2UtZ3JvdXA9b2JzZXJ2YWJpbGl0eSBz" + - "eW5jCgojIEJ5IHJlbGVhc2UgbmFtZQpIRUxNRklMRV9FTlY9PGVudj4gaGVsbWZpbGUgLS1zZWxl" + - "Y3RvciBuYW1lPWNhc3NhbmRyYSBzeW5jCkhFTE1GSUxFX0VOVj08ZW52PiBoZWxtZmlsZSAtLXNl" + - "bGVjdG9yIG5hbWU9YWRtaW4taXNzdWVyLXByb3h5IHN5bmMKSEVMTUZJTEVfRU5WPTxlbnY+IGhl" + - "bG1maWxlIC0tc2VsZWN0b3IgbmFtZT1sbG0tcmVxdWVzdC1yb3V0ZXIgc3luYwpIRUxNRklMRV9F" + - "TlY9PGVudj4gaGVsbWZpbGUgLS1zZWxlY3RvciBuYW1lPWxsbS1hcGktZ2F0ZXdheSBzeW5jCgoj" + - "IFRlbXBsYXRlIG9ubHkgKGRyeSBydW4pCkhFTE1GSUxFX0VOVj08ZW52PiBoZWxtZmlsZSAtLXNl" + - "bGVjdG9yIG5hbWU9Y2Fzc2FuZHJhIHRlbXBsYXRlCgojIERlc3Ryb3kgYSBzaW5nbGUgcmVsZWFz" + - "ZQpIRUxNRklMRV9FTlY9PGVudj4gaGVsbWZpbGUgLS1zZWxlY3RvciBuYW1lPWNhc3NhbmRyYSBk" + - "ZXN0cm95CmBgYAoKTm90ZTogYGFkbWluLWlzc3Vlci1wcm94eWAgaGFzIG5vIGByZWxlYXNlLWdy" + - "b3VwYCBsYWJlbC4gVXNlIGAtLXNlbGVjdG9yIG5hbWU9YWRtaW4taXNzdWVyLXByb3h5YC4KUEsH" + - "CBMR7I0KKgAACioAAFBLAwQUAAgAAAAAACEAAAAAAAAAAAAAAAAAPwAJAGRhdGEvbnZjZi1zZWxm" + - "LW1hbmFnZWQtaW5zdGFsbGF0aW9uL3JlZmVyZW5jZXMvbnZjZi11aS1hZGRvbi5tZFVUBQABAKbO" + - "EiMgTlZDRiBVSSBBZGRvbgoKTlZDRiBVSSBpcyBhbiBvcHRpb25hbCwgY3VzdG9tZXItZmFjaW5n" + - "IE5WQ0YgYWRtaW4tcGFuZWwgVUkuIEl0IGlzIGRpc2FibGVkIGJ5CmRlZmF1bHQgYW5kIGF2YWls" + - "YWJsZSBvbmx5IGluIHN0YWNrIHBhY2thZ2VzIHRoYXQgaW5jbHVkZSB0aGUgTlZDRiBVSSBhZGRv" + - "bi4KCiMjIFdoZW4gdG8gZW5hYmxlCgpFbmFibGUgTlZDRiBVSSBvbmx5IHdoZW4geW91IG5lZWQg" + - "dGhlIGFkbWluLXBhbmVsIFVJIGFuZCB5b3VyIGV4dHJhY3RlZCBzdGFjawpwYWNrYWdlIGluY2x1" + - "ZGVzIHRoZSBhZGRvbi4gSWYgdGhlIHBhY2thZ2UgZG9lcyBub3QgY29udGFpbiBhIGBudmNmLXVp" + - "YCByZWxlYXNlCmFuZCBgbnZjZlVpYCByb3V0ZSB2YWx1ZXMsIHNraXAgdGhlIGFkZG9uIHVudGls" + - "IHlvdSB1c2UgYSBwYWNrYWdlIHRoYXQgaW5jbHVkZXMKdGhlbS4KCiMjIEVuYWJsZSB0aGUgYWRk" + - "b24KCkVuYWJsZSBpdCBpbiBgZW52aXJvbm1lbnRzLzxlbnYtbmFtZT4ueWFtbGA6CgpgYGB5YW1s" + - "CmFkZG9uczoKICBudmNmVWk6CiAgICBlbmFibGVkOiB0cnVlCmBgYAoKIyMgQ3JlYXRlIG5hbWVz" + - "cGFjZSBhbmQgaW1hZ2UgcHVsbCBzZWNyZXQKCkNyZWF0ZSB0aGUgbmFtZXNwYWNlLCBhbmQgYW4g" + - "aW1hZ2UgcHVsbCBzZWNyZXQgb25seSBpZiBwdWxsaW5nIGZyb20gYSBwcml2YXRlCnJlZ2lzdHJ5" + - "OgoKYGBgYmFzaAprdWJlY3RsIGNyZWF0ZSBuYW1lc3BhY2UgbnZjZi11aSAtLWRyeS1ydW49Y2xp" + - "ZW50IC1vIHlhbWwgfCBrdWJlY3RsIGFwcGx5IC1mIC0KCiMgT25seSBpZiBwdWxsaW5nIGZyb20g" + - "YSBwcml2YXRlIHJlZ2lzdHJ5IChlLmcuLCBOR0MgbnZjci5pbykKZXhwb3J0IE5HQ19BUElfS0VZ" + - "PSI8eW91ci1rZXk+IgprdWJlY3RsIGNyZWF0ZSBzZWNyZXQgZG9ja2VyLXJlZ2lzdHJ5IG52Y3It" + - "Y3JlZHMgXAogIC0tZG9ja2VyLXNlcnZlcj1udmNyLmlvIFwKICAtLWRvY2tlci11c2VybmFtZT0n" + - "JG9hdXRodG9rZW4nIFwKICAtLWRvY2tlci1wYXNzd29yZD0iJE5HQ19BUElfS0VZIiBcCiAgLS1u" + - "YW1lc3BhY2U9bnZjZi11aSBcCiAgLS1kcnktcnVuPWNsaWVudCAtbyB5YW1sIHwga3ViZWN0bCBh" + - "cHBseSAtZiAtCmBgYAoKVGhlIGRlZmF1bHQgcm91dGUgaG9zdCBpcyBgbnZjZi11aS48ZG9tYWlu" + - "PmAgYW5kIHRoZSBiYWNrZW5kIGlzCmBudmNmLXVpLm52Y2YtdWk6ODMwMGAuCgojIyBBcHBseSB0" + - "aGUgc2VydmljZSBhbmQgcm91dGUKCkFmdGVyIGNvbmZpcm1pbmcgdGhlIHBhY2thZ2UgaW5jbHVk" + - "ZXMgdGhlIGBudmNmLXVpYCByZWxlYXNlLCBwcmV2aWV3IGFuZCBhcHBseQp0aGUgc2VydmljZSBh" + - "bmQgcm91dGU6CgpgYGBiYXNoCkhFTE1GSUxFX0VOVj08ZW52LW5hbWU+IGhlbG1maWxlIC0tc2Vs" + - "ZWN0b3IgbmFtZT1udmNmLXVpIHRlbXBsYXRlCkhFTE1GSUxFX0VOVj08ZW52LW5hbWU+IGhlbG1m" + - "aWxlIC0tc2VsZWN0b3IgbmFtZT1udmNmLXVpIHN5bmMKSEVMTUZJTEVfRU5WPTxlbnYtbmFtZT4g" + - "aGVsbWZpbGUgLS1zZWxlY3RvciByZWxlYXNlLWdyb3VwPWluZ3Jlc3Mgc3luYwpgYGAKCiMjIFZl" + - "cmlmeQoKVmVyaWZ5IHRoZSByb3V0ZSBvbmx5IHdoZW4gdGhlIGFkZG9uIGlzIHByZXNlbnQgYW5k" + - "IGVuYWJsZWQ6CgpgYGBiYXNoCmt1YmVjdGwgZ2V0IGRlcGxveSxzdmMgLW4gbnZjZi11aQprdWJl" + - "Y3RsIGdldCBodHRwcm91dGUgLUEgfCBncmVwIC1pIG52Y2YtdWkKY3VybCAtaSAtSCAiSG9zdDog" + - "bnZjZi11aS48ZG9tYWluPiIgImh0dHA6Ly88Z2F0ZXdheS1hZGRyZXNzPi9zdGF0dXMiCmBgYAoK" + - "IyMgU2VjdXJpdHkKClRoZSBOVkNGIFVJIGFkbWluIHBhbmVsIGlzIGN1cnJlbnRseSB1bmF1dGhl" + - "bnRpY2F0ZWQuIERvIG5vdCBleHBvc2UgaXQgdG8gdGhlCnB1YmxpYyBpbnRlcm5ldC4gUmVzdHJp" + - "Y3QgYWNjZXNzIHRvIGEgdHJ1c3RlZCBuZXR3b3JrLCBWUE4sIG9yIGFuIGF1dGhlbnRpY2F0aW5n" + - "CnByb3h5IGluIGZyb250IG9mIHRoZSBgbnZjZi11aWAgcm91dGUuClBLBwg4+SevuQcAALkHAABQ" + - "SwMEFAAIAAAAAAAhAAAAAAAAAAAAAAAAAD4ACQBkYXRhL252Y2Ytc2VsZi1tYW5hZ2VkLWluc3Rh" + - "bGxhdGlvbi9yZWZlcmVuY2VzL3B1bGwtc2VjcmV0cy5tZFVUBQABAKbOEiMgSW1hZ2UgUHVsbCBT" + - "ZWNyZXRzIFJlZmVyZW5jZQoKIyMgT3ZlcnZpZXcKCldoZW4gcHVsbGluZyBOVkNGIGltYWdlcyBm" + - "cm9tIGEgcHJpdmF0ZSByZWdpc3RyeSAoZm9yIGV4YW1wbGUsIE5HQwpgbnZjci5pb2ApLCBLdWJl" + - "cm5ldGVzIG5lZWRzIGBpbWFnZVB1bGxTZWNyZXRzYCBvbiBldmVyeSBwb2QuIENvbnRyb2wtcGxh" + - "bmUgYW5kCmNvbXB1dGUtcGxhbmUgY3JlZGVudGlhbHMgYXJlIGNvbmZpZ3VyZWQgaW4gdGhlaXIg" + - "b3duIGNsdXN0ZXIgY29udGV4dHMuIERvIG5vdAp1c2Ugb25lIG1peGVkIG5hbWVzcGFjZSBsaXN0" + - "LgoKVGhlIHJlY29tbWVuZGVkIGNvbnRyb2wtcGxhbmUgYXBwcm9hY2ggdXNlcyBLeXZlcm5vIHRv" + - "IGluamVjdCB0aGUgc2VjcmV0IGludG8KY29udHJvbC1wbGFuZSBwb2RzIGF0IGFkbWlzc2lvbiB0" + - "aW1lLiBUaGUgY29tcHV0ZS1wbGFuZSBjaGFydCBhbmQgb3BlcmF0b3IgdXNlCnRoZSBzZWNyZXQg" + - "aW4gYG52Y2Etb3BlcmF0b3JgIGFuZCBtaXJyb3IgaXQgdG8gYG52Y2Etc3lzdGVtYC4KCiMjIEt5" + - "dmVybm8gQXBwcm9hY2ggKFJlY29tbWVuZGVkKQoKIyMjIDEuIEluc3RhbGwgS3l2ZXJubwoKYGBg" + - "YmFzaApoZWxtIHJlcG8gYWRkIGt5dmVybm8gaHR0cHM6Ly9reXZlcm5vLmdpdGh1Yi5pby9reXZl" + - "cm5vLwpoZWxtIHJlcG8gdXBkYXRlCmhlbG0gaW5zdGFsbCBreXZlcm5vIGt5dmVybm8va3l2ZXJu" + - "byAtbiBreXZlcm5vIC0tY3JlYXRlLW5hbWVzcGFjZSBcCiAgLS1rdWJlLWNvbnRleHQgPGNvbnRy" + - "b2wtcGxhbmUtY29udGV4dD4KYGBgCgojIyMgMi4gQ3JlYXRlIGNvbnRyb2wtcGxhbmUgcHVsbCBz" + + "ICAgICAgIlRoZSBhZ2VudCB1c2VkICdoZWxtZmlsZSAtLXNlbGVjdG9yIHJlbGVhc2UtZ3JvdXA9" + + "ZGVwZW5kZW5jaWVzIHN5bmMnIHRvIGRlcGxveSBvbmx5IGRlcGVuZGVuY2llcyIsCiAgICAgICJU" + + "aGUgYWdlbnQgdmVyaWZpZWQgcG9kcyBpbiBkZXBlbmRlbmN5IG5hbWVzcGFjZXMgKG5hdHMtc3lz" + + "dGVtLCBjYXNzYW5kcmEtc3lzdGVtLCB2YXVsdC1zeXN0ZW0pIiwKICAgICAgIlRoZSBhZ2VudCBw" + + "cm92aWRlZCB0aGUgc2VsZWN0b3IgcmVmZXJlbmNlIGZvciB0YXJnZXRpbmcgb3RoZXIgcmVsZWFz" + + "ZSBncm91cHMiCiAgICBdCiAgfSwKICB7CiAgICAiaWQiOiAiUzctZGVidWctaW1hZ2VwdWxsYmFj" + + "a29mZiIsCiAgICAicXVlc3Rpb24iOiAiQ2Fzc2FuZHJhIHBvZHMgYXJlIGluIEltYWdlUHVsbEJh" + + "Y2tPZmYgd2l0aCA0MDEgVW5hdXRob3JpemVkIiwKICAgICJleHBlY3RlZF9za2lsbCI6ICJudmNm" + + "LXNlbGYtbWFuYWdlZC1pbnN0YWxsYXRpb24iLAogICAgImdyb3VuZF90cnV0aCI6ICJUaGUgYWdl" + + "bnQgcmVhZCB0aGUgZGVidWdnaW5nIHJlZmVyZW5jZSBhbmQgcmFuIGRpYWdub3N0aWMgY29tbWFu" + + "ZHMgdG8gaWRlbnRpZnkgdGhlIHJvb3QgY2F1c2U6IGRlc2NyaWJlZCB0aGUgcG9kLCBjaGVja2Vk" + + "IGZvciBwdWxsIHNlY3JldHMsIGNoZWNrZWQgaW1hZ2VQdWxsU2VjcmV0cyBvbiB0aGUgcG9kIHNw" + + "ZWMgYW5kIHNlcnZpY2UgYWNjb3VudC4gSXQgaWRlbnRpZmllZCB0aGUgaXNzdWUgKG1pc3Npbmcg" + + "c2VjcmV0LCB3cm9uZyBjcmVkZW50aWFscywgb3IgbWlzc2luZyBpbWFnZVB1bGxTZWNyZXRzKSBh" + + "bmQgcHJvdmlkZWQgdGhlIGNvcnJlY3QgZml4LiIsCiAgICAiZXhwZWN0ZWRfYmVoYXZpb3IiOiBb" + + "CiAgICAgICJUaGUgYWdlbnQgcmVhZCB0aGUgbnZjZi1zZWxmLW1hbmFnZWQtaW5zdGFsbGF0aW9u" + + "IFNLSUxMLm1kIGFuZCByZWZlcmVuY2VzL2RlYnVnZ2luZy5tZCIsCiAgICAgICJUaGUgYWdlbnQg" + + "cmFuICdrdWJlY3RsIGRlc2NyaWJlIHBvZCcgdG8gc2VlIHRoZSBJbWFnZVB1bGxCYWNrT2ZmIGV2" + + "ZW50cyIsCiAgICAgICJUaGUgYWdlbnQgY2hlY2tlZCBmb3IgcHVsbCBzZWNyZXRzIGluIHRoZSBj" + + "YXNzYW5kcmEtc3lzdGVtIG5hbWVzcGFjZSIsCiAgICAgICJUaGUgYWdlbnQgY2hlY2tlZCBpbWFn" + + "ZVB1bGxTZWNyZXRzIG9uIHRoZSBwb2Qgc3BlYyBhbmQvb3Igc2VydmljZSBhY2NvdW50IiwKICAg" + + "ICAgIlRoZSBhZ2VudCBpZGVudGlmaWVkIHRoZSByb290IGNhdXNlIGFuZCBwcm92aWRlZCBhIHNw" + + "ZWNpZmljIGZpeCIKICAgIF0KICB9LAogIHsKICAgICJpZCI6ICJTOS1yZW1vdGUtZWtzLXJvdXRp" + + "bmctYW5kLXNlY3JldHMiLAogICAgInF1ZXN0aW9uIjogIkNvbmZpZ3VyZSBhIHJlbW90ZSBFS1Mg" + + "Y29tcHV0ZSBjbHVzdGVyIHRvIHJlYWNoIG15IE5WQ0YgY29udHJvbCBwbGFuZSB0aHJvdWdoIGl0" + + "cyBFbnZveSBHYXRld2F5LCBpbmNsdWRpbmcgTkFUUyBhbmQgcHJpdmF0ZS1yZWdpc3RyeSBwdWxs" + + "IHNlY3JldHMuIiwKICAgICJleHBlY3RlZF9za2lsbCI6ICJudmNmLXNlbGYtbWFuYWdlZC1pbnN0" + + "YWxsYXRpb24iLAogICAgImdyb3VuZF90cnV0aCI6ICJUaGUgYWdlbnQgY29uZmlndXJlZCB0aGUg" + + "Y29udHJvbCBwbGFuZSB0byB1c2UgdGhlIEVudm95IEdhdGV3YXkgY29udHJvbGxlciBpbiBlbnZv" + + "eS1nYXRld2F5LXN5c3RlbSBhbmQgR2F0ZXdheS9udmNmLWdhdGV3YXkgaW4gZW52b3ktZ2F0ZXdh" + + "eSwgc2V0IGFsbCBzaXggcmVtb3RlIHdvcmtlciBlbmRwb2ludHMgYW5kIHRoZSBncnBjLXByb3h5" + + "IGxvY2FsIG92ZXJyaWRlLCBlbmFibGVkIE5WQ0YgYW5kIE5WQ1QgZ1JQQyByb3V0ZXMgcGx1cyBO" + + "QVRTLCBhbmQgZW5hYmxlZCBPcGVuQmFvIGlzc3VlciBkaXNjb3ZlcnkuIEl0IGNvbmZpZ3VyZWQg" + + "dGhlIGJhcmUtR2F0ZXdheSBDTEkgVVJMIGFuZCBIb3N0IGZpZWxkcyBpbmNsdWRpbmcgaWNtc19o" + + "b3N0LCBwbHVzIHRoZSBjb21wdXRlLXBsYW5lIElDTVMsIFJlVmFsLCBhbmQgTkFUUyBleHRlcm5h" + + "bCBVUkxzIGFuZCBob3N0IG92ZXJyaWRlcy4gSXQgY3JlYXRlZCB0aGUgcHVsbCBzZWNyZXQgaW4g" + + "YWxsIGNvbnRyb2wtcGxhbmUgbmFtZXNwYWNlcyBpbmNsdWRpbmcgY2VydC1tYW5hZ2VyLCBidXQg" + + "b25seSBpbiBudmNhLW9wZXJhdG9yIG9uIHRoZSBjb21wdXRlIGNsdXN0ZXI7IHRoZSBjaGFydCBh" + + "bmQgb3BlcmF0b3IgbWlycm9yIGl0IHRvIG52Y2Etc3lzdGVtLiIsCiAgICAiZXhwZWN0ZWRfYmVo" + + "YXZpb3IiOiBbCiAgICAgICJUaGUgYWdlbnQgcmVhZCB0aGUgbnZjZi1zZWxmLW1hbmFnZWQtaW5z" + + "dGFsbGF0aW9uIFNLSUxMLm1kIGJlZm9yZSB0YWtpbmcgYWN0aW9uIiwKICAgICAgIlRoZSBhZ2Vu" + + "dCBzZXQgaW5ncmVzcy5nYXRld2F5QXBpLmNvbnRyb2xsZXJOYW1lc3BhY2UgdG8gZW52b3ktZ2F0" + + "ZXdheS1zeXN0ZW0iLAogICAgICAiVGhlIGFnZW50IHNldCBzaGFyZWQsIGdycGMsIGFuZCBuYXRz" + + "IEdhdGV3YXkgcmVmZXJlbmNlcyB0byBuYW1lIG52Y2YtZ2F0ZXdheSBhbmQgbmFtZXNwYWNlIGVu" + + "dm95LWdhdGV3YXkiLAogICAgICAiVGhlIGFnZW50IHNldCBnbG9iYWwud29ya2VyRW5kcG9pbnRz" + + "Lm52Y2ZTZXJ2aWNlVVJMLCBudmNmR3JwY1NlcnZpY2VVUkwsIG52Y2ZOYXRzU2VydmljZVVSTCwg" + + "bnZjdFNlcnZpY2VVUkwsIG52Y3RHcnBjU2VydmljZVVSTCwgYW5kIGludm9jYXRpb25TZXJ2aWNl" + + "VVJMIiwKICAgICAgIlRoZSBhZ2VudCBzZXQgZ3JwY3Byb3h5Lm52Y2ZHcnBjU2VydmljZVVSTCB0" + + "byB0aGUgY29udHJvbC1wbGFuZS1sb2NhbCBBUEkgZ1JQQyBzZXJ2aWNlIiwKICAgICAgIlRoZSBh" + + "Z2VudCBlbmFibGVkIGJvdGggaW5ncmVzcy5nYXRld2F5QXBpLnJvdXRlcy5udmNmQXBpLmdycGMg" + + "YW5kIG52Y3RBcGkuZ3JwYyB3aXRoIHdvcmtlci1hcGkgYW5kIHdvcmtlci10YXNrcyBob3N0bmFt" + + "ZXMiLAogICAgICAiVGhlIGFnZW50IGV4cGxpY2l0bHkgc2V0IGluZ3Jlc3MuZ2F0ZXdheUFwaS5y" + + "b3V0ZXMubmF0cy5lbmFibGVkIHRvIHRydWUiLAogICAgICAiVGhlIGFnZW50IHNldCBvcGVuYmFv" + + "Lm1pZ3JhdGlvbnMuaXNzdWVyRGlzY292ZXJ5LmVuYWJsZWQgdG8gdHJ1ZSIsCiAgICAgICJUaGUg" + + "YWdlbnQgY29uZmlndXJlZCB0aGUgYmFyZS1HYXRld2F5IENMSSBVUkwgYW5kIEhvc3QgZmllbGRz" + + "LCBpbmNsdWRpbmcgc2NoZW1lLWxlc3MgaWNtc19ob3N0IiwKICAgICAgIlRoZSBhZ2VudCBjb25m" + + "aWd1cmVkIGNvbXB1dGUtcGxhbmUgaWNtc1NlcnZpY2VVUkwsIGljbXNTZXJ2aWNlSG9zdEhlYWRl" + + "ck92ZXJyaWRlLCByZXZhbFNlcnZpY2VVUkwsIHJldmFsU2VydmljZUhvc3RIZWFkZXJPdmVycmlk" + + "ZSwgbmF0c1VSTCwgYW5kIG5hdHNIb3N0T3ZlcnJpZGUiLAogICAgICAiVGhlIGNvbnRyb2wtcGxh" + + "bmUgcHVsbC1zZWNyZXQgbmFtZXNwYWNlIGxpc3QgaW5jbHVkZWQgY2VydC1tYW5hZ2VyIiwKICAg" + + "ICAgIlRoZSBjb21wdXRlLXBsYW5lIHNlY3JldCB3YXMgY3JlYXRlZCBvbmx5IGluIG52Y2Etb3Bl" + + "cmF0b3IgYW5kIG1pcnJvcmVkIGJ5IHRoZSBjaGFydCBhbmQgb3BlcmF0b3IgdG8gbnZjYS1zeXN0" + + "ZW0iCiAgICBdCiAgfSwKICB7CiAgICAiaWQiOiAiRzExLXNwbGl0LXRlYXJkb3duIiwKICAgICJx" + + "dWVzdGlvbiI6ICJUZWFyIGRvd24gbXkgc3BsaXQgY29udHJvbC1wbGFuZSBhbmQgY29tcHV0ZS1w" + + "bGFuZSBkZXBsb3ltZW50IHNhZmVseS4gU2hvdyBib3RoIHRoZSBIZWxtZmlsZSB2YWx1ZXMgYW5k" + + "IENMSSBwcm9maWxlIHZhcmlhbnRzLiIsCiAgICAiZXhwZWN0ZWRfc2tpbGwiOiAibnZjZi1zZWxm" + + "LW1hbmFnZWQtaW5zdGFsbGF0aW9uIiwKICAgICJncm91bmRfdHJ1dGgiOiAiVGhlIEhlbG1maWxl" + + "IGZsb3cgZGVzdHJveWVkIGFuZCBjb25maXJtZWQgZGVyZWdpc3RyYXRpb24gb2YgZXZlcnkgY29t" + + "cHV0ZSBjbHVzdGVyIGJlZm9yZSBkZXN0cm95aW5nIHRoZSBjb250cm9sIHBsYW5lIGFuZCBwYXNz" + + "ZWQgYm90aCBjb250cm9sLXBsYW5lIGt1YmVjb25maWcgZm9ybXMuIEJlZm9yZSBlaXRoZXIgaGFu" + + "ZG9mZiBjb3VsZCBkZXN0cm95IHRoZSBjb250cm9sIHBsYW5lLCBib3RoIHRlYXJkb3duIGZsb3dz" + + "IHdhcm5lZCB0aGF0IG5hbWVzcGFjZSBwcmVzZXJ2YXRpb24gZG9lcyBub3QgcHJlc2VydmUgdGhl" + + "IEhlbG0gcmVsZWFzZSwgcmVxdWlyZWQgZWZmZWN0aXZlIGNlcnRNYW5hZ2VyLmVuYWJsZWQ6IGZh" + + "bHNlIGZvciBhbiBleHRlcm5hbCBpbnN0YWxsYXRpb24gYW5kIGFib3J0ZWQgaWYgdHJ1ZSwgYW5k" + + "IHZlcmlmaWVkIG1hdGNoaW5nIHJlbGVhc2Ugb3duZXJzaGlwIGZvciBhbiBpbnRlbmRlZCBzdGFj" + + "ay1tYW5hZ2VkIHJlbGVhc2UsIGFib3J0aW5nIG9uIG1pc3Npbmcgb3IgbWlzbWF0Y2hlZCBldmlk" + + "ZW5jZS4gVGhleSBwcmVzZXJ2ZWQgdGhlIG5hbWVzcGFjZSBieSBkZWZhdWx0IGFuZCBkaWQgbm90" + + "IGRlbGV0ZSBpdCB3aXRob3V0IG5hbWVzcGFjZSBwcm92ZW5hbmNlIGFuZCBleHBsaWNpdCBjb25m" + + "aXJtYXRpb24uIFRoZSBDTEkgZmxvdyByZXRhaW5lZCB0aGUgZ2VuZXJhdGVkIHJlZ2lzdHJhdGlv" + + "biB2YWx1ZXMsIHVzZWQgdGhlIGZ1bGwtcHJlZml4IHN0YXR1cyBjb21tYW5kIHRvIGluc3BlY3Qg" + + "bGl2ZSByZWdpc3RyYXRpb25zLCBzdGF0ZWQgdGhhdCBuYW1lZC1jbHVzdGVyIHBsYW4tb25seSBk" + + "b2VzIG5vdCBxdWVyeSBJQ01TIGFuZCBjYW5ub3QgcHJlZGljdCBjb25kaXRpb25hbCBjb250cm9s" + + "LXBsYW5lIHJlbW92YWwsIGFuZCBnb3QgZXhwbGljaXQgY29uZmlybWF0aW9uIHdoZW4gdGhlIHRh" + + "cmdldCB3YXMgdGhlIGxhc3QgcmVnaXN0ZXJlZCBjbHVzdGVyLiBJdCB0aGVuIHByZXZpZXdlZCBh" + + "bmQgcmFuIGRvd24gd2l0aCB0aGUgZnVsbCBwZXJzaXN0ZW50IHByZWZpeCwgcmVtb3ZpbmcgdGhl" + + "IGNvbnRyb2wgcGxhbmUgb25seSBhZnRlciB0aGUgbGFzdCByZWdpc3RyYXRpb24gd2FzIGdvbmUu" + + "IiwKICAgICJleHBlY3RlZF9iZWhhdmlvciI6IFsKICAgICAgIlRoZSBhZ2VudCByZWFkIHRoZSBu" + + "dmNmLXNlbGYtbWFuYWdlZC1pbnN0YWxsYXRpb24gU0tJTEwubWQgYmVmb3JlIHRha2luZyBhY3Rp" + + "b24iLAogICAgICAiRm9yIGV2ZXJ5IGNvbXB1dGUgY2x1c3RlciwgdGhlIEhlbG1maWxlIGZsb3cg" + + "cmFuIHRoZSBjb21wdXRlLXBsYW5lIE1ha2VmaWxlIGRlc3Ryb3kgdGFyZ2V0IHdpdGggaXRzIGNv" + + "bXB1dGUga3ViZWNvbmZpZyIsCiAgICAgICJBZnRlciBjb25maXJtYXRpb24sIHRoZSBIZWxtZmls" + + "ZSBmbG93IHJhbiBjbHVzdGVyIGRlbGV0ZSB3aXRoIGNsdXN0ZXJJRCBhbmQgbmNhSUQgYmVmb3Jl" + + "IGNvbnRyb2wtcGxhbmUgZGVzdHJ1Y3Rpb24iLAogICAgICAiQmVmb3JlIGVpdGhlciBoYW5kb2Zm" + + "IGNvdWxkIGRlc3Ryb3kgdGhlIGNvbnRyb2wgcGxhbmUsIGJvdGggdGVhcmRvd24gZmxvd3Mgd2Fy" + + "bmVkIHRoYXQgbmFtZXNwYWNlIHByZXNlcnZhdGlvbiBkb2VzIG5vdCBwcmVzZXJ2ZSB0aGUgSGVs" + + "bSByZWxlYXNlLCByZXF1aXJlZCBlZmZlY3RpdmUgY2VydE1hbmFnZXIuZW5hYmxlZDogZmFsc2Ug" + + "Zm9yIGFuIGV4dGVybmFsIGluc3RhbGxhdGlvbiBhbmQgYWJvcnRlZCBpZiB0cnVlLCBhbmQgdmVy" + + "aWZpZWQgaW50ZW5kZWQgc3RhY2sgb3duZXJzaGlwIHdoZW4gZW5hYmxlZCwgYWJvcnRpbmcgb24g" + + "bWlzc2luZyBvciBtaXNtYXRjaGVkIGV2aWRlbmNlIiwKICAgICAgIkJvdGggdGVhcmRvd24gZmxv" + + "d3MgcHJlc2VydmVkIHRoZSBjZXJ0LW1hbmFnZXIgbmFtZXNwYWNlIGJ5IGRlZmF1bHQgYW5kIGRp" + + "ZCBub3QgZGVsZXRlIGl0IHdpdGhvdXQgbmFtZXNwYWNlIHByb3ZlbmFuY2UgYW5kIGV4cGxpY2l0" + + "IGNvbmZpcm1hdGlvbiIsCiAgICAgICJUaGUgSGVsbWZpbGUgZmxvdyByYW4gY29udHJvbC1wbGFu" + + "ZSBNYWtlZmlsZSBkZXN0cm95IHdpdGggYm90aCBLVUJFQ09ORklHIGFuZCBLVUJFQ09ORklHX0ZJ" + + "TEUiLAogICAgICAiVGhlIENMSSBmbG93IHJldGFpbmVkIG91dC88Y2x1c3Rlci1uYW1lPi1yZWdp" + + "c3Rlci12YWx1ZXMueWFtbCB1bnRpbCB0ZWFyZG93biBjb21wbGV0ZWQiLAogICAgICAiQmVmb3Jl" + + "IHJlYWwgZG93biwgdGhlIENMSSBmbG93IHVzZWQgdGhlIGZ1bGwtcHJlZml4IHN0YXR1cyBjb21t" + + "YW5kIHRvIGluc3BlY3QgbGl2ZSByZWdpc3RyYXRpb25zLCBzdGF0ZWQgdGhhdCBuYW1lZC1jbHVz" + + "dGVyIHBsYW4tb25seSBkb2VzIG5vdCBxdWVyeSBJQ01TIGFuZCBjYW5ub3QgcHJlZGljdCBjb25k" + + "aXRpb25hbCBjb250cm9sLXBsYW5lIHJlbW92YWwsIHdhcm5lZCB3aGVuIHRoZSB0YXJnZXQgd2Fz" + + "IHRoZSBsYXN0IHJlZ2lzdGVyZWQgY2x1c3RlciwgYW5kIGdvdCBleHBsaWNpdCBjb25maXJtYXRp" + + "b24iLAogICAgICAiVGhlIENMSSBmbG93IHVzZWQgLS1jb25maWcsIGJvdGggc3RhY2sgcGF0aHMs" + + "IC0tZW52LCAtLXBsYWluLCBib3RoIGNvbnRleHRzLCBhbmQgLS1pY21zLXVybCBmb3IgZG93biAt" + + "LXBsYW4tb25seSBhbmQgdGhlbiBkb3duIiwKICAgICAgIlRoZSBhZ2VudCBzdGF0ZWQgdGhhdCBk" + + "b3duIHVucmVnaXN0ZXJzIGVhY2ggY29tcHV0ZSBjbHVzdGVyIGFuZCByZW1vdmVzIHRoZSBjb250" + + "cm9sIHBsYW5lIG9ubHkgYWZ0ZXIgbm8gcmVnaXN0cmF0aW9ucyByZW1haW4iCiAgICBdCiAgfSwK" + + "ICB7CiAgICAiaWQiOiAiUzEzLXZhbHVlcy1wcmVjZWRlbmNlIiwKICAgICJxdWVzdGlvbiI6ICJJ" + + "IGFkZGVkIGEgdmFsdWUgdG8gbXkgZW52aXJvbm1lbnQgWUFNTCBidXQgaXQncyBiZWluZyBzaWxl" + + "bnRseSBpZ25vcmVkLiBIb3cgZG8gaGVsbWZpbGUgdmFsdWVzIHdvcmsgaW4gTlZDRj8iLAogICAg" + + "ImV4cGVjdGVkX3NraWxsIjogIm52Y2Ytc2VsZi1tYW5hZ2VkLWluc3RhbGxhdGlvbiIsCiAgICAi" + + "Z3JvdW5kX3RydXRoIjogIlRoZSBhZ2VudCBleHBsYWluZWQgdGhlIHZhbHVlcyBwcmVjZWRlbmNl" + + "IGNoYWluIChiYXNlLnlhbWwsIGVudiBZQU1MLCBnbG9iYWwueWFtbC5nb3RtcGwsIHNlY3JldHMs" + + "IGlubGluZSB2YWx1ZXMpLCBpZGVudGlmaWVkIHRoYXQgZ2xvYmFsLnlhbWwuZ290bXBsIG9ubHkg" + + "cGFzc2VzIHRocm91Z2ggc3BlY2lmaWMga2V5cywgYW5kIGV4cGxhaW5lZCB0aGF0IGFyYml0cmFy" + + "eSBjaGFydCB2YWx1ZXMgbXVzdCB1c2UgcmVsZWFzZSBpbmxpbmUgdmFsdWVzIGJsb2Nrcy4iLAog" + + "ICAgImV4cGVjdGVkX2JlaGF2aW9yIjogWwogICAgICAiVGhlIGFnZW50IHJlYWQgdGhlIG52Y2Yt" + + "c2VsZi1tYW5hZ2VkLWluc3RhbGxhdGlvbiBTS0lMTC5tZCBiZWZvcmUgdGFraW5nIGFjdGlvbiIs" + + "CiAgICAgICJUaGUgYWdlbnQgZXhwbGFpbmVkIHRoZSB2YWx1ZXMgcHJlY2VkZW5jZSBjaGFpbjog" + + "YmFzZS55YW1sIC0+IGVudiBZQU1MIC0+IGdsb2JhbC55YW1sLmdvdG1wbCAtPiBzZWNyZXRzIC0+" + + "IGlubGluZSB2YWx1ZXMiLAogICAgICAiVGhlIGFnZW50IGlkZW50aWZpZWQgdGhhdCBnbG9iYWwu" + + "eWFtbC5nb3RtcGwgb25seSBwYXNzZXMgdGhyb3VnaCBzcGVjaWZpYyBrZXlzIChjYXNzYW5kcmEu" + + "cmVwbGljYUNvdW50LCBnbG9iYWwuc3RvcmFnZUNsYXNzLCBnbG9iYWwuaW1hZ2UsIGV0Yy4pIiwK" + + "ICAgICAgIlRoZSBhZ2VudCBleHBsYWluZWQgdGhhdCBhcmJpdHJhcnkgY2hhcnQgdmFsdWVzIG11" + + "c3QgYmUgc2V0IHZpYSByZWxlYXNlIGlubGluZSB2YWx1ZXM6IGJsb2NrcyIsCiAgICAgICJUaGUg" + + "YWdlbnQgbGlzdGVkIGV4YW1wbGVzIG9mIHZhbHVlcyB0aGF0IGFyZSBwcm9wYWdhdGVkIGFuZCBh" + + "cmJpdHJhcnkgY2hhcnQgdmFsdWVzIHRoYXQgcmVxdWlyZSBhIHJlbGVhc2UgdmFsdWVzIGJsb2Nr" + + "IgogICAgXQogIH0sCiAgewogICAgImlkIjogIlMxNC1lbmFibGUtbGxtLWZ1bmN0aW9ucyIsCiAg" + + "ICAicXVlc3Rpb24iOiAiSSBuZWVkIHRvIGRlcGxveSBmdW5jdGlvblR5cGU6IExMTSBmdW5jdGlv" + + "bnMgb24gYSBzZWxmLW1hbmFnZWQsIHNpbmdsZS1ub2RlIGlzb2xhdGVkIHRlc3QgY2x1c3Rlci4g" + + "V2hhdCBIZWxtZmlsZSBjb25maWd1cmF0aW9uIGFuZCB2ZXJpZmljYXRpb24gc3RlcHMgZG8gSSBu" + + "ZWVkPyIsCiAgICAiZXhwZWN0ZWRfc2tpbGwiOiAibnZjZi1zZWxmLW1hbmFnZWQtaW5zdGFsbGF0" + + "aW9uIiwKICAgICJncm91bmRfdHJ1dGgiOiAiVGhlIGFnZW50IGVuYWJsZWQgdGhlIExMTSBhZGRv" + + "biB3aXRoIGFkZG9ucy5sbG0uZW5hYmxlZCwgY29uZmlndXJlZCBzaW5nbGUgcmVwbGljYXMgZm9y" + + "IHRoZSBMTE0gQVBJIEdhdGV3YXkgYW5kIHJlcXVlc3Qgcm91dGVyLCBhbmQgZXhwbGFpbmVkIHRo" + + "YXQgdGhlIHJlcXVlc3Qgcm91dGVyIHVzZXMgcG93ZXItb2YtdHdvIHdoZW4gbm8gbG9hZC1iYWxh" + + "bmNlciBjb25maWd1cmF0aW9uIGlzIHNldC4gSXQgbGlua2VkIHRoZSBjYW5vbmljYWwgcmVxdWVz" + + "dC1yb3V0ZXIgbG9hZC1iYWxhbmNpbmcgZ3VpZGUgZm9yIG90aGVyIHJvdXRpbmcgbWV0aG9kcy4g" + + "Rm9yIHRoZSBpc29sYXRlZCBwbGFpbnRleHQgY2x1c3RlciwgaXQgcmVsaWVkIG9uIHRoZSBzZWxm" + + "LW1hbmFnZWQgc3RhY2sgZGVmYXVsdCBmb3IgdGhlIGJ1bmRsZWQgZ2F0ZXdheS10by1BUEkgYXV0" + + "aGVudGljYXRpb24gaG9wIGFuZCBpbmNsdWRlZCBhZ2VudENvbmZpZy5tZXJnZUNvbmZpZy53b3Jr" + + "bG9hZC5zdGFyZ2F0ZVFVSUNJbnNlY3VyZSBmb3IgdGhlIHNlcGFyYXRlIHdvcmtlciB0cmFuc3Bv" + + "cnQsIHdpdGggYSB3YXJuaW5nIHRoYXQgcGxhaW50ZXh0IHdvcmtlciB0cmFuc3BvcnQgaXMgbm90" + + "IGZvciBwcm9kdWN0aW9uLiBJdCByZWZyZXNoZWQgdGhlIGNvbXB1dGUgcGxhbmUgdXNpbmcgdGhl" + + "IHNhbWUgaGFuZG9mZiB1c2VkIGR1cmluZyBpbnN0YWxsYXRpb24sIHJlZGVwbG95ZWQgZXhpc3Rp" + + "bmcgTExNIGZ1bmN0aW9ucywgYW5kIHZlcmlmaWVkIHRoZSBMTE0gZGVwbG95bWVudHMsIHJvdXRl" + + "LCBhbmQgbGxtLXdvcmtlciBzaWRlY2FyLiIsCiAgICAiZXhwZWN0ZWRfYmVoYXZpb3IiOiBbCiAg" + + "ICAgICJUaGUgYWdlbnQgdXNlZCBhZGRvbnMubGxtLmVuYWJsZWQsIG5vdCB0aGUgb2Jzb2xldGUg" + + "bGxtLmVuYWJsZWQgcGF0aCIsCiAgICAgICJUaGUgYWdlbnQgc2V0IGdhdGV3YXkgYW5kIHJlcXVl" + + "c3RSb3V0ZXIgcmVwbGljYUNvdW50IHRvIDEgZm9yIHRoZSBzaW5nbGUtbm9kZSB0ZXN0IGNsdXN0" + + "ZXIiLAogICAgICAiVGhlIGFnZW50IHN0YXRlZCB0aGF0IHRoZSByZXF1ZXN0IHJvdXRlciBkZWZh" + + "dWx0cyB0byBwb3dlci1vZi10d28gYW5kIGxpbmtlZCB0aGUgY2Fub25pY2FsIHJlcXVlc3Qtcm91" + + "dGVyIGxvYWQtYmFsYW5jaW5nIGd1aWRlIGZvciBvdGhlciBtZXRob2RzIiwKICAgICAgIlRoZSBh" + + "Z2VudCBleHBsYWluZWQgdGhhdCB0aGUgYnVuZGxlZCBnYXRld2F5LXRvLUFQSSBhdXRoZW50aWNh" + + "dGlvbiBob3AgZGVmYXVsdHMgdG8gcGxhaW50ZXh0LCBpbmNsdWRlZCB3b3JrbG9hZC5zdGFyZ2F0" + + "ZVFVSUNJbnNlY3VyZSBvbmx5IGZvciB0aGUgaXNvbGF0ZWQgd29ya2VyIHRyYW5zcG9ydCwgYW5k" + + "IHdhcm5lZCBhZ2FpbnN0IHBsYWludGV4dCB3b3JrZXIgdHJhbnNwb3J0IGluIHByb2R1Y3Rpb24i" + + "LAogICAgICAiVGhlIGFnZW50IGFwcGxpZWQgdGhlIGNvbnRyb2wtcGxhbmUgSGVsbWZpbGUgZW52" + + "aXJvbm1lbnQsIHJlZnJlc2hlZCB0aGUgY29tcHV0ZSBwbGFuZSB3aXRoIHRoZSBzYW1lIHNlbGVj" + + "dGVkIGhhbmRvZmYsIGFuZCByZWNyZWF0ZWQgb3IgcmVkZXBsb3llZCBleGlzdGluZyBMTE0gZnVu" + + "Y3Rpb25zIiwKICAgICAgIlRoZSBhZ2VudCB2ZXJpZmllZCBsbG0tYXBpLWdhdGV3YXksIGxsbS1y" + + "ZXF1ZXN0LXJvdXRlciwgdGhlIExMTSBIVFRQUm91dGUsIGFuZCB0aGUgbGxtLXdvcmtlciBzaWRl" + + "Y2FyIGluY2x1ZGluZyAtLXF1aWMtaW5zZWN1cmUiCiAgICBdCiAgfSwKICB7CiAgICAiaWQiOiAi" + + "RzgtTkVHLW5vdC1mb3ItazNkIiwKICAgICJxdWVzdGlvbiI6ICJTZXQgdXAgYSBsb2NhbCBOVkNG" + + "IGRldiBjbHVzdGVyIHVzaW5nIGszZCBmb3IgdGVzdGluZyIsCiAgICAiZXhwZWN0ZWRfc2tpbGwi" + + "OiBudWxsLAogICAgInNob3VsZF90cmlnZ2VyIjogZmFsc2UsCiAgICAiZ3JvdW5kX3RydXRoIjog" + + "IlRoaXMgaXMgYSBsb2NhbCBrM2QgZGV2ZWxvcG1lbnQgcmVxdWVzdCB0aGF0IGJlbG9uZ3MgdG8g" + + "dGhlIG52Y2Ytc2VsZi1ob3N0ZWQtbG9jYWwtZGV2IHNraWxsLCBub3QgbnZjZi1zZWxmLW1hbmFn" + + "ZWQtaW5zdGFsbGF0aW9uLiBUaGUgYWdlbnQgZGlkIG5vdCByZWFkIG9yIHVzZSB0aGUgc2VsZi1t" + + "YW5hZ2VkIGluc3RhbGxhdGlvbiBza2lsbCBhbmQgaW5zdGVhZCBwcm92aWRlZCBrM2Qtc3BlY2lm" + + "aWMgZ3VpZGFuY2UuIiwKICAgICJleHBlY3RlZF9iZWhhdmlvciI6IFsKICAgICAgIlRoZSBhZ2Vu" + + "dCBkaWQgbm90IHJlYWQgbnZjZi1zZWxmLW1hbmFnZWQtaW5zdGFsbGF0aW9uL1NLSUxMLm1kIiwK" + + "ICAgICAgIlRoZSBhZ2VudCBkaWQgbm90IHByb3ZpZGUgaGVsbWZpbGUtYmFzZWQgcHJvZHVjdGlv" + + "biBkZXBsb3ltZW50IGluc3RydWN0aW9ucyIsCiAgICAgICJUaGUgYWdlbnQgcHJvdmlkZWQgazNk" + + "LXNwZWNpZmljIGxvY2FsIGRldmVsb3BtZW50IGd1aWRhbmNlIG9yIGRlZmVycmVkIHRvIHRoZSBh" + + "cHByb3ByaWF0ZSBsb2NhbC1kZXYgc2tpbGwiCiAgICBdCiAgfQpdClBLBwhHVWpWSlYAAEpWAABQ" + + "SwMEFAAIAAAAAAAhAAAAAAAAAAAAAAAAAC8ACQBkYXRhL252Y2Ytc2VsZi1tYW5hZ2VkLWluc3Rh" + + "bGxhdGlvbi9leGFtcGxlcy5tZFVUBQABAKbOEiMgRXhhbXBsZXMKCldvcmtlZCBleGFtcGxlcyBi" + + "YXNlZCBvbiByZWFsIGRlcGxveW1lbnQgc2NlbmFyaW9zLgoKIyMgRXhhbXBsZSAxOiBPdmVycmlk" + + "ZSBDYXNzYW5kcmEgUmVzb3VyY2VzCgpUaGUgZGVmYXVsdCBDYXNzYW5kcmEgcmVzb3VyY2UgbGlt" + + "aXRzIGNhdXNlIE9PTSBvbiBsYXJnZSBpbnN0YW5jZSB0eXBlcyAoZS5nLiwgYHA1LjQ4eGxhcmdl" + + "YCkuCgpQcm9ibGVtOiBDYXNzYW5kcmEgcG9kcyByZXN0YXJ0IHdpdGggYE9PTUtpbGxlZGAuCgpT" + + "b2x1dGlvbjogT3ZlcnJpZGUgcmVzb3VyY2VzIHZpYSBoZWxtZmlsZSByZWxlYXNlIHZhbHVlcyBi" + + "bG9jay4KCiMjIyBTdGVwIDE6IENhcHR1cmUgYmFzZWxpbmUKCmBgYGJhc2gKSEVMTUZJTEVfRU5W" + + "PTxlbnY+IGhlbG1maWxlIC0tc2VsZWN0b3IgbmFtZT1jYXNzYW5kcmEgdGVtcGxhdGUgPiAvdG1w" + + "L2Nhc3MtYmVmb3JlLnlhbWwKZ3JlcCAtQTggJ3Jlc291cmNlczonIC90bXAvY2Fzcy1iZWZvcmUu" + + "eWFtbCB8IGhlYWQgLTEwCmBgYAoKIyMjIFN0ZXAgMjogQWRkIG92ZXJyaWRlIHRvIGhlbG1maWxl" + + "LmQvMDEtZGVwZW5kZW5jaWVzLnlhbWwuZ290bXBsCgpJbiB0aGUgZXhhbXBsZXMgYmVsb3csIGA8" + + "cHJpdmF0ZS12YWx1ZXM+YCByZWZlcnMgdG8gdGhlIGBzZWNyZXRzL2AgZGlyZWN0b3J5IGF0IHRo" + + "ZSBoZWxtZmlsZSBzdGFjayByb290LgoKYGBgeWFtbAotIG5hbWU6IGNhc3NhbmRyYQogIHZlcnNp" + + "b246IDAuOC4wCiAgY29uZGl0aW9uOiBjYXNzYW5kcmEuZW5hYmxlZAogIG5hbWVzcGFjZTogY2Fz" + + "c2FuZHJhLXN5c3RlbQogIDw8OiAqZGVwZW5kZW5jeQogIHZhbHVlczoKICAgIC0gLi4vZ2xvYmFs" + + "LnlhbWwuZ290bXBsCiAgICAtIC4uLzxwcml2YXRlLXZhbHVlcz4ve3sgcmVxdWlyZWRFbnYgIkhF" + + "TE1GSUxFX0VOViIgfX0tc2VjcmV0cy55YW1sCiAgICAtIGNhc3NhbmRyYToKICAgICAgICByZXNv" + + "dXJjZXM6CiAgICAgICAgICBsaW1pdHM6CiAgICAgICAgICAgIGNwdTogIjgiCiAgICAgICAgICAg" + + "IG1lbW9yeTogODE5Mk1pCiAgICAgICAgICByZXF1ZXN0czoKICAgICAgICAgICAgY3B1OiAiMiIK" + + "ICAgICAgICAgICAgbWVtb3J5OiA0MDk2TWkKYGBgCgojIyMgU3RlcCAzOiBWZXJpZnkgYW5kIGFw" + + "cGx5CgpgYGBiYXNoCkhFTE1GSUxFX0VOVj08ZW52PiBoZWxtZmlsZSAtLXNlbGVjdG9yIG5hbWU9" + + "Y2Fzc2FuZHJhIHRlbXBsYXRlID4gL3RtcC9jYXNzLWFmdGVyLnlhbWwKZGlmZiAvdG1wL2Nhc3Mt" + + "YmVmb3JlLnlhbWwgL3RtcC9jYXNzLWFmdGVyLnlhbWwgICMgQ29uZmlybSByZXNvdXJjZXMgY2hh" + + "bmdlZApIRUxNRklMRV9FTlY9PGVudj4gaGVsbWZpbGUgLS1zZWxlY3RvciBuYW1lPWNhc3NhbmRy" + + "YSBzeW5jCmBgYAoKTm90ZTogYHJlc291cmNlUHJlc2V0YCAoYSBCaXRuYW1pIGZlYXR1cmUpIGlz" + + "IG5vdCBhdmFpbGFibGUgaW4gdGhlIE5WQ0YgY2Fzc2FuZHJhIHdyYXBwZXIgY2hhcnQuIFVzZSBl" + + "eHBsaWNpdCBgcmVzb3VyY2VzYCBpbnN0ZWFkLgoKLS0tCgojIyBFeGFtcGxlIDI6IERlcGxveSBm" + + "cm9tIE5HQyBQcml2YXRlIFJlZ2lzdHJ5IHdpdGggUHVsbCBTZWNyZXRzCgpEZXBsb3kgdGhlIE5W" + + "Q0Ygc3RhY2sgcHVsbGluZyBpbWFnZXMgZGlyZWN0bHkgZnJvbSBOR0MgKGBudmNyLmlvYCkgaW5z" + + "dGVhZCBvZiBhIG1pcnJvcmVkIHJlZ2lzdHJ5LCB1c2luZyBpbWFnZSBwdWxsIHNlY3JldHMuCgoj" + + "IyMgU3RlcCAxOiBDb25maWd1cmUgZW52aXJvbm1lbnQgZm9yIE5HQwoKYGBgeWFtbAojIGVudmly" + + "b25tZW50cy88ZW52Pi55YW1sCmdsb2JhbDoKICBoZWxtOgogICAgc291cmNlczoKICAgICAgcmVn" + + "aXN0cnk6IG52Y3IuaW8KICAgICAgcmVwb3NpdG9yeTogbnZpZGlhL252Y2YKICBpbWFnZToKICAg" + + "IHJlZ2lzdHJ5OiBudmNyLmlvCiAgICByZXBvc2l0b3J5OiBudmlkaWEvbnZjZgpgYGAKCiMjIyBT" + + "dGVwIDI6IEF1dGhlbnRpY2F0ZSBsb2NhbGx5IChmb3IgaGVsbWZpbGUgY2hhcnQgcHVsbHMpCgpg" + + "YGBiYXNoCmRvY2tlciBsb2dpbiBudmNyLmlvIC11ICckb2F1dGh0b2tlbicgLXAgIiROR0NfQVBJ" + + "X0tFWSIKaGVsbSByZWdpc3RyeSBsb2dpbiBudmNyLmlvIC11ICckb2F1dGh0b2tlbicgLXAgIiRO" + + "R0NfQVBJX0tFWSIKYGBgCgojIyMgU3RlcCAzOiBDcmVhdGUgbmFtZXNwYWNlcyBhbmQgcHVsbCBz" + "ZWNyZXRzCgpgYGBiYXNoCmV4cG9ydCBOR0NfQVBJX0tFWT0iPHlvdXItbmdjLWFwaS1rZXk+IgoK" + "Zm9yIG5zIGluIGNhc3NhbmRyYS1zeXN0ZW0gbmF0cy1zeXN0ZW0gbnZjZiBhcGkta2V5cyBlc3Mg" + "c2lzIHZhdWx0LXN5c3RlbSBjZXJ0LW1hbmFnZXI7IGRvCiAga3ViZWN0bCAtLWNvbnRleHQgPGNv" + @@ -3593,123 +3315,951 @@ const skillDataZipBase64 = "" + "XAogICAgLS1kb2NrZXItdXNlcm5hbWU9JyRvYXV0aHRva2VuJyBcCiAgICAtLWRvY2tlci1wYXNz" + "d29yZD0iJE5HQ19BUElfS0VZIiBcCiAgICAtLW5hbWVzcGFjZT0iJG5zIiBcCiAgICAtLWRyeS1y" + "dW49Y2xpZW50IC1vIHlhbWwgfCBrdWJlY3RsIC0tY29udGV4dCA8Y29udHJvbC1wbGFuZS1jb250" + - "ZXh0PiBhcHBseSAtZiAtCmRvbmUKYGBgCgpGb3Igbm9uLU5HQyByZWdpc3RyaWVzLCByZXBsYWNl" + - "IGAtLWRvY2tlci1zZXJ2ZXJgLCBgLS1kb2NrZXItdXNlcm5hbWVgLCBhbmQgYC0tZG9ja2VyLXBh" + - "c3N3b3JkYC4KCiMjIyAzLiBBcHBseSBLeXZlcm5vIENsdXN0ZXJQb2xpY3kKCmBgYHlhbWwKYXBp" + - "VmVyc2lvbjoga3l2ZXJuby5pby92MQpraW5kOiBDbHVzdGVyUG9saWN5Cm1ldGFkYXRhOgogIG5h" + - "bWU6IG52Y2YtYWRkLWltYWdlcHVsbHNlY3JldHMKc3BlYzoKICBiYWNrZ3JvdW5kOiBmYWxzZQog" + - "IHJ1bGVzOgogICAgLSBuYW1lOiBhZGQtaW1hZ2VwdWxsc2VjcmV0LXRvLW52Y2YtcG9kcwogICAg" + - "ICBtYXRjaDoKICAgICAgICBhbnk6CiAgICAgICAgLSByZXNvdXJjZXM6CiAgICAgICAgICAgIGtp" + - "bmRzOgogICAgICAgICAgICAtIFBvZAogICAgICAgICAgICBuYW1lc3BhY2VzOgogICAgICAgICAg" + - "ICAtICJudmNmIgogICAgICAgICAgICAtICJhcGkta2V5cyIKICAgICAgICAgICAgLSAic2lzIgog" + - "ICAgICAgICAgICAtICJlc3MiCiAgICAgICAgICAgIC0gIm5hdHMtc3lzdGVtIgogICAgICAgICAg" + - "ICAtICJjYXNzYW5kcmEtc3lzdGVtIgogICAgICAgICAgICAtICJ2YXVsdC1zeXN0ZW0iCiAgICAg" + - "ICAgICAgIC0gImNlcnQtbWFuYWdlciIKICAgICAgbXV0YXRlOgogICAgICAgIHBhdGNoU3RyYXRl" + - "Z2ljTWVyZ2U6CiAgICAgICAgICBtZXRhZGF0YToKICAgICAgICAgICAgYW5ub3RhdGlvbnM6CiAg" + - "ICAgICAgICAgICAgbnZjZi5udmlkaWEuY29tL2ltYWdlcHVsbHNlY3JldC1pbmplY3RlZC1ieTog" + - "a3l2ZXJubwogICAgICAgICAgc3BlYzoKICAgICAgICAgICAgaW1hZ2VQdWxsU2VjcmV0czoKICAg" + - "ICAgICAgICAgLSBuYW1lOiBudmNyLWNyZWRzCmBgYAoKYGBgYmFzaAprdWJlY3RsIC0tY29udGV4" + - "dCA8Y29udHJvbC1wbGFuZS1jb250ZXh0PiBhcHBseSBcCiAgLWYga3l2ZXJuby1pbWFnZXB1bGxz" + - "ZWNyZXQtcG9saWN5LnlhbWwKYGBgCgojIyMgNC4gSW5zdGFsbCB0aGUgY29udHJvbCBwbGFuZQoK" + - "Q29udGludWUgd2l0aCB0aGUgc2VsZWN0ZWQgY29udHJvbC1wbGFuZSBpbnN0YWxsYXRpb24gaW4K" + - "W1NwbGl0IENvbXB1dGUtUGxhbmUgSW5zdGFsbGF0aW9uXShjb21wdXRlLXBsYW5lLWluc3RhbGxh" + - "dGlvbi5tZCkuIE5vCmNvbnRyb2wtcGxhbmUgaGVsbWZpbGUgbW9kaWZpY2F0aW9ucyBhcmUgbmVl" + - "ZGVkLiBLeXZlcm5vIGluamVjdHMgdGhlIHB1bGwKc2VjcmV0IGludG8gbmV3IGNvbnRyb2wtcGxh" + - "bmUgcG9kcy4KCiMjIENvbXB1dGUtUGxhbmUgU2VjcmV0CgpDcmVhdGUgdGhlIHNlY3JldCBvbmx5" + - "IGluIGBudmNhLW9wZXJhdG9yYCBvbiB0aGUgY29tcHV0ZSBjbHVzdGVyOgoKYGBgYmFzaAprdWJl" + - "Y3RsIC0tY29udGV4dCA8Y29tcHV0ZS1wbGFuZS1jb250ZXh0PiBjcmVhdGUgbmFtZXNwYWNlIG52" + - "Y2Etb3BlcmF0b3IgXAogIC0tZHJ5LXJ1bj1jbGllbnQgLW8geWFtbCB8IFwKICBrdWJlY3RsIC0t" + - "Y29udGV4dCA8Y29tcHV0ZS1wbGFuZS1jb250ZXh0PiBhcHBseSAtZiAtCgprdWJlY3RsIC0tY29u" + - "dGV4dCA8Y29tcHV0ZS1wbGFuZS1jb250ZXh0PiBjcmVhdGUgc2VjcmV0IGRvY2tlci1yZWdpc3Ry" + - "eSBudmNyLWNyZWRzIFwKICAtLWRvY2tlci1zZXJ2ZXI9bnZjci5pbyBcCiAgLS1kb2NrZXItdXNl" + - "cm5hbWU9JyRvYXV0aHRva2VuJyBcCiAgLS1kb2NrZXItcGFzc3dvcmQ9IiROR0NfQVBJX0tFWSIg" + - "XAogIC0tbmFtZXNwYWNlPW52Y2Etb3BlcmF0b3IgXAogIC0tZHJ5LXJ1bj1jbGllbnQgLW8geWFt" + - "bCB8IFwKICBrdWJlY3RsIC0tY29udGV4dCA8Y29tcHV0ZS1wbGFuZS1jb250ZXh0PiBhcHBseSAt" + - "ZiAtCmBgYAoKUmVmZXJlbmNlIGl0IGluIHRoZSBjb21wdXRlLXBsYW5lIGVudmlyb25tZW50IGZp" + - "bGU6CgpgYGB5YW1sCmdsb2JhbDoKICBpbWFnZVB1bGxTZWNyZXRzOgogICAgLSBuYW1lOiBudmNy" + - "LWNyZWRzCmBgYAoKVGhlIGNoYXJ0IGFuZCBvcGVyYXRvciB1c2UgdGhhdCByZWZlcmVuY2UgaW4g" + - "YG52Y2Etb3BlcmF0b3JgIGFuZCBtaXJyb3IgdGhlCnNlY3JldCB0byBgbnZjYS1zeXN0ZW1gLiBE" + - "byBub3QgY3JlYXRlIGEgYnJvYWRlciBtYW51YWwgY29tcHV0ZSBuYW1lc3BhY2UgbGlzdC4gRm9s" + - "bG93CltTcGxpdCBDb21wdXRlLVBsYW5lIEluc3RhbGxhdGlvbl0oY29tcHV0ZS1wbGFuZS1pbnN0" + - "YWxsYXRpb24ubWQpIGZvciB0aGUKc2VsZWN0ZWQgaW5zdGFsbCBmbG93LgoKIyMgVmVyaWZpY2F0" + - "aW9uCgpgYGBiYXNoCiMgQ2hlY2sgdGhhdCBhIGNvbnRyb2wtcGxhbmUgcG9kIGhhcyB0aGUgaW5q" + - "ZWN0ZWQgc2VjcmV0Cmt1YmVjdGwgLS1jb250ZXh0IDxjb250cm9sLXBsYW5lLWNvbnRleHQ+IGdl" + - "dCBwb2QgLW4gPG5hbWVzcGFjZT4gPHBvZC1uYW1lPiBcCiAgLW8ganNvbnBhdGg9J3suc3BlYy5p" + - "bWFnZVB1bGxTZWNyZXRzfScKIyBFeHBlY3RlZDogW3sibmFtZSI6Im52Y3ItY3JlZHMifV0KCiMg" + - "Q2hlY2sgdGhlIEt5dmVybm8gYW5ub3RhdGlvbgprdWJlY3RsIC0tY29udGV4dCA8Y29udHJvbC1w" + - "bGFuZS1jb250ZXh0PiBnZXQgcG9kIC1uIDxuYW1lc3BhY2U+IDxwb2QtbmFtZT4gXAogIC1vIGpz" + - "b25wYXRoPSd7Lm1ldGFkYXRhLmFubm90YXRpb25zLm52Y2ZcLm52aWRpYVwuY29tL2ltYWdlcHVs" + - "bHNlY3JldC1pbmplY3RlZC1ieX0nCiMgRXhwZWN0ZWQ6IGt5dmVybm8KCiMgQ2hlY2sgcHVsbCBl" + - "dmVudHMKa3ViZWN0bCAtLWNvbnRleHQgPGNvbnRyb2wtcGxhbmUtY29udGV4dD4gZ2V0IGV2ZW50" + - "cyAtbiA8bmFtZXNwYWNlPiBcCiAgLS1zb3J0LWJ5PScubGFzdFRpbWVzdGFtcCcgfCBncmVwIC1p" + - "IHB1bGwKIyBTaG91bGQgc2hvdyAiU3VjY2Vzc2Z1bGx5IHB1bGxlZCIgbm90ICI0MDEgVW5hdXRo" + - "b3JpemVkIgoKIyBDaGVjayB0aGUgY2hhcnQvb3BlcmF0b3IgbWlycm9yIG9uIHRoZSBjb21wdXRl" + - "IGNsdXN0ZXIKa3ViZWN0bCAtLWNvbnRleHQgPGNvbXB1dGUtcGxhbmUtY29udGV4dD4gZ2V0IHNl" + - "Y3JldCBudmNyLWNyZWRzIFwKICAtbiBudmNhLXN5c3RlbQpgYGAKCiMjIFdoZW4gUHVsbCBTZWNy" + - "ZXRzIEFyZSBOb3QgTmVlZGVkCgotICoqQVdTIEVDUiB3aXRoIElBTSBub2RlIHJvbGVzKio6IElm" + - "IHlvdXIgbm9kZXMgaGF2ZSBgQW1hem9uRUMyQ29udGFpbmVyUmVnaXN0cnlSZWFkT25seWAgSUFN" + - "IHBvbGljeSwgS3ViZXJuZXRlcyBjYW4gcHVsbCBmcm9tIEVDUiB3aXRob3V0IGV4cGxpY2l0IHNl" + - "Y3JldHMuCi0gKipQdWJsaWMgcmVnaXN0cmllcyoqOiBObyBwdWxsIHNlY3JldHMgbmVlZGVkLgot" + - "ICoqQ1NQIGJ1aWx0LWluIGNyZWRlbnRpYWwgaGVscGVycyoqOiBHS0UgQXJ0aWZhY3QgUmVnaXN0" + - "cnksIEF6dXJlIEFDUiB3aXRoIG1hbmFnZWQgaWRlbnRpdHksIGFuZCBzaW1pbGFyIHNlcnZpY2Vz" + - "LgoKIyMgVHJvdWJsZXNob290aW5nCgojIyMgUG9kcyBzdGlsbCBpbiBJbWFnZVB1bGxCYWNrT2Zm" + - "IGFmdGVyIGFwcGx5aW5nIHBvbGljeQoKVGhlIEt5dmVybm8gcG9saWN5IG9ubHkgYWZmZWN0cyBw" + - "b2RzIGNyZWF0ZWQgKiphZnRlcioqIHRoZSBwb2xpY3kgaXMgYXBwbGllZC4gRGVsZXRlIHN0dWNr" + - "IHBvZHMgdG8gdHJpZ2dlciByZWNyZWF0aW9uOgoKYGBgYmFzaAprdWJlY3RsIC0tY29udGV4dCA8" + - "dGFyZ2V0LWNvbnRleHQ+IGRlbGV0ZSBwb2RzIC1uIDxuYW1lc3BhY2U+IC0tYWxsCmBgYAoKIyMj" + - "IEt5dmVybm8gYWRtaXNzaW9uIGNvbnRyb2xsZXIgbm90IHJ1bm5pbmcKCmBgYGJhc2gKa3ViZWN0" + - "bCAtLWNvbnRleHQgPGNvbnRyb2wtcGxhbmUtY29udGV4dD4gZ2V0IHBvZHMgLW4ga3l2ZXJubwoj" + - "IEFsbCBwb2RzIHNob3VsZCBiZSBSdW5uaW5nCmBgYAoKIyMjIFBvbGljeSBub3QgbWF0Y2hpbmcK" + - "CmBgYGJhc2gKa3ViZWN0bCAtLWNvbnRleHQgPGNvbnRyb2wtcGxhbmUtY29udGV4dD4gZ2V0IGNs" + - "dXN0ZXJwb2xpY3kgbnZjZi1hZGQtaW1hZ2VwdWxsc2VjcmV0cwojIFNob3VsZCBzaG93IFJFQURZ" + - "OiBUcnVlCmBgYAoKIyMjIFdyb25nIHNlY3JldCBuYW1lCgpUaGUgcG9saWN5IHJlZmVyZW5jZXMg" + - "YG52Y3ItY3JlZHNgLiBWZXJpZnkgdGhlIHNlY3JldCBleGlzdHMgd2l0aCB0aGF0IGV4YWN0IG5h" + - "bWU6CgpgYGBiYXNoCmt1YmVjdGwgLS1jb250ZXh0IDx0YXJnZXQtY29udGV4dD4gZ2V0IHNlY3Jl" + - "dCBudmNyLWNyZWRzIC1uIDxuYW1lc3BhY2U+CmBgYApQSwcIS0I1qAAWAAAAFgAAUEsBAhQAFAAI" + - "AAAAAAAhAFrmNOJJEAAASRAAABIACQAAAAAAAAAAAAAAAAAAAGRhdGEvbWFuaWZlc3QuanNvblVU" + - "BQABAKbOElBLAQIUABQACAAAAAAAIQB0stv9VGEAAFRhAAAjAAkAAAAAAAAAAAAAAJIQAABkYXRh" + - "L252Y2Ytc2VsZi1tYW5hZ2VkLWNsaS9TS0lMTC5tZFVUBQABAKbOElBLAQIUABQACAAAAAAAIQCc" + - "t34H8QoAAPEKAAAzAAkAAAAAAAAAAAAAAEByAABkYXRhL252Y2Ytc2VsZi1tYW5hZ2VkLWNsaS9l" + - "eGFtcGxlcy9jaS1waXBlbGluZXMubWRVVAUAAQCmzhJQSwECFAAUAAgAAAAAACEAoRTjgV0MAABd" + - "DAAANAAJAAAAAAAAAAAAAACbfQAAZGF0YS9udmNmLXNlbGYtbWFuYWdlZC1jbGkvZXhhbXBsZXMv" + - "bXVsdGktY2x1c3Rlci5tZFVUBQABAKbOElBLAQIUABQACAAAAAAAIQBCJ0kPBQ0AAAUNAAA3AAkA" + - "AAAAAAAAAAAAAGOKAABkYXRhL252Y2Ytc2VsZi1tYW5hZ2VkLWNsaS9wcm9tcHRzL2FkZC1jb21w" + - "dXRlLXBsYW5lLm1kVVQFAAEAps4SUEsBAhQAFAAIAAAAAAAhANTejTSfGQAAnxkAADkACQAAAAAA" + - "AAAAAAAA1pcAAGRhdGEvbnZjZi1zZWxmLW1hbmFnZWQtY2xpL3Byb21wdHMvY3JlYXRlLWFuZC1y" + - "dW4tdGFzay5tZFVUBQABAKbOElBLAQIUABQACAAAAAAAIQDSQ9wEGCUAABglAAA3AAkAAAAAAAAA" + - "AAAAAOWxAABkYXRhL252Y2Ytc2VsZi1tYW5hZ2VkLWNsaS9wcm9tcHRzL2RlcGxveS1hbmQtaW52" + - "b2tlLm1kVVQFAAEAps4SUEsBAhQAFAAIAAAAAAAhAHKSLCa+DgAAvg4AAD0ACQAAAAAAAAAAAAAA" + - "a9cAAGRhdGEvbnZjZi1zZWxmLW1hbmFnZWQtY2xpL3Byb21wdHMvZGlhZ25vc2UtZmFpbGVkLWlu" + - "c3RhbGwubWRVVAUAAQCmzhJQSwECFAAUAAgAAAAAACEAQnHmHgEoAAABKAAAOgAJAAAAAAAAAAAA" + - "AACd5gAAZGF0YS9udmNmLXNlbGYtbWFuYWdlZC1jbGkvcHJvbXB0cy9pbnN0YWxsLWZyb20tc2Ny" + - "YXRjaC5tZFVUBQABAKbOElBLAQIUABQACAAAAAAAIQCq4Tq5yAcAAMgHAAA5AAkAAAAAAAAAAAAA" + - "AA8PAQBkYXRhL252Y2Ytc2VsZi1tYW5hZ2VkLWNsaS9wcm9tcHRzL3JvdGF0ZS1jbHVzdGVyLWp3" + - "a3MubWRVVAUAAQCmzhJQSwECFAAUAAgAAAAAACEA2odjVuQhAADkIQAALgAJAAAAAAAAAAAAAABH" + - "FwEAZGF0YS9udmNmLXNlbGYtbWFuYWdlZC1jbGkvcHJvbXB0cy90ZWFyZG93bi5tZFVUBQABAKbO" + - "ElBLAQIUABQACAAAAAAAIQDuDZorIiMAACIjAAAwAAkAAAAAAAAAAAAAAJA5AQBkYXRhL252Y2Yt" + - "c2VsZi1tYW5hZ2VkLWNsaS9yZWZlcmVuY2UvY29tbWFuZHMubWRVVAUAAQCmzhJQSwECFAAUAAgA" + - "AAAAACEAc6daZNUGAADVBgAAMgAJAAAAAAAAAAAAAAAZXQEAZGF0YS9udmNmLXNlbGYtbWFuYWdl" + - "ZC1jbGkvcmVmZXJlbmNlL2V4aXQtY29kZXMubWRVVAUAAQCmzhJQSwECFAAUAAgAAAAAACEAj+uq" + - "8PwlAAD8JQAALQAJAAAAAAAAAAAAAABXZAEAZGF0YS9udmNmLXNlbGYtbWFuYWdlZC1jbGkvcmVm" + - "ZXJlbmNlL2ZsYWdzLm1kVVQFAAEAps4SUEsBAhQAFAAIAAAAAAAhAPOuE4R0EQAAdBEAADcACQAA" + - "AAAAAAAAAAAAt4oBAGRhdGEvbnZjZi1zZWxmLW1hbmFnZWQtY2xpL3JlZmVyZW5jZS90cm91Ymxl" + - "c2hvb3RpbmcubWRVVAUAAQCmzhJQSwECFAAUAAgAAAAAACEAEcMUk0liAABJYgAALAAJAAAAAAAA" + - "AAAAAACZnAEAZGF0YS9udmNmLXNlbGYtbWFuYWdlZC1pbnN0YWxsYXRpb24vU0tJTEwubWRVVAUA" + - "AQCmzhJQSwECFAAUAAgAAAAAACEAR1VqVkpWAABKVgAANAAJAAAAAAAAAAAAAABF/wEAZGF0YS9u" + - "dmNmLXNlbGYtbWFuYWdlZC1pbnN0YWxsYXRpb24vZXZhbHMvZXZhbHMuanNvblVUBQABAKbOElBL" + - "AQIUABQACAAAAAAAIQACUk5AfygAAH8oAAAvAAkAAAAAAAAAAAAAAPpVAgBkYXRhL252Y2Ytc2Vs" + - "Zi1tYW5hZ2VkLWluc3RhbGxhdGlvbi9leGFtcGxlcy5tZFVUBQABAKbOElBLAQIUABQACAAAAAAA" + - "IQCWY7xZEjAAABIwAABMAAkAAAAAAAAAAAAAAN9+AgBkYXRhL252Y2Ytc2VsZi1tYW5hZ2VkLWlu" + - "c3RhbGxhdGlvbi9yZWZlcmVuY2VzL2NvbXB1dGUtcGxhbmUtaW5zdGFsbGF0aW9uLm1kVVQFAAEA" + - "ps4SUEsBAhQAFAAIAAAAAAAhALV6cDu1MAAAtTAAADsACQAAAAAAAAAAAAAAdK8CAGRhdGEvbnZj" + - "Zi1zZWxmLW1hbmFnZWQtaW5zdGFsbGF0aW9uL3JlZmVyZW5jZXMvZGVidWdnaW5nLm1kVVQFAAEA" + - "ps4SUEsBAhQAFAAIAAAAAAAhABMR7I0KKgAACioAAEQACQAAAAAAAAAAAAAAm+ACAGRhdGEvbnZj" + - "Zi1zZWxmLW1hbmFnZWQtaW5zdGFsbGF0aW9uL3JlZmVyZW5jZXMvaGVsbWZpbGUtc3RydWN0dXJl" + - "Lm1kVVQFAAEAps4SUEsBAhQAFAAIAAAAAAAhADj5J6+5BwAAuQcAAD8ACQAAAAAAAAAAAAAAIAsD" + - "AGRhdGEvbnZjZi1zZWxmLW1hbmFnZWQtaW5zdGFsbGF0aW9uL3JlZmVyZW5jZXMvbnZjZi11aS1h" + - "ZGRvbi5tZFVUBQABAKbOElBLAQIUABQACAAAAAAAIQBLQjWoABYAAAAWAAA+AAkAAAAAAAAAAAAA" + - "AE8TAwBkYXRhL252Y2Ytc2VsZi1tYW5hZ2VkLWluc3RhbGxhdGlvbi9yZWZlcmVuY2VzL3B1bGwt" + - "c2VjcmV0cy5tZFVUBQABAKbOElBLBQYAAAAAFwAXAK8JAADEKQMAAAA=" + "ZXh0PiBhcHBseSAtZiAtCmRvbmUKYGBgCgojIyMgU3RlcCA0OiBBZGQgaW1hZ2VQdWxsU2VjcmV0" + + "cyB0byBoZWxtZmlsZSByZWxlYXNlcwoKQWRkIHRoZSBjaGFydC1zcGVjaWZpYyBgaW1hZ2VQdWxs" + + "U2VjcmV0c2Aga2V5IHRvIGVhY2ggcmVsZWFzZS4gRXhhbXBsZSBmb3IgY2Fzc2FuZHJhIGluIGBo" + + "ZWxtZmlsZS5kLzAxLWRlcGVuZGVuY2llcy55YW1sLmdvdG1wbGA6CgpgYGB5YW1sCi0gbmFtZTog" + + "Y2Fzc2FuZHJhCiAgdmVyc2lvbjogMC44LjAKICBjb25kaXRpb246IGNhc3NhbmRyYS5lbmFibGVk" + + "CiAgbmFtZXNwYWNlOiBjYXNzYW5kcmEtc3lzdGVtCiAgPDw6ICpkZXBlbmRlbmN5CiAgdmFsdWVz" + + "OgogICAgLSAuLi9nbG9iYWwueWFtbC5nb3RtcGwKICAgIC0gLi4vPHByaXZhdGUtdmFsdWVzPi97" + + "eyByZXF1aXJlZEVudiAiSEVMTUZJTEVfRU5WIiB9fS1zZWNyZXRzLnlhbWwKICAgIC0gY2Fzc2Fu" + + "ZHJhOgogICAgICAgIGdsb2JhbDoKICAgICAgICAgIGltYWdlUHVsbFNlY3JldHM6CiAgICAgICAg" + + "ICAgIC0gbnZjci1jcmVkcwpgYGAKClNlZSBbcmVmZXJlbmNlcy9wdWxsLXNlY3JldHMubWRdKHJl" + + "ZmVyZW5jZXMvcHVsbC1zZWNyZXRzLm1kKSBmb3IgdGhlIGtleSBmb3IgZXZlcnkgY2hhcnQuCgpU" + + "aGVzZSBhcmUgY29udHJvbC1wbGFuZSBuYW1lc3BhY2VzLiBGb3IgYSBzZXBhcmF0ZSBjb21wdXRl" + + "IGNsdXN0ZXIsIGNyZWF0ZSB0aGUKc2VjcmV0IG9ubHkgaW4gYG52Y2Etb3BlcmF0b3JgOyB0aGUg" + + "Y2hhcnQgYW5kIG9wZXJhdG9yIG1pcnJvciBpdCB0bwpgbnZjYS1zeXN0ZW1gLiBTZWUgW1NwbGl0" + + "IENvbXB1dGUtUGxhbmUgSW5zdGFsbGF0aW9uXShyZWZlcmVuY2VzL2NvbXB1dGUtcGxhbmUtaW5z" + + "dGFsbGF0aW9uLm1kI3B1bGwtc2VjcmV0cykuCgojIyMgU3RlcCA1OiBEZXBsb3kKCmBgYGJhc2gK" + + "SEVMTUZJTEVfRU5WPTxlbnY+IGhlbG1maWxlIC0ta3ViZS1jb250ZXh0IDxjb250cm9sLXBsYW5l" + + "LWNvbnRleHQ+IHN5bmMKYGBgCgojIyMgU3RlcCA2OiBQYXRjaCBTZXJ2aWNlQWNjb3VudHMgZm9y" + + "IGNoYXJ0cyB3aXRob3V0IG5hdGl2ZSBzdXBwb3J0CgpBZnRlciBQaGFzZSAxIChkZXBlbmRlbmNp" + + "ZXMpIGNvbXBsZXRlcywgT3BlbkJhbyBwb2RzIHdpbGwgYmUgaW4gYEltYWdlUHVsbEJhY2tPZmZg" + + "LiBQYXRjaCBhbmQgcmVzdGFydDoKCmBgYGJhc2gKZm9yIG5zIGluIHZhdWx0LXN5c3RlbSBudmNm" + + "IGVzczsgZG8KICBmb3Igc2EgaW4gJChrdWJlY3RsIC0tY29udGV4dCA8Y29udHJvbC1wbGFuZS1j" + + "b250ZXh0PiBnZXQgc2EgLW4gIiRucyIgXAogICAgLS1uby1oZWFkZXJzIC1vIGN1c3RvbS1jb2x1" + + "bW5zPSI6bWV0YWRhdGEubmFtZSIpOyBkbwogICAga3ViZWN0bCAtLWNvbnRleHQgPGNvbnRyb2wt" + + "cGxhbmUtY29udGV4dD4gcGF0Y2ggc2VydmljZWFjY291bnQgLW4gIiRucyIgIiRzYSIgXAogICAg" + + "ICAtcCAneyJpbWFnZVB1bGxTZWNyZXRzIjogW3sibmFtZSI6ICJudmNyLWNyZWRzIn1dfScKICBk" + + "b25lCmRvbmUKa3ViZWN0bCAtLWNvbnRleHQgPGNvbnRyb2wtcGxhbmUtY29udGV4dD4gZGVsZXRl" + + "IHBvZHMgLW4gdmF1bHQtc3lzdGVtIC0tYWxsCmBgYAoKVmVyaWZ5OgoKYGBgYmFzaAprdWJlY3Rs" + + "IC0tY29udGV4dCA8Y29udHJvbC1wbGFuZS1jb250ZXh0PiBnZXQgZXZlbnRzIC1uIHZhdWx0LXN5" + + "c3RlbSBcCiAgLS1zb3J0LWJ5PScubGFzdFRpbWVzdGFtcCcgfCBncmVwIC1pIHB1bGwKIyBTaG91" + + "bGQgc2hvdyAiU3VjY2Vzc2Z1bGx5IHB1bGxlZCBpbWFnZSIgZnJvbSBudmNyLmlvCmBgYAoKLS0t" + + "CgojIyBFeGFtcGxlIDM6IEZ1bGwgVGVhcmRvd24gYW5kIFJlaW5zdGFsbAoKV2hlbiBhIGRlcGxv" + + "eW1lbnQgaXMgYnJva2VuIGFuZCB5b3UgbmVlZCBhIGNsZWFuIHNsYXRlLgoKIyMjIFN0ZXAgMTog" + + "RGVzdHJveSB0aGUgY29tcHV0ZS1wbGFuZSBzdGFjawoKYGBgYmFzaAptYWtlIC1DIGRlcGxveS9z" + + "dGFja3MvbnZjZi1jb21wdXRlLXBsYW5lIGRlc3Ryb3kgXAogIENMVVNURVJfTkFNRT08Y2x1c3Rl" + + "ci1uYW1lPiBcCiAgSEVMTUZJTEVfRU5WPTxlbnY+IFwKICBLVUJFQ09ORklHX0ZJTEU9PGNvbXB1" + + "dGUtcGxhbmUta3ViZWNvbmZpZz4KYGBgCgpJZiBjb21wdXRlIGNsZWFudXAgaXMgc3R1Y2sgb24g" + + "ZmluYWxpemVycywgdXNlIHRoZSBjb21wdXRlLWNvbnRleHQgcHJvY2VkdXJlIGluCltkZWJ1Z2dp" + + "bmcubWRdKHJlZmVyZW5jZXMvZGVidWdnaW5nLm1kI2ZhaWx1cmUtbnZjYS1jbGVhbnVwLXN0dWNr" + + "LWZpbmFsaXplcnMpLgoKIyMjIFN0ZXAgMjogRGVyZWdpc3RlciB0aGUgY29tcHV0ZSBjbHVzdGVy" + + "CgpSZWFkIGBjbHVzdGVySURgIGFuZCBgbmNhSURgIGZyb20KYGRlcGxveS9zdGFja3MvbnZjZi1j" + + "b21wdXRlLXBsYW5lL3JlZ2lzdHJhdGlvbi88Y2x1c3Rlci1uYW1lPi1yZWdpc3Rlci12YWx1ZXMu" + + "eWFtbGAuClNob3cgdGhlIElEIGFuZCBnZXQgY29uZmlybWF0aW9uIGJlZm9yZSBkZWxldGluZyB0" + + "aGUgSUNNUyByb3c6CgpgYGBiYXNoCjxudmNmLWNsaT4gLS1jb25maWcgPGNvbmZpZz4gY2x1c3Rl" + + "ciBkZWxldGUgXAogIC0tY2x1c3Rlci1pZCA8Y2x1c3Rlci1pZC1mcm9tLXJlZ2lzdHJhdGlvbi12" + + "YWx1ZXM+IFwKICAtLW5jYS1pZCA8bmNhLWlkPiBcCiAgLS1pZ25vcmUtbWlzc2luZwpgYGAKClJl" + + "cGVhdCBzdGVwcyAxIGFuZCAyIGZvciBldmVyeSBjb21wdXRlIGNsdXN0ZXIuCgojIyMgU3RlcCAz" + + "OiBEZXN0cm95IHRoZSBjb250cm9sLXBsYW5lIHN0YWNrCgpQcmVzZXJ2ZSB0aGUgYGNlcnQtbWFu" + + "YWdlcmAgbmFtZXNwYWNlIGJ5IGRlZmF1bHQuIE5hbWVzcGFjZSBwcmVzZXJ2YXRpb24gZG9lcwpu" + + "b3QgcHJlc2VydmUgdGhlIEhlbG0gcmVsZWFzZS4gQmVmb3JlIHJ1bm5pbmcgZGVzdHJveSwgaWRl" + + "bnRpZnkgd2hldGhlcgpjZXJ0LW1hbmFnZXIgaXMgZXh0ZXJuYWwuIEZvciBhbiBleHRlcm5hbCBp" + + "bnN0YWxsYXRpb24sIHJlcXVpcmUgZWZmZWN0aXZlCmBjZXJ0TWFuYWdlci5lbmFibGVkOiBmYWxz" + + "ZWAgYW5kIGFib3J0IGlmIGl0IGlzIHRydWUuIEZvciBhbiBpbnRlbmRlZApzdGFjay1tYW5hZ2Vk" + + "IHJlbGVhc2Ugd2l0aCBlZmZlY3RpdmUgYGNlcnRNYW5hZ2VyLmVuYWJsZWQ6IHRydWVgLCB2ZXJp" + + "ZnkKbWF0Y2hpbmcgc3RhY2sgSGVsbSByZWxlYXNlIG1ldGFkYXRhIGFuZCBhYm9ydCBvbiBtaXNz" + + "aW5nIG9yIG1pc21hdGNoZWQKZXZpZGVuY2UuIE9ubHkgcGxhbiBuYW1lc3BhY2UgcmVtb3ZhbCBh" + + "ZnRlciB2ZXJpZnlpbmcgbmFtZXNwYWNlIHByb3ZlbmFuY2UgYW5kCmdldHRpbmcgZXhwbGljaXQg" + + "Y29uZmlybWF0aW9uOyBvdGhlcndpc2UgZG8gbm90IGRlbGV0ZSBpdC4KCmBgYGJhc2gKS1VCRUNP" + + "TkZJRz08Y29udHJvbC1wbGFuZS1rdWJlY29uZmlnPiBcCm1ha2UgLUMgZGVwbG95L3N0YWNrcy9z" + + "ZWxmLW1hbmFnZWQgZGVzdHJveSBcCiAgSEVMTUZJTEVfRU5WPTxlbnY+IFwKICBLVUJFQ09ORklH" + + "X0ZJTEU9PGNvbnRyb2wtcGxhbmUta3ViZWNvbmZpZz4KYGBgCgpQYXNzIGJvdGgga3ViZWNvbmZp" + + "ZyBmb3JtcyBiZWNhdXNlIGNvbnRyb2wtcGxhbmUgbmFtZXNwYWNlIGNsZWFudXAgdXNlcwphbWJp" + + "ZW50IGBrdWJlY3RsYC4KCiMjIyBTdGVwIDQ6IFZlcmlmeSBjbGVhbgoKYGBgYmFzaAprdWJlY3Rs" + + "IC0tY29udGV4dCA8Y29tcHV0ZS1wbGFuZS1jb250ZXh0PiBnZXQgbnMgfCBcCiAgZ3JlcCAtRSAn" + + "KG52Y2F8bnZjZi1iYWNrZW5kfGdyb3ZlfGR5bmFtbyknCmt1YmVjdGwgLS1jb250ZXh0IDxjb250" + + "cm9sLXBsYW5lLWNvbnRleHQ+IGdldCBucyB8IFwKICBncmVwIC1FICcoY2Fzc2FuZHJhfG5hdHN8" + + "dmF1bHR8bnZjZnxhcGkta2V5c3xlc3N8c2lzKScKIyBCb3RoIGNvbW1hbmRzIHNob3VsZCByZXR1" + + "cm4gZW1wdHkuCmBgYAoKIyMjIFN0ZXAgNTogUmVpbnN0YWxsIGluIHNwbGl0IG9yZGVyCgpGb2xs" + + "b3cgW0NsZWFuIEluc3RhbGxhdGlvbl0oU0tJTEwubWQjY2xlYW4taW5zdGFsbGF0aW9uKSwgdGhl" + + "biByZXR1cm4gdG8gdGhlCnNhbWUgaGFuZG9mZiBpbiBbU3BsaXQgQ29tcHV0ZS1QbGFuZSBJbnN0" + + "YWxsYXRpb25dKHJlZmVyZW5jZXMvY29tcHV0ZS1wbGFuZS1pbnN0YWxsYXRpb24ubWQpLgoKLS0t" + + "CgojIyBFeGFtcGxlIDQ6IFJlY292ZXIgZnJvbSBhIEdhdGV3YXkgRW5kcG9pbnQgQ2hhbmdlCgpU" + + "aGUgc2VwYXJhdGVseSBwcm92aXNpb25lZCBHYXRld2F5IG9yIGl0cyBsb2FkIGJhbGFuY2VyIHdh" + + "cyByZWNyZWF0ZWQgYW5kIGdvdCBhIG5ldyBlbmRwb2ludC4KCiMjIyBTdGVwIDE6IEdldCB0aGUg" + + "bmV3IGVuZHBvaW50CgpgYGBiYXNoCkdBVEVXQVlfQUREUj0kKGt1YmVjdGwgLS1jb250ZXh0IDxj" + + "b250cm9sLXBsYW5lLWNvbnRleHQ+IGdldCBnYXRld2F5IFwKICBudmNmLWdhdGV3YXkgLW4gZW52" + + "b3ktZ2F0ZXdheSBcCiAgLW8ganNvbnBhdGg9J3suc3RhdHVzLmFkZHJlc3Nlc1swXS52YWx1ZX0n" + + "KQp0ZXN0IC1uICIkR0FURVdBWV9BRERSIgpgYGAKCiMjIyBTdGVwIDI6IFVwZGF0ZSBETlMgYW5k" + + "IGRpcmVjdCBhZGRyZXNzIGZpZWxkcwoKS2VlcCBgZ2xvYmFsLmRvbWFpbmAsIHJvdXRlIGhvc3Ru" + + "YW1lcywgYW5kIENMSSBIb3N0IGZpZWxkcyB1bmNoYW5nZWQuIFBvaW50IHRoZQpETlMgcmVjb3Jk" + + "cyBmb3IgcmVuZGVyZWQgcm91dGUgaG9zdG5hbWVzLCBzdWNoIGFzIGBhcGkuPGRvbWFpbj5gIGFu" + + "ZApgKi5pbnZvY2F0aW9uLjxkb21haW4+YCwgdG8gYEdBVEVXQVlfQUREUmAuIEZvciBhIHRlbXBv" + + "cmFyeSBlbnZpcm9ubWVudCB3aXRob3V0CkROUywga2VlcCBpdHMgcmVzZXJ2ZWQgcm91dGUgc3Vm" + + "Zml4IGFuZCBjb25uZWN0IHRvIGBHQVRFV0FZX0FERFJgIHdpdGggdGhlCm1hdGNoaW5nIGBIb3N0" + + "YCBoZWFkZXIuCgpGb3IgYSBzcGxpdCBkZXBsb3ltZW50IGNvbmZpZ3VyZWQgd2l0aCBkaXJlY3Qg" + + "R2F0ZXdheSBhZGRyZXNzZXMsIHJlcGxhY2Ugb25seQp0aGUgYWRkcmVzcy1iZWFyaW5nIHZhbHVl" + + "czogY29udHJvbC1wbGFuZSBgbnZjZk5hdHNTZXJ2aWNlVVJMYCwgY29tcHV0ZS1wbGFuZQpJQ01T" + + "LCBSZVZhbCwgYW5kIE5BVFMgVVJMcywgYW5kIENMSSBVUkwgZmllbGRzLiBLZWVwIHRoZWlyIGRv" + + "bWFpbiBhbmQgSG9zdApvdmVycmlkZXMgdW5jaGFuZ2VkLiBSZWZyZXNoIHJlZ2lzdHJhdGlvbiBh" + + "bmQgaW5zdGFsbGF0aW9uIHRocm91Z2ggdGhlIHNhbWUKaGFuZG9mZiB1c2VkIG9yaWdpbmFsbHku" + + "IFNlZQpbU3BsaXQgQ29tcHV0ZS1QbGFuZSBJbnN0YWxsYXRpb25dKHJlZmVyZW5jZXMvY29tcHV0" + + "ZS1wbGFuZS1pbnN0YWxsYXRpb24ubWQpCmZvciB0aGUgZmllbGRzIGFuZCBjb21wbGV0ZSBjb21t" + + "YW5kcy4KCiMjIyBTdGVwIDM6IFZlcmlmeSBib3RoIGNvbnRleHRzCgpgYGBiYXNoCmRpZyArc2hv" + + "cnQgImFwaS48ZG9tYWluPiIKY3VybCAtSCAiSG9zdDogYXBpLjxkb21haW4+IiAiaHR0cDovLyRH" + + "QVRFV0FZX0FERFIvaGVhbHRoIgprdWJlY3RsIC0tY29udGV4dCA8Y29udHJvbC1wbGFuZS1jb250" + + "ZXh0PiBnZXQgZ2F0ZXdheSBudmNmLWdhdGV3YXkgXAogIC1uIGVudm95LWdhdGV3YXkgLW8gd2lk" + + "ZQprdWJlY3RsIC0tY29udGV4dCA8Y29udHJvbC1wbGFuZS1jb250ZXh0PiBnZXQgaHR0cHJvdXRl" + + "cyAtQQprdWJlY3RsIC0tY29udGV4dCA8Y29udHJvbC1wbGFuZS1jb250ZXh0PiBnZXQgdGNwcm91" + + "dGVzIC1BCmt1YmVjdGwgLS1jb250ZXh0IDxjb21wdXRlLXBsYW5lLWNvbnRleHQ+IHJvbGxvdXQg" + + "c3RhdHVzIFwKICBkZXBsb3ltZW50L252Y2Etb3BlcmF0b3IgLW4gbnZjYS1vcGVyYXRvciAtLXRp" + + "bWVvdXQ9MTBtCmt1YmVjdGwgLS1jb250ZXh0IDxjb21wdXRlLXBsYW5lLWNvbnRleHQ+IHdhaXQg" + + "XAogIG52Y2ZiYWNrZW5kLzxjb21wdXRlLXBsYW5lLWNsdXN0ZXItbmFtZT4gLW4gbnZjYS1vcGVy" + + "YXRvciBcCiAgLS1mb3I9anNvbnBhdGg9J3suc3RhdHVzLmFnZW50U3RhdHVzfSc9aGVhbHRoeSAt" + + "LXRpbWVvdXQ9MTBtCmBgYAoKLS0tCgojIyBFeGFtcGxlIDU6IERlcGxveSBPbmx5IERlcGVuZGVu" + + "Y2llcwoKVXNlZnVsIGZvciB0ZXN0aW5nIG9yIHdoZW4gaXRlcmF0aW5nIG9uIHNlcnZpY2UgY29u" + + "ZmlndXJhdGlvbi4KCmBgYGJhc2gKIyBEZXBsb3kganVzdCBOQVRTLCBDYXNzYW5kcmEsIE9wZW5C" + + "YW8KSEVMTUZJTEVfRU5WPTxlbnY+IGhlbG1maWxlIC0tc2VsZWN0b3IgcmVsZWFzZS1ncm91cD1k" + + "ZXBlbmRlbmNpZXMgc3luYwoKIyBDaGVjayBzdGF0dXMKa3ViZWN0bCBnZXQgcG9kcyAtbiBuYXRz" + + "LXN5c3RlbQprdWJlY3RsIGdldCBwb2RzIC1uIGNhc3NhbmRyYS1zeXN0ZW0Ka3ViZWN0bCBnZXQg" + + "cG9kcyAtbiB2YXVsdC1zeXN0ZW0KCiMgRGVwbG95IGp1c3Qgb25lIHNwZWNpZmljIHJlbGVhc2UK" + + "SEVMTUZJTEVfRU5WPTxlbnY+IGhlbG1maWxlIC0tc2VsZWN0b3IgbmFtZT1jYXNzYW5kcmEgc3lu" + + "YwpgYGAKCiMjIyBTZWxlY3RvciByZWZlcmVuY2UKCnwgU2VsZWN0b3IgfCBSZWxlYXNlcyB8Cnwt" + + "LS0tLS0tLS0tfC0tLS0tLS0tLS18CnwgYHJlbGVhc2UtZ3JvdXA9ZGVwZW5kZW5jaWVzYCB8IG5h" + + "dHMsIGNhc3NhbmRyYSwgb3BlbmJhby1zZXJ2ZXIgfAp8IGByZWxlYXNlLWdyb3VwPXNlcnZpY2Vz" + + "YCB8IGFwaS1rZXlzLCBzaXMsIGFwaSwgaW52b2NhdGlvbi1zZXJ2aWNlLCBncnBjLXByb3h5LCBl" + + "c3MtYXBpLCBub3Rhcnktc2VydmljZSwgcmV2YWwsIG9wdGlvbmFsIGxsbS1yZXF1ZXN0LXJvdXRl" + + "ciBhbmQgbGxtLWFwaS1nYXRld2F5IHdoZW4gYGxsbS5lbmFibGVkPXRydWVgLCBvcHRpb25hbCB2" + + "YW5pdHktZ2F0ZXdheSBvbmx5IGluIHN0YWNrIHBhY2thZ2VzIHRoYXQgaW5jbHVkZSB0aGUgYWRk" + + "b24gYW5kIGhhdmUgYGFkZG9ucy52YW5pdHlHYXRld2F5LmVuYWJsZWQ9dHJ1ZWAsIG9wdGlvbmFs" + + "IG52Y2YtdWkgb25seSBpbiBzdGFjayBwYWNrYWdlcyB0aGF0IGluY2x1ZGUgdGhlIGFkZG9uIGFu" + + "ZCBoYXZlIGBhZGRvbnMubnZjZlVpLmVuYWJsZWQ9dHJ1ZWAgfAp8IGBuYW1lPWxsbS1yZXF1ZXN0" + + "LXJvdXRlcmAgfCBEZXBsb3kganVzdCB0aGUgTExNIHJlcXVlc3Qgcm91dGVyIHdoZW4gYGxsbS5l" + + "bmFibGVkPXRydWVgIHwKfCBgbmFtZT1sbG0tYXBpLWdhdGV3YXlgIHwgRGVwbG95IGp1c3QgdGhl" + + "IExMTSBBUEkgZ2F0ZXdheSB3aGVuIGBsbG0uZW5hYmxlZD10cnVlYCB8CnwgYG5hbWU9dmFuaXR5" + + "LWdhdGV3YXlgIHwgRGVwbG95IGp1c3QgVmFuaXR5IEdhdGV3YXkgb25seSB3aGVuIHRoZSBzdGFj" + + "ayBwYWNrYWdlIGluY2x1ZGVzIHRoYXQgcmVsZWFzZSBhbmQgYGFkZG9ucy52YW5pdHlHYXRld2F5" + + "LmVuYWJsZWQ9dHJ1ZWAgfAp8IGBuYW1lPW52Y2YtdWlgIHwgRGVwbG95IE5WQ0YgVUkgb25seSB3" + + "aGVuIHRoZSBzdGFjayBwYWNrYWdlIGluY2x1ZGVzIHRoYXQgcmVsZWFzZSBhbmQgYGFkZG9ucy5u" + + "dmNmVWkuZW5hYmxlZD10cnVlYCB8CnwgYHJlbGVhc2UtZ3JvdXA9aW5ncmVzc2AgfCBpbmdyZXNz" + + "IChnYXRld2F5IHJvdXRlcykgfAp8IGBuYW1lPTxyZWxlYXNlPmAgfCBBbnkgaW5kaXZpZHVhbCBy" + + "ZWxlYXNlIGJ5IG5hbWUgfAoKTm90ZTogYGFkbWluLWlzc3Vlci1wcm94eWAgZG9lcyBub3QgaGF2" + + "ZSBhIHJlbGVhc2UtZ3JvdXAgbGFiZWwuIFRhcmdldCBpdCB3aXRoIGAtLXNlbGVjdG9yIG5hbWU9" + + "YWRtaW4taXNzdWVyLXByb3h5YC4KUEsHCAJSTkB/KAAAfygAAFBLAwQUAAgAAAAAACEAAAAAAAAA" + + "AAAAAAAATAAJAGRhdGEvbnZjZi1zZWxmLW1hbmFnZWQtaW5zdGFsbGF0aW9uL3JlZmVyZW5jZXMv" + + "Y29tcHV0ZS1wbGFuZS1pbnN0YWxsYXRpb24ubWRVVAUAAQCmzhIjIFNwbGl0IENvbXB1dGUtUGxh" + + "bmUgSW5zdGFsbGF0aW9uCgpUaGUgY29udHJvbCBwbGFuZSBhbmQgY29tcHV0ZSBwbGFuZSBhcmUg" + + "c2VwYXJhdGUgc3RhY2tzLiBJbnN0YWxsIHRoZSBjb250cm9sCnBsYW5lIGZpcnN0LCB0aGVuIHJl" + + "Z2lzdGVyIGFuZCBpbnN0YWxsIGVhY2ggY29tcHV0ZSBjbHVzdGVyLiBDaG9vc2Ugb25lIGhhbmRv" + + "ZmYKYmVsb3cgYW5kIHVzZSBpdCBmb3IgdGhlIGZ1bGwgbGlmZWN5Y2xlLiBEbyBub3QgY29tYmlu" + + "ZSBhIENMSS1nZW5lcmF0ZWQgcHJvZmlsZQp3aXRoIHRoZSBIZWxtZmlsZSB2YWx1ZXMgZmxvdy4K" + + "CiMjIENob29zZSBhIEhhbmRvZmYKCnwgRmxvdyB8IFNvdXJjZSBvZiBjb250cm9sLXBsYW5lIGVu" + + "ZHBvaW50cyB8IFJlZ2lzdHJhdGlvbiBhcnRpZmFjdCB8CnwtLS0tLS18LS0tLS0tLS0tLS0tLS0t" + + "LS0tLS0tLS0tLS0tLS0tLS0tLS18LS0tLS0tLS0tLS0tLS0tLS0tLS0tLS18CnwgSGVsbWZpbGUg" + + "dmFsdWVzIHwgU2VwYXJhdGUgY29udHJvbC1wbGFuZSBhbmQgY29tcHV0ZS1wbGFuZSBlbnZpcm9u" + + "bWVudCBmaWxlcyB8IGBkZXBsb3kvc3RhY2tzL252Y2YtY29tcHV0ZS1wbGFuZS9yZWdpc3RyYXRp" + + "b24vPGNsdXN0ZXI+LXJlZ2lzdGVyLXZhbHVlcy55YW1sYCB8CnwgQ0xJIHByb2ZpbGUgfCBDTEkt" + + "Z2VuZXJhdGVkIGBkZXBsb3kvc3RhY2tzL3NlbGYtbWFuYWdlZC9vdXQvY29udHJvbC1wbGFuZS1w" + + "cm9maWxlLnlhbWxgIHwgQ0xJLWdlbmVyYXRlZCBgZGVwbG95L3N0YWNrcy9udmNmLWNvbXB1dGUt" + + "cGxhbmUvb3V0LzxjbHVzdGVyPi1yZWdpc3Rlci12YWx1ZXMueWFtbGAgfAoKVGhlIENMSSBwcm9m" + + "aWxlIGlzIG1hbmRhdG9yeSBmb3IgdGhlIENMSSBmbG93IGFuZCBtdXN0IG5vdCBiZSBoYW5kLWF1" + + "dGhvcmVkLgpUaGUgSGVsbWZpbGUgZmxvdyBkb2VzIG5vdCBjb25zdW1lIGEgY29udHJvbC1wbGFu" + + "ZSBwcm9maWxlLgoKQ29tbWFuZHMgYmVsb3cgdXNlIG1vbm9yZXBvIHBhdGhzIGZyb20gdGhlIHJl" + + "cG9zaXRvcnkgcm9vdC4gT3BlcmF0b3JzIHVzaW5nCmV4dHJhY3RlZCBwYWNrYWdlcyBuZWVkIG1h" + + "dGNoaW5nLXZlcnNpb24gYG52Y2Ytc2VsZi1tYW5hZ2VkLXN0YWNrYCBhbmQKYG52Y2YtY29tcHV0" + + "ZS1wbGFuZS1zdGFja2AgYnVuZGxlcy4gU3Vic3RpdHV0ZSB0aG9zZSBidW5kbGUgcm9vdHMgZm9y" + + "CmBkZXBsb3kvc3RhY2tzL3NlbGYtbWFuYWdlZGAgYW5kIGBkZXBsb3kvc3RhY2tzL252Y2YtY29t" + + "cHV0ZS1wbGFuZWAuCgojIyBIZWxtZmlsZSBWYWx1ZXMgRmxvdwoKQ3JlYXRlIGJvdGggZW52aXJv" + + "bm1lbnQgZmlsZXMgYmVmb3JlIGluc3RhbGxhdGlvbjoKCi0gYGRlcGxveS9zdGFja3Mvc2VsZi1t" + + "YW5hZ2VkL2Vudmlyb25tZW50cy88ZW52Pi55YW1sYCBmb3IgY29udHJvbC1wbGFuZQogIHJlZ2lz" + + "dHJ5LCBkb21haW4sIEdhdGV3YXksIHJvdXRlcywgYW5kIHNlcnZpY2Ugc2V0dGluZ3MuCi0gYGRl" + + "cGxveS9zdGFja3MvbnZjZi1jb21wdXRlLXBsYW5lL2Vudmlyb25tZW50cy88ZW52Pi55YW1sYCBm" + + "b3IgY29tcHV0ZS1wbGFuZQogIHJlZ2lzdHJ5LCBwdWxsIHNlY3JldHMsIGFuZCBjb21wdXRlLXJl" + + "YWNoYWJsZSBJQ01TLCBSZVZhbCwgYW5kIE5BVFMgdmFsdWVzLgoKRm9yIEVLUywgdXNlIHRoZSBj" + + "YW5vbmljYWwKW0NTUCBFbmQtdG8tRW5kIEV4YW1wbGVdKGh0dHBzOi8vZG9jcy5udmlkaWEuY29t" + + "L252Y2YvdjAuNi4wLXJjL2NzcC1lbmQtdG8tZW5kLWV4YW1wbGUpLgpUaGUgY29udHJvbC1wbGFu" + + "ZSBlbnZpcm9ubWVudCBrZWVwcyB0aGUgRW52b3kgR2F0ZXdheSBjb250cm9sbGVyIGluCmBlbnZv" + + "eS1nYXRld2F5LXN5c3RlbWAsIGF0dGFjaGVzIHJvdXRlcyB0byBgR2F0ZXdheS9udmNmLWdhdGV3" + + "YXlgIGluCmBlbnZveS1nYXRld2F5YCwgYW5kIGVuYWJsZXMgdGhlIE5BVFMgcm91dGUuIFRoZSBj" + + "b21wdXRlLXBsYW5lIGVudmlyb25tZW50IHVzZXMKdGhlIGV4dGVybmFsIGFkZHJlc3MgcGx1cyB0" + + "aGUgc2VydmljZSBob3N0IG92ZXJyaWRlcy4gSGVyZSwKYDxnYXRld2F5LWFkZHJlc3M+YCBpcyB0" + + "aGUgTkxCIGFkZHJlc3MgYW5kIGA8Z2F0ZXdheS1kb21haW4+YCBpcyB0aGUgRE5TIGJhc2UKd2hv" + + "c2Ugc2VydmljZSBzdWJkb21haW5zIHJlc29sdmUgdG8gdGhhdCBOTEI6CgpgYGB5YW1sCiMgZGVw" + + "bG95L3N0YWNrcy9zZWxmLW1hbmFnZWQvZW52aXJvbm1lbnRzLzxlbnY+LnlhbWwKZ2xvYmFsOgog" + + "IGRvbWFpbjogIjxnYXRld2F5LWRvbWFpbj4iCiAgd29ya2VyRW5kcG9pbnRzOgogICAgbnZjZlNl" + + "cnZpY2VVUkw6ICJodHRwOi8vYXBpLjxnYXRld2F5LWRvbWFpbj4iCiAgICBudmNmR3JwY1NlcnZp" + + "Y2VVUkw6ICJodHRwOi8vd29ya2VyLWFwaS48Z2F0ZXdheS1kb21haW4+IgogICAgbnZjZk5hdHNT" + + "ZXJ2aWNlVVJMOiAibmF0czovLzxnYXRld2F5LWFkZHJlc3M+OjQyMjIiCiAgICBudmN0U2Vydmlj" + + "ZVVSTDogImh0dHA6Ly90YXNrcy48Z2F0ZXdheS1kb21haW4+IgogICAgbnZjdEdycGNTZXJ2aWNl" + + "VVJMOiAiaHR0cDovL3dvcmtlci10YXNrcy48Z2F0ZXdheS1kb21haW4+IgogICAgaW52b2NhdGlv" + + "blNlcnZpY2VVUkw6ICJodHRwOi8vaW52b2NhdGlvbi48Z2F0ZXdheS1kb21haW4+IgoKZ3JwY3By" + + "b3h5OgogIG52Y2ZHcnBjU2VydmljZVVSTDogImh0dHA6Ly9hcGkubnZjZi5zdmMuY2x1c3Rlci5s" + + "b2NhbDo5MDkwIgoKaW5ncmVzczoKICBnYXRld2F5QXBpOgogICAgY29udHJvbGxlck5hbWVzcGFj" + + "ZTogZW52b3ktZ2F0ZXdheS1zeXN0ZW0KICAgIGdhdGV3YXlzOgogICAgICBzaGFyZWQ6IHtuYW1l" + + "OiBudmNmLWdhdGV3YXksIG5hbWVzcGFjZTogZW52b3ktZ2F0ZXdheX0KICAgICAgZ3JwYzoge25h" + + "bWU6IG52Y2YtZ2F0ZXdheSwgbmFtZXNwYWNlOiBlbnZveS1nYXRld2F5fQogICAgICBuYXRzOgog" + + "ICAgICAgIG5hbWU6IG52Y2YtZ2F0ZXdheQogICAgICAgIG5hbWVzcGFjZTogZW52b3ktZ2F0ZXdh" + + "eQogICAgICAgIGxpc3RlbmVyTmFtZTogbmF0cwogICAgcm91dGVzOgogICAgICBudmNmQXBpOgog" + + "ICAgICAgIGdycGM6CiAgICAgICAgICBlbmFibGVkOiB0cnVlCiAgICAgICAgICBob3N0bmFtZXM6" + + "IFsid29ya2VyLWFwaS48Z2F0ZXdheS1kb21haW4+Il0KICAgICAgbnZjdEFwaToKICAgICAgICBn" + + "cnBjOgogICAgICAgICAgZW5hYmxlZDogdHJ1ZQogICAgICAgICAgaG9zdG5hbWVzOiBbIndvcmtl" + + "ci10YXNrcy48Z2F0ZXdheS1kb21haW4+Il0KICAgICAgbmF0czoKICAgICAgICBlbmFibGVkOiB0" + + "cnVlCgpvcGVuYmFvOgogIG1pZ3JhdGlvbnM6CiAgICBpc3N1ZXJEaXNjb3Zlcnk6CiAgICAgIGVu" + + "YWJsZWQ6IHRydWUKYGBgCgpgYGB5YW1sCiMgZGVwbG95L3N0YWNrcy9udmNmLWNvbXB1dGUtcGxh" + + "bmUvZW52aXJvbm1lbnRzLzxlbnY+LnlhbWwKZ2xvYmFsOgogIG52Y2FPcGVyYXRvcjoKICAgIHNl" + + "bGZNYW5hZ2VkOgogICAgICBpY21zU2VydmljZVVSTDogImh0dHA6Ly88Z2F0ZXdheS1hZGRyZXNz" + + "PiIKICAgICAgaWNtc1NlcnZpY2VIb3N0SGVhZGVyT3ZlcnJpZGU6ICJzaXMuPGdhdGV3YXktZG9t" + + "YWluPiIKICAgICAgcmV2YWxTZXJ2aWNlVVJMOiAiaHR0cDovLzxnYXRld2F5LWFkZHJlc3M+Igog" + + "ICAgICByZXZhbFNlcnZpY2VIb3N0SGVhZGVyT3ZlcnJpZGU6ICJyZXZhbC48Z2F0ZXdheS1kb21h" + + "aW4+IgogICAgICBuYXRzVVJMOiAibmF0czovLzxnYXRld2F5LWFkZHJlc3M+OjQyMjIiCiAgICAg" + + "IG5hdHNIb3N0T3ZlcnJpZGU6ICJuYXRzLjxnYXRld2F5LWRvbWFpbj4iCmBgYAoKUHJldmlldyBh" + + "bmQgaW5zdGFsbCB0aGUgY29udHJvbCBwbGFuZSBhZ2FpbnN0IGl0cyBjb250ZXh0OgoKYGBgYmFz" + + "aAptYWtlIC1DIGRlcGxveS9zdGFja3Mvc2VsZi1tYW5hZ2VkIHRlbXBsYXRlIFwKICBIRUxNRklM" + + "RV9FTlY9PGVudj4gXAogIEtVQkVDT05GSUdfRklMRT08Y29udHJvbC1wbGFuZS1rdWJlY29uZmln" + + "PgoKbWFrZSAtQyBkZXBsb3kvc3RhY2tzL3NlbGYtbWFuYWdlZCBpbnN0YWxsIFwKICBIRUxNRklM" + + "RV9FTlY9PGVudj4gXAogIEtVQkVDT05GSUdfRklMRT08Y29udHJvbC1wbGFuZS1rdWJlY29uZmln" + + "PgpgYGAKCkJlZm9yZSByZWdpc3RlcmluZyB0aHJvdWdoIGEgYmFyZSBHYXRld2F5IGFkZHJlc3Ms" + + "IGNyZWF0ZSBvciB1cGRhdGUgdGhlIENMSQpjb25maWcgcGFzc2VkIGFzIGBOVkNGX0NMSV9DT05G" + + "SUdgOgoKYGBgeWFtbApiYXNlX2h0dHBfdXJsOiAiaHR0cDovLzxnYXRld2F5LWFkZHJlc3M+Igpp" + + "bnZva2VfdXJsOiAiaHR0cDovLzxnYXRld2F5LWFkZHJlc3M+IgpiYXNlX2dycGNfdXJsOiAiPGdh" + + "dGV3YXktYWRkcmVzcz46MTAwODEiCmFwaV9rZXlzX3NlcnZpY2VfdXJsOiAiaHR0cDovLzxnYXRl" + + "d2F5LWFkZHJlc3M+IgppY21zX3VybDogImh0dHA6Ly88Z2F0ZXdheS1hZGRyZXNzPiIKYXBpX2hv" + + "c3Q6ICJhcGkuPGdhdGV3YXktZG9tYWluPiIKYXBpX2tleXNfaG9zdDogImFwaS1rZXlzLjxnYXRl" + + "d2F5LWRvbWFpbj4iCmludm9rZV9ob3N0OiAiaW52b2NhdGlvbi48Z2F0ZXdheS1kb21haW4+Igpp" + + "Y21zX2hvc3Q6ICJzaXMuPGdhdGV3YXktZG9tYWluPiIKYGBgCgpIb3N0IGZpZWxkcyBjb250YWlu" + + "IGhvc3RuYW1lcyBvbmx5LCB3aXRob3V0IGEgVVJMIHNjaGVtZS4gYElDTVNfVVJMYCBtYXBzIG9u" + + "bHkKdG8gdGhlIHJlZ2lzdHJhdGlvbiBjb21tYW5kJ3MgYC0taWNtcy11cmxgOyBgaWNtc19ob3N0" + + "YCBpbiB0aGUgY29uZmlnIHN1cHBsaWVzCnRoZSBHYXRld2F5IEhvc3QgaGVhZGVyLiBUaGVyZSBp" + + "cyBubyBgLS1pY21zLWhvc3RgIE1ha2Ugb3IgQ0xJIGZsYWcuCgpSZWdpc3RlciBmcm9tIHRoZSBj" + + "b21wdXRlIGNvbnRleHQsIHRoZW4gaW5zdGFsbCB0aGUgY29tcHV0ZSBwbGFuZToKCmBgYGJhc2gK" + + "bWFrZSAtQyBkZXBsb3kvc3RhY2tzL252Y2YtY29tcHV0ZS1wbGFuZSByZWdpc3Rlci1jbHVzdGVy" + + "IFwKICBDTFVTVEVSX05BTUU9PGNsdXN0ZXItbmFtZT4gXAogIE5DQV9JRD08bmNhLWlkPiBcCiAg" + + "Q0xVU1RFUl9SRUdJT049PHJlZ2lvbj4gXAogIElDTVNfVVJMPWh0dHA6Ly88Z2F0ZXdheS1hZGRy" + + "ZXNzPiBcCiAgS1VCRUNPTkZJR19GSUxFPTxjb21wdXRlLXBsYW5lLWt1YmVjb25maWc+IFwKICBO" + + "VkNGX0NMST08cGF0aC10by1udmNmLWNsaT4gXAogIE5WQ0ZfQ0xJX0NPTkZJRz08cGF0aC10by1u" + + "dmNmLWNsaS1jb25maWc+CgptYWtlIC1DIGRlcGxveS9zdGFja3MvbnZjZi1jb21wdXRlLXBsYW5l" + + "IGluc3RhbGwgXAogIENMVVNURVJfTkFNRT08Y2x1c3Rlci1uYW1lPiBcCiAgSEVMTUZJTEVfRU5W" + + "PTxlbnY+IFwKICBLVUJFQ09ORklHX0ZJTEU9PGNvbXB1dGUtcGxhbmUta3ViZWNvbmZpZz4KYGBg" + + "CgpSZWdpc3RyYXRpb24gZGlzY292ZXJzIHRoZSBPSURDIGlzc3VlciBhbmQgSldLUyBmcm9tIHRo" + + "ZSBjb21wdXRlIGt1YmUgY29udGV4dC4KSW4gYSBtdWx0aS1jbHVzdGVyIGRlcGxveW1lbnQsIGBL" + + "VUJFQ09ORklHX0ZJTEVgIGlzIHJlcXVpcmVkIG9uIGJvdGgKY29tcHV0ZS1wbGFuZSBjb21tYW5k" + + "cy4gSW4gYSBzYW1lLWNsdXN0ZXIgZGVwbG95bWVudCBpdCBtYXkgYmUgb21pdHRlZCBvbmx5Cndo" + + "ZW4gdGhlIGFtYmllbnQgY29udGV4dCBpcyBhbHJlYWR5IHRoZSB0YXJnZXQgY2x1c3Rlci4KCiMj" + + "IENMSSBQcm9maWxlIEZsb3cKClJ1biB0aGUgQ0xJIGNvbnRyb2wtcGxhbmUgaW5zdGFsbCBmaXJz" + + "dC4gSXQgY3JlYXRlcyB0aGUgcHJvZmlsZSBjb25zdW1lZCBieQpjb21wdXRlLXBsYW5lIHJlZ2lz" + + "dHJhdGlvbjoKCmBgYGJhc2gKbnZjZi1jbGkgLS1jb25maWcgPGNvbmZpZz4gc2VsZi1ob3N0ZWQg" + + "XAogIC0tY29udHJvbC1wbGFuZS1zdGFjayBkZXBsb3kvc3RhY2tzL3NlbGYtbWFuYWdlZCBcCiAg" + + "LS1jb21wdXRlLXBsYW5lLXN0YWNrIGRlcGxveS9zdGFja3MvbnZjZi1jb21wdXRlLXBsYW5lIFwK" + + "ICAtLWVudiA8ZW52PiBcCiAgLS1wbGFpbiBcCiAgLS1jb250cm9sLXBsYW5lLWNvbnRleHQgPGNv" + + "bnRyb2wtcGxhbmUtY29udGV4dD4gXAogIC0tY29tcHV0ZS1wbGFuZS1jb250ZXh0IDxjb21wdXRl" + + "LXBsYW5lLWNvbnRleHQ+IFwKICBpbnN0YWxsIC0tY29udHJvbC1wbGFuZSBcCiAgLS1jbHVzdGVy" + + "LW5hbWUgPGNvbnRyb2wtcGxhbmUtY2x1c3Rlci1uYW1lPiBcCiAgLS1yZWdpb24gPHJlZ2lvbj4g" + + "XAogIC0tbmNhLWlkIDxuY2EtaWQ+Cgp0ZXN0IC1zIGRlcGxveS9zdGFja3Mvc2VsZi1tYW5hZ2Vk" + + "L291dC9jb250cm9sLXBsYW5lLXByb2ZpbGUueWFtbApgYGAKClVzZSB0aGUgZnVsbCBwZXJzaXN0" + + "ZW50IHByZWZpeCBmb3IgYm90aCBjb21wdXRlLXBsYW5lIGNvbW1hbmRzOgoKYGBgYmFzaApudmNm" + + "LWNsaSAtLWNvbmZpZyA8Y29uZmlnPiBzZWxmLWhvc3RlZCBcCiAgLS1jb250cm9sLXBsYW5lLXN0" + + "YWNrIGRlcGxveS9zdGFja3Mvc2VsZi1tYW5hZ2VkIFwKICAtLWNvbXB1dGUtcGxhbmUtc3RhY2sg" + + "ZGVwbG95L3N0YWNrcy9udmNmLWNvbXB1dGUtcGxhbmUgXAogIC0tZW52IDxlbnY+IFwKICAtLXBs" + + "YWluIFwKICBjb21wdXRlLXBsYW5lIHJlZ2lzdGVyIFwKICAtLWNvbnRyb2wtcGxhbmUtcHJvZmls" + + "ZSBkZXBsb3kvc3RhY2tzL3NlbGYtbWFuYWdlZC9vdXQvY29udHJvbC1wbGFuZS1wcm9maWxlLnlh" + + "bWwgXAogIC0tY2x1c3Rlci1uYW1lIDxjb21wdXRlLXBsYW5lLWNsdXN0ZXItbmFtZT4gXAogIC0t" + + "a3ViZS1jb250ZXh0IDxjb21wdXRlLXBsYW5lLWNvbnRleHQ+IFwKICAtLXJlZ2lvbiA8cmVnaW9u" + + "PiBcCiAgLS1vdXRwdXQgZGVwbG95L3N0YWNrcy9udmNmLWNvbXB1dGUtcGxhbmUvb3V0Lzxjb21w" + + "dXRlLXBsYW5lLWNsdXN0ZXItbmFtZT4tcmVnaXN0ZXItdmFsdWVzLnlhbWwKCm52Y2YtY2xpIC0t" + + "Y29uZmlnIDxjb25maWc+IHNlbGYtaG9zdGVkIFwKICAtLWNvbnRyb2wtcGxhbmUtc3RhY2sgZGVw" + + "bG95L3N0YWNrcy9zZWxmLW1hbmFnZWQgXAogIC0tY29tcHV0ZS1wbGFuZS1zdGFjayBkZXBsb3kv" + + "c3RhY2tzL252Y2YtY29tcHV0ZS1wbGFuZSBcCiAgLS1lbnYgPGVudj4gXAogIC0tcGxhaW4gXAog" + + "IGNvbXB1dGUtcGxhbmUgaW5zdGFsbCBcCiAgLS12YWx1ZXMgZGVwbG95L3N0YWNrcy9udmNmLWNv" + + "bXB1dGUtcGxhbmUvb3V0Lzxjb21wdXRlLXBsYW5lLWNsdXN0ZXItbmFtZT4tcmVnaXN0ZXItdmFs" + + "dWVzLnlhbWwgXAogIC0ta3ViZS1jb250ZXh0IDxjb21wdXRlLXBsYW5lLWNvbnRleHQ+IFwKICAt" + + "LWNsdXN0ZXItbmFtZSA8Y29tcHV0ZS1wbGFuZS1jbHVzdGVyLW5hbWU+CmBgYAoKUmVnaXN0cmF0" + + "aW9uIHNlbGVjdHMgdGhlIHByb2ZpbGUncyBpbi1jbHVzdGVyIG9yIGNvbXB1dGUtcmVhY2hhYmxl" + + "IGVuZHBvaW50cwpmcm9tIHRoZSBjb21wdXRlIGNvbnRleHQgYW5kIHByb2JlcyBpdHMgSldLUy4g" + + "RG8gbm90IHJlcGxhY2UgcHJvZmlsZSBlbmRwb2ludHMKd2l0aCBoYW5kLWF1dGhvcmVkIEhlbG1m" + + "aWxlIGVuZHBvaW50IHZhbHVlcy4KCiMjIFB1bGwgU2VjcmV0cwoKQ3JlYXRlIHRoZSBjb250cm9s" + + "LXBsYW5lIHNlY3JldCBpbiB0aGVzZSBuYW1lc3BhY2VzIG9uIHRoZSBjb250cm9sLXBsYW5lCmNv" + + "bnRleHQ6CgpgYGB0ZXh0CmNhc3NhbmRyYS1zeXN0ZW0gbmF0cy1zeXN0ZW0gbnZjZiBhcGkta2V5" + + "cyBlc3Mgc2lzIHZhdWx0LXN5c3RlbSBjZXJ0LW1hbmFnZXIKYGBgCgpPbiB0aGUgY29tcHV0ZSBj" + + "b250ZXh0LCBjcmVhdGUgdGhlIHNlY3JldCBvbmx5IGluIGBudmNhLW9wZXJhdG9yYCBhbmQgcmVm" + + "ZXJlbmNlCml0IHRocm91Z2ggYGdsb2JhbC5pbWFnZVB1bGxTZWNyZXRzYCBpbiB0aGUgY29tcHV0" + + "ZS1wbGFuZSBlbnZpcm9ubWVudC4gVGhlIGNoYXJ0CmFuZCBvcGVyYXRvciB1c2UgaXQgaW4gYG52" + + "Y2Etb3BlcmF0b3JgIGFuZCBtaXJyb3IgaXQgdG8gYG52Y2Etc3lzdGVtYC4gRG8gbm90IGFkZApl" + + "aXRoZXIgbmFtZXNwYWNlIHRvIHRoZSBjb250cm9sLXBsYW5lIGxpc3QuIEZvciBjb21tYW5kcyBh" + + "bmQgcG9saWN5IGV4YW1wbGVzLApzZWUgW3B1bGwtc2VjcmV0cy5tZF0ocHVsbC1zZWNyZXRzLm1k" + + "KS4KCiMjIFZlcmlmaWNhdGlvbgoKVXNlIHRoZSBleHBsaWNpdCBHYXRld2F5IG5hbWUgYW5kIG5h" + + "bWVzcGFjZToKCmBgYGJhc2gKa3ViZWN0bCAtLWNvbnRleHQgPGNvbnRyb2wtcGxhbmUtY29udGV4" + + "dD4gZ2V0IGdhdGV3YXkgbnZjZi1nYXRld2F5IFwKICAtbiBlbnZveS1nYXRld2F5IC1vIHdpZGUK" + + "a3ViZWN0bCAtLWNvbnRleHQgPGNvbnRyb2wtcGxhbmUtY29udGV4dD4gZ2V0IHRjcHJvdXRlIC1u" + + "IGVudm95LWdhdGV3YXkKa3ViZWN0bCAtLWNvbnRleHQgPGNvbXB1dGUtcGxhbmUtY29udGV4dD4g" + + "cm9sbG91dCBzdGF0dXMgXAogIGRlcGxveW1lbnQvbnZjYS1vcGVyYXRvciAtbiBudmNhLW9wZXJh" + + "dG9yIC0tdGltZW91dD0xMG0Ka3ViZWN0bCAtLWNvbnRleHQgPGNvbXB1dGUtcGxhbmUtY29udGV4" + + "dD4gd2FpdCBcCiAgbnZjZmJhY2tlbmQvPGNvbXB1dGUtcGxhbmUtY2x1c3Rlci1uYW1lPiAtbiBu" + + "dmNhLW9wZXJhdG9yIFwKICAtLWZvcj1qc29ucGF0aD0ney5zdGF0dXMuYWdlbnRTdGF0dXN9Jz1o" + + "ZWFsdGh5IC0tdGltZW91dD0xMG0Ka3ViZWN0bCAtLWNvbnRleHQgPGNvbXB1dGUtcGxhbmUtY29u" + + "dGV4dD4gZ2V0IHNlY3JldCA8cHVsbC1zZWNyZXQ+IC1uIG52Y2Etc3lzdGVtCmBgYAoKIyMgVGVh" + + "cmRvd24KCkRlc3Ryb3kgYW5kIGRlcmVnaXN0ZXIgZXZlcnkgY29tcHV0ZSBwbGFuZSBiZWZvcmUg" + + "dGhlIGNvbnRyb2wgcGxhbmUuIEJvdGgKaGFuZG9mZnMgY2FuIGRlc3Ryb3kgdGhlIGNvbnRyb2wg" + + "cGxhbmUsIHNvIGFwcGx5IHRoaXMgcHJlZmxpZ2h0IGJlZm9yZSBlaXRoZXIKb25lLiBQcmVzZXJ2" + + "ZSB0aGUgYGNlcnQtbWFuYWdlcmAgbmFtZXNwYWNlIGJ5IGRlZmF1bHQuIE5hbWVzcGFjZSBwcmVz" + + "ZXJ2YXRpb24KZG9lcyBub3QgcHJlc2VydmUgdGhlIEhlbG0gcmVsZWFzZS4gSWRlbnRpZnkgd2hl" + + "dGhlciBjZXJ0LW1hbmFnZXIgaXMgZXh0ZXJuYWwuCkZvciBhbiBleHRlcm5hbCBpbnN0YWxsYXRp" + + "b24sIHJlcXVpcmUgZWZmZWN0aXZlIGBjZXJ0TWFuYWdlci5lbmFibGVkOiBmYWxzZWAKYW5kIGFi" + + "b3J0IGlmIGl0IGlzIHRydWUuIEZvciBhbiBpbnRlbmRlZCBzdGFjay1tYW5hZ2VkIHJlbGVhc2Ug" + + "d2l0aCBlZmZlY3RpdmUKYGNlcnRNYW5hZ2VyLmVuYWJsZWQ6IHRydWVgLCB2ZXJpZnkgbWF0Y2hp" + + "bmcgc3RhY2sgSGVsbSByZWxlYXNlIG1ldGFkYXRhIGFuZAphYm9ydCBvbiBtaXNzaW5nIG9yIG1p" + + "c21hdGNoZWQgZXZpZGVuY2UuIE9ubHkgcGxhbiBuYW1lc3BhY2UgcmVtb3ZhbCBhZnRlcgp2ZXJp" + + "ZnlpbmcgbmFtZXNwYWNlIHByb3ZlbmFuY2UgYW5kIGdldHRpbmcgZXhwbGljaXQgY29uZmlybWF0" + + "aW9uOyBvdGhlcndpc2UKZG8gbm90IGRlbGV0ZSBpdC4KCkZvciB0aGUgSGVsbWZpbGUgdmFsdWVz" + + "IGZsb3csIHJldGFpbiBlYWNoIHJlZ2lzdHJhdGlvbiB2YWx1ZXMgZmlsZSB1bnRpbApjbGVhbnVw" + + "IGlzIHZlcmlmaWVkOgoKYGBgYmFzaAptYWtlIC1DIGRlcGxveS9zdGFja3MvbnZjZi1jb21wdXRl" + + "LXBsYW5lIGRlc3Ryb3kgXAogIENMVVNURVJfTkFNRT08Y2x1c3Rlci1uYW1lPiBcCiAgSEVMTUZJ" + + "TEVfRU5WPTxlbnY+IFwKICBLVUJFQ09ORklHX0ZJTEU9PGNvbXB1dGUtcGxhbmUta3ViZWNvbmZp" + + "Zz4KCjxudmNmLWNsaT4gLS1jb25maWcgPGNvbmZpZz4gY2x1c3RlciBkZWxldGUgXAogIC0tY2x1" + + "c3Rlci1pZCA8Y2x1c3Rlci1pZC1mcm9tLXJlZ2lzdHJhdGlvbi12YWx1ZXM+IFwKICAtLW5jYS1p" + + "ZCA8bmNhLWlkPiBcCiAgLS1pZ25vcmUtbWlzc2luZwpgYGAKClJlYWQgYGNsdXN0ZXJJRGAgYW5k" + + "IGBuY2FJRGAgZnJvbQpgZGVwbG95L3N0YWNrcy9udmNmLWNvbXB1dGUtcGxhbmUvcmVnaXN0cmF0" + + "aW9uLzxjbHVzdGVyLW5hbWU+LXJlZ2lzdGVyLXZhbHVlcy55YW1sYC4KU2hvdyB0aGUgZXhhY3Qg" + + "Y2x1c3RlciBJRCBhbmQgZ2V0IGNvbmZpcm1hdGlvbiBiZWZvcmUgYGNsdXN0ZXIgZGVsZXRlYC4g" + + "UmVwZWF0CmNvbXB1dGUgZGVzdHJveSBhbmQgZGVyZWdpc3RyYXRpb24gZm9yIGV2ZXJ5IGNvbXB1" + + "dGUgY2x1c3RlciwgdGhlbiBkZXN0cm95IHRoZQpjb250cm9sIHBsYW5lLgoKYGBgYmFzaApLVUJF" + + "Q09ORklHPTxjb250cm9sLXBsYW5lLWt1YmVjb25maWc+IFwKbWFrZSAtQyBkZXBsb3kvc3RhY2tz" + + "L3NlbGYtbWFuYWdlZCBkZXN0cm95IFwKICBIRUxNRklMRV9FTlY9PGVudj4gXAogIEtVQkVDT05G" + + "SUdfRklMRT08Y29udHJvbC1wbGFuZS1rdWJlY29uZmlnPgpgYGAKCkJvdGggYEtVQkVDT05GSUdg" + + "IGFuZCBgS1VCRUNPTkZJR19GSUxFYCBhcmUgcmVxdWlyZWQgb24gdGhlIGNvbnRyb2wtcGxhbmUK" + + "ZGVzdHJveSBiZWNhdXNlIGl0cyBuYW1lc3BhY2UgY2xlYW51cCB1c2VzIGFtYmllbnQgYGt1YmVj" + + "dGxgLgoKRm9yIHRoZSBDTEkgcHJvZmlsZSBmbG93LCBrZWVwCmBkZXBsb3kvc3RhY2tzL252Y2Yt" + + "Y29tcHV0ZS1wbGFuZS9vdXQvPGNsdXN0ZXItbmFtZT4tcmVnaXN0ZXItdmFsdWVzLnlhbWxgCnVu" + + "dGlsIHRlYXJkb3duIGNvbXBsZXRlcyBiZWNhdXNlIGBkb3duYCByZWFkcyBpdHMgY2x1c3RlciBJ" + + "RC4gRmlyc3QgcXVlcnkgdGhlCmxpdmUgcmVnaXN0cmF0aW9uIHNldCB3aXRoIHRoZSBzYW1lIGNv" + + "bXBsZXRlIHBlcnNpc3RlbnQgcHJlZml4OgoKYGBgYmFzaApudmNmLWNsaSAtLWNvbmZpZyA8Y29u" + + "ZmlnPiBzZWxmLWhvc3RlZCBcCiAgLS1jb250cm9sLXBsYW5lLXN0YWNrIGRlcGxveS9zdGFja3Mv" + + "c2VsZi1tYW5hZ2VkIFwKICAtLWNvbXB1dGUtcGxhbmUtc3RhY2sgZGVwbG95L3N0YWNrcy9udmNm" + + "LWNvbXB1dGUtcGxhbmUgXAogIC0tZW52IDxlbnY+IFwKICAtLXBsYWluIFwKICAtLWNvbnRyb2wt" + + "cGxhbmUtY29udGV4dCA8Y29udHJvbC1wbGFuZS1jb250ZXh0PiBcCiAgLS1jb21wdXRlLXBsYW5l" + + "LWNvbnRleHQgPGNvbXB1dGUtcGxhbmUtY29udGV4dD4gXAogIC0taWNtcy11cmwgPGNvbXB1dGUt" + + "cmVhY2hhYmxlLWljbXMtdXJsPiBcCiAgc3RhdHVzIFwKICAtLWNsdXN0ZXItbmFtZSA8Y29tcHV0" + + "ZS1wbGFuZS1jbHVzdGVyLW5hbWU+IFwKICAtLW5jYS1pZCA8bmNhLWlkPgpgYGAKClNob3cgdGhl" + + "IHJlZ2lzdGVyZWQgY29tcHV0ZS1wbGFuZSBsaXN0LiBOYW1lZC1jbHVzdGVyIGBkb3duIC0tcGxh" + + "bi1vbmx5YCBkb2VzCm5vdCBxdWVyeSBJQ01TLCBzbyBpdCBwcmV2aWV3cyBvbmx5IHRoYXQgY29t" + + "cHV0ZSBwbGFuZSBhbmQgY2Fubm90IHNob3cgdGhlCmNvbmRpdGlvbmFsIGNvbnRyb2wtcGxhbmUg" + + "cmVtb3ZhbC4gSWYgdGhlIHRhcmdldCBpcyB0aGUgbGFzdCByZWdpc3RlcmVkCmNsdXN0ZXIsIHJl" + + "YWwgYGRvd25gIGFsc28gZGVzdHJveXMgdGhlIGNvbnRyb2wgcGxhbmUuIEdldCBleHBsaWNpdCBj" + + "b25maXJtYXRpb24KZm9yIGJvdGggcmVtb3ZhbHMgYmVmb3JlIGNvbnRpbnVpbmcuIFRoZW4gcHJl" + + "dmlldyBhbmQgZXhlY3V0ZSB3aXRoIHRoZSBzYW1lCnByZWZpeDoKCmBgYGJhc2gKbnZjZi1jbGkg" + + "LS1jb25maWcgPGNvbmZpZz4gc2VsZi1ob3N0ZWQgXAogIC0tY29udHJvbC1wbGFuZS1zdGFjayBk" + + "ZXBsb3kvc3RhY2tzL3NlbGYtbWFuYWdlZCBcCiAgLS1jb21wdXRlLXBsYW5lLXN0YWNrIGRlcGxv" + + "eS9zdGFja3MvbnZjZi1jb21wdXRlLXBsYW5lIFwKICAtLWVudiA8ZW52PiBcCiAgLS1wbGFpbiBc" + + "CiAgLS1jb250cm9sLXBsYW5lLWNvbnRleHQgPGNvbnRyb2wtcGxhbmUtY29udGV4dD4gXAogIC0t" + + "Y29tcHV0ZS1wbGFuZS1jb250ZXh0IDxjb21wdXRlLXBsYW5lLWNvbnRleHQ+IFwKICAtLWljbXMt" + + "dXJsIDxjb21wdXRlLXJlYWNoYWJsZS1pY21zLXVybD4gXAogIGRvd24gLS1wbGFuLW9ubHkgXAog" + + "IC0tY2x1c3Rlci1uYW1lIDxjb21wdXRlLXBsYW5lLWNsdXN0ZXItbmFtZT4gXAogIC0tbmNhLWlk" + + "IDxuY2EtaWQ+CgpudmNmLWNsaSAtLWNvbmZpZyA8Y29uZmlnPiBzZWxmLWhvc3RlZCBcCiAgLS1j" + + "b250cm9sLXBsYW5lLXN0YWNrIGRlcGxveS9zdGFja3Mvc2VsZi1tYW5hZ2VkIFwKICAtLWNvbXB1" + + "dGUtcGxhbmUtc3RhY2sgZGVwbG95L3N0YWNrcy9udmNmLWNvbXB1dGUtcGxhbmUgXAogIC0tZW52" + + "IDxlbnY+IFwKICAtLXBsYWluIFwKICAtLWNvbnRyb2wtcGxhbmUtY29udGV4dCA8Y29udHJvbC1w" + + "bGFuZS1jb250ZXh0PiBcCiAgLS1jb21wdXRlLXBsYW5lLWNvbnRleHQgPGNvbXB1dGUtcGxhbmUt" + + "Y29udGV4dD4gXAogIC0taWNtcy11cmwgPGNvbXB1dGUtcmVhY2hhYmxlLWljbXMtdXJsPiBcCiAg" + + "ZG93biBcCiAgLS1jbHVzdGVyLW5hbWUgPGNvbXB1dGUtcGxhbmUtY2x1c3Rlci1uYW1lPiBcCiAg" + + "LS1uY2EtaWQgPG5jYS1pZD4KYGBgCgpSZXBlYXQgYGRvd25gIGZvciBlYWNoIGNvbXB1dGUgY2x1" + + "c3Rlci4gRWFjaCBydW4gZGVzdHJveXMgdGhhdCBjb21wdXRlIHBsYW5lIGFuZAp1bnJlZ2lzdGVy" + + "cyBpdHMgSUNNUyByb3cuIEl0IHJlbW92ZXMgdGhlIGNvbnRyb2wgcGxhbmUgb25seSBhZnRlciBu" + + "byBjbHVzdGVyCnJlZ2lzdHJhdGlvbnMgcmVtYWluLgpQSwcIlmO8WRIwAAASMAAAUEsDBBQACAAA" + + "AAAAIQAAAAAAAAAAAAAAAAA7AAkAZGF0YS9udmNmLXNlbGYtbWFuYWdlZC1pbnN0YWxsYXRpb24v" + + "cmVmZXJlbmNlcy9kZWJ1Z2dpbmcubWRVVAUAAQCmzhIjIERlYnVnZ2luZyBSZWZlcmVuY2UKClJl" + + "Y2lwZXMgZm9yIGRpYWdub3NpbmcgYW5kIGZpeGluZyBjb21tb24gTlZDRiBzZWxmLW1hbmFnZWQg" + + "c3RhY2sgZmFpbHVyZXMuCgojIyBRdWljayBTdGF0dXMgQ29tbWFuZHMKCmBgYGJhc2gKIyBBbGwg" + + "cG9kcyBhY3Jvc3MgYWxsIG5hbWVzcGFjZXMKa3ViZWN0bCBnZXQgcG9kcyAtQSAtbyB3aWRlCgoj" + + "IFBvZHMgaW4gYSBzcGVjaWZpYyBuYW1lc3BhY2UKa3ViZWN0bCBnZXQgcG9kcyAtbiA8bmFtZXNw" + + "YWNlPgoKIyBBbGwgaGVsbSByZWxlYXNlcwpoZWxtIGxpc3QgLUEKCiMgUmVjZW50IGV2ZW50cyAo" + + "bW9zdCB1c2VmdWwgZm9yIGRpYWdub3NpbmcgZmFpbHVyZXMpCmt1YmVjdGwgZ2V0IGV2ZW50cyAt" + + "biA8bmFtZXNwYWNlPiAtLXNvcnQtYnk9Jy5sYXN0VGltZXN0YW1wJwoKIyBEZXNjcmliZSBhIHNw" + + "ZWNpZmljIHBvZCAoc2hvd3MgZXZlbnRzLCBjb25kaXRpb25zLCB2b2x1bWVzKQprdWJlY3RsIGRl" + + "c2NyaWJlIHBvZCAtbiA8bmFtZXNwYWNlPiA8cG9kLW5hbWU+CmBgYAoKIyMgRmFpbHVyZTogSW1h" + + "Z2VQdWxsQmFja09mZgoKIyMjIFN5bXB0b21zCgpgYGB0ZXh0Ck5BTUUgICAgICAgICAgIFJFQURZ" + + "ICAgU1RBVFVTICAgICAgICAgICAgIFJFU1RBUlRTICAgQUdFCmNhc3NhbmRyYS0wICAgIDAvMSAg" + + "ICAgSW1hZ2VQdWxsQmFja09mZiAgIDAgICAgICAgICAgNW0KYGBgCgpFdmVudHMgc2hvdzoKCmBg" + + "YHRleHQKRmFpbGVkIHRvIHB1bGwgaW1hZ2UgIm52Y3IuaW8vLi4uL2ltYWdlOnRhZyI6IDQwMSBV" + + "bmF1dGhvcml6ZWQKYGBgCgojIyMgRGlhZ25vc2lzCgpgYGBiYXNoCiMgQ2hlY2sgd2hhdCBpbWFn" + + "ZSBpcyBmYWlsaW5nCmt1YmVjdGwgZGVzY3JpYmUgcG9kIC1uIDxuYW1lc3BhY2U+IDxwb2QtbmFt" + + "ZT4gfCBncmVwIC1BNSAiRXZlbnRzOiIKCiMgQ2hlY2sgaWYgcHVsbCBzZWNyZXQgZXhpc3RzIGlu" + + "IHRoZSBuYW1lc3BhY2UKa3ViZWN0bCBnZXQgc2VjcmV0IG52Y3ItY3JlZHMgLW4gPG5hbWVzcGFj" + + "ZT4KCiMgQ2hlY2sgaWYgdGhlIHBvZCBzcGVjIGhhcyBpbWFnZVB1bGxTZWNyZXRzCmt1YmVjdGwg" + + "Z2V0IHBvZCAtbiA8bmFtZXNwYWNlPiA8cG9kLW5hbWU+IC1vIGpzb25wYXRoPSd7LnNwZWMuaW1h" + + "Z2VQdWxsU2VjcmV0c30nCgojIENoZWNrIGlmIHRoZSBTZXJ2aWNlQWNjb3VudCBoYXMgaW1hZ2VQ" + + "dWxsU2VjcmV0cwprdWJlY3RsIGdldCBzYSA8c2EtbmFtZT4gLW4gPG5hbWVzcGFjZT4gLW8ganNv" + + "bnBhdGg9J3suaW1hZ2VQdWxsU2VjcmV0c30nCmBgYAoKIyMjIEZpeGVzCgoxLiAqKlNlY3JldCBt" + + "aXNzaW5nIGluIG5hbWVzcGFjZSoqOiBDcmVhdGUgaXQKCiAgIGBgYGJhc2gKICAga3ViZWN0bCBj" + + "cmVhdGUgc2VjcmV0IGRvY2tlci1yZWdpc3RyeSBudmNyLWNyZWRzIFwKICAgICAtLWRvY2tlci1z" + + "ZXJ2ZXI9bnZjci5pbyBcCiAgICAgLS1kb2NrZXItdXNlcm5hbWU9JyRvYXV0aHRva2VuJyBcCiAg" + + "ICAgLS1kb2NrZXItcGFzc3dvcmQ9IiROR0NfQVBJX0tFWSIgXAogICAgIC0tbmFtZXNwYWNlPTxu" + + "YW1lc3BhY2U+IFwKICAgICAtLWRyeS1ydW49Y2xpZW50IC1vIHlhbWwgfCBrdWJlY3RsIGFwcGx5" + + "IC1mIC0KICAgYGBgCgoyLiAqKlNlY3JldCBleGlzdHMgYnV0IHBvZCBkb2Vzbid0IHJlZmVyZW5j" + + "ZSBpdCoqOiBUaGUgY2hhcnQgbmVlZHMgYGltYWdlUHVsbFNlY3JldHNgIGNvbmZpZ3VyZWQgdmlh" + + "IGhlbG1maWxlIHZhbHVlcy4gU2VlIFtwdWxsLXNlY3JldHMubWRdKHB1bGwtc2VjcmV0cy5tZCku" + + "CgozLiAqKkNoYXJ0IGRvZXNuJ3Qgc3VwcG9ydCBpbWFnZVB1bGxTZWNyZXRzKiogKG9wZW5iYW8s" + + "IGludm9jYXRpb24tc2VydmljZSwgZXNzLWFwaSwgbm90YXJ5LXNlcnZpY2UpOiBQYXRjaCBTZXJ2" + + "aWNlQWNjb3VudHMgYW5kIHJlc3RhcnQgcG9kcy4gU2VlIFtwdWxsLXNlY3JldHMubWRdKHB1bGwt" + + "c2VjcmV0cy5tZCkuCgo0LiAqKldyb25nIGNyZWRlbnRpYWxzIGluIHNlY3JldCoqOiBEZWxldGUg" + + "YW5kIHJlY3JlYXRlCgogICBgYGBiYXNoCiAgIGt1YmVjdGwgZGVsZXRlIHNlY3JldCBudmNyLWNy" + + "ZWRzIC1uIDxuYW1lc3BhY2U+CiAgICMgUmVjcmVhdGUgd2l0aCBjb3JyZWN0IGNyZWRlbnRpYWxz" + + "CiAgIGBgYAoKIyMgRmFpbHVyZTogSW5pdDowLzEgU3R1Y2sgKFZhdWx0IEFnZW50KQoKIyMjIFN5" + + "bXB0b21zCgpTZXJ2aWNlIHBvZHMgc3R1Y2sgaW4gYEluaXQ6MC8xYCBmb3IgbWludXRlczoKCmBg" + + "YGJhc2gKTkFNRSAgICAgICAgICAgICAgICAgICAgICAgIFJFQURZICAgU1RBVFVTICAgICBSRVNU" + + "QVJUUyAgIEFHRQpudmNmLWFwaS03ZjRjNzZmNzg4LTQ0dmx0ICAwLzIgICAgIEluaXQ6MC8xICAg" + + "MCAgICAgICAgICAxMG0KYGBgCgojIyMgRGlhZ25vc2lzCgpUaGUgaW5pdCBjb250YWluZXIgaXMg" + + "dGhlIHZhdWx0LWFnZW50LWluaXQgaW5qZWN0b3Igd2FpdGluZyBmb3IgT3BlbkJhby4KCmBgYGJh" + + "c2gKIyBDaGVjayBPcGVuQmFvIHBvZHMKa3ViZWN0bCBnZXQgcG9kcyAtbiB2YXVsdC1zeXN0ZW0K" + + "CiMgQ2hlY2sgaWYgT3BlbkJhbyBtaWdyYXRpb24gam9iIHJhbgprdWJlY3RsIGdldCBqb2JzIC1u" + + "IHZhdWx0LXN5c3RlbQoKIyBDaGVjayBPcGVuQmFvIHBvZCBsb2dzCmt1YmVjdGwgbG9ncyAtbiB2" + + "YXVsdC1zeXN0ZW0gb3BlbmJhby1zZXJ2ZXItMCAtYyBvcGVuYmFvCgojIENoZWNrIGluaXQgY29u" + + "dGFpbmVyIGxvZ3Mgb24gdGhlIHN0dWNrIHBvZAprdWJlY3RsIGxvZ3MgLW4gPG5hbWVzcGFjZT4g" + + "PHBvZC1uYW1lPiAtYyB2YXVsdC1hZ2VudC1pbml0CmBgYAoKIyMjIEZpeGVzCgoxLiAqKk9wZW5C" + + "YW8gcG9kcyBub3QgcnVubmluZyoqOiBDaGVjayB0aGVpciBldmVudHMgZm9yIGltYWdlIHB1bGwg" + + "aXNzdWVzLCByZXNvdXJjZSBpc3N1ZXMsIGV0Yy4KCjIuICoqT3BlbkJhbyBtaWdyYXRpb24gam9i" + + "IGRpZG4ndCBydW4qKjogVGhpcyBoYXBwZW5zIHdoZW4gYGhlbG1maWxlIHN5bmNgIHdhcyBpbnRl" + + "cnJ1cHRlZC4gRGVzdHJveSBhbmQgcmUtc3luYyBvcGVuYmFvOgoKICAgYGBgYmFzaAogICBIRUxN" + + "RklMRV9FTlY9PGVudj4gaGVsbWZpbGUgLS1zZWxlY3RvciBuYW1lPW9wZW5iYW8tc2VydmVyIGRl" + + "c3Ryb3kKICAga3ViZWN0bCBkZWxldGUgbmFtZXNwYWNlIHZhdWx0LXN5c3RlbQogICBrdWJlY3Rs" + + "IGNyZWF0ZSBuYW1lc3BhY2UgdmF1bHQtc3lzdGVtCiAgICMgUmUtY3JlYXRlIHB1bGwgc2VjcmV0" + + "IGlmIG5lZWRlZAogICBIRUxNRklMRV9FTlY9PGVudj4gaGVsbWZpbGUgLS1zZWxlY3RvciBuYW1l" + + "PW9wZW5iYW8tc2VydmVyIHN5bmMKICAgYGBgCgozLiAqKk9wZW5CYW8gcG9kcyBydW5uaW5nIGJ1" + + "dCBub3QgaW5pdGlhbGl6ZWQqKjogQ2hlY2sgdW5zZWFsIHN0YXR1czoKCiAgIGBgYGJhc2gKICAg" + + "a3ViZWN0bCBleGVjIC1uIHZhdWx0LXN5c3RlbSBvcGVuYmFvLXNlcnZlci0wIC1jIG9wZW5iYW8g" + + "LS0gYmFvIHN0YXR1cwogICBgYGAKCjQuICoqYHZhdWx0LWFnZW50LWluaXRgIGxvZ3MgYGF1dGgv" + + "and0L2xvZ2luYCAtPiBgNDAwIC4uLiBubyBrbm93biBrZXkgc3VjY2Vzc2Z1bGx5IHZhbGlkYXRl" + + "ZCB0aGUgdG9rZW4gc2lnbmF0dXJlYCoqCiAgIChjb21tb24gb24gQUtTLCBhbmQgb24gYW55IGNs" + + "dXN0ZXIgd2hvc2UgT0lEQyBpc3N1ZXIgcHVibGlzaGVzIGEgcm90YXRpbmcgbXVsdGkta2V5IEpX" + + "S1MpOgogICB0aGUgT3BlbkJhbyBgYXV0aC9qd3RgIG1ldGhvZCB3YXMgY29uZmlndXJlZCB3aXRo" + + "IGEgc2luZ2xlIHN0YXRpYyBtb3VudGVkIHB1YmxpYyBrZXkKICAgKGBqd3RfdmFsaWRhdGlvbl9w" + + "dWJrZXlzYCkgaW5zdGVhZCBvZiB0aGUgaXNzdWVyJ3MgbGl2ZSBKV0tTLCBzbyBpdCBjYW5ub3Qg" + + "dmFsaWRhdGUgdGhlCiAgIGNsdXN0ZXItc2lnbmVkIFNlcnZpY2VBY2NvdW50IHRva2VucyB0aGUg" + + "dmF1bHQtYWdlbnRzIHByZXNlbnQuIEV2ZXJ5IHNlcnZpY2UncwogICBgdmF1bHQtYWdlbnQtaW5p" + + "dGAgdGhlbiBsb29wcyBvbiB0aGF0IDQwMCBhbmQgdGhlIHBvZCBuZXZlciBsZWF2ZXMgYEluaXQ6" + + "MC8xYC4gVGhpcyBpcyB0aGUKICAgYmVoYXZpb3Igb2YgdGhlIGJhc2UgZGVmYXVsdCBgb3BlbmJh" + + "by5taWdyYXRpb25zLmlzc3VlckRpc2NvdmVyeS5lbmFibGVkOiBmYWxzZWA7IHRoZQogICBtaWdy" + + "YXRpb24gbG9nIHNob3dzIGBPSURDIGlzc3VlciBkaXNjb3ZlcnkgaXMgZGlzYWJsZWQuIFVzaW5n" + + "IFNlcnZpY2VBY2NvdW50IGlzc3VlciBhbmQKICAgbW91bnRlZCBwdWJsaWMga2V5IGZhbGxiYWNr" + + "YC4gRW5hYmxlIGlzc3VlciBkaXNjb3Zlcnkgc28gdGhlIG1pZ3JhdGlvbiB1c2VzIHRoZSBsaXZl" + + "IEpXS1MsCiAgIHRoZW4gcmUtcnVuIGl0OgoKICAgYGBgYmFzaAogICAjIEluIHlvdXIgZW52IHZh" + + "bHVlcyAoZS5nLiBlbnZpcm9ubWVudHMvPGVudj4ueWFtbCksIHNldDoKICAgIyAgIG9wZW5iYW86" + + "CiAgICMgICAgIG1pZ3JhdGlvbnM6CiAgICMgICAgICAgaXNzdWVyRGlzY292ZXJ5OgogICAjICAg" + + "ICAgICAgZW5hYmxlZDogdHJ1ZQogICBrdWJlY3RsIGRlbGV0ZSBqb2IgLW4gdmF1bHQtc3lzdGVt" + + "IG9wZW5iYW8tc2VydmVyLW1pZ3JhdGlvbnMKICAgSEVMTUZJTEVfRU5WPTxlbnY+IGhlbG1maWxl" + + "IC0tc2VsZWN0b3IgbmFtZT1vcGVuYmFvLXNlcnZlciBzeW5jCiAgIGBgYAoKICAgVGhlIG1pZ3Jh" + + "dGlvbiBsb2cgc2hvdWxkIHRoZW4gc2hvdyBgUmVjZWl2ZWQgZGlzY292ZXJ5IGRvY3VtZW50IGZy" + + "b20KICAgLi4uLy53ZWxsLWtub3duL29wZW5pZC1jb25maWd1cmF0aW9uYCBhbmQgYEZpbmFsIE9w" + + "ZW5CYW8gSldUIEpXS1MgVVJMIHNldCB0bzoKICAgLi4uL29wZW5pZC92MS9qd2tzYCwgYW5kIHRo" + + "ZSBzZXJ2aWNlIHBvZHMgYXV0aGVudGljYXRlIGFuZCByZWFjaCBSZWFkeSB3aXRoaW4gfjEgbWlu" + + "LgogICBBS1MgY2x1c3RlcnMgd2l0aCB0aGUgY2x1c3RlciBPSURDIGlzc3VlciBlbmFibGVkICh0" + + "aGUgYGFrcy1jbHVzdGVyYCBkZWZhdWx0KSBhbHdheXMgbmVlZAogICB0aGlzIGJlY2F1c2UgdGhl" + + "IHN0YXRpYy1wdWJrZXkgZmFsbGJhY2sgY2Fubm90IG1hdGNoIHRoZSBpc3N1ZXIncyByb3RhdGlu" + + "ZyBzaWduaW5nIGtleXMuCgojIyBGYWlsdXJlOiBPT01LaWxsZWQgb24gQ2Fzc2FuZHJhCgojIyMg" + + "U3ltcHRvbXMKCmBgYHRleHQKTkFNRSAgICAgICAgICBSRUFEWSAgIFNUQVRVUyAgICBSRVNUQVJU" + + "UyAgIEFHRQpjYXNzYW5kcmEtMCAgIDAvMSAgICAgT09NS2lsbGVkICAzICAgICAgICAgMTBtCmBg" + + "YAoKIyMjIERpYWdub3NpcwoKYGBgYmFzaAprdWJlY3RsIGRlc2NyaWJlIHBvZCAtbiBjYXNzYW5k" + + "cmEtc3lzdGVtIGNhc3NhbmRyYS0wIHwgZ3JlcCAtQTMgIkxhc3QgU3RhdGUiCmBgYAoKIyMjIEZp" + + "eAoKT3ZlcnJpZGUgQ2Fzc2FuZHJhIHJlc291cmNlcyB2aWEgaGVsbWZpbGUgdmFsdWVzLiBTZWUg" + + "RXhhbXBsZSAxIGluCltleGFtcGxlcy5tZF0oLi4vZXhhbXBsZXMubWQpLgoKYGBgeWFtbAotIGNh" + + "c3NhbmRyYToKICAgIHJlc291cmNlczoKICAgICAgbGltaXRzOgogICAgICAgIGNwdTogIjgiCiAg" + + "ICAgICAgbWVtb3J5OiA4MTkyTWkKICAgICAgcmVxdWVzdHM6CiAgICAgICAgY3B1OiAiMiIKICAg" + + "ICAgICBtZW1vcnk6IDQwOTZNaQpgYGAKCk5vdGU6IGByZXNvdXJjZVByZXNldGAgaXMgbm90IGF2" + + "YWlsYWJsZSBpbiB0aGUgTlZDRiBjYXNzYW5kcmEgd3JhcHBlciBjaGFydC4gVXNlIGV4cGxpY2l0" + + "IGByZXNvdXJjZXNgLgoKIyMgRmFpbHVyZTogUG9kcyBTdHVjayBpbiBQZW5kaW5nCgojIyMgU3lt" + + "cHRvbXMKCmBgYHRleHQKTkFNRSAgICAgICAgICBSRUFEWSAgIFNUQVRVUyAgICBSRVNUQVJUUyAg" + + "IEFHRQpjYXNzYW5kcmEtMCAgIDAvMSAgICAgUGVuZGluZyAgIDAgICAgICAgICAgMTBtCmBgYAoK" + + "IyMjIERpYWdub3NpcwoKYGBgYmFzaAojIENoZWNrIGV2ZW50cyBmb3Igc2NoZWR1bGluZyBmYWls" + + "dXJlcwprdWJlY3RsIGRlc2NyaWJlIHBvZCAtbiA8bmFtZXNwYWNlPiA8cG9kLW5hbWU+CgojIENv" + + "bW1vbiBjYXVzZXM6CiMgLSAiMC8xMiBub2RlcyBhcmUgYXZhaWxhYmxlOiAxMiBub2RlKHMpIGRp" + + "ZG4ndCBtYXRjaCBQb2QncyBub2RlIGFmZmluaXR5IgojIC0gIjAvMTIgbm9kZXMgYXJlIGF2YWls" + + "YWJsZTogMTIgSW5zdWZmaWNpZW50IG1lbW9yeSIKIyAtICJwZXJzaXN0ZW50dm9sdW1lY2xhaW0g" + + "bm90IGZvdW5kIgpgYGAKCiMjIyBGaXhlcwoKMS4gKipOb2RlIHNlbGVjdG9yIG1pc21hdGNoKio6" + + "IENoZWNrIGBub2RlU2VsZWN0b3JzYCBpbiBlbnZpcm9ubWVudCBmaWxlCgogICBgYGBiYXNoCiAg" + + "IGt1YmVjdGwgZ2V0IG5vZGVzIC0tc2hvdy1sYWJlbHMgfCBncmVwIG52Y2YKICAgYGBgCgoyLiAq" + + "KlN0b3JhZ2UgY2xhc3Mgbm90IGZvdW5kKio6IENoZWNrIHN0b3JhZ2UgY2xhc3MgZXhpc3RzCgog" + + "ICBgYGBiYXNoCiAgIGt1YmVjdGwgZ2V0IHN0b3JhZ2VjbGFzcwogICBgYGAKCjMuICoqSW5zdWZm" + + "aWNpZW50IHJlc291cmNlcyoqOiBDaGVjayBub2RlIGNhcGFjaXR5CgogICBgYGBiYXNoCiAgIGt1" + + "YmVjdGwgZGVzY3JpYmUgbm9kZSA8bm9kZS1uYW1lPiB8IGdyZXAgLUE1ICJBbGxvY2F0ZWQgcmVz" + + "b3VyY2VzIgogICBgYGAKCiMjIEZhaWx1cmU6IEhlbG0gUmVsZWFzZSBpbiBGYWlsZWQgU3RhdGUK" + + "CiMjIyBTeW1wdG9tcwoKYGBgYmFzaApoZWxtIGxpc3QgLUEKIyBTaG93cyByZWxlYXNlIHdpdGgg" + + "U1RBVFVTOiBmYWlsZWQKYGBgCgpSZS1ydW5uaW5nIGBoZWxtZmlsZSBzeW5jYCBhcHBlYXJzIHRv" + + "IHN1Y2NlZWQgYnV0IHNlcnZpY2VzIGRvbid0IHdvcmsgKG1pZ3JhdGlvbnMgc2tpcHBlZCkuCgoj" + + "IyMgRGlhZ25vc2lzCgpgYGBiYXNoCmhlbG0gaGlzdG9yeSA8cmVsZWFzZS1uYW1lPiAtbiA8bmFt" + + "ZXNwYWNlPgpgYGAKCiMjIyBGaXgKCk11c3QgZGVzdHJveSB0aGUgZmFpbGVkIHJlbGVhc2UgYW5k" + + "IHJlLXN5bmMgKG5vdCBqdXN0IGFwcGx5KToKCmBgYGJhc2gKSEVMTUZJTEVfRU5WPTxlbnY+IGhl" + + "bG1maWxlIC0tc2VsZWN0b3IgbmFtZT08cmVsZWFzZT4gZGVzdHJveQojIElmIG5hbWVzcGFjZSBu" + + "ZWVkcyBjbGVhbnVwOgprdWJlY3RsIGRlbGV0ZSBuYW1lc3BhY2UgPG5hbWVzcGFjZT4Ka3ViZWN0" + + "bCBjcmVhdGUgbmFtZXNwYWNlIDxuYW1lc3BhY2U+CiMgUmUtY3JlYXRlIHB1bGwgc2VjcmV0IGlm" + + "IG5lZWRlZApIRUxNRklMRV9FTlY9PGVudj4gaGVsbWZpbGUgLS1zZWxlY3RvciBuYW1lPTxyZWxl" + + "YXNlPiBzeW5jCmBgYAoKIyMgRmFpbHVyZTogQWNjb3VudCBCb290c3RyYXAgSm9iIEZhaWxlZAoK" + + "IyMjIFN5bXB0b21zCgpTZXJ2aWNlcyBkZXBsb3kgYnV0IGZ1bmN0aW9ucyBjYW4ndCBiZSBjcmVh" + + "dGVkLiBFdmVudHMgc2hvdyBib290c3RyYXAgam9iIGZhaWx1cmUuCgojIyMgRGlhZ25vc2lzCgpg" + + "YGBiYXNoCiMgQ2hlY2sgYm9vdHN0cmFwIGpvYiBzdGF0dXMKa3ViZWN0bCBnZXQgam9icyAtbiBu" + + "dmNmCgojIEdldCBsb2dzIChqb2IgYXV0by1kZWxldGVzIGFmdGVyIH41IG1pbnV0ZXMpCmt1YmVj" + + "dGwgbG9ncyBqb2IvbnZjZi1hcGktYWNjb3VudC1ib290c3RyYXAgLW4gbnZjZgoKIyBDaGVjayBB" + + "UEkgbG9ncwprdWJlY3RsIGxvZ3MgLW4gbnZjZiAtbCBhcHAua3ViZXJuZXRlcy5pby9uYW1lPW52" + + "Y2YtYXBpIC0tdGFpbD0xMDAKYGBgCgojIyMgQ29tbW9uIENhdXNlcwoKMS4gKipXcm9uZyBiYXNl" + + "NjQgY3JlZGVudGlhbHMqKjogQ3JlZGVudGlhbHMgaW4gYHNlY3JldHMvPGVudj4tc2VjcmV0cy55" + + "YW1sYCBtdXN0IGJlIGAkb2F1dGh0b2tlbjpBUElfS0VZYCBiYXNlNjQtZW5jb2RlZCwgbm90IGp1" + + "c3QgdGhlIEFQSSBrZXkKCiAgIGBgYGJhc2gKICAgIyBWZXJpZnkgeW91ciBlbmNvZGVkIGNyZWRl" + + "bnRpYWwKICAgZWNobyAnWU9VUl9CQVNFNjRfU1RSSU5HJyB8IGJhc2U2NCAtZAogICAjIFNob3Vs" + + "ZCBvdXRwdXQ6ICRvYXV0aHRva2VuOm52YXBpLXh4eHh4CiAgIGBgYAoKMi4gKipSZWdpc3RyeSB1" + + "bnJlYWNoYWJsZSoqOiBBUEkgY2FuJ3QgcmVhY2ggdGhlIHJlZ2lzdHJ5IHNwZWNpZmllZCBpbiBg" + + "YWNjb3VudEJvb3RzdHJhcC5yZWdpc3RyeUNyZWRlbnRpYWxzYAoKIyMjIEZpeAoKRml4IGNyZWRl" + + "bnRpYWxzIGluIHNlY3JldHMgZmlsZSwgdGhlbiByZWNvdmVyIHNlcnZpY2VzIHdpdGhvdXQgZGVz" + + "dHJveWluZyBkZXBlbmRlbmNpZXM6CgpgYGBiYXNoCkhFTE1GSUxFX0VOVj08ZW52PiBoZWxtZmls" + + "ZSAtLXNlbGVjdG9yIHJlbGVhc2UtZ3JvdXA9c2VydmljZXMgZGVzdHJveQprdWJlY3RsIGRlbGV0" + + "ZSBuYW1lc3BhY2UgbnZjZiBhcGkta2V5cyBlc3Mgc2lzIC0taWdub3JlLW5vdC1mb3VuZAprdWJl" + + "Y3RsIGNyZWF0ZSBuYW1lc3BhY2UgbnZjZiAmJiBrdWJlY3RsIGNyZWF0ZSBuYW1lc3BhY2UgYXBp" + + "LWtleXMgJiYgXAogIGt1YmVjdGwgY3JlYXRlIG5hbWVzcGFjZSBlc3MgJiYga3ViZWN0bCBjcmVh" + + "dGUgbmFtZXNwYWNlIHNpcwojIFJlLWNyZWF0ZSBwdWxsIHNlY3JldHMgaWYgbmVlZGVkCkhFTE1G" + + "SUxFX0VOVj08ZW52PiBoZWxtZmlsZSAtLXNlbGVjdG9yIHJlbGVhc2UtZ3JvdXA9c2VydmljZXMg" + + "c3luYwpgYGAKCiMjIEZhaWx1cmU6IE5WQ0EgQ2xlYW51cCBTdHVjayAoRmluYWxpemVycykKCiMj" + + "IyBTeW1wdG9tcwoKQ29tcHV0ZS1wbGFuZSBjbGVhbnVwIGhhbmdzLiBOYW1lc3BhY2VzCnN0dWNr" + + "IGluIGBUZXJtaW5hdGluZ2A6CgpgYGBiYXNoCmt1YmVjdGwgZ2V0IG5zCiMgbnZjYS1vcGVyYXRv" + + "ciAgICBUZXJtaW5hdGluZyAgIDEwbQojIG52Y2YtYmFja2VuZCAgICAgVGVybWluYXRpbmcgICAx" + + "MG0KYGBgCgojIyMgRml4CgpSdW4gdGhpcyBvbmx5IGFnYWluc3QgdGhlIGNvbXB1dGUgY29udGV4" + + "dCwgYWZ0ZXIgdGhlIG5vcm1hbCBjb21wdXRlLXBsYW5lCnRlYXJkb3duIGhhcyBmYWlsZWQuIFJl" + + "bW92ZSBmaW5hbGl6ZXJzIGZyb20gTlZDRkJhY2tlbmQgY3VzdG9tIHJlc291cmNlcyBhbmQKZm9y" + + "Y2UtZGVsZXRlIHN0dWNrIG5hbWVzcGFjZXM6CgpgYGBiYXNoCmV4cG9ydCBDT01QVVRFX0NPTlRF" + + "WFQ9PGNvbXB1dGUtcGxhbmUtY29udGV4dD4KCiMgUmVtb3ZlIGZpbmFsaXplcnMgZnJvbSBOVkNG" + + "QmFja2VuZCByZXNvdXJjZXMKa3ViZWN0bCAtLWNvbnRleHQgIiRDT01QVVRFX0NPTlRFWFQiIGdl" + + "dCBudmNmYmFja2VuZHMgLUEgLW8ganNvbiB8IFwKICBqcSAnLml0ZW1zW10gfCAubWV0YWRhdGEu" + + "bmFtZXNwYWNlICsgIi8iICsgLm1ldGFkYXRhLm5hbWUnIC1yIHwgXAogIHhhcmdzIC1Je30gc2gg" + + "LWMgJ2NvbnRleHQ9IiQxIjsgbnM9IiR7MiUlLyp9IjsgbmFtZT0iJHsyIyMqL30iOyBrdWJlY3Rs" + + "IC0tY29udGV4dCAiJGNvbnRleHQiIHBhdGNoIG52Y2ZiYWNrZW5kICIkbmFtZSIgLW4gIiRucyIg" + + "LS10eXBlPW1lcmdlIC1wICJ7XCJtZXRhZGF0YVwiOntcImZpbmFsaXplcnNcIjpbXX19IicgXyAi" + + "JENPTVBVVEVfQ09OVEVYVCIge30KCiMgRm9yY2UtZGVsZXRlIHN0dWNrIG5hbWVzcGFjZXMgYnkg" + + "Y2xlYXJpbmcgdGhlaXIgZmluYWxpemVycwpmb3IgbnMgaW4gbnZjYS1vcGVyYXRvciBudmNmLWJh" + + "Y2tlbmQ7IGRvCiAga3ViZWN0bCAtLWNvbnRleHQgIiRDT01QVVRFX0NPTlRFWFQiIGdldCBuYW1l" + + "c3BhY2UgIiRucyIgLW8ganNvbiAyPi9kZXYvbnVsbCB8IFwKICAgIGpxICcuc3BlYy5maW5hbGl6" + + "ZXJzID0gW10nIHwgXAogICAga3ViZWN0bCAtLWNvbnRleHQgIiRDT01QVVRFX0NPTlRFWFQiIHJl" + + "cGxhY2UgXAogICAgICAtLXJhdyAiL2FwaS92MS9uYW1lc3BhY2VzLyRucy9maW5hbGl6ZSIgLWYg" + + "LSAyPi9kZXYvbnVsbApkb25lCmBgYAoKIyMgRmFpbHVyZTogTlZDQSBPcGVyYXRvciBNaXNzaW5n" + + "IG9yIE5vdCBSZWFkeQoKIyMjIFN5bXB0b21zCgpBZnRlciBjb21wdXRlLXBsYW5lIGluc3RhbGwg" + + "Y29tcGxldGVzLCBubyBgbnZjYS1vcGVyYXRvcmAgRGVwbG95bWVudCBleGlzdHMsIG9yCml0IGV4" + + "aXN0cyBidXQgcG9kcyBhcmUgbm90IHJlYWR5OgoKYGBgYmFzaAprdWJlY3RsIC0tY29udGV4dCA8" + + "Y29tcHV0ZS1wbGFuZS1jb250ZXh0PiBnZXQgZGVwbG95bWVudCBudmNhLW9wZXJhdG9yIFwKICAt" + + "biBudmNhLW9wZXJhdG9yCmt1YmVjdGwgLS1jb250ZXh0IDxjb21wdXRlLXBsYW5lLWNvbnRleHQ+" + + "IGdldCBwb2RzIC1uIG52Y2Etb3BlcmF0b3IKYGBgCgojIyMgRGlhZ25vc2lzIGFuZCBGaXhlcwoK" + + "Rmlyc3QgaWRlbnRpZnkgd2hldGhlciB0aGUgaW5zdGFsbGF0aW9uIHVzZWQgdGhlIEhlbG1maWxl" + + "IHZhbHVlcyBmbG93IG9yIHRoZQpDTEkgcHJvZmlsZSBmbG93LiBSZS1ydW4gb25seSB0aGF0IGZs" + + "b3cncyByZWdpc3RyYXRpb24gYW5kIGluc3RhbGxhdGlvbiBzdGVwcwpmcm9tIFtTcGxpdCBDb21w" + + "dXRlLVBsYW5lIEluc3RhbGxhdGlvbl0oY29tcHV0ZS1wbGFuZS1pbnN0YWxsYXRpb24ubWQpLiBE" + + "byBub3QKc3Vic3RpdHV0ZSBvbmUgaGFuZG9mZiBmb3IgdGhlIG90aGVyLgoKSWYgaW5zdGFsbGF0" + + "aW9uIHN1Y2NlZWRzIGJ1dCBwb2RzIHN0aWxsIGZhaWwsIGluc3BlY3QgdGhlIGNvbXB1dGUgY29u" + + "dGV4dDoKCmBgYGJhc2gKa3ViZWN0bCAtLWNvbnRleHQgPGNvbXB1dGUtcGxhbmUtY29udGV4dD4g" + + "ZGVzY3JpYmUgZGVwbG95IG52Y2Etb3BlcmF0b3IgXAogIC1uIG52Y2Etb3BlcmF0b3IKa3ViZWN0" + + "bCAtLWNvbnRleHQgPGNvbXB1dGUtcGxhbmUtY29udGV4dD4gbG9ncyBkZXBsb3ltZW50L252Y2Et" + + "b3BlcmF0b3IgXAogIC1uIG52Y2Etb3BlcmF0b3IgLS10YWlsPTIwMAprdWJlY3RsIC0tY29udGV4" + + "dCA8Y29tcHV0ZS1wbGFuZS1jb250ZXh0PiBnZXQgbnZjZmJhY2tlbmQgLW4gbnZjYS1vcGVyYXRv" + + "cgpgYGAKCkZvciBwcml2YXRlIHJlZ2lzdHJ5IGZhaWx1cmVzLCB2ZXJpZnkgdGhhdCB0aGUgcHVs" + + "bCBzZWNyZXQgZXhpc3RzIGluCmBudmNhLW9wZXJhdG9yYC4gVGhlIGNoYXJ0IGFuZCBvcGVyYXRv" + + "ciBtaXJyb3IgaXQgdG8gYG52Y2Etc3lzdGVtYDsgZG8gbm90IHVzZSBhCmJyb2FkZXIgbWFudWFs" + + "IG5hbWVzcGFjZSBsb29wLiBTZWUKW3B1bGwtc2VjcmV0cy5tZF0ocHVsbC1zZWNyZXRzLm1kI2Nv" + + "bXB1dGUtcGxhbmUtc2VjcmV0KS4KCiMjIEZhaWx1cmU6IEdhdGV3YXkgRW5kcG9pbnQgQ2hhbmdl" + + "ZAoKIyMjIFN5bXB0b21zCgpSZXF1ZXN0cyB0aHJvdWdoIHRoZSBjb25maWd1cmVkIHJvdXRlIGRv" + + "bWFpbiBmYWlsIGFmdGVyIHRoZSBHYXRld2F5IG9yIGl0cyBsb2FkIGJhbGFuY2VyIGlzIHJlcGxh" + + "Y2VkLiBUaGUgR2F0ZXdheSByZXBvcnRzIGEgbmV3IGVuZHBvaW50LgoKIyMjIERpYWdub3NpcwoK" + + "YGBgYmFzaApHQVRFV0FZX0FERFI9JChrdWJlY3RsIC0tY29udGV4dCA8Y29udHJvbC1wbGFuZS1j" + + "b250ZXh0PiBnZXQgZ2F0ZXdheSBcCiAgbnZjZi1nYXRld2F5IC1uIGVudm95LWdhdGV3YXkgXAog" + + "IC1vIGpzb25wYXRoPSd7LnN0YXR1cy5hZGRyZXNzZXNbMF0udmFsdWV9JykKdGVzdCAtbiAiJEdB" + + "VEVXQVlfQUREUiIKZGlnICtzaG9ydCAiYXBpLjxkb21haW4+IgpjdXJsIC1IICJIb3N0OiBhcGku" + + "PGRvbWFpbj4iICJodHRwOi8vJEdBVEVXQVlfQUREUi9oZWFsdGgiCgojIENoZWNrIGRpcmVjdCBh" + + "ZGRyZXNzIGZpZWxkcyB1c2VkIGJ5IGEgc3BsaXQgZGVwbG95bWVudApncmVwIG52Y2ZOYXRzU2Vy" + + "dmljZVVSTCBkZXBsb3kvc3RhY2tzL3NlbGYtbWFuYWdlZC9lbnZpcm9ubWVudHMvPGVudj4ueWFt" + + "bApncmVwIC1FICdpY21zU2VydmljZXxyZXZhbFNlcnZpY2V8bmF0cycgXAogIGRlcGxveS9zdGFj" + + "a3MvbnZjZi1jb21wdXRlLXBsYW5lL2Vudmlyb25tZW50cy88ZW52Pi55YW1sCmBgYAoKIyMjIEZp" + + "eAoKS2VlcCBgZ2xvYmFsLmRvbWFpbmAsIHJvdXRlIGhvc3RuYW1lcywgYW5kIEhvc3Qgb3ZlcnJp" + + "ZGVzIHVuY2hhbmdlZC4gVXBkYXRlIEROUwpvciB0aGUgZGlyZWN0IGNsaWVudCBkZXN0aW5hdGlv" + + "bi4gRm9yIHNwbGl0IGRlcGxveW1lbnRzLCB1cGRhdGUgb25seSBkaXJlY3QKR2F0ZXdheS1hZGRy" + + "ZXNzIGZpZWxkcyBhbmQgcmVmcmVzaCByZWdpc3RyYXRpb24gYW5kIGluc3RhbGxhdGlvbiB0aHJv" + + "dWdoIHRoZQpzYW1lIHNlbGVjdGVkIGhhbmRvZmYuIFNlZQpbRXhhbXBsZSA0XSguLi9leGFtcGxl" + + "cy5tZCNleGFtcGxlLTQtcmVjb3Zlci1mcm9tLWEtZ2F0ZXdheS1lbmRwb2ludC1jaGFuZ2UpLgoK" + + "IyMgVXNlZnVsIE5hbWVzcGFjZS10by1TZXJ2aWNlIE1hcHBpbmcKClF1aWNrIHJlZmVyZW5jZSBm" + + "b3Igd2hpY2ggc2VydmljZXMgcnVuIGluIHdoaWNoIG5hbWVzcGFjZToKCnwgTmFtZXNwYWNlIHwg" + + "U2VydmljZXMgfAp8LS0tLS0tLS0tLS18LS0tLS0tLS0tLXwKfCBuYXRzLXN5c3RlbSB8IG5hdHMg" + + "fAp8IGNhc3NhbmRyYS1zeXN0ZW0gfCBjYXNzYW5kcmEgfAp8IHZhdWx0LXN5c3RlbSB8IG9wZW5i" + + "YW8tc2VydmVyIHwKfCBudmNmIHwgYXBpLCBpbnZvY2F0aW9uLXNlcnZpY2UsIGdycGMtcHJveHks" + + "IG5vdGFyeS1zZXJ2aWNlLCByZXZhbCwgbGxtLXJlcXVlc3Qtcm91dGVyLCBsbG0tYXBpLWdhdGV3" + + "YXkgfAp8IGFwaS1rZXlzIHwgYXBpLWtleXMsIGFkbWluLWlzc3Vlci1wcm94eSB8CnwgZXNzIHwg" + + "ZXNzLWFwaSB8Cnwgc2lzIHwgc2lzIHwKfCBlbnZveS1nYXRld2F5LXN5c3RlbSB8IGVudm95IGdh" + + "dGV3YXkgKGluZ3Jlc3MgY29udHJvbGxlcikgfAp8IGVudm95LWdhdGV3YXkgfCBnYXRld2F5IHJl" + + "c291cmNlICsgcm91dGVzIHwKUEsHCLV6cDu1MAAAtTAAAFBLAwQUAAgAAAAAACEAAAAAAAAAAAAA" + + "AAAARAAJAGRhdGEvbnZjZi1zZWxmLW1hbmFnZWQtaW5zdGFsbGF0aW9uL3JlZmVyZW5jZXMvaGVs" + + "bWZpbGUtc3RydWN0dXJlLm1kVVQFAAEAps4SIyBIZWxtZmlsZSBTdHJ1Y3R1cmUgUmVmZXJlbmNl" + + "CgojIyBEaXJlY3RvcnkgTGF5b3V0CgpgYGBiYXNoCm52Y2Ytc2VsZi1tYW5hZ2VkLXN0YWNrLwp8" + + "LS0gaGVsbWZpbGUuZC8KfCAgIHwtLSAwMS1kZXBlbmRlbmNpZXMueWFtbC5nb3RtcGwgICMgTkFU" + + "UywgQ2Fzc2FuZHJhLCBPcGVuQmFvCnwgICB8LS0gMDItY29yZS55YW1sLmdvdG1wbCAgICAgICAg" + + "ICAjIE5WQ0Ygc2VydmljZXMgKyBpbmdyZXNzCnwgICBgLS0gMDMtb2JzZXJ2YWJpbGl0eS55YW1s" + + "LmdvdG1wbCAjIE9ic2VydmFiaWxpdHkgc3RhY2sgKG9wdGlvbmFsKQp8LS0gZW52aXJvbm1lbnRz" + + "Lwp8ICAgfC0tIGJhc2UueWFtbCAgICAgICAgICAgICAgICAgICAgIyBEZWZhdWx0IHZhbHVlcyAo" + + "YWxsIGVudmlyb25tZW50cykKfCAgIGAtLSA8ZW52LW5hbWU+LnlhbWwgICAgICAgICAgICAgICMg" + + "UGVyLWVudmlyb25tZW50IG92ZXJyaWRlcwp8LS0gc2VjcmV0cy8KfCAgIGAtLSA8ZW52LW5hbWU+" + + "LXNlY3JldHMueWFtbCAgICAgICMgU2Vuc2l0aXZlIHZhbHVlcyAocmVnaXN0cnkgY3JlZHMsIHBh" + + "c3N3b3JkcykKYC0tIGdsb2JhbC55YW1sLmdvdG1wbCAgICAgICAgICAgICAgICMgR28gdGVtcGxh" + + "dGUgdGhhdCBjb25zdHJ1Y3RzIHBlci1jaGFydCB2YWx1ZXMKCm52Y2YtY29tcHV0ZS1wbGFuZS1z" + + "dGFjay8KfC0tIGhlbG1maWxlLmQvCnwgICB8LS0gMDEtZGVwZW5kZW5jaWVzLnlhbWwuZ290bXBs" + + "ICAjIENvbXB1dGUgcGxhbmUgZGVwZW5kZW5jeSBjb21wb25lbnRzCnwgICBgLS0gMDItbnZjYS55" + + "YW1sLmdvdG1wbCAgICAgICAgICAjIG52Y2Etb3BlcmF0b3IgY2hhcnQgYW5kIG52Y2EgY29uZmln" + + "dXJhdGlvbgp8LS0gZW52aXJvbm1lbnRzLwp8ICAgfC0tIGJhc2UueWFtbCAgICAgICAgICAgICAg" + + "ICAgICAgIyBEZWZhdWx0IHZhbHVlcyAoYWxsIGVudmlyb25tZW50cykKfCAgIGAtLSA8ZW52LW5h" + + "bWU+LnlhbWwgICAgICAgICAgICAgICMgUGVyLWVudmlyb25tZW50IG92ZXJyaWRlcwpgLS0gZ2xv" + + "YmFsLnlhbWwuZ290bXBsICAgICAgICAgICAgICAgIyBHbyB0ZW1wbGF0ZSB0aGF0IGNvbnN0cnVj" + + "dHMgcGVyLWNoYXJ0IHZhbHVlcwpgYGAKClRoZXNlIHN0YWNrcyBoYXZlIHNlcGFyYXRlIGVudmly" + + "b25tZW50cyBhbmQgY2x1c3RlciBjb250ZXh0cy4gVGhlIEhlbG1maWxlCnZhbHVlcyBmbG93IGF1" + + "dGhvcnMgYm90aCBlbnZpcm9ubWVudCBmaWxlcy4gVGhlIENMSSBwcm9maWxlIGZsb3cgaW5zdGVh" + + "ZCB1c2VzCnRoZSBDTEktZ2VuZXJhdGVkIGNvbnRyb2wtcGxhbmUgcHJvZmlsZSBhcyBpdHMgZW5k" + + "cG9pbnQgc291cmNlLiBEbyBub3QgbWl4IHRoZQpoYW5kb2Zmcy4gU2VlIFtTcGxpdCBDb21wdXRl" + + "LVBsYW5lIEluc3RhbGxhdGlvbl0oY29tcHV0ZS1wbGFuZS1pbnN0YWxsYXRpb24ubWQpLgoKIyMg" + + "R290bXBsIEZpbGVzIGFuZCBUaGVpciBSZWxlYXNlcwoKIyMjIDAxLWRlcGVuZGVuY2llcy55YW1s" + + "LmdvdG1wbAoKfCBSZWxlYXNlIHwgQ2hhcnQgfCBOYW1lc3BhY2UgfCBOb3RlcyB8CnwtLS0tLS0t" + + "LS18LS0tLS0tLXwtLS0tLS0tLS0tLXwtLS0tLS0tfAp8IG5hdHMgfCBoZWxtLW52Y2YtbmF0cyB8" + + "IG5hdHMtc3lzdGVtIHwgTWVzc2FnaW5nIHwKfCBvcGVuYmFvLXNlcnZlciB8IGhlbG0tbnZjZi1v" + + "cGVuYmFvLXNlcnZlciB8IHZhdWx0LXN5c3RlbSB8IFNlY3JldHMgbWFuYWdlbWVudCwgZGVwZW5k" + + "cyBvbiBuYXRzIHwKfCBjYXNzYW5kcmEgfCBoZWxtLW52Y2YtY2Fzc2FuZHJhIHwgY2Fzc2FuZHJh" + + "LXN5c3RlbSB8IERhdGFiYXNlIHwKClVzZXMgYDw8OiAqZGVwZW5kZW5jeWAgdGVtcGxhdGUgaW5o" + + "ZXJpdGFuY2Ugd2l0aCBgcmVsZWFzZS1ncm91cDogZGVwZW5kZW5jaWVzYCBsYWJlbC4KCiMjIyAw" + + "Mi1jb3JlLnlhbWwuZ290bXBsCgp8IFJlbGVhc2UgfCBDaGFydCB8IE5hbWVzcGFjZSB8IExhYmVs" + + "IHwKfC0tLS0tLS0tLXwtLS0tLS0tfC0tLS0tLS0tLS0tfC0tLS0tLS18CnwgYXBpLWtleXMgfCBo" + + "ZWxtLW52Y2YtYXBpLWtleXMgfCBhcGkta2V5cyB8IHNlcnZpY2VzIHwKfCBzaXMgfCBoZWxtLW52" + + "Y2Ytc2lzIHwgc2lzIHwgc2VydmljZXMgfAp8IGFwaSB8IGhlbG0tbnZjZi1hcGkgfCBudmNmIHwg" + + "c2VydmljZXMgfAp8IGludm9jYXRpb24tc2VydmljZSB8IGhlbG0tbnZjZi1pbnZvY2F0aW9uLXNl" + + "cnZpY2UgfCBudmNmIHwgc2VydmljZXMgfAp8IGdycGMtcHJveHkgfCBoZWxtLW52Y2YtZ3JwYy1w" + + "cm94eSB8IG52Y2YgfCBzZXJ2aWNlcyB8CnwgZXNzLWFwaSB8IGhlbG0tbnZjZi1lc3MtYXBpIHwg" + + "ZXNzIHwgc2VydmljZXMgfAp8IG5vdGFyeS1zZXJ2aWNlIHwgaGVsbS1udmNmLW5vdGFyeS1zZXJ2" + + "aWNlIHwgbnZjZiB8IHNlcnZpY2VzIHwKfCByZXZhbCB8IGhlbG0tcmV2YWwgfCBudmNmIHwgc2Vy" + + "dmljZXMgfAp8IGxsbS1yZXF1ZXN0LXJvdXRlciB8IG52Y2YvaGVsbS1udmNmLWxsbS1yZXF1ZXN0" + + "LXJvdXRlciB8IG52Y2YgfCBzZXJ2aWNlcyB8CnwgbGxtLWFwaS1nYXRld2F5IHwgbnZjZi9oZWxt" + + "LW52Y2YtbGxtLWFwaS1nYXRld2F5IHwgbnZjZiB8IHNlcnZpY2VzIHwKfCBhZG1pbi1pc3N1ZXIt" + + "cHJveHkgfCBoZWxtLWFkbWluLXRva2VuLWlzc3Vlci1wcm94eSB8IGFwaS1rZXlzIHwgKG5vIHJl" + + "bGVhc2UtZ3JvdXAgbGFiZWwpIHwKfCBpbmdyZXNzIHwgbnZjZi1nYXRld2F5LXJvdXRlcyB8IGVu" + + "dm95LWdhdGV3YXktc3lzdGVtIHwgaW5ncmVzcyB8CgpNb3N0IHNlcnZpY2VzIHVzZSBgaW5oZXJp" + + "dDogW3t0ZW1wbGF0ZTogc2VydmljZX1dYC4gTExNIHJlbGVhc2VzIHVzZSBPQ0kgY2hhcnRzIGZy" + + "b20gdGhlIGBudmNmYCByZXBvc2l0b3J5IHdpdGggc3RhbmRhbG9uZSBgdmFsdWVzOiBbLi4vZ2xv" + + "YmFsLnlhbWwuZ290bXBsXWAgYmxvY2tzOyB0aGV5IGFyZSBnYXRlZCBvbiBgYWRkb25zLmxsbS5l" + + "bmFibGVkYC4gYGFkbWluLWlzc3Vlci1wcm94eWAgYW5kIGBpbmdyZXNzYCBoYXZlIHN0YW5kYWxv" + + "bmUgYHZhbHVlczpgIGJsb2Nrcy4KCiMjIyAwMy1vYnNlcnZhYmlsaXR5LnlhbWwuZ290bXBsCgp8" + + "IFJlbGVhc2UgfCBDaGFydCB8IE5hbWVzcGFjZSB8IExhYmVsIHwKfC0tLS0tLS0tLXwtLS0tLS0t" + + "fC0tLS0tLS0tLS0tfC0tLS0tLS18CnwgKG9ic2VydmFiaWxpdHkgcmVsZWFzZXMpIHwgKHZhcmlv" + + "dXMpIHwgb2JzZXJ2YWJpbGl0eSB8IG9ic2VydmFiaWxpdHkgfAoKR2F0ZWQgb24gb2JzZXJ2YWJp" + + "bGl0eS1zcGVjaWZpYyBmbGFncyBpbiB0aGUgZW52aXJvbm1lbnQgZmlsZS4gU2tpcHBlZCBpZiBk" + + "aXNhYmxlZC4KCiMjIFRlbXBsYXRlIEluaGVyaXRhbmNlCgojIyMgYDw8OiAqZGVwZW5kZW5jeWAg" + + "KFlBTUwgbWVyZ2UpCgpVc2VkIGluIGAwMS1kZXBlbmRlbmNpZXMueWFtbC5nb3RtcGxgLiBNZXJn" + + "ZXMgdGhlIHRlbXBsYXRlJ3MgcHJvcGVydGllcyBpbnRvIHRoZSByZWxlYXNlLgoKKipHb3RjaGEq" + + "KjogWUFNTCBtZXJnZSByZXBsYWNlcyBsaXN0cy4gSWYgeW91IGFkZCBhIGB2YWx1ZXM6YCBrZXkg" + + "dG8gdGhlIHJlbGVhc2UsIGl0ICoqcmVwbGFjZXMqKiB0aGUgdGVtcGxhdGUncyBgdmFsdWVzOmAg" + + "bGlzdCBlbnRpcmVseS4gWW91IG11c3QgcmUtaW5jbHVkZSBhbGwgdGVtcGxhdGUgdmFsdWVzOgoK" + + "SW4gdGhlIHRlbXBsYXRlIGJlbG93LCBgPHByaXZhdGUtdmFsdWVzPmAgcmVmZXJzIHRvIHRoZSBg" + + "c2VjcmV0cy9gIGRpcmVjdG9yeSBhdCB0aGUgaGVsbWZpbGUgc3RhY2sgcm9vdC4KCmBgYHlhbWwK" + + "IyBUZW1wbGF0ZSBkZWZpbmVzOgp0ZW1wbGF0ZXM6CiAgZGVwZW5kZW5jeTogJmRlcGVuZGVuY3kK" + + "ICAgIGNoYXJ0OiBudmNmL2hlbG0tbnZjZi17eyAuUmVsZWFzZS5OYW1lIH19CiAgICB2YWx1ZXM6" + + "CiAgICAgIC0gLi4vZ2xvYmFsLnlhbWwuZ290bXBsCiAgICAgIC0gLi4vPHByaXZhdGUtdmFsdWVz" + + "Pi97eyByZXF1aXJlZEVudiAiSEVMTUZJTEVfRU5WIiB9fS1zZWNyZXRzLnlhbWwKCiMgV2hlbiBv" + + "dmVycmlkaW5nLCBNVVNUIHJlLWluY2x1ZGUgYm90aDoKLSBuYW1lOiBjYXNzYW5kcmEKICA8PDog" + + "KmRlcGVuZGVuY3kKICB2YWx1ZXM6CiAgICAtIC4uL2dsb2JhbC55YW1sLmdvdG1wbCAgICAgICAg" + + "ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAjIE11c3QgcmUtaW5jbHVkZQog" + + "ICAgLSAuLi88cHJpdmF0ZS12YWx1ZXM+L3t7IHJlcXVpcmVkRW52ICJIRUxNRklMRV9FTlYiIH19" + + "LXNlY3JldHMueWFtbCAgICMgTXVzdCByZS1pbmNsdWRlCiAgICAtIGNhc3NhbmRyYTogICAgICAg" + + "ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIyBZb3Vy" + + "IG92ZXJyaWRlCiAgICAgICAgcmVzb3VyY2VzOgogICAgICAgICAgbGltaXRzOgogICAgICAgICAg" + + "ICBtZW1vcnk6IDgxOTJNaQpgYGAKCiMjIyBgaW5oZXJpdGAgKEhlbG1maWxlIG5hdGl2ZSkKClVz" + + "ZWQgaW4gYDAyLWNvcmUueWFtbC5nb3RtcGxgLiBIZWxtZmlsZSdzIG5hdGl2ZSBpbmhlcml0YW5j" + + "ZSBtZWNoYW5pc20uCgpgYGB5YW1sCi0gbmFtZTogYXBpCiAgdmVyc2lvbjogMS42LjAKICBuYW1l" + + "c3BhY2U6IG52Y2YKICBpbmhlcml0OgogICAgLSB0ZW1wbGF0ZTogc2VydmljZQpgYGAKCldoZW4g" + + "YWRkaW5nIGB2YWx1ZXM6YCB0byBhbiBpbmhlcml0ZWQgcmVsZWFzZSwgeW91IGFsc28gbmVlZCB0" + + "byByZS1pbmNsdWRlIHRoZSB0ZW1wbGF0ZSdzIHZhbHVlcyBmaWxlcyBzaW5jZSBgdmFsdWVzYCBp" + + "cyBhIGxpc3QgdGhhdCBnZXRzIHJlcGxhY2VkLgoKIyMgVmFsdWVzIFByZWNlZGVuY2UKCkZyb20g" + + "bG93ZXN0IHRvIGhpZ2hlc3QgcHJpb3JpdHk6CgoxLiBgZW52aXJvbm1lbnRzL2Jhc2UueWFtbGAg" + + "LS0gZGVmYXVsdHMgc2hhcmVkIGFjcm9zcyBhbGwgZW52aXJvbm1lbnRzCjIuIGBlbnZpcm9ubWVu" + + "dHMvPGVudj4ueWFtbGAgLS0gcGVyLWVudmlyb25tZW50IG92ZXJyaWRlcwozLiBgZ2xvYmFsLnlh" + + "bWwuZ290bXBsYCAtLSBHbyB0ZW1wbGF0ZSBwcm9jZXNzaW5nIChjb25zdHJ1Y3RzIGNoYXJ0LXNw" + + "ZWNpZmljIHN0cnVjdHVyZSkKNC4gYDxwcml2YXRlLXZhbHVlcz4vPGVudj4tc2VjcmV0cy55YW1s" + + "YCAtLSBzZW5zaXRpdmUgdmFsdWVzCjUuIElubGluZSBgdmFsdWVzOmAgYmxvY2tzIG9uIHJlbGVh" + + "c2VzIC0tIGhpZ2hlc3QgcHJlY2VkZW5jZQoKIyMgV2hhdCBnbG9iYWwueWFtbC5nb3RtcGwgUGFz" + + "c2VzIFRocm91Z2gKCmBnbG9iYWwueWFtbC5nb3RtcGxgIHJlYWRzIGZyb20gYC5WYWx1ZXNgICh0" + + "aGUgbWVyZ2VkIGVudmlyb25tZW50ICsgZW52LXNwZWNpZmljIFlBTUwpIGFuZCBjb25zdHJ1Y3Rz" + + "IGNoYXJ0LXNwZWNpZmljIHZhbHVlcy4gSXQgb25seSBwYXNzZXMgdGhyb3VnaCBrZXlzIGl0IGV4" + + "cGxpY2l0bHkgcmVmZXJlbmNlczoKCiMjIyBDYXNzYW5kcmEKCi0gYGNhc3NhbmRyYS5yZXBsaWNh" + + "Q291bnRgCi0gYGNhc3NhbmRyYS5pbWFnZS4qYCAocmVnaXN0cnksIHJlcG9zaXRvcnkpCi0gYGNh" + + "c3NhbmRyYS5taWdyYXRpb25zLmltYWdlLipgCi0gYGNhc3NhbmRyYS5wZXJzaXN0ZW5jZS5zaXpl" + + "YAotIGBjYXNzYW5kcmEubm9kZVNlbGVjdG9yYCAoaWYgYGdsb2JhbC5ub2RlU2VsZWN0b3JzLmVu" + + "YWJsZWRgKQotIGBjYXNzYW5kcmEuZ2xvYmFsLmRlZmF1bHRTdG9yYWdlQ2xhc3NgCgojIyMgTkFU" + + "UwoKLSBgbmF0cy5jb250YWluZXIuaW1hZ2UuKmAKLSBgbmF0cy5yZWxvYWRlci5pbWFnZS4qYAot" + + "IGBuYXRzLm5hdHNCb3guY29udGFpbmVyLmltYWdlLipgCi0gYG5hdHMuY29uZmlnLmpldHN0cmVh" + + "bS5maWxlU3RvcmUucHZjLnN0b3JhZ2VDbGFzc05hbWVgCi0gYG5hdHMucG9kVGVtcGxhdGUubWVy" + + "Z2Uuc3BlYy5ub2RlU2VsZWN0b3JgIChpZiBlbmFibGVkKQoKIyMjIE9wZW5CYW8KCi0gYG9wZW5i" + + "YW8ubWlncmF0aW9ucy5pbWFnZS4qYCBhbmQgYG9wZW5iYW8ubWlncmF0aW9ucy5lbnZgCi0gYG9w" + + "ZW5iYW8uaW5qZWN0b3IuaW1hZ2UuKmAKLSBgb3BlbmJhby5zZXJ2ZXIuaW1hZ2UuKmAKLSBgb3Bl" + + "bmJhby5zZXJ2ZXIuZGF0YVN0b3JhZ2UuKmAKLSBOb2RlIHNlbGVjdG9ycyAoaWYgZW5hYmxlZCkK" + + "CiMjIyBTZXJ2aWNlcyAoQVBJLCBTSVMsIGV0Yy4pCgotIGA8c2VydmljZT4uaW1hZ2UuKmAgKHJl" + + "Z2lzdHJ5LCByZXBvc2l0b3J5KQotIGA8c2VydmljZT4ubm9kZVNlbGVjdG9yYCAoaWYgZW5hYmxl" + + "ZCkKLSBgPHNlcnZpY2U+LmVudi4qYCAob2JzZXJ2YWJpbGl0eSBzZXR0aW5ncykKCiMjIyBMTE0g" + + "RnVuY3Rpb24gRW5hYmxlbWVudAoKRW5hYmxlIHRoZSBMTE0gYWRkb24gYmVmb3JlIGNyZWF0aW5n" + + "IG9yIGludm9raW5nIGZ1bmN0aW9ucyB3aXRoCmBmdW5jdGlvblR5cGU6ICJMTE0iYC4gVGhlIGFk" + + "ZG9uIGRlcGxveXMgYGxsbS1yZXF1ZXN0LXJvdXRlcmAgYW5kCmBsbG0tYXBpLWdhdGV3YXlgLCBh" + + "ZGRzIHRoZSBgbGxtLmludm9jYXRpb24uPGRvbWFpbj5gIHJvdXRlIHdoZW4gR2F0ZXdheSBBUEkK" + + "aW5ncmVzcyBpcyBlbmFibGVkLCBhbmQgY29uZmlndXJlcyB3b3JrZXJzIHRvIHVzZSB0aGUgTExN" + + "IHNpZGVjYXIuIFRoZSBIZWxtZmlsZQpjb25kaXRpb24gaXMgYGFkZG9ucy5sbG0uZW5hYmxlZGAu" + + "IERvIG5vdCB1c2UgdGhlIG9ic29sZXRlIGBsbG0uZW5hYmxlZGAgcGF0aC4KCkZvciBhIHNpbmds" + + "ZS1ub2RlIGlzb2xhdGVkIHRlc3QgY2x1c3RlciwgYWRkIHRoaXMgY29uZmlndXJhdGlvbiB0bwpg" + + "ZW52aXJvbm1lbnRzLzxlbnY+LnlhbWxgIGJlZm9yZSBhcHBseWluZyB0aGUgc3RhY2s6CgpgYGB5" + + "YW1sCmFkZG9uczoKICBsbG06CiAgICBlbmFibGVkOiB0cnVlCiAgICBnYXRld2F5OgogICAgICBy" + + "ZXBsaWNhQ291bnQ6IDEKICAgICAgbWV0cmljczoKICAgICAgICBzZXJ2aWNlTW9uaXRvcjoKICAg" + + "ICAgICAgIGVuYWJsZWQ6IGZhbHNlCiAgICByZXF1ZXN0Um91dGVyOgogICAgICByZXBsaWNhQ291" + + "bnQ6IDEKICAgICAgbWV0cmljczoKICAgICAgICBzZXJ2aWNlTW9uaXRvcjoKICAgICAgICAgIGVu" + + "YWJsZWQ6IGZhbHNlCgphZ2VudENvbmZpZzoKICBtZXJnZUNvbmZpZzogfAogICAgY2x1c3RlcjoK" + + "ICAgICAgdmFsaWRhdGlvblBvbGljeToKICAgICAgICBuYW1lOiBVbnJlc3RyaWN0ZWQKICAgIHdv" + + "cmtsb2FkOgogICAgICBzdGFyZ2F0ZVFVSUNJbnNlY3VyZTogdHJ1ZQpgYGAKClVzZSBgcmVwbGlj" + + "YUNvdW50OiAxYCBvbmx5IGZvciBsb2NhbCBvciBzaW5nbGUtbm9kZSB0ZXN0IGNsdXN0ZXJzLiBU" + + "aGUgYnVuZGxlZApOVkNGIEFQSSBjdXJyZW50bHkgc2VydmVzIHBsYWludGV4dCBnUlBDIG9uIHBv" + + "cnQgOTA5MCwgc28gdGhlIHNlbGYtbWFuYWdlZCBzdGFjawpkZWZhdWx0cyB0aGUgTExNIEFQSSBH" + + "YXRld2F5IGF1dGhlbnRpY2F0aW9uIGhvcCB0byBwbGFpbnRleHQuIFNldApgYWRkb25zLmxsbS5n" + + "YXRld2F5LmF1dGguZ3JwY0luc2VjdXJlOiBmYWxzZWAgb25seSB3aXRoIGEgVExTLWNhcGFibGUg" + + "TlZDRiBBUEkuCgpXb3JrZXItdG8tcm91dGVyIHRyYW5zcG9ydCBpcyBzZXBhcmF0ZS4gRm9yIHNo" + + "YXJlZCBvciBwcm9kdWN0aW9uIGNsdXN0ZXJzLCB1c2UKdGhlIHJlcXVpcmVkIHJlcGxpY2EgY291" + + "bnQgYW5kIHRoZSBtYW5hZ2VkIFRMUyBjb25maWd1cmF0aW9uLiBUaGUKYHdvcmtsb2FkLnN0YXJn" + + "YXRlUVVJQ0luc2VjdXJlYCBzZXR0aW5nIGVuYWJsZXMgcGxhaW50ZXh0IHdvcmtlciB0cmFuc3Bv" + + "cnQgYW5kCm11c3Qgbm90IGJlIHVzZWQgaW4gcHJvZHVjdGlvbi4KClRoZSByZXF1ZXN0IHJvdXRl" + + "ciB1c2VzIGBwb3dlci1vZi10d29gIHdoZW4gbm8gbG9hZC1iYWxhbmNlciBjb25maWd1cmF0aW9u" + + "IGlzCnNldC4gQ29uZmlndXJlIG90aGVyIHJvdXRpbmcgbWV0aG9kcyB3aXRoIHRoZQpbTExNIFJl" + + "cXVlc3QgUm91dGVyIExvYWQgQmFsYW5jaW5nXShodHRwczovL2dpdGh1Yi5jb20vTlZJRElBL252" + + "Y2YvYmxvYi9tYWluL2RvY3Mvc2VsZi1tYW5hZ2VkL2xsbS1yZXF1ZXN0LXJvdXRlci1sb2FkLWJh" + + "bGFuY2luZy5tZCkKZ3VpZGUuCgpJZiB0aGUgc2lkZWNhciBpbWFnZSBpcyBtaXJyb3JlZCBvdXRz" + + "aWRlIHRoZSBzdGFjaydzIGRlZmF1bHQgaW1hZ2UgcmVnaXN0cnkgYW5kCnJlcG9zaXRvcnksIHNl" + + "dCB0aGUgZ2VuZXJhdGVkIHdvcmtlciBzaWRlY2FyIGltYWdlIGV4cGxpY2l0bHk6CgpgYGB5YW1s" + + "CmFwaToKICByZW1vdGVDb25maWc6CiAgICBjb25maWdEYXRhOgogICAgICBudmNmOgogICAgICAg" + + "IHNpZGVjYXJzOgogICAgICAgICAgbGxtLXJvdXRlci1jbGllbnQtaW1hZ2U6IDxyZWdpc3RyeT4v" + + "PHJlcG9zaXRvcnk+L3B5bG9uOjAuMTQuMQpgYGAKClRoZSBsZWdhY3kgYGFwaS5lbnYuTlZDRl9T" + + "SURFQ0FSU19MTE1fUk9VVEVSX0NMSUVOVF9JTUFHRWAgcGF0aCBpcyBkZXByZWNhdGVkLgpUaGUg" + + "c3RhY2sgdHJhbnNsYXRlcyBpdCBmb3Igb25lIGNvbXBhdGliaWxpdHkgd2luZG93LCBidXQgbmV3" + + "IGNvbmZpZ3VyYXRpb25zCm11c3QgdXNlIHRoZSByZW1vdGUtY29uZmlnIHBhdGguIENvbmZsaWN0" + + "aW5nIHZhbHVlcyBmYWlsIHJlbmRlcmluZy4KClJlbmRlciBhbmQgYXBwbHkgdGhlIHVwZGF0ZWQg" + + "Y29udHJvbC1wbGFuZSBlbnZpcm9ubWVudCwgdGhlbiByZWZyZXNoIGV2ZXJ5CnJlZ2lzdGVyZWQg" + + "Y29tcHV0ZSBwbGFuZSB1c2luZyB0aGUgc2FtZSBoYW5kb2ZmIHVzZWQgZm9yIGl0cyBpbnN0YWxs" + + "YXRpb24gc28KTlZDQSByZWNlaXZlcyBgYWdlbnRDb25maWcubWVyZ2VDb25maWdgOgoKYGBgYmFz" + + "aApIRUxNRklMRV9FTlY9PGVudj4gaGVsbWZpbGUgdGVtcGxhdGUKSEVMTUZJTEVfRU5WPTxlbnY+" + + "IGhlbG1maWxlIHN5bmMKYGBgCgpVc2UgdGhlIGNvbXBsZXRlIGNvbXB1dGUtcGxhbmUgaW5zdGFs" + + "bCBjb21tYW5kIGZyb20KW1NwbGl0IENvbXB1dGUtUGxhbmUgSW5zdGFsbGF0aW9uXShjb21wdXRl" + + "LXBsYW5lLWluc3RhbGxhdGlvbi5tZCkuIEEKQ0xJLXByb2ZpbGUgaW5zdGFsbGF0aW9uIG11c3Qg" + + "cmVtYWluIHByb2ZpbGUtZHJpdmVuOyBhIEhlbG1maWxlIGluc3RhbGxhdGlvbgptdXN0IHJlbWFp" + + "biB2YWx1ZXMtZHJpdmVuLgoKRXhpc3RpbmcgTExNIGZ1bmN0aW9uIHZlcnNpb25zIHJldGFpbiB0" + + "aGUgd29ya2VyLXNpZGVjYXIgaW1hZ2UgbWV0YWRhdGEKY2FwdHVyZWQgd2hlbiB0aGUgdmVyc2lv" + + "biBpcyBjcmVhdGVkLiBSZXBsYWNpbmcgcG9kcyBvciByZWRlcGxveWluZyB0aGUgc2FtZQp2ZXJz" + + "aW9uIGRvZXMgbm90IGFwcGx5IGEgbmV3IFB5bG9uIGltYWdlLiBBZnRlciB0aGUgY29udHJvbC1w" + + "bGFuZSB1cGRhdGUsCmNyZWF0ZSBhbmQgZGVwbG95IGEgbmV3IGZ1bmN0aW9uIHZlcnNpb24uIFZl" + + "cmlmeSB0aGUgY29udHJvbCBwbGFuZSwgcm91dGUsIGFuZAp3b3JrZXIgc2lkZWNhcjoKCmBgYGJh" + + "c2gKa3ViZWN0bCBnZXQgZGVwbG95IC1uIG52Y2YgbGxtLWFwaS1nYXRld2F5Cmt1YmVjdGwgZ2V0" + + "IHN0YXRlZnVsc2V0IC1uIG52Y2YgbGxtLXJlcXVlc3Qtcm91dGVyCmt1YmVjdGwgZ2V0IHBvZHMg" + + "LW4gbnZjZiB8IGdyZXAgLUUgJ2xsbS1hcGktZ2F0ZXdheXxsbG0tcmVxdWVzdC1yb3V0ZXInCmt1" + + "YmVjdGwgZ2V0IGh0dHByb3V0ZSAtQSB8IGdyZXAgbGxtCmt1YmVjdGwgLW4gbnZjZi1iYWNrZW5k" + + "IGdldCBwb2QgPGZ1bmN0aW9uLXBvZD4gXAogIC1vIGpzb25wYXRoPSd7cmFuZ2UgLnNwZWMuY29u" + + "dGFpbmVyc1s/KEAubmFtZT09ImxsbS13b3JrZXIiKV0uYXJnc1sqXX17Ln17IlxcbiJ9e2VuZH0n" + + "CmBgYAoKRm9yIGxvY2FsIHBsYWludGV4dCBjbHVzdGVycywgdGhlIGBsbG0td29ya2VyYCBhcmdz" + + "IG11c3QgaW5jbHVkZQpgLS1xdWljLWluc2VjdXJlYC4KCkZvciBsb2FkLWJhbGFuY2VyIHNjaGVt" + + "YSBhbmQgYWxnb3JpdGhtIGJlaGF2aW9yLCBzZWUgdGhlCltTdGFyZ2F0ZSBjb25maWd1cmF0aW9u" + + "IGd1aWRlXShodHRwczovL2dpdGh1Yi5jb20vTlZJRElBL252Y2YvYmxvYi9tYWluL3NyYy9saWJy" + + "YXJpZXMvcnVzdC9zdGFyZ2F0ZS9kb2NzL2xvYWQtYmFsYW5jZXItY29uZmlndXJhdGlvbi5tZCku" + + "CgojIyBIZWxtZmlsZSBTZWxlY3RvcnMKClRhcmdldCBzcGVjaWZpYyByZWxlYXNlcyBvciBncm91" + + "cHM6CgpgYGBiYXNoCiMgQnkgcmVsZWFzZSBncm91cApIRUxNRklMRV9FTlY9PGVudj4gaGVsbWZp" + + "bGUgLS1zZWxlY3RvciByZWxlYXNlLWdyb3VwPWRlcGVuZGVuY2llcyBzeW5jCkhFTE1GSUxFX0VO" + + "Vj08ZW52PiBoZWxtZmlsZSAtLXNlbGVjdG9yIHJlbGVhc2UtZ3JvdXA9c2VydmljZXMgc3luYwpI" + + "RUxNRklMRV9FTlY9PGVudj4gaGVsbWZpbGUgLS1zZWxlY3RvciByZWxlYXNlLWdyb3VwPWluZ3Jl" + + "c3Mgc3luYwpIRUxNRklMRV9FTlY9PGVudj4gaGVsbWZpbGUgLS1zZWxlY3RvciByZWxlYXNlLWdy" + + "b3VwPW9ic2VydmFiaWxpdHkgc3luYwoKIyBCeSByZWxlYXNlIG5hbWUKSEVMTUZJTEVfRU5WPTxl" + + "bnY+IGhlbG1maWxlIC0tc2VsZWN0b3IgbmFtZT1jYXNzYW5kcmEgc3luYwpIRUxNRklMRV9FTlY9" + + "PGVudj4gaGVsbWZpbGUgLS1zZWxlY3RvciBuYW1lPWFkbWluLWlzc3Vlci1wcm94eSBzeW5jCkhF" + + "TE1GSUxFX0VOVj08ZW52PiBoZWxtZmlsZSAtLXNlbGVjdG9yIG5hbWU9bGxtLXJlcXVlc3Qtcm91" + + "dGVyIHN5bmMKSEVMTUZJTEVfRU5WPTxlbnY+IGhlbG1maWxlIC0tc2VsZWN0b3IgbmFtZT1sbG0t" + + "YXBpLWdhdGV3YXkgc3luYwoKIyBUZW1wbGF0ZSBvbmx5IChkcnkgcnVuKQpIRUxNRklMRV9FTlY9" + + "PGVudj4gaGVsbWZpbGUgLS1zZWxlY3RvciBuYW1lPWNhc3NhbmRyYSB0ZW1wbGF0ZQoKIyBEZXN0" + + "cm95IGEgc2luZ2xlIHJlbGVhc2UKSEVMTUZJTEVfRU5WPTxlbnY+IGhlbG1maWxlIC0tc2VsZWN0" + + "b3IgbmFtZT1jYXNzYW5kcmEgZGVzdHJveQpgYGAKCk5vdGU6IGBhZG1pbi1pc3N1ZXItcHJveHlg" + + "IGhhcyBubyBgcmVsZWFzZS1ncm91cGAgbGFiZWwuIFVzZSBgLS1zZWxlY3RvciBuYW1lPWFkbWlu" + + "LWlzc3Vlci1wcm94eWAuClBLBwgTEeyNCioAAAoqAABQSwMEFAAIAAAAAAAhAAAAAAAAAAAAAAAA" + + "AD8ACQBkYXRhL252Y2Ytc2VsZi1tYW5hZ2VkLWluc3RhbGxhdGlvbi9yZWZlcmVuY2VzL252Y2Yt" + + "dWktYWRkb24ubWRVVAUAAQCmzhIjIE5WQ0YgVUkgQWRkb24KCk5WQ0YgVUkgaXMgYW4gb3B0aW9u" + + "YWwsIGN1c3RvbWVyLWZhY2luZyBOVkNGIGFkbWluLXBhbmVsIFVJLiBJdCBpcyBkaXNhYmxlZCBi" + + "eQpkZWZhdWx0IGFuZCBhdmFpbGFibGUgb25seSBpbiBzdGFjayBwYWNrYWdlcyB0aGF0IGluY2x1" + + "ZGUgdGhlIE5WQ0YgVUkgYWRkb24uCgojIyBXaGVuIHRvIGVuYWJsZQoKRW5hYmxlIE5WQ0YgVUkg" + + "b25seSB3aGVuIHlvdSBuZWVkIHRoZSBhZG1pbi1wYW5lbCBVSSBhbmQgeW91ciBleHRyYWN0ZWQg" + + "c3RhY2sKcGFja2FnZSBpbmNsdWRlcyB0aGUgYWRkb24uIElmIHRoZSBwYWNrYWdlIGRvZXMgbm90" + + "IGNvbnRhaW4gYSBgbnZjZi11aWAgcmVsZWFzZQphbmQgYG52Y2ZVaWAgcm91dGUgdmFsdWVzLCBz" + + "a2lwIHRoZSBhZGRvbiB1bnRpbCB5b3UgdXNlIGEgcGFja2FnZSB0aGF0IGluY2x1ZGVzCnRoZW0u" + + "CgojIyBFbmFibGUgdGhlIGFkZG9uCgpFbmFibGUgaXQgaW4gYGVudmlyb25tZW50cy88ZW52LW5h" + + "bWU+LnlhbWxgOgoKYGBgeWFtbAphZGRvbnM6CiAgbnZjZlVpOgogICAgZW5hYmxlZDogdHJ1ZQpg" + + "YGAKCiMjIENyZWF0ZSBuYW1lc3BhY2UgYW5kIGltYWdlIHB1bGwgc2VjcmV0CgpDcmVhdGUgdGhl" + + "IG5hbWVzcGFjZSwgYW5kIGFuIGltYWdlIHB1bGwgc2VjcmV0IG9ubHkgaWYgcHVsbGluZyBmcm9t" + + "IGEgcHJpdmF0ZQpyZWdpc3RyeToKCmBgYGJhc2gKa3ViZWN0bCBjcmVhdGUgbmFtZXNwYWNlIG52" + + "Y2YtdWkgLS1kcnktcnVuPWNsaWVudCAtbyB5YW1sIHwga3ViZWN0bCBhcHBseSAtZiAtCgojIE9u" + + "bHkgaWYgcHVsbGluZyBmcm9tIGEgcHJpdmF0ZSByZWdpc3RyeSAoZS5nLiwgTkdDIG52Y3IuaW8p" + + "CmV4cG9ydCBOR0NfQVBJX0tFWT0iPHlvdXIta2V5PiIKa3ViZWN0bCBjcmVhdGUgc2VjcmV0IGRv" + + "Y2tlci1yZWdpc3RyeSBudmNyLWNyZWRzIFwKICAtLWRvY2tlci1zZXJ2ZXI9bnZjci5pbyBcCiAg" + + "LS1kb2NrZXItdXNlcm5hbWU9JyRvYXV0aHRva2VuJyBcCiAgLS1kb2NrZXItcGFzc3dvcmQ9IiRO" + + "R0NfQVBJX0tFWSIgXAogIC0tbmFtZXNwYWNlPW52Y2YtdWkgXAogIC0tZHJ5LXJ1bj1jbGllbnQg" + + "LW8geWFtbCB8IGt1YmVjdGwgYXBwbHkgLWYgLQpgYGAKClRoZSBkZWZhdWx0IHJvdXRlIGhvc3Qg" + + "aXMgYG52Y2YtdWkuPGRvbWFpbj5gIGFuZCB0aGUgYmFja2VuZCBpcwpgbnZjZi11aS5udmNmLXVp" + + "OjgzMDBgLgoKIyMgQXBwbHkgdGhlIHNlcnZpY2UgYW5kIHJvdXRlCgpBZnRlciBjb25maXJtaW5n" + + "IHRoZSBwYWNrYWdlIGluY2x1ZGVzIHRoZSBgbnZjZi11aWAgcmVsZWFzZSwgcHJldmlldyBhbmQg" + + "YXBwbHkKdGhlIHNlcnZpY2UgYW5kIHJvdXRlOgoKYGBgYmFzaApIRUxNRklMRV9FTlY9PGVudi1u" + + "YW1lPiBoZWxtZmlsZSAtLXNlbGVjdG9yIG5hbWU9bnZjZi11aSB0ZW1wbGF0ZQpIRUxNRklMRV9F" + + "TlY9PGVudi1uYW1lPiBoZWxtZmlsZSAtLXNlbGVjdG9yIG5hbWU9bnZjZi11aSBzeW5jCkhFTE1G" + + "SUxFX0VOVj08ZW52LW5hbWU+IGhlbG1maWxlIC0tc2VsZWN0b3IgcmVsZWFzZS1ncm91cD1pbmdy" + + "ZXNzIHN5bmMKYGBgCgojIyBWZXJpZnkKClZlcmlmeSB0aGUgcm91dGUgb25seSB3aGVuIHRoZSBh" + + "ZGRvbiBpcyBwcmVzZW50IGFuZCBlbmFibGVkOgoKYGBgYmFzaAprdWJlY3RsIGdldCBkZXBsb3ks" + + "c3ZjIC1uIG52Y2YtdWkKa3ViZWN0bCBnZXQgaHR0cHJvdXRlIC1BIHwgZ3JlcCAtaSBudmNmLXVp" + + "CmN1cmwgLWkgLUggIkhvc3Q6IG52Y2YtdWkuPGRvbWFpbj4iICJodHRwOi8vPGdhdGV3YXktYWRk" + + "cmVzcz4vc3RhdHVzIgpgYGAKCiMjIFNlY3VyaXR5CgpUaGUgTlZDRiBVSSBhZG1pbiBwYW5lbCBp" + + "cyBjdXJyZW50bHkgdW5hdXRoZW50aWNhdGVkLiBEbyBub3QgZXhwb3NlIGl0IHRvIHRoZQpwdWJs" + + "aWMgaW50ZXJuZXQuIFJlc3RyaWN0IGFjY2VzcyB0byBhIHRydXN0ZWQgbmV0d29yaywgVlBOLCBv" + + "ciBhbiBhdXRoZW50aWNhdGluZwpwcm94eSBpbiBmcm9udCBvZiB0aGUgYG52Y2YtdWlgIHJvdXRl" + + "LgpQSwcIOPknr7kHAAC5BwAAUEsDBBQACAAAAAAAIQAAAAAAAAAAAAAAAAA+AAkAZGF0YS9udmNm" + + "LXNlbGYtbWFuYWdlZC1pbnN0YWxsYXRpb24vcmVmZXJlbmNlcy9wdWxsLXNlY3JldHMubWRVVAUA" + + "AQCmzhIjIEltYWdlIFB1bGwgU2VjcmV0cyBSZWZlcmVuY2UKCiMjIE92ZXJ2aWV3CgpXaGVuIHB1" + + "bGxpbmcgTlZDRiBpbWFnZXMgZnJvbSBhIHByaXZhdGUgcmVnaXN0cnkgKGZvciBleGFtcGxlLCBO" + + "R0MKYG52Y3IuaW9gKSwgS3ViZXJuZXRlcyBuZWVkcyBgaW1hZ2VQdWxsU2VjcmV0c2Agb24gZXZl" + + "cnkgcG9kLiBDb250cm9sLXBsYW5lIGFuZApjb21wdXRlLXBsYW5lIGNyZWRlbnRpYWxzIGFyZSBj" + + "b25maWd1cmVkIGluIHRoZWlyIG93biBjbHVzdGVyIGNvbnRleHRzLiBEbyBub3QKdXNlIG9uZSBt" + + "aXhlZCBuYW1lc3BhY2UgbGlzdC4KClRoZSByZWNvbW1lbmRlZCBjb250cm9sLXBsYW5lIGFwcHJv" + + "YWNoIHVzZXMgS3l2ZXJubyB0byBpbmplY3QgdGhlIHNlY3JldCBpbnRvCmNvbnRyb2wtcGxhbmUg" + + "cG9kcyBhdCBhZG1pc3Npb24gdGltZS4gVGhlIGNvbXB1dGUtcGxhbmUgY2hhcnQgYW5kIG9wZXJh" + + "dG9yIHVzZQp0aGUgc2VjcmV0IGluIGBudmNhLW9wZXJhdG9yYCBhbmQgbWlycm9yIGl0IHRvIGBu" + + "dmNhLXN5c3RlbWAuCgojIyBLeXZlcm5vIEFwcHJvYWNoIChSZWNvbW1lbmRlZCkKCiMjIyAxLiBJ" + + "bnN0YWxsIEt5dmVybm8KCmBgYGJhc2gKaGVsbSByZXBvIGFkZCBreXZlcm5vIGh0dHBzOi8va3l2" + + "ZXJuby5naXRodWIuaW8va3l2ZXJuby8KaGVsbSByZXBvIHVwZGF0ZQpoZWxtIGluc3RhbGwga3l2" + + "ZXJubyBreXZlcm5vL2t5dmVybm8gLW4ga3l2ZXJubyAtLWNyZWF0ZS1uYW1lc3BhY2UgXAogIC0t" + + "a3ViZS1jb250ZXh0IDxjb250cm9sLXBsYW5lLWNvbnRleHQ+CmBgYAoKIyMjIDIuIENyZWF0ZSBj" + + "b250cm9sLXBsYW5lIHB1bGwgc2VjcmV0cwoKYGBgYmFzaApleHBvcnQgTkdDX0FQSV9LRVk9Ijx5" + + "b3VyLW5nYy1hcGkta2V5PiIKCmZvciBucyBpbiBjYXNzYW5kcmEtc3lzdGVtIG5hdHMtc3lzdGVt" + + "IG52Y2YgYXBpLWtleXMgZXNzIHNpcyB2YXVsdC1zeXN0ZW0gY2VydC1tYW5hZ2VyOyBkbwogIGt1" + + "YmVjdGwgLS1jb250ZXh0IDxjb250cm9sLXBsYW5lLWNvbnRleHQ+IGNyZWF0ZSBuYW1lc3BhY2Ug" + + "IiRucyIgXAogICAgLS1kcnktcnVuPWNsaWVudCAtbyB5YW1sIHwga3ViZWN0bCAtLWNvbnRleHQg" + + "PGNvbnRyb2wtcGxhbmUtY29udGV4dD4gYXBwbHkgLWYgLQpkb25lCgpmb3IgbnMgaW4gY2Fzc2Fu" + + "ZHJhLXN5c3RlbSBuYXRzLXN5c3RlbSBudmNmIGFwaS1rZXlzIGVzcyBzaXMgdmF1bHQtc3lzdGVt" + + "IGNlcnQtbWFuYWdlcjsgZG8KICBrdWJlY3RsIC0tY29udGV4dCA8Y29udHJvbC1wbGFuZS1jb250" + + "ZXh0PiBjcmVhdGUgc2VjcmV0IGRvY2tlci1yZWdpc3RyeSBudmNyLWNyZWRzIFwKICAgIC0tZG9j" + + "a2VyLXNlcnZlcj1udmNyLmlvIFwKICAgIC0tZG9ja2VyLXVzZXJuYW1lPSckb2F1dGh0b2tlbicg" + + "XAogICAgLS1kb2NrZXItcGFzc3dvcmQ9IiROR0NfQVBJX0tFWSIgXAogICAgLS1uYW1lc3BhY2U9" + + "IiRucyIgXAogICAgLS1kcnktcnVuPWNsaWVudCAtbyB5YW1sIHwga3ViZWN0bCAtLWNvbnRleHQg" + + "PGNvbnRyb2wtcGxhbmUtY29udGV4dD4gYXBwbHkgLWYgLQpkb25lCmBgYAoKRm9yIG5vbi1OR0Mg" + + "cmVnaXN0cmllcywgcmVwbGFjZSBgLS1kb2NrZXItc2VydmVyYCwgYC0tZG9ja2VyLXVzZXJuYW1l" + + "YCwgYW5kIGAtLWRvY2tlci1wYXNzd29yZGAuCgojIyMgMy4gQXBwbHkgS3l2ZXJubyBDbHVzdGVy" + + "UG9saWN5CgpgYGB5YW1sCmFwaVZlcnNpb246IGt5dmVybm8uaW8vdjEKa2luZDogQ2x1c3RlclBv" + + "bGljeQptZXRhZGF0YToKICBuYW1lOiBudmNmLWFkZC1pbWFnZXB1bGxzZWNyZXRzCnNwZWM6CiAg" + + "YmFja2dyb3VuZDogZmFsc2UKICBydWxlczoKICAgIC0gbmFtZTogYWRkLWltYWdlcHVsbHNlY3Jl" + + "dC10by1udmNmLXBvZHMKICAgICAgbWF0Y2g6CiAgICAgICAgYW55OgogICAgICAgIC0gcmVzb3Vy" + + "Y2VzOgogICAgICAgICAgICBraW5kczoKICAgICAgICAgICAgLSBQb2QKICAgICAgICAgICAgbmFt" + + "ZXNwYWNlczoKICAgICAgICAgICAgLSAibnZjZiIKICAgICAgICAgICAgLSAiYXBpLWtleXMiCiAg" + + "ICAgICAgICAgIC0gInNpcyIKICAgICAgICAgICAgLSAiZXNzIgogICAgICAgICAgICAtICJuYXRz" + + "LXN5c3RlbSIKICAgICAgICAgICAgLSAiY2Fzc2FuZHJhLXN5c3RlbSIKICAgICAgICAgICAgLSAi" + + "dmF1bHQtc3lzdGVtIgogICAgICAgICAgICAtICJjZXJ0LW1hbmFnZXIiCiAgICAgIG11dGF0ZToK" + + "ICAgICAgICBwYXRjaFN0cmF0ZWdpY01lcmdlOgogICAgICAgICAgbWV0YWRhdGE6CiAgICAgICAg" + + "ICAgIGFubm90YXRpb25zOgogICAgICAgICAgICAgIG52Y2YubnZpZGlhLmNvbS9pbWFnZXB1bGxz" + + "ZWNyZXQtaW5qZWN0ZWQtYnk6IGt5dmVybm8KICAgICAgICAgIHNwZWM6CiAgICAgICAgICAgIGlt" + + "YWdlUHVsbFNlY3JldHM6CiAgICAgICAgICAgIC0gbmFtZTogbnZjci1jcmVkcwpgYGAKCmBgYGJh" + + "c2gKa3ViZWN0bCAtLWNvbnRleHQgPGNvbnRyb2wtcGxhbmUtY29udGV4dD4gYXBwbHkgXAogIC1m" + + "IGt5dmVybm8taW1hZ2VwdWxsc2VjcmV0LXBvbGljeS55YW1sCmBgYAoKIyMjIDQuIEluc3RhbGwg" + + "dGhlIGNvbnRyb2wgcGxhbmUKCkNvbnRpbnVlIHdpdGggdGhlIHNlbGVjdGVkIGNvbnRyb2wtcGxh" + + "bmUgaW5zdGFsbGF0aW9uIGluCltTcGxpdCBDb21wdXRlLVBsYW5lIEluc3RhbGxhdGlvbl0oY29t" + + "cHV0ZS1wbGFuZS1pbnN0YWxsYXRpb24ubWQpLiBObwpjb250cm9sLXBsYW5lIGhlbG1maWxlIG1v" + + "ZGlmaWNhdGlvbnMgYXJlIG5lZWRlZC4gS3l2ZXJubyBpbmplY3RzIHRoZSBwdWxsCnNlY3JldCBp" + + "bnRvIG5ldyBjb250cm9sLXBsYW5lIHBvZHMuCgojIyBDb21wdXRlLVBsYW5lIFNlY3JldAoKQ3Jl" + + "YXRlIHRoZSBzZWNyZXQgb25seSBpbiBgbnZjYS1vcGVyYXRvcmAgb24gdGhlIGNvbXB1dGUgY2x1" + + "c3RlcjoKCmBgYGJhc2gKa3ViZWN0bCAtLWNvbnRleHQgPGNvbXB1dGUtcGxhbmUtY29udGV4dD4g" + + "Y3JlYXRlIG5hbWVzcGFjZSBudmNhLW9wZXJhdG9yIFwKICAtLWRyeS1ydW49Y2xpZW50IC1vIHlh" + + "bWwgfCBcCiAga3ViZWN0bCAtLWNvbnRleHQgPGNvbXB1dGUtcGxhbmUtY29udGV4dD4gYXBwbHkg" + + "LWYgLQoKa3ViZWN0bCAtLWNvbnRleHQgPGNvbXB1dGUtcGxhbmUtY29udGV4dD4gY3JlYXRlIHNl" + + "Y3JldCBkb2NrZXItcmVnaXN0cnkgbnZjci1jcmVkcyBcCiAgLS1kb2NrZXItc2VydmVyPW52Y3Iu" + + "aW8gXAogIC0tZG9ja2VyLXVzZXJuYW1lPSckb2F1dGh0b2tlbicgXAogIC0tZG9ja2VyLXBhc3N3" + + "b3JkPSIkTkdDX0FQSV9LRVkiIFwKICAtLW5hbWVzcGFjZT1udmNhLW9wZXJhdG9yIFwKICAtLWRy" + + "eS1ydW49Y2xpZW50IC1vIHlhbWwgfCBcCiAga3ViZWN0bCAtLWNvbnRleHQgPGNvbXB1dGUtcGxh" + + "bmUtY29udGV4dD4gYXBwbHkgLWYgLQpgYGAKClJlZmVyZW5jZSBpdCBpbiB0aGUgY29tcHV0ZS1w" + + "bGFuZSBlbnZpcm9ubWVudCBmaWxlOgoKYGBgeWFtbApnbG9iYWw6CiAgaW1hZ2VQdWxsU2VjcmV0" + + "czoKICAgIC0gbmFtZTogbnZjci1jcmVkcwpgYGAKClRoZSBjaGFydCBhbmQgb3BlcmF0b3IgdXNl" + + "IHRoYXQgcmVmZXJlbmNlIGluIGBudmNhLW9wZXJhdG9yYCBhbmQgbWlycm9yIHRoZQpzZWNyZXQg" + + "dG8gYG52Y2Etc3lzdGVtYC4gRG8gbm90IGNyZWF0ZSBhIGJyb2FkZXIgbWFudWFsIGNvbXB1dGUg" + + "bmFtZXNwYWNlIGxpc3QuIEZvbGxvdwpbU3BsaXQgQ29tcHV0ZS1QbGFuZSBJbnN0YWxsYXRpb25d" + + "KGNvbXB1dGUtcGxhbmUtaW5zdGFsbGF0aW9uLm1kKSBmb3IgdGhlCnNlbGVjdGVkIGluc3RhbGwg" + + "Zmxvdy4KCiMjIFZlcmlmaWNhdGlvbgoKYGBgYmFzaAojIENoZWNrIHRoYXQgYSBjb250cm9sLXBs" + + "YW5lIHBvZCBoYXMgdGhlIGluamVjdGVkIHNlY3JldAprdWJlY3RsIC0tY29udGV4dCA8Y29udHJv" + + "bC1wbGFuZS1jb250ZXh0PiBnZXQgcG9kIC1uIDxuYW1lc3BhY2U+IDxwb2QtbmFtZT4gXAogIC1v" + + "IGpzb25wYXRoPSd7LnNwZWMuaW1hZ2VQdWxsU2VjcmV0c30nCiMgRXhwZWN0ZWQ6IFt7Im5hbWUi" + + "OiJudmNyLWNyZWRzIn1dCgojIENoZWNrIHRoZSBLeXZlcm5vIGFubm90YXRpb24Ka3ViZWN0bCAt" + + "LWNvbnRleHQgPGNvbnRyb2wtcGxhbmUtY29udGV4dD4gZ2V0IHBvZCAtbiA8bmFtZXNwYWNlPiA8" + + "cG9kLW5hbWU+IFwKICAtbyBqc29ucGF0aD0ney5tZXRhZGF0YS5hbm5vdGF0aW9ucy5udmNmXC5u" + + "dmlkaWFcLmNvbS9pbWFnZXB1bGxzZWNyZXQtaW5qZWN0ZWQtYnl9JwojIEV4cGVjdGVkOiBreXZl" + + "cm5vCgojIENoZWNrIHB1bGwgZXZlbnRzCmt1YmVjdGwgLS1jb250ZXh0IDxjb250cm9sLXBsYW5l" + + "LWNvbnRleHQ+IGdldCBldmVudHMgLW4gPG5hbWVzcGFjZT4gXAogIC0tc29ydC1ieT0nLmxhc3RU" + + "aW1lc3RhbXAnIHwgZ3JlcCAtaSBwdWxsCiMgU2hvdWxkIHNob3cgIlN1Y2Nlc3NmdWxseSBwdWxs" + + "ZWQiIG5vdCAiNDAxIFVuYXV0aG9yaXplZCIKCiMgQ2hlY2sgdGhlIGNoYXJ0L29wZXJhdG9yIG1p" + + "cnJvciBvbiB0aGUgY29tcHV0ZSBjbHVzdGVyCmt1YmVjdGwgLS1jb250ZXh0IDxjb21wdXRlLXBs" + + "YW5lLWNvbnRleHQ+IGdldCBzZWNyZXQgbnZjci1jcmVkcyBcCiAgLW4gbnZjYS1zeXN0ZW0KYGBg" + + "CgojIyBXaGVuIFB1bGwgU2VjcmV0cyBBcmUgTm90IE5lZWRlZAoKLSAqKkFXUyBFQ1Igd2l0aCBJ" + + "QU0gbm9kZSByb2xlcyoqOiBJZiB5b3VyIG5vZGVzIGhhdmUgYEFtYXpvbkVDMkNvbnRhaW5lclJl" + + "Z2lzdHJ5UmVhZE9ubHlgIElBTSBwb2xpY3ksIEt1YmVybmV0ZXMgY2FuIHB1bGwgZnJvbSBFQ1Ig" + + "d2l0aG91dCBleHBsaWNpdCBzZWNyZXRzLgotICoqUHVibGljIHJlZ2lzdHJpZXMqKjogTm8gcHVs" + + "bCBzZWNyZXRzIG5lZWRlZC4KLSAqKkNTUCBidWlsdC1pbiBjcmVkZW50aWFsIGhlbHBlcnMqKjog" + + "R0tFIEFydGlmYWN0IFJlZ2lzdHJ5LCBBenVyZSBBQ1Igd2l0aCBtYW5hZ2VkIGlkZW50aXR5LCBh" + + "bmQgc2ltaWxhciBzZXJ2aWNlcy4KCiMjIFRyb3VibGVzaG9vdGluZwoKIyMjIFBvZHMgc3RpbGwg" + + "aW4gSW1hZ2VQdWxsQmFja09mZiBhZnRlciBhcHBseWluZyBwb2xpY3kKClRoZSBLeXZlcm5vIHBv" + + "bGljeSBvbmx5IGFmZmVjdHMgcG9kcyBjcmVhdGVkICoqYWZ0ZXIqKiB0aGUgcG9saWN5IGlzIGFw" + + "cGxpZWQuIERlbGV0ZSBzdHVjayBwb2RzIHRvIHRyaWdnZXIgcmVjcmVhdGlvbjoKCmBgYGJhc2gK" + + "a3ViZWN0bCAtLWNvbnRleHQgPHRhcmdldC1jb250ZXh0PiBkZWxldGUgcG9kcyAtbiA8bmFtZXNw" + + "YWNlPiAtLWFsbApgYGAKCiMjIyBLeXZlcm5vIGFkbWlzc2lvbiBjb250cm9sbGVyIG5vdCBydW5u" + + "aW5nCgpgYGBiYXNoCmt1YmVjdGwgLS1jb250ZXh0IDxjb250cm9sLXBsYW5lLWNvbnRleHQ+IGdl" + + "dCBwb2RzIC1uIGt5dmVybm8KIyBBbGwgcG9kcyBzaG91bGQgYmUgUnVubmluZwpgYGAKCiMjIyBQ" + + "b2xpY3kgbm90IG1hdGNoaW5nCgpgYGBiYXNoCmt1YmVjdGwgLS1jb250ZXh0IDxjb250cm9sLXBs" + + "YW5lLWNvbnRleHQ+IGdldCBjbHVzdGVycG9saWN5IG52Y2YtYWRkLWltYWdlcHVsbHNlY3JldHMK" + + "IyBTaG91bGQgc2hvdyBSRUFEWTogVHJ1ZQpgYGAKCiMjIyBXcm9uZyBzZWNyZXQgbmFtZQoKVGhl" + + "IHBvbGljeSByZWZlcmVuY2VzIGBudmNyLWNyZWRzYC4gVmVyaWZ5IHRoZSBzZWNyZXQgZXhpc3Rz" + + "IHdpdGggdGhhdCBleGFjdCBuYW1lOgoKYGBgYmFzaAprdWJlY3RsIC0tY29udGV4dCA8dGFyZ2V0" + + "LWNvbnRleHQ+IGdldCBzZWNyZXQgbnZjci1jcmVkcyAtbiA8bmFtZXNwYWNlPgpgYGAKUEsHCEtC" + + "NagAFgAAABYAAFBLAQIUABQACAAAAAAAIQAvBrsjShAAAEoQAAASAAkAAAAAAAAAAAAAAAAAAABk" + + "YXRhL21hbmlmZXN0Lmpzb25VVAUAAQCmzhJQSwECFAAUAAgAAAAAACEAD/LJi9lkAADZZAAAIwAJ" + + "AAAAAAAAAAAAAACTEAAAZGF0YS9udmNmLXNlbGYtbWFuYWdlZC1jbGkvU0tJTEwubWRVVAUAAQCm" + + "zhJQSwECFAAUAAgAAAAAACEAeppPnCoRAAAqEQAAMwAJAAAAAAAAAAAAAADGdQAAZGF0YS9udmNm" + + "LXNlbGYtbWFuYWdlZC1jbGkvZXhhbXBsZXMvY2ktcGlwZWxpbmVzLm1kVVQFAAEAps4SUEsBAhQA" + + "FAAIAAAAAAAhAKfPOcP+FAAA/hQAADQACQAAAAAAAAAAAAAAWocAAGRhdGEvbnZjZi1zZWxmLW1h" + + "bmFnZWQtY2xpL2V4YW1wbGVzL211bHRpLWNsdXN0ZXIubWRVVAUAAQCmzhJQSwECFAAUAAgAAAAA" + + "ACEAAXmp+f4ZAAD+GQAANwAJAAAAAAAAAAAAAADDnAAAZGF0YS9udmNmLXNlbGYtbWFuYWdlZC1j" + + "bGkvcHJvbXB0cy9hZGQtY29tcHV0ZS1wbGFuZS5tZFVUBQABAKbOElBLAQIUABQACAAAAAAAIQDU" + + "3o00nxkAAJ8ZAAA5AAkAAAAAAAAAAAAAAC+3AABkYXRhL252Y2Ytc2VsZi1tYW5hZ2VkLWNsaS9w" + + "cm9tcHRzL2NyZWF0ZS1hbmQtcnVuLXRhc2subWRVVAUAAQCmzhJQSwECFAAUAAgAAAAAACEA0kPc" + + "BBglAAAYJQAANwAJAAAAAAAAAAAAAAA+0QAAZGF0YS9udmNmLXNlbGYtbWFuYWdlZC1jbGkvcHJv" + + "bXB0cy9kZXBsb3ktYW5kLWludm9rZS5tZFVUBQABAKbOElBLAQIUABQACAAAAAAAIQBykiwmvg4A" + + "AL4OAAA9AAkAAAAAAAAAAAAAAMT2AABkYXRhL252Y2Ytc2VsZi1tYW5hZ2VkLWNsaS9wcm9tcHRz" + + "L2RpYWdub3NlLWZhaWxlZC1pbnN0YWxsLm1kVVQFAAEAps4SUEsBAhQAFAAIAAAAAAAhALRJ3ydW" + + "KQAAVikAADoACQAAAAAAAAAAAAAA9gUBAGRhdGEvbnZjZi1zZWxmLW1hbmFnZWQtY2xpL3Byb21w" + + "dHMvaW5zdGFsbC1mcm9tLXNjcmF0Y2gubWRVVAUAAQCmzhJQSwECFAAUAAgAAAAAACEAqcEm9y4J" + + "AAAuCQAAOQAJAAAAAAAAAAAAAAC9LwEAZGF0YS9udmNmLXNlbGYtbWFuYWdlZC1jbGkvcHJvbXB0" + + "cy9yb3RhdGUtY2x1c3Rlci1qd2tzLm1kVVQFAAEAps4SUEsBAhQAFAAIAAAAAAAhANqHY1bkIQAA" + + "5CEAAC4ACQAAAAAAAAAAAAAAWzkBAGRhdGEvbnZjZi1zZWxmLW1hbmFnZWQtY2xpL3Byb21wdHMv" + + "dGVhcmRvd24ubWRVVAUAAQCmzhJQSwECFAAUAAgAAAAAACEAxDKuZBcmAAAXJgAAMAAJAAAAAAAA" + + "AAAAAACkWwEAZGF0YS9udmNmLXNlbGYtbWFuYWdlZC1jbGkvcmVmZXJlbmNlL2NvbW1hbmRzLm1k" + + "VVQFAAEAps4SUEsBAhQAFAAIAAAAAAAhAMn5RW9oEAAAaBAAADIACQAAAAAAAAAAAAAAIoIBAGRh" + + "dGEvbnZjZi1zZWxmLW1hbmFnZWQtY2xpL3JlZmVyZW5jZS9leGl0LWNvZGVzLm1kVVQFAAEAps4S" + + "UEsBAhQAFAAIAAAAAAAhALUu+JLDYQAAw2EAAC0ACQAAAAAAAAAAAAAA85IBAGRhdGEvbnZjZi1z" + + "ZWxmLW1hbmFnZWQtY2xpL3JlZmVyZW5jZS9mbGFncy5tZFVUBQABAKbOElBLAQIUABQACAAAAAAA" + + "IQAauLPzYSEAAGEhAAA3AAkAAAAAAAAAAAAAABr1AQBkYXRhL252Y2Ytc2VsZi1tYW5hZ2VkLWNs" + + "aS9yZWZlcmVuY2UvdHJvdWJsZXNob290aW5nLm1kVVQFAAEAps4SUEsBAhQAFAAIAAAAAAAhABHD" + + "FJNJYgAASWIAACwACQAAAAAAAAAAAAAA6RYCAGRhdGEvbnZjZi1zZWxmLW1hbmFnZWQtaW5zdGFs" + + "bGF0aW9uL1NLSUxMLm1kVVQFAAEAps4SUEsBAhQAFAAIAAAAAAAhAEdValZKVgAASlYAADQACQAA" + + "AAAAAAAAAAAAlXkCAGRhdGEvbnZjZi1zZWxmLW1hbmFnZWQtaW5zdGFsbGF0aW9uL2V2YWxzL2V2" + + "YWxzLmpzb25VVAUAAQCmzhJQSwECFAAUAAgAAAAAACEAAlJOQH8oAAB/KAAALwAJAAAAAAAAAAAA" + + "AABK0AIAZGF0YS9udmNmLXNlbGYtbWFuYWdlZC1pbnN0YWxsYXRpb24vZXhhbXBsZXMubWRVVAUA" + + "AQCmzhJQSwECFAAUAAgAAAAAACEAlmO8WRIwAAASMAAATAAJAAAAAAAAAAAAAAAv+QIAZGF0YS9u" + + "dmNmLXNlbGYtbWFuYWdlZC1pbnN0YWxsYXRpb24vcmVmZXJlbmNlcy9jb21wdXRlLXBsYW5lLWlu" + + "c3RhbGxhdGlvbi5tZFVUBQABAKbOElBLAQIUABQACAAAAAAAIQC1enA7tTAAALUwAAA7AAkAAAAA" + + "AAAAAAAAAMQpAwBkYXRhL252Y2Ytc2VsZi1tYW5hZ2VkLWluc3RhbGxhdGlvbi9yZWZlcmVuY2Vz" + + "L2RlYnVnZ2luZy5tZFVUBQABAKbOElBLAQIUABQACAAAAAAAIQATEeyNCioAAAoqAABEAAkAAAAA" + + "AAAAAAAAAOtaAwBkYXRhL252Y2Ytc2VsZi1tYW5hZ2VkLWluc3RhbGxhdGlvbi9yZWZlcmVuY2Vz" + + "L2hlbG1maWxlLXN0cnVjdHVyZS5tZFVUBQABAKbOElBLAQIUABQACAAAAAAAIQA4+SevuQcAALkH" + + "AAA/AAkAAAAAAAAAAAAAAHCFAwBkYXRhL252Y2Ytc2VsZi1tYW5hZ2VkLWluc3RhbGxhdGlvbi9y" + + "ZWZlcmVuY2VzL252Y2YtdWktYWRkb24ubWRVVAUAAQCmzhJQSwECFAAUAAgAAAAAACEAS0I1qAAW" + + "AAAAFgAAPgAJAAAAAAAAAAAAAACfjQMAZGF0YS9udmNmLXNlbGYtbWFuYWdlZC1pbnN0YWxsYXRp" + + "b24vcmVmZXJlbmNlcy9wdWxsLXNlY3JldHMubWRVVAUAAQCmzhJQSwUGAAAAABcAFwCvCQAAFKQD" + + "AAAA" var ( skillDataOnce sync.Once diff --git a/src/clis/nvcf-cli/internal/selfhosted/BUILD.bazel b/src/clis/nvcf-cli/internal/selfhosted/BUILD.bazel index 7737a9a8b9..fd462e2e0b 100644 --- a/src/clis/nvcf-cli/internal/selfhosted/BUILD.bazel +++ b/src/clis/nvcf-cli/internal/selfhosted/BUILD.bazel @@ -18,7 +18,9 @@ load("@rules_go//go:def.bzl", "go_library", "go_test") go_library( name = "selfhosted", srcs = [ + "cluster_reach.go", "clustervalidator.go", + "dockercreds.go", "helm_runtime.go", "inotify_probe.go", "orchestrator_errors.go", @@ -26,8 +28,11 @@ go_library( "preflight.go", "pullsecret.go", "register.go", + "registry_cred.go", "render.go", "stack.go", + "stack_namespaces.go", + "stale_namespace.go", "validatortag.go", ], importpath = "nvcf-cli/internal/selfhosted", @@ -37,15 +42,20 @@ go_library( "//src/clis/nvcf-cli/internal/selfhosted/controlplaneprofile", "//src/clis/nvcf-cli/internal/selfhosted/helmruntime", "//src/clis/nvcf-cli/internal/selfhosted/progress", + "//src/clis/nvcf-cli/internal/selfhosted/severity", "@com_github_masterminds_semver_v3//:semver", "@io_k8s_api//batch/v1:batch", "@io_k8s_api//core/v1:core", "@io_k8s_api//rbac/v1:rbac", "@io_k8s_apimachinery//pkg/api/errors", + "@io_k8s_apimachinery//pkg/api/resource", "@io_k8s_apimachinery//pkg/apis/meta/v1:meta", + "@io_k8s_apimachinery//pkg/labels", + "@io_k8s_apimachinery//pkg/types", "@io_k8s_client_go//kubernetes", "@io_k8s_client_go//rest", "@io_k8s_client_go//tools/clientcmd", + "@io_k8s_sigs_yaml//:yaml", "@org_golang_x_sync//errgroup", ], ) @@ -53,17 +63,29 @@ go_library( go_test( name = "selfhosted_test", srcs = [ + "cluster_reach_test.go", + "clustervalidator_lifecycle_test.go", + "clustervalidator_ownership_test.go", "clustervalidator_test.go", + "credential_scope_test.go", + "credhelper_test.go", "inotify_probe_test.go", + "main_test.go", + "miniapiserver_test.go", "orchestrator_errors_test.go", "output_test.go", "preflight_test.go", "pullsecret_test.go", "register_test.go", + "registry_cred_test.go", "render_test.go", + "stack_namespaces_test.go", "stack_test.go", + "stale_namespace_test.go", + "validator_config_parity_test.go", "validatortag_test.go", ], + data = glob(["testdata/**"]) + ["//:nvcf-cli-stacks"], embed = [":selfhosted"], deps = [ "//src/clis/nvcf-cli/internal/client", @@ -72,15 +94,22 @@ go_test( "@com_github_masterminds_semver_v3//:semver", "@com_github_stretchr_testify//assert", "@com_github_stretchr_testify//require", + "@io_k8s_api//apps/v1:apps", "@io_k8s_api//batch/v1:batch", "@io_k8s_api//core/v1:core", "@io_k8s_api//rbac/v1:rbac", "@io_k8s_apimachinery//pkg/api/errors", "@io_k8s_apimachinery//pkg/apis/meta/v1:meta", + "@io_k8s_apimachinery//pkg/labels", "@io_k8s_apimachinery//pkg/runtime", "@io_k8s_apimachinery//pkg/runtime/schema", + "@io_k8s_apimachinery//pkg/types", + "@io_k8s_apimachinery//pkg/util/validation", + "@io_k8s_client_go//kubernetes", "@io_k8s_client_go//kubernetes/fake", + "@io_k8s_client_go//kubernetes/typed/core/v1:core", "@io_k8s_client_go//rest", "@io_k8s_client_go//testing", + "@io_k8s_sigs_yaml//:yaml", ], ) diff --git a/src/clis/nvcf-cli/internal/selfhosted/cluster_reach.go b/src/clis/nvcf-cli/internal/selfhosted/cluster_reach.go new file mode 100644 index 0000000000..b8cd888ca0 --- /dev/null +++ b/src/clis/nvcf-cli/internal/selfhosted/cluster_reach.go @@ -0,0 +1,174 @@ +/* +SPDX-FileCopyrightText: Copyright (c) NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package selfhosted + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/client-go/kubernetes" +) + +// ClusterUnreachableError reports a cluster the run could not contact at all: +// its kubeconfig or context did not load, or no API call to it succeeded, +// whatever stopped it: a refused or dropped connection, a name that did not +// resolve, failed TLS verification, rejected credentials or a credential +// plugin that failed, no answer in time, or a server error. Unlike an error +// part way through a probe, it means nothing about the cluster was checked. +type ClusterUnreachableError struct { + Context string + Err error +} + +func (e *ClusterUnreachableError) Error() string { + name := e.Context + if name == "" { + name = "the current kubeconfig context" + } + return fmt.Sprintf("cannot reach %s: %v", name, e.Err) +} + +func (e *ClusterUnreachableError) Unwrap() error { return e.Err } + +// clusterFirstCallTimeout bounds each round trip of the first API call to a +// cluster. A var so tests can shorten it. +var clusterFirstCallTimeout = 15 * time.Second + +// firstCallRetryDelay is the pause before a first call that met a server error +// is tried again. A var so tests can shorten it. +var firstCallRetryDelay = time.Second + +// firstCallBodyLimit caps the body of a first-call response, which is read +// within the call's bound. /version answers in well under a kilobyte. +const firstCallBodyLimit = 1 << 20 + +// connectCluster builds a client for kubeContext, its transport wrapped by +// wraps, and makes its first API call. A rejected token or a server error gets +// one more try. When the call still fails, whatever the error, the cluster is +// a ClusterUnreachableError, unless the run's own budget ended or it was +// interrupted meanwhile: then the run, not the cluster, stopped it, and the +// probe that follows reports that. +func connectCluster( + ctx context.Context, kubeContext string, wraps ...func(http.RoundTripper) http.RoundTripper, +) (kubernetes.Interface, error) { + restCfg, err := loadKubeConfig(kubeContext) + if err != nil { + return nil, &ClusterUnreachableError{Context: kubeContext, Err: fmt.Errorf("building kubeconfig: %w", err)} + } + restCfg.Wrap(boundFirstCall) + for _, wrap := range wraps { + restCfg.Wrap(wrap) + } + client, err := kubernetes.NewForConfig(restCfg) + if err != nil { + return nil, &ClusterUnreachableError{Context: kubeContext, Err: fmt.Errorf("building kubernetes client: %w", err)} + } + err = firstClusterCall(ctx, client, true) + switch { + case ctx.Err() != nil: + // The run's budget ended or it was interrupted: not the cluster's doing. + case apierrors.IsUnauthorized(err): + // A credential plugin replaces a cached token the server rejected on + // the next call. + err = firstClusterCall(ctx, client, true) + case isServerError(err): + // client-go retries a server error only when it carries a + // Retry-After, and a load balancer in front of a rolling API server + // answers 502 or 503 without one. A connection dropped before the + // response, or part way through its body, client-go retries itself. + // This is one more round trip: after a Retry-After, client-go's own + // retries are spent, and running them again doubled the time before a + // cluster that keeps failing is called unreachable. + select { + case <-ctx.Done(): + case <-time.After(firstCallRetryDelay): + err = firstClusterCall(ctx, client, false) + } + } + if err != nil && ctx.Err() == nil { + return nil, &ClusterUnreachableError{Context: kubeContext, Err: err} + } + return client, nil +} + +// isServerError reports an API call the server answered with a 5xx status. +func isServerError(err error) bool { + var status apierrors.APIStatus + return errors.As(err, &status) && status.Status().Code >= http.StatusInternalServerError +} + +// firstClusterCall asks the server for its version, each round trip bounded +// by clusterFirstCallTimeout. retries leaves client-go its own retries, of a +// dropped connection and of an answer with a Retry-After; without it the +// call is a single round trip. +func firstClusterCall(ctx context.Context, client kubernetes.Interface, retries bool) error { + ctx = context.WithValue(ctx, firstCallBound{}, clusterFirstCallTimeout) + req := client.Discovery().RESTClient().Get().AbsPath("/version") + if !retries { + req = req.MaxRetries(0) + } + return req.Do(ctx).Error() +} + +// firstCallBound is the context key for the bound firstClusterCall puts on +// each of its round trips. +type firstCallBound struct{} + +// boundFirstCall wraps a client's transport to bound a round trip whose +// context carries a firstCallBound, its body included. An exec or auth +// provider wraps the transport outside it and fetches its credentials before +// the round trip, and logs in again after a 401 once it returns, so a login +// the operator is waiting on does not count, while an API server that takes +// the connection and never answers still does. +func boundFirstCall(rt http.RoundTripper) http.RoundTripper { + return boundedRoundTripper{next: rt} +} + +type boundedRoundTripper struct{ next http.RoundTripper } + +func (b boundedRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + bound, ok := req.Context().Value(firstCallBound{}).(time.Duration) + if !ok { + return b.next.RoundTrip(req) + } + ctx, cancel := context.WithTimeout(req.Context(), bound) + defer cancel() + resp, err := b.next.RoundTrip(req.WithContext(ctx)) + if err != nil { + return nil, err + } + // Read within the round trip, the body is not cut off by the bound running + // out during a login after it, and a connection dropped part way through + // the body fails the round trip, which client-go retries. + body, err := io.ReadAll(io.LimitReader(resp.Body, firstCallBodyLimit+1)) + _ = resp.Body.Close() + if err != nil { + return nil, err + } + if len(body) > firstCallBodyLimit { + return nil, fmt.Errorf("response to %s is larger than %d bytes", req.URL.Path, firstCallBodyLimit) + } + resp.Body = io.NopCloser(bytes.NewReader(body)) + return resp, nil +} diff --git a/src/clis/nvcf-cli/internal/selfhosted/cluster_reach_test.go b/src/clis/nvcf-cli/internal/selfhosted/cluster_reach_test.go new file mode 100644 index 0000000000..616466c04d --- /dev/null +++ b/src/clis/nvcf-cli/internal/selfhosted/cluster_reach_test.go @@ -0,0 +1,352 @@ +/* +SPDX-FileCopyrightText: Copyright (c) NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package selfhosted + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/client-go/kubernetes" +) + +// writeKubeconfig points KUBECONFIG at a file with one context, "test", for +// server, with a static token. +func writeKubeconfig(t *testing.T, server string) { + t.Helper() + writeKubeconfigFor(t, server, " token: test-token\n") +} + +// writeKubeconfigFor is writeKubeconfig with user, indented to sit under +// user:, as the credentials. client-go sends them only over TLS, so an https +// server's certificate is not verified. +func writeKubeconfigFor(t *testing.T, server, user string) { + t.Helper() + path := filepath.Join(t.TempDir(), "config") + body := fmt.Sprintf(`apiVersion: v1 +kind: Config +clusters: +- name: test + cluster: + server: %s + insecure-skip-tls-verify: %t +contexts: +- name: test + context: + cluster: test + user: test +users: +- name: test + user: +%scurrent-context: test +`, server, strings.HasPrefix(server, "https://"), user) + require.NoError(t, os.WriteFile(path, []byte(body), 0o600)) + t.Setenv("KUBECONFIG", path) +} + +// A cluster no API call succeeds against is unreachable, whatever the error: a +// context that does not exist, refused credentials, a refused connection, no +// answer, or a server error. Nothing about it was checked. +func TestConnectCluster_ClassifiesAClusterItCannotReach(t *testing.T) { + status := func(code int) string { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(code) + })) + t.Cleanup(srv.Close) + return srv.URL + } + closed := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + closed.Close() + release := make(chan struct{}) + silent := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + select { + case <-r.Context().Done(): + case <-release: + } + })) + t.Cleanup(silent.Close) + t.Cleanup(func() { close(release) }) + prev := clusterFirstCallTimeout + clusterFirstCallTimeout = 200 * time.Millisecond + t.Cleanup(func() { clusterFirstCallTimeout = prev }) + + for name, tc := range map[string]struct { + server, context string + unreachable bool + }{ + "missing context": {server: status(http.StatusOK), context: "nope", unreachable: true}, + "rejected token": {server: status(http.StatusUnauthorized), context: "test", unreachable: true}, + "refused connection": {server: closed.URL, context: "test", unreachable: true}, + "no answer": {server: silent.URL, context: "test", unreachable: true}, + "server error": {server: status(http.StatusInternalServerError), context: "test", unreachable: true}, + "forbidden": {server: status(http.StatusForbidden), context: "test", unreachable: true}, + "answers the request": {server: status(http.StatusOK), context: "test"}, + } { + writeKubeconfig(t, tc.server) + _, err := connectCluster(context.Background(), tc.context) + var unreachable *ClusterUnreachableError + assert.Equal(t, tc.unreachable, errors.As(err, &unreachable), "%s: %v", name, err) + if !tc.unreachable { + assert.NoError(t, err, name) + } + } +} + +// A run whose own budget ends during the first call is not told the cluster +// is unreachable: the budget, not the cluster, stopped it. +func TestConnectCluster_SpentBudgetIsNotUnreachable(t *testing.T) { + release := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + select { + case <-r.Context().Done(): + case <-release: + } + })) + t.Cleanup(srv.Close) + t.Cleanup(func() { close(release) }) + writeKubeconfig(t, srv.URL) + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + _, err := connectCluster(ctx, "test") + var unreachable *ClusterUnreachableError + assert.False(t, errors.As(err, &unreachable), "%v", err) +} + +// credentialPlugin writes an exec credential plugin that runs the shell body +// and then returns the token body left in $token, and returns the kubeconfig +// user that runs it. +func credentialPlugin(t *testing.T, body string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "plugin") + script := "#!/bin/sh\n" + body + "\nprintf '{\"apiVersion\":\"client.authentication.k8s.io/v1\"," + + "\"kind\":\"ExecCredential\",\"status\":{\"token\":\"%s\"}}' \"$token\"\n" + require.NoError(t, os.WriteFile(path, []byte(script), 0o755)) + return fmt.Sprintf(` exec: + apiVersion: client.authentication.k8s.io/v1 + command: %s + interactiveMode: Never +`, path) +} + +// Credentials from a plugin are fetched within the first call, so its time +// does not count against the call's bound, and a cached token the server +// rejects is replaced and tried again. A plugin that fails gets no call +// through: the cluster is unreachable. +func TestConnectCluster_CredentialPlugin(t *testing.T) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer fresh" { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(srv.Close) + prev := clusterFirstCallTimeout + clusterFirstCallTimeout = 500 * time.Millisecond + t.Cleanup(func() { clusterFirstCallTimeout = prev }) + + for name, tc := range map[string]struct { + body string + unreachable bool + }{ + "slower than the bound": {body: "sleep 1; token=fresh"}, + "rotates a rejected token": { + body: `token=fresh; if [ ! -e "$0.ran" ]; then : > "$0.ran"; token=stale; fi`, + }, + "fails": {body: "exit 1", unreachable: true}, + "token rejected twice": {body: "token=stale", unreachable: true}, + } { + writeKubeconfigFor(t, srv.URL, credentialPlugin(t, tc.body)) + _, err := connectCluster(context.Background(), "test") + var unreachable *ClusterUnreachableError + assert.Equal(t, tc.unreachable, errors.As(err, &unreachable), "%s: %v", name, err) + if !tc.unreachable { + assert.NoError(t, err, name) + } + } +} + +// With a credential plugin, the bound still covers the network once the +// credentials are in hand: an API server that takes the connection and never +// answers is unreachable after the bound, not after the run's whole budget. +func TestConnectCluster_CredentialPluginAgainstASilentServer(t *testing.T) { + release := make(chan struct{}) + srv := httptest.NewTLSServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + select { + case <-r.Context().Done(): + case <-release: + } + })) + t.Cleanup(srv.Close) + t.Cleanup(func() { close(release) }) + prev := clusterFirstCallTimeout + clusterFirstCallTimeout = 500 * time.Millisecond + t.Cleanup(func() { clusterFirstCallTimeout = prev }) + + for name, body := range map[string]string{ + "fast plugin": "token=fresh", + "slow plugin": "sleep 1; token=fresh", + } { + writeKubeconfigFor(t, srv.URL, credentialPlugin(t, body)) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + start := time.Now() + _, err := connectCluster(ctx, "test") + cancel() + var unreachable *ClusterUnreachableError + assert.True(t, errors.As(err, &unreachable), "%s: %v", name, err) + assert.Less(t, time.Since(start), 5*time.Second, name) + } +} + +// shortenFirstCallRetry makes the pause before the first call's retry short. +func shortenFirstCallRetry(t *testing.T) { + t.Helper() + prev := firstCallRetryDelay + firstCallRetryDelay = 10 * time.Millisecond + t.Cleanup(func() { firstCallRetryDelay = prev }) +} + +// A server error on the first call, without a Retry-After for client-go to +// honour, as from a load balancer while an API server rolls, gets one more +// try. A second failure is still unreachable. +func TestConnectCluster_RetriesAServerErrorOnce(t *testing.T) { + shortenFirstCallRetry(t) + for _, code := range []int{ + http.StatusInternalServerError, http.StatusBadGateway, + http.StatusServiceUnavailable, http.StatusGatewayTimeout, + } { + for _, failures := range []int32{1, 2} { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if calls.Add(1) <= failures { + w.WriteHeader(code) + return + } + w.WriteHeader(http.StatusOK) + })) + writeKubeconfig(t, srv.URL) + _, err := connectCluster(context.Background(), "test") + srv.Close() + var unreachable *ClusterUnreachableError + assert.Equal(t, failures > 1, errors.As(err, &unreachable), "%d x%d: %v", code, failures, err) + assert.EqualValues(t, 2, calls.Load(), "%d x%d", code, failures) + } + } +} + +// A server error with a Retry-After, client-go retries itself. The one more +// try connectCluster makes after it is a single round trip: running +// client-go's whole retry loop again doubled the time before a cluster that +// keeps failing is called unreachable. +func TestConnectCluster_RetryAfterRetriesIsOneMoreRoundTrip(t *testing.T) { + shortenFirstCallRetry(t) + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + calls.Add(1) + w.Header().Set("Retry-After", "0") + w.WriteHeader(http.StatusServiceUnavailable) + })) + t.Cleanup(srv.Close) + writeKubeconfig(t, srv.URL) + + restCfg, err := loadKubeConfig("test") + require.NoError(t, err) + client, err := kubernetes.NewForConfig(restCfg) + require.NoError(t, err) + require.Error(t, client.Discovery().RESTClient().Get().AbsPath("/version").Do(context.Background()).Error()) + ownRetries := calls.Swap(0) + require.Greater(t, ownRetries, int32(1), "client-go retries an answer with a Retry-After") + + _, err = connectCluster(context.Background(), "test") + var unreachable *ClusterUnreachableError + assert.True(t, errors.As(err, &unreachable), "%v", err) + assert.Equal(t, ownRetries+1, calls.Load()) +} + +// A connection dropped on the first call is tried again, whether it drops +// before the response or part way through its body. +func TestConnectCluster_RetriesADroppedConnection(t *testing.T) { + shortenFirstCallRetry(t) + for name, drop := range map[string]func(net.Conn){ + "closed": func(c net.Conn) { _ = c.Close() }, + "reset": func(c net.Conn) { + _ = c.(*net.TCPConn).SetLinger(0) + _ = c.Close() + }, + "closed in the body": func(c net.Conn) { + _, _ = c.Write([]byte("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n" + + "Content-Length: 100\r\n\r\n{\"major\"")) + _ = c.Close() + }, + } { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if calls.Add(1) == 1 { + conn, _, err := http.NewResponseController(w).Hijack() + require.NoError(t, err) + drop(conn) + return + } + w.WriteHeader(http.StatusOK) + })) + writeKubeconfig(t, srv.URL) + _, err := connectCluster(context.Background(), "test") + srv.Close() + assert.NoError(t, err, name) + assert.EqualValues(t, 2, calls.Load(), name) + } +} + +// A body that follows its headers, as an HTTP/1.1 server may send a 401, is +// read within the first call's bound, before a credential plugin logs in +// again: a login slower than the bound does not cut off the body and leave the +// cluster unreachable. +func TestConnectCluster_SlowReloginAfterADelayedRejectionBody(t *testing.T) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") == "Bearer fresh" { + w.WriteHeader(http.StatusOK) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + http.NewResponseController(w).Flush() + time.Sleep(50 * time.Millisecond) + _, _ = w.Write([]byte(`{"kind":"Status","apiVersion":"v1","status":"Failure",` + + `"reason":"Unauthorized","code":401}`)) + })) + t.Cleanup(srv.Close) + prev := clusterFirstCallTimeout + clusterFirstCallTimeout = 500 * time.Millisecond + t.Cleanup(func() { clusterFirstCallTimeout = prev }) + + writeKubeconfigFor(t, srv.URL, credentialPlugin(t, + `if [ -e "$0.ran" ]; then sleep 1; token=fresh; else : > "$0.ran"; token=stale; fi`)) + _, err := connectCluster(context.Background(), "test") + assert.NoError(t, err) +} diff --git a/src/clis/nvcf-cli/internal/selfhosted/clustervalidator.go b/src/clis/nvcf-cli/internal/selfhosted/clustervalidator.go index 5df457fa76..35f81cddf1 100644 --- a/src/clis/nvcf-cli/internal/selfhosted/clustervalidator.go +++ b/src/clis/nvcf-cli/internal/selfhosted/clustervalidator.go @@ -18,10 +18,19 @@ limitations under the License. package selfhosted import ( + "cmp" "context" + cryptorand "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" "fmt" "io" + "math" + "net" "regexp" + "sort" + "strconv" "strings" "time" @@ -29,26 +38,100 @@ import ( corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/kubernetes" ) const ( - clusterValidatorNamespace = "default" - clusterValidatorName = "nvcf-preflight-validator" - clusterValidatorContainer = "validator" - clusterValidatorAppLabel = "nvcf-cluster-validator" - clusterValidatorPollInterval = 2 * time.Second - clusterValidatorTTLSeconds = int32(600) - clusterValidatorHintURL = "https://docs.nvidia.com/nvcf/self-managed-clusters#cluster-validator" + clusterValidatorNamespace = "default" + clusterValidatorName = "nvcf-preflight-validator" + clusterValidatorContainer = "validator" + clusterValidatorAppLabel = "nvcf-cluster-validator" + clusterValidatorTTLSeconds = int32(600) + clusterValidatorHintURL = "https://docs.nvidia.com/nvcf/self-managed-clusters#cluster-validator" + // orphanValidatorRBACTTL is the minimum age, on the apiserver's clock, + // before leftover validator objects are reclaimed. It must exceed the + // longest a run or its pod can live, so a live run is never hit. + orphanValidatorRBACTTL = 30 * time.Minute + // preservedValidatorTTL bounds how long --no-cleanup artifacts survive. + // Exempting them outright left a cluster-wide ClusterRole and an NGC-key + // Secret in place forever once nobody ran the check again. + preservedValidatorTTL = 24 * time.Hour + // clusterValidatorManagedBy tells this CLI's per-run objects apart from the + // fixed-name set released CLIs create. Those releases DeleteCollection on + // managed-by=nvcf-cli, so sharing the value let each version delete the + // other's in-flight Jobs, Secrets and RBAC. + clusterValidatorManagedBy = "nvcf-cli-validator" + // clusterValidatorRunLabel carries the run ID on everything one run + // creates, so the cleanup hint can address exactly that run's objects. + clusterValidatorRunLabel = "nvcf.nvidia.com/validator-run" ) // Vars (not consts) so tests can shorten without the full production budget. var ( clusterValidatorTimeout = 5 * time.Minute + clusterValidatorPollInterval = 2 * time.Second clusterValidatorLogFetchTimeout = 10 * time.Second + // validatorCleanupTimeout bounds each deferred sweep. They run after an + // interrupt too, and a second signal or CI's follow-up SIGTERM can end the + // process at any time, so an unreachable apiserver must not hold it open. + validatorCleanupTimeout = 30 * time.Second ) +// validatorDeferredSweeps is how many deferred sweeps one run can make, each +// bounded by validatorCleanupTimeout: the pull secret, the RBAC, and for the +// control-plane role the network-checks ConfigMap. +const validatorDeferredSweeps = 3 + +// validatorGradeReads is how many calls, each bounded by +// clusterValidatorLogFetchTimeout, one run can make outside its own timeout: +// the ownership patch, the transcript, the transcript again for a validator +// that finished late, and the container's exit code. +const validatorGradeReads = 4 + +// validatorTeardownBudget is the longest an interrupted validator spends +// removing its node-to-node probe: the DaemonSet, the pods and the namespace, +// one after another, each with its own 20s budget. It is the validator's +// clustervalidator.ProbeCleanupBound, which a run cut short by its own timeout +// spends too. +const validatorTeardownBudget = 3 * 20 * time.Second + +// validatorPodStartSlack is the time the validator pod may take to be +// scheduled and pull its image. The Job's deadline counts it, and the +// validator's own timeout does not. +const validatorPodStartSlack = 2 * time.Minute + +// validatorDeadlineOffset is how long after the end of the run's own timeout +// the Job's active deadline ends its pod. VALIDATOR_TIMEOUT ends no later than +// the run's own timeout, so past it the pod has the time to start and to tear +// down the probe of a check its timeout cut short. The deadline plus the pod's +// termination grace must fall inside validatorDeadlineGrace, the wait for it. +const validatorDeadlineOffset = validatorPodStartSlack + validatorTeardownBudget + +// clusterValidatorTerminationGrace is the validator pod's termination grace +// period, the same as the chart's nvcaop.clusterValidatorJobSpec. It leaves +// room past validatorTeardownBudget, so the kubelet does not kill the pod +// before the probe namespace is deleted. +const clusterValidatorTerminationGrace = 120 * time.Second + +// ClusterValidatorRunCeiling is the longest one validator run can take: its +// own timeout, then on a timeout the wait for the Job's deadline to end the +// pod and the reads that grade it, then the deferred sweeps, which run in turn +// on fresh contexts, plus a margin for the pod's termination grace period. +func ClusterValidatorRunCeiling() time.Duration { + return clusterValidatorTimeout + validatorDeadlineGrace + validatorGradeReads*clusterValidatorLogFetchTimeout + + validatorDeferredSweeps*validatorCleanupTimeout + validatorRunMargin +} + +// cleanupContext is a fresh, bounded context for one deferred sweep. It does +// not derive from the run's ctx, which may already be cancelled. +func cleanupContext() (context.Context, context.CancelFunc) { + return context.WithTimeout(context.Background(), validatorCleanupTimeout) +} + // Kubelet Waiting.Reason values that mean the pod will never start without // operator intervention. Detecting any short-circuits the 5-minute wait. var pullFailureReasons = map[string]struct{}{ @@ -71,6 +154,25 @@ type ClusterValidatorParams struct { Image string PullSecret string NoCleanup bool + // Role selects which check set the validator runs: "control-plane" or + // "compute-plane" (empty = compute-plane default). Passed to the Job as + // VALIDATOR_ROLE. See clustervalidator.RoleControlPlane / RoleComputePlane. + Role string + // Registries are probed for reachability by the control-plane validator: + // the hosts the local credential check probes, each as a warning only. + // Ignored for the compute-plane role. + Registries []RegistryEntry + // Env is passed to the validator container: namespace overrides, the + // probe image, and whether the control plane is expected to be installed. + Env map[string]string + // Tolerations are added to the control-plane ones the Job always + // carries, for clusters whose nodes use other taints, as the chart's + // clusterValidator.tolerations does. + Tolerations []corev1.Toleration + // OnStart, when set, receives the run's ID before the run creates + // anything, so the caller can say how to remove the run's objects if the + // process is stopped before the run's own teardown ends. + OnStart func(runID string) } // Err is non-nil only when the run failed to execute (RBAC bootstrap, @@ -79,9 +181,32 @@ type ClusterValidatorParams struct { type ClusterValidatorResult struct { Passed bool ExitCode int32 - Logs string - JobName string - Err error + // Reason is why the validator container or its pod ended, when the + // cluster says more than its exit code: OOMKilled, Evicted. + Reason string + Logs string + // LogsErr is why the transcript could not be read, if it could not. + LogsErr error + JobName string + // RunID labels every object the run created (clusterValidatorRunLabel). + RunID string + // Created reports that the run created at least one object in the + // cluster, so a kept run has something to remove. + Created bool + Err error + // LeftBehind reports that this run's RBAC and other objects were kept + // because its pod could still be running, so the caller can print the + // command that removes them rather than leave them for the orphan sweep. + LeftBehind bool + // SweepErr is why removing the run's objects failed. They are still in + // the cluster, so the caller prints the command that removes them. + SweepErr error + // Suspended reports that a kept Job whose pod could not pull was + // suspended, so the pod was deleted and has no logs to read. + Suspended bool + // Notes say what the run did that the operator should know about, such + // as copying a pull credential into the validator namespace. + Notes []string } type ClusterValidator func(ctx context.Context, params ClusterValidatorParams) ClusterValidatorResult @@ -98,12 +223,28 @@ func NewClusterValidator() ClusterValidator { if err != nil { return ClusterValidatorResult{Err: fmt.Errorf("building kubernetes client: %w", err)} } - return runClusterValidator(ctx, client, p.Image, p.PullSecret, p.NoCleanup) + return runValidatorJob(ctx, client, p) } } +// runClusterValidator is runValidatorJob with the parameters spelled out. +func runClusterValidator( + ctx context.Context, client kubernetes.Interface, image, pullSecret string, noCleanup bool, role string, + registries []RegistryEntry, env map[string]string, tolerations ...corev1.Toleration, +) ClusterValidatorResult { + return runValidatorJob(ctx, client, ClusterValidatorParams{ + Image: image, PullSecret: pullSecret, NoCleanup: noCleanup, Role: role, + Registries: registries, Env: env, Tolerations: tolerations, + }) +} + // Testable core. Pass a fake clientset to unit-test without a real cluster. -func runClusterValidator(ctx context.Context, client kubernetes.Interface, image, pullSecret string, noCleanup bool) ClusterValidatorResult { +// The result is named so the deferred sweeps can report what they could not +// remove. +func runValidatorJob( + ctx context.Context, client kubernetes.Interface, p ClusterValidatorParams, +) (res ClusterValidatorResult) { + image, pullSecret, noCleanup, role := p.Image, p.PullSecret, p.NoCleanup, p.Role if image == "" { // Defensive: callers gate on configured image before invoking the // validator, so this branch shouldn't fire in normal use. @@ -113,187 +254,1305 @@ func runClusterValidator(ctx context.Context, client kubernetes.Interface, image vctx, cancel := context.WithTimeout(ctx, clusterValidatorTimeout) defer cancel() - // Resolver errors are non-fatal: fall through to the caller's value and - // let waitForClusterValidatorJob surface ImagePullBackOff if needed. - if resolved, err := resolveValidatorPullSecret(ctx, client, pullSecret, image); err == nil { + // podMayBeRunning is true only between a successful Job create and the + // point its pods are seen to end. Every cleanup defer below keys off it: + // outside that window nothing is using the pull secret or the RBAC, so + // reclaiming them is safe, and inside it reclaiming them breaks the pod + // that is still running. + podMayBeRunning := false + + // Random per-run identity. Every object this run creates carries it, so a + // predictable name cannot be pre-created by someone else and then either + // bound to the validator's cluster-wide permissions or overwritten with the + // NGC credential. Generated before anything is created, because the pull + // secret name needs it too. + runID, err := newValidatorRunID() + if err != nil { + return ClusterValidatorResult{Err: err} + } + if p.OnStart != nil { + p.OnStart(runID) + } + // Everything this run's Creates returned. Cleanup deletes only these, + // pinned to the UID each Create returned: a name this run failed to claim + // belongs to someone else, whatever labels it carries. + created := runObjects{} + + // sweep runs one deferred delete of this run's objects of the given kinds + // on a fresh, bounded context, and only once no pod of this run can still + // need them. --no-cleanup keeps everything. A delete that fails leaves + // objects behind, so the result carries its error and the caller prints + // the removal command. + sweep := func(kinds ...string) { + if noCleanup || podMayBeRunning { + return + } + cctx, cancel := cleanupContext() + defer cancel() + if err := deleteCreated(cctx, client, created, kinds...); err != nil { + res.SweepErr = errors.Join(res.SweepErr, err) + res.Created = true + } + } + + // Register the cleanup before the bootstrap, not after. ensureClusterValidatorRBAC + // creates its objects in sequence and returns on the first failure, so a + // kubeconfig that can create a ServiceAccount but not a cluster-scoped + // ClusterRole would otherwise abandon the ServiceAccount on every attempt. + // The names are per-run, so nothing self-heals by reuse: under --wait that + // leaks one object per poll. + defer sweep(validatorRBACKinds...) + rbacErr := ensureClusterValidatorRBAC(vctx, client, role, runID, noCleanup, created) + + // Reclaim leftovers from dead runs. Their age is measured on the + // apiserver's clock, read from what this run just created, so a skewed + // workstation clock cannot make an overlapping run's objects look stale. + // This run's objects and an explicitly named pull Secret are never taken. + if now, ok := created.serverTime(); ok { + sweepOrphanClusterValidatorRBAC(vctx, client, now, orphanValidatorRBACTTL, runID, pullSecret) + } + if rbacErr != nil { + // RunID lets --no-cleanup print the command that removes whatever the + // failed bootstrap already created. + return ClusterValidatorResult{ + RunID: runID, Created: len(created) > 0, Err: fmt.Errorf("bootstrapping validator RBAC: %w", rbacErr), + } + } + + // The pull secret is resolved only once the run can start a Job: minting + // it before the bootstrap wrote the NGC key into the namespace on every + // run whose kubeconfig could not create the RBAC. A resolver error does + // not stop the run: the image may be public. It is kept, so a pull that + // then fails names the step that failed rather than only the registry's + // "unauthorized". + resolved, pullSecretNote, pullSecretErr := resolveValidatorPullSecret( + ctx, client, pullSecret, image, role, runID, noCleanup, created) + if pullSecretErr == nil { pullSecret = resolved } - // Sweep any pull secrets we created (mirror or env-mint) after the - // Job terminates. Uses a fresh context so cleanup runs even when - // vctx has expired. Operator-supplied secrets via the flag aren't - // labeled by us and are skipped. - defer sweepManagedPullSecrets(context.Background(), client) + if pullSecretNote != "" { + defer func() { res.Notes = append(res.Notes, pullSecretNote) }() + } + // Sweep only the Secret this run created, and only once the pod can no + // longer need it. Sweeping unconditionally deletes the Secret out from + // under a pod that is still retrying its pull, which the kubelet then + // reports as FailedToRetrieveImagePullSecret. An operator-supplied or + // adopted Secret was not created by this run and is never touched. + defer sweep(kindSecret) - if err := ensureClusterValidatorRBAC(vctx, client); err != nil { - return ClusterValidatorResult{Err: fmt.Errorf("bootstrapping validator RBAC: %w", err)} + // For the control-plane role, create a ConfigMap with reachability + // endpoints and enforcement config so the validator runs its configurable + // checks. Best-effort: on a failure the Job reads no ConfigMap, and the + // validator skips the configurable checks. + var configNote string + configName := clusterValidatorNoConfigName + if role == clusterValidatorControlPlaneRole { + // Same gating as the other sweeps: keep it when the pod may still + // read it, or when the operator asked to keep the run's artifacts. + defer sweep(kindConfigMap) + if err := ensureClusterValidatorConfig(vctx, client, p.Registries, role, runID, noCleanup, created); err != nil { + // The validator skips configurable reachability checks silently + // unless this note reaches the transcript. + configNote = fmt.Sprintf("note: validator config not applied (%v); reachability checks may be skipped", err) + } else { + // Only a ConfigMap this run created is wired in. One that already + // held the name was put there by someone else, and pointing the + // Job at it would run their endpoints and enforcement settings. + configName = clusterValidatorConfigRunName(runID) + } } - sweepPriorClusterValidatorJobs(vctx, client) + // No sweep of earlier Jobs: every Job now carries a deadline and a TTL, + // so a role-wide sweep could only ever hit an overlapping run's live Job. + // Named like the RBAC, per role and run, so two runs never share a name. + jobName := clusterValidatorRBACName(role, runID) + spec := buildClusterValidatorJob(jobName, image, pullSecret, role, runID, configName, noCleanup, p.Env, + p.Tolerations...) + // Both counted from now, not from the start of the run: the sweep, the + // pull-secret scan and the bootstrap have used some of its timeout. + left := timeLeft(vctx) + setValidatorTimeout(spec, left) + if spec.Spec.ActiveDeadlineSeconds != nil { + deadline := validatorActiveDeadlineSeconds(left) + spec.Spec.ActiveDeadlineSeconds = &deadline + } + job, err := client.BatchV1().Jobs(clusterValidatorNamespace).Create(vctx, spec, metav1.CreateOptions{}) + if err != nil { + // A create cut off by an interrupt or a dropped connection may still + // have been applied, and a Job left behind would run with its RBAC + // swept. AlreadyExists means the name was someone else's, which is + // never touched; any other Job under the name is acted on only if it + // carries this run's ID. Without --no-cleanup it is stopped, and when + // its pod may still be running its objects are kept and the result + // says how to remove them. Under --no-cleanup it is kept, but + // suspended: nothing follows it, and with no deadline a pod that + // cannot pull would retry forever. + res := ClusterValidatorResult{RunID: runID, Created: true, Err: fmt.Errorf("creating validator Job: %w", err)} + switch { + case apierrors.IsAlreadyExists(err): + case noCleanup: + if serr := suspendValidatorJob(client, jobName, runID, created); serr != nil && !apierrors.IsNotFound(serr) { + res.Err = fmt.Errorf("%w; suspending the kept Job failed: %v", res.Err, serr) + } + default: + podMayBeRunning = !stopValidatorJob(client, jobName, runID, created) + res.LeftBehind = podMayBeRunning + } + return res + } + created.add(kindJob, job) + podMayBeRunning = true - jobName := fmt.Sprintf("%s-%d", clusterValidatorName, time.Now().UnixNano()) - if _, err := client.BatchV1().Jobs(clusterValidatorNamespace).Create( - vctx, buildClusterValidatorJob(jobName, image, pullSecret, noCleanup), metav1.CreateOptions{}, - ); err != nil { - return ClusterValidatorResult{Err: fmt.Errorf("creating validator Job: %w", err)} + // The Job owns this run's pull secret and ConfigMap, so they go when the + // Job goes, even on paths where the deferred sweeps are suppressed: its + // deadline and TTL remove it. The ServiceAccount is deliberately not + // owned: the ClusterRole and binding are cluster-scoped and cannot be, + // and deleting the account alone would leave a binding that anyone able + // to recreate that ServiceAccount name could inherit. + // Not under --no-cleanup: a kept Secret or ConfigMap owned by the kept Job + // would be garbage-collected the moment an operator re-runs that Job by + // deleting and recreating it. They stay unowned, and a later check's + // orphan sweep reclaims them once preservedValidatorTTL has passed. On a + // fresh context: an interrupt right after the create must not leave them + // unowned. + if !noCleanup { + octx, ocancel := context.WithTimeout(context.Background(), clusterValidatorLogFetchTimeout) + ownByJob(octx, client, job, created) + ocancel() } final, waitErr := waitForClusterValidatorJob(vctx, client, jobName) + // The run's own context has ended: the operator interrupted it, or the + // check's time budget ran out. Nothing will wait for a later result, so + // the Job is stopped now rather than left to its deadline. + abandoned := waitErr != nil && ctx.Err() != nil + var pullErr *validatorImagePullError + isPullErr := errors.As(waitErr, &pullErr) + if isPullErr && pullSecretErr != nil { + waitErr = fmt.Errorf("%w; the run's pull secret was not created: %w", waitErr, pullSecretErr) + } + ownTimeout := waitErr != nil && !abandoned && !isPullErr && errors.Is(waitErr, context.DeadlineExceeded) + if ownTimeout { + // The validator's own timeout, not the check's budget: its result is + // that it did not finish, which is not the budget cutting it short. + waitErr = fmt.Errorf("the validator did not finish within %s", clusterValidatorTimeout) + } - // Fetch logs under a fresh ctx from the parent: vctx is expired on the - // timeout path, and dropping the partial transcript hurts most there. - logCtx, logCancel := context.WithTimeout(ctx, clusterValidatorLogFetchTimeout) - defer logCancel() - rawLogs, _ := fetchClusterValidatorLogs(logCtx, client, jobName) - cleaned := cleanValidatorOutput(rawLogs) + // Read the transcript before anything stops the pod, on a fresh context: + // the run's own may be over, and a run that timed out is where the + // partial transcript matters most. Not after an interrupt, which only + // wants the cleanup. + var rawLogs string + var logsErr error + if !errors.Is(ctx.Err(), context.Canceled) { + rawLogs, logsErr = readValidatorLogs(client, jobName) + } - if waitErr != nil { - return ClusterValidatorResult{ - JobName: jobName, - Logs: cleaned, - Err: waitErr, + // A pull failure is different: the container never started, so nothing + // uses the RBAC, the pull secret or the ConfigMap. Delete the Job so its + // pod stops retrying, and let the deferred sweeps run now rather than + // leaving a cluster-wide ClusterRole and an NGC-key Secret for a later + // run's orphan sweep, or for nobody. A kept Job is suspended instead. + // Each stop deletes the Job in the foreground and waits, bounded, for its + // pod to end before the deferred sweeps revoke its RBAC. The validator + // removes its own probe namespace and DaemonSet on SIGTERM, and needs + // that RBAC to do it. + stopped, suspended := false, false + var suspendErr error + // endRun stops the Job, or under --no-cleanup suspends it when its pod is + // failing to pull: a kept Job has no deadline, so that pod would retry + // forever. + endRun := func(pullFailing bool) { + switch { + case !noCleanup: + podMayBeRunning = !stopValidatorJob(client, jobName, runID, created) + stopped = true + case pullFailing || validatorPodPullFailing(client, jobName): + suspended, suspendErr = true, suspendValidatorJob(client, jobName, runID, created) + } + } + switch { + case waitErr == nil: + // The Job counts an evicted or preempted pod as failed while the pod + // still runs its SIGTERM cleanup, which needs the RBAC. + if !noCleanup { + podMayBeRunning = !waitValidatorPodsDone(client, jobName, validatorStopTimeout) + } + case isPullErr || abandoned: + endRun(isPullErr) + default: + // The validator's own timeout passed, or its Job could not be read + // for a while. Its active deadline ends the pod soon after the + // timeout, so wait for the Job to finish, then grade on what it + // actually did: a validator that finished late, or while its Job + // could not be read, still has a result. The Job is the authority: + // its status can trail its pod's, and before its pod exists an empty + // pod list proves nothing. An interrupt or the end of the check's + // budget during the wait ends the run instead. + job, podsDone, ended := awaitValidatorJob(ctx, client, jobName, validatorDeadlineGrace+timeLeft(vctx)) + podMayBeRunning = !podsDone + switch { + case ended: + endRun(false) + if !ownTimeout { + // The result was still to come when the run ended, so this + // is the interrupt or the budget, not the validator. + waitErr = fmt.Errorf("waiting for job %s: %w (after %w)", jobName, ctx.Err(), waitErr) + } + case job != nil: + final, waitErr = job, nil + // The partial transcript stays unless the full one can be read. + if logs, err := readValidatorLogs(client, jobName); logs != "" { + rawLogs, logsErr = logs, nil + } else if err != nil { + logsErr = err + } } } - // Separate short ctx so a slow log fetch can't burn the exit-code lookup's - // budget and silently return -1 on clean completions. - exitCtx, exitCancel := context.WithTimeout(ctx, 5*time.Second) + if waitErr == nil && jobDeadlineExceeded(final) { + // The deadline stopped a validator that never finished: that is its + // own timeout, not failed checks. + waitErr = fmt.Errorf("the validator did not finish within %s (stopped at its active deadline)", + clusterValidatorTimeout) + } + + cleaned := cleanValidatorOutput(rawLogs) + if configNote != "" { + cleaned = configNote + "\n" + cleaned + } + res = ClusterValidatorResult{ + JobName: jobName, + RunID: runID, + Created: true, + Logs: cleaned, + LogsErr: logsErr, + LeftBehind: podMayBeRunning && !noCleanup, + Suspended: suspended && suspendErr == nil, + Err: waitErr, + } + if suspendErr != nil { + res.Err = fmt.Errorf("%w; suspending the kept Job failed, so its pod keeps retrying the pull: %v", + cmp.Or(waitErr, errors.New("the run ended")), suspendErr) + } + if stopped { + // The Job is gone, so a hint to read its logs would 404. + res.JobName = "" + } + if res.Err != nil { + return res + } + + // A fresh context: the run's own may have ended while the Job was read. + exitCtx, exitCancel := context.WithTimeout(context.Background(), clusterValidatorLogFetchTimeout) defer exitCancel() - return ClusterValidatorResult{ - Passed: final.Status.Succeeded > 0, - ExitCode: containerExitCode(exitCtx, client, jobName), - Logs: cleaned, - JobName: jobName, + res.Passed = jobSucceeded(final) + res.ExitCode, res.Reason = containerTermination(exitCtx, client, jobName) + return res +} + +// timeLeft is how long ctx has before its deadline, or zero. +func timeLeft(ctx context.Context) time.Duration { + if deadline, ok := ctx.Deadline(); ok { + return max(time.Until(deadline), 0) + } + return 0 +} + +// validatorTimeoutEnv bounds the validator's checks. The launcher owns it, so +// extraValidatorEnv never overrides it: set below the time this run waits, it +// has the validator end with its own verdict, naming the checks it could not +// finish, instead of being cut off without one. +const validatorTimeoutEnv = "VALIDATOR_TIMEOUT" + +// validatorSummaryMargin is the part of the run's wait left to the validator +// after its own timeout, to print its summary and exit. +const validatorSummaryMargin = 30 * time.Second + +// validatorRunTimeout is VALIDATOR_TIMEOUT, in seconds, for a run with left to +// wait: validatorSummaryMargin less, or half of left when that is short. +func validatorRunTimeout(left time.Duration) string { + d := max(left-validatorSummaryMargin, left/2) + return strconv.FormatInt(max(int64(d/time.Second), 1), 10) + "s" +} + +// setValidatorTimeout sets the Job's VALIDATOR_TIMEOUT for a run with left to +// wait. +func setValidatorTimeout(job *batchv1.Job, left time.Duration) { + env := job.Spec.Template.Spec.Containers[0].Env + for i := range env { + if env[i].Name == validatorTimeoutEnv { + env[i].Value = validatorRunTimeout(left) + } + } +} + +// validatorActiveDeadlineSeconds is the Job's active deadline when left of +// the run's own timeout remains: validatorDeadlineOffset after it ends, or +// after the one-second VALIDATOR_TIMEOUT a run with no time left still gets. +func validatorActiveDeadlineSeconds(left time.Duration) int64 { + return max(int64(math.Ceil(left.Seconds())), 1) + int64(validatorDeadlineOffset/time.Second) +} + +// readValidatorLogs fetches the validator's transcript on a fresh context +// bounded by clusterValidatorLogFetchTimeout, so it works after the run's own +// context has ended. A failed read is retried inside that bound: a kubelet +// restart or a reset stream is not the transcript. +func readValidatorLogs(client kubernetes.Interface, jobName string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), clusterValidatorLogFetchTimeout) + defer cancel() + for { + logs, err := fetchClusterValidatorLogs(ctx, client, jobName) + if err == nil || apierrors.IsForbidden(err) || apierrors.IsUnauthorized(err) { + return logs, err + } + select { + case <-ctx.Done(): + return logs, err + case <-time.After(clusterValidatorPollInterval): + } + } +} + +// jobFinished reports whether the Job has reached its result: a pod +// succeeded or failed, or it carries a terminal condition. +func jobFinished(job *batchv1.Job) bool { + if job.Status.Succeeded > 0 || job.Status.Failed > 0 { + return true + } + for _, c := range job.Status.Conditions { + if c.Status == corev1.ConditionTrue && (c.Type == batchv1.JobComplete || c.Type == batchv1.JobFailed) { + return true + } + } + return false +} + +// jobSucceeded reports whether the validator exited 0. +func jobSucceeded(job *batchv1.Job) bool { + if job.Status.Succeeded > 0 { + return true + } + for _, c := range job.Status.Conditions { + if c.Type == batchv1.JobComplete && c.Status == corev1.ConditionTrue { + return true + } + } + return false +} + +// jobDeadlineExceeded reports whether the Job's active deadline ended it. +func jobDeadlineExceeded(job *batchv1.Job) bool { + for _, c := range job.Status.Conditions { + if c.Status == corev1.ConditionTrue && (c.Type == batchv1.JobFailed || c.Type == batchv1.JobFailureTarget) && + c.Reason == batchv1.JobReasonDeadlineExceeded { + return true + } + } + return false +} + +// awaitValidatorJob waits up to limit for the Job to finish and its pods to +// end, or until run ends: an interrupt, or the check's budget. It returns the +// Job once it has finished, or nil, whether its pods had all ended, and +// whether run ended first. Reads happen on their own context, so they work +// after run has ended. +func awaitValidatorJob( + run context.Context, client kubernetes.Interface, jobName string, limit time.Duration, +) (finished *batchv1.Job, podsDone, ended bool) { + ctx, cancel := context.WithTimeout(context.Background(), limit) + defer cancel() + for { + job, err := client.BatchV1().Jobs(clusterValidatorNamespace).Get(ctx, jobName, metav1.GetOptions{}) + if err == nil && jobFinished(job) { + finished = job + } + podsDone = validatorPodsEnded(ctx, client, jobName) + if podsDone && (finished != nil || apierrors.IsNotFound(err)) { + return finished, true, false + } + select { + case <-run.Done(): + return finished, podsDone, true + case <-ctx.Done(): + return finished, podsDone, false + case <-time.After(time.Second): + } + } +} + +// extraValidatorEnv returns env as container variables in a stable order, +// skipping empty values and any name the Job already sets, so an override can +// never change the role, the config name or preflight mode. +func extraValidatorEnv(base []corev1.EnvVar, env map[string]string) []corev1.EnvVar { + set := map[string]bool{} + for _, e := range base { + set[e.Name] = true + } + names := make([]string, 0, len(env)) + for k, v := range env { + if v != "" && !set[k] { + names = append(names, k) + } + } + sort.Strings(names) + out := make([]corev1.EnvVar, 0, len(names)) + for _, k := range names { + out = append(out, corev1.EnvVar{Name: k, Value: env[k]}) + } + return out +} + +// Kinds of object one run creates, as runObjects records them. +const ( + kindServiceAccount = "ServiceAccount" + kindClusterRole = "ClusterRole" + kindClusterRoleBinding = "ClusterRoleBinding" + kindRole = "Role" + kindRoleBinding = "RoleBinding" + kindSecret = "Secret" + kindConfigMap = "ConfigMap" + kindJob = "Job" +) + +// validatorRBACKinds are the RBAC kinds in the order they are removed: the +// bindings first, so the grants go before the roles and the account they name. +var validatorRBACKinds = []string{ + kindClusterRoleBinding, kindRoleBinding, kindClusterRole, kindRole, kindServiceAccount, +} + +// runObjects records each object one run created, by kind, as its Create +// returned it. It is the ownership test for every delete the run makes: the +// managed labels are public constants anyone can copy, and a name the run +// failed to claim with AlreadyExists holds someone else's object. +type runObjects map[string]metav1.Object + +// add records o under kind. A nil runObjects records nothing. +func (r runObjects) add(kind string, o metav1.Object) { + if r != nil && o != nil { + r[kind] = o + } +} + +// serverTime is the apiserver's clock as of this run's latest Create, from the +// creationTimestamps the Creates returned. ok is false until one has. +func (r runObjects) serverTime() (now time.Time, ok bool) { + for _, o := range r { + if ts := o.GetCreationTimestamp().Time; ts.After(now) { + now = ts + } + } + return now, !now.IsZero() +} + +// deleteCreated deletes this run's objects of the given kinds, in order, each +// pinned to the UID its Create returned. It returns what it could not remove, +// so the run can say how to remove it. An object already gone is not an +// error, nor is a UID mismatch: the name then holds someone else's object, +// and this run's is gone. +func deleteCreated(ctx context.Context, client kubernetes.Interface, created runObjects, kinds ...string) error { + var errs []error + for _, kind := range kinds { + o, ok := created[kind] + if !ok { + continue + } + name, opts := o.GetName(), deleteExactly(o) + var err error + switch kind { + case kindServiceAccount: + err = client.CoreV1().ServiceAccounts(clusterValidatorNamespace).Delete(ctx, name, opts) + case kindClusterRole: + err = client.RbacV1().ClusterRoles().Delete(ctx, name, opts) + case kindClusterRoleBinding: + err = client.RbacV1().ClusterRoleBindings().Delete(ctx, name, opts) + case kindRole: + err = client.RbacV1().Roles(clusterValidatorNamespace).Delete(ctx, name, opts) + case kindRoleBinding: + err = client.RbacV1().RoleBindings(clusterValidatorNamespace).Delete(ctx, name, opts) + case kindSecret: + err = client.CoreV1().Secrets(clusterValidatorNamespace).Delete(ctx, name, opts) + case kindConfigMap: + err = client.CoreV1().ConfigMaps(clusterValidatorNamespace).Delete(ctx, name, opts) + } + if err != nil && !apierrors.IsNotFound(err) && !apierrors.IsConflict(err) { + errs = append(errs, fmt.Errorf("removing validator %s %s: %w", kind, name, err)) + } + } + return errors.Join(errs...) +} + +// ownByJob sets the Job as the controller-less owner of the pull Secret and +// ConfigMap this run created, so garbage collection removes them with it. +// Best-effort: a failure only means they wait for the deferred or orphan sweep. +func ownByJob(ctx context.Context, client kubernetes.Interface, job *batchv1.Job, created runObjects) { + if job == nil || job.UID == "" { + return + } + for _, kind := range []string{kindSecret, kindConfigMap} { + o, ok := created[kind] + if !ok { + continue + } + // The UID is immutable, so carrying it makes the apiserver reject the + // patch on an object that replaced this run's under the same name, + // instead of making it a dependent the Job's deletion then removes. + patch, err := json.Marshal(map[string]any{"metadata": map[string]any{ + "uid": o.GetUID(), + "ownerReferences": []metav1.OwnerReference{{ + APIVersion: "batch/v1", Kind: "Job", Name: job.Name, UID: job.UID, + }}, + }}) + if err != nil { + return + } + switch kind { + case kindSecret: + _, _ = client.CoreV1().Secrets(clusterValidatorNamespace).Patch( + ctx, o.GetName(), types.MergePatchType, patch, metav1.PatchOptions{}) + case kindConfigMap: + _, _ = client.CoreV1().ConfigMaps(clusterValidatorNamespace).Patch( + ctx, o.GetName(), types.MergePatchType, patch, metav1.PatchOptions{}) + } + } +} + +// thisRunsValidatorJob returns this run's Job: the one its Create returned, +// or, when a Create errored but may still have been applied, the Job under +// name read back, only if it carries this run's ID, which is unguessable. It +// returns nil with the read's error when the Job could not be read, and nil +// with no error when the name holds another run's Job. +func thisRunsValidatorJob( + ctx context.Context, client kubernetes.Interface, name, runID string, created runObjects, +) (metav1.Object, error) { + if target, ok := created[kindJob]; ok { + return target, nil + } + job, err := client.BatchV1().Jobs(clusterValidatorNamespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return nil, err + } + if runID == "" || job.Labels[clusterValidatorRunLabel] != runID || !hasValidatorManagedLabels(job.Labels) { + return nil, nil + } + return job, nil +} + +// deleteValidatorJob removes this run's Job and its pod, pinned to the Job's +// UID (see thisRunsValidatorJob). It reports whether the Job may be this +// run's: false only when there is none, or it is another's. +func deleteValidatorJob( + ctx context.Context, client kubernetes.Interface, name, runID string, created runObjects, +) (mayBeOurs bool) { + dctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + jobs := client.BatchV1().Jobs(clusterValidatorNamespace) + target, err := thisRunsValidatorJob(dctx, client, name, runID, created) + if target == nil { + // Unreadable is unknown, so its pod may be this run's. + return err != nil && !apierrors.IsNotFound(err) + } + // Foreground: the Job is removed only after its pod, so the pod gets its + // SIGTERM and grace period while its RBAC still exists. + propagation := metav1.DeletePropagationForeground + opts := deleteExactly(target) + opts.PropagationPolicy = &propagation + _ = jobs.Delete(dctx, name, opts) + return true +} + +// Production values of the waits TestMain shortens, so tests can pin them. +const ( + defaultValidatorStopTimeout = clusterValidatorTerminationGrace + 15*time.Second + defaultValidatorDeadlineGrace = validatorDeadlineOffset + clusterValidatorTerminationGrace + 30*time.Second + defaultValidatorPullFailureGrace = 90 * time.Second +) + +// Vars (not consts) so tests can shorten them. +var ( + // validatorStopTimeout bounds the wait for a stopped Job's pod to end. + // It covers the pod's termination grace period. + validatorStopTimeout = defaultValidatorStopTimeout + // validatorDeadlineGrace bounds the wait, after this run's own timeout, + // for the Job's active deadline (validatorDeadlineOffset later) to end + // the pod, including its termination grace period, and for the Job to + // record it. + validatorDeadlineGrace = defaultValidatorDeadlineGrace + // validatorRunMargin is ClusterValidatorRunCeiling's allowance for the + // pod's termination grace period and the API round trips between waits. + validatorRunMargin = 30 * time.Second + // validatorPullFailureGrace is how long a pull failure must persist before + // it is final. The kubelet's image backoff starts at 10s and doubles, and + // a failed sync is retried after about 10s more, so its second retry can + // start 30 to 50s after the first failure. A minute and a half covers + // it, so a registry outage of under a minute does not fail the run. A + // pull error that cannot clear (see permanentPullFailure) is final at once. + validatorPullFailureGrace = defaultValidatorPullFailureGrace + // validatorGetErrorLimit is how many consecutive failed Job reads end the + // wait. One 5xx or a refused connection during an apiserver restart is + // not the Job's result. + validatorGetErrorLimit = 5 +) + +// stopValidatorJob deletes this run's Job and reports whether its pods ended +// within validatorStopTimeout. +func stopValidatorJob(client kubernetes.Interface, name, runID string, created runObjects) bool { + if !deleteValidatorJob(context.Background(), client, name, runID, created) { + // Another run's pods under this name are not this run's to wait for. + return true + } + return waitValidatorPodsDone(client, name, validatorStopTimeout) +} + +// suspendValidatorJob stops this run's kept Job from creating or retrying its +// pod. The patch carries the Job's UID, so it fails on any other object that +// holds the name. A Job under the name that is not this run's is left alone. +func suspendValidatorJob(client kubernetes.Interface, name, runID string, created runObjects) error { + ctx, cancel := context.WithTimeout(context.Background(), clusterValidatorLogFetchTimeout) + defer cancel() + jobs := client.BatchV1().Jobs(clusterValidatorNamespace) + target, err := thisRunsValidatorJob(ctx, client, name, runID, created) + if target == nil { + return err + } + patch, err := json.Marshal(map[string]any{ + "metadata": map[string]any{"uid": target.GetUID()}, + "spec": map[string]any{"suspend": true}, + }) + if err != nil { + return err + } + _, err = jobs.Patch(ctx, name, types.MergePatchType, patch, metav1.PatchOptions{}) + return err +} + +// validatorPodPullFailing reports, on a fresh context, whether the Job's pod +// is failing to pull its image. +func validatorPodPullFailing(client kubernetes.Interface, jobName string) bool { + ctx, cancel := context.WithTimeout(context.Background(), clusterValidatorLogFetchTimeout) + defer cancel() + reason, _ := podPullFailureReason(ctx, client, jobName) + return reason != "" +} + +// waitValidatorPodsDone waits up to limit, on a fresh context, until no pod of +// the Job can still run, and reports whether that happened. +func waitValidatorPodsDone(client kubernetes.Interface, jobName string, limit time.Duration) bool { + ctx, cancel := context.WithTimeout(context.Background(), limit) + defer cancel() + for { + if validatorPodsEnded(ctx, client, jobName) { + return true + } + select { + case <-ctx.Done(): + return false + case <-time.After(time.Second): + } + } +} + +// validatorPodsEnded reports whether no pod of the Job can still run. A pod +// being deleted may still be running its SIGTERM cleanup, so it has not +// ended. With no pods, that holds only once the Job will create none: it has +// finished, is being deleted, or is gone. +func validatorPodsEnded(ctx context.Context, client kubernetes.Interface, jobName string) bool { + pods, err := client.CoreV1().Pods(clusterValidatorNamespace).List(ctx, + metav1.ListOptions{LabelSelector: "job-name=" + jobName}) + if err != nil { + return false + } + if len(pods.Items) == 0 { + job, err := client.BatchV1().Jobs(clusterValidatorNamespace).Get(ctx, jobName, metav1.GetOptions{}) + if err != nil { + return apierrors.IsNotFound(err) + } + return jobFinished(job) || job.DeletionTimestamp != nil + } + for i := range pods.Items { + pod := &pods.Items[i] + if pod.DeletionTimestamp != nil || (pod.Status.Phase != corev1.PodSucceeded && pod.Status.Phase != corev1.PodFailed) { + return false + } } + return true } -// Creates the SA/ClusterRole/ClusterRoleBinding the validator pod runs under, -// idempotent via AlreadyExists tolerance. Permissions mirror the -// nvca-operator chart's validator rbac.yaml (read-only). Resources persist -// across runs; the sweep only deletes Jobs. -func ensureClusterValidatorRBAC(ctx context.Context, client kubernetes.Interface) error { - labels := clusterValidatorLabels() +// validatorImagePullError reports that the validator pod could not pull its +// image, so its container never started. +type validatorImagePullError struct{ reason, message string } + +func (e *validatorImagePullError) Error() string { + return fmt.Sprintf("validator pod cannot pull image (%s): %s", e.reason, e.message) +} + +// ensureClusterValidatorRBAC creates this run's ServiceAccount, ClusterRole, +// ClusterRoleBinding, and the Role and RoleBinding that let it read its config +// ConfigMap, recording each in created. Create only: the names are unique per +// run, so an object that already exists was not made by this run. +func ensureClusterValidatorRBAC( + ctx context.Context, client kubernetes.Interface, role, runID string, preserve bool, created runObjects, +) error { + roleLabels := clusterValidatorRunLabels(role, runID, preserve) + name := clusterValidatorRBACName(role, runID) sa := &corev1.ServiceAccount{ ObjectMeta: metav1.ObjectMeta{ - Name: clusterValidatorName, + Name: name, Namespace: clusterValidatorNamespace, - Labels: labels, + Labels: roleLabels, }, } - if _, err := client.CoreV1().ServiceAccounts(clusterValidatorNamespace).Create(ctx, sa, metav1.CreateOptions{}); err != nil && !apierrors.IsAlreadyExists(err) { + // No AlreadyExists tolerance: the name carries a random per-run suffix, so + // anything already sitting there was not put there by this run and must not + // be adopted and bound to the validator ClusterRole. + gotSA, err := client.CoreV1().ServiceAccounts(clusterValidatorNamespace).Create(ctx, sa, metav1.CreateOptions{}) + if err != nil { return fmt.Errorf("create service account: %w", err) } + created.add(kindServiceAccount, gotSA) cr := &rbacv1.ClusterRole{ - ObjectMeta: metav1.ObjectMeta{Name: clusterValidatorName, Labels: labels}, - Rules: []rbacv1.PolicyRule{ - {APIGroups: []string{""}, Resources: []string{"nodes", "pods", "namespaces", "services", "configmaps"}, Verbs: []string{"get", "list", "watch"}}, - {APIGroups: []string{"storage.k8s.io"}, Resources: []string{"csidrivers", "storageclasses"}, Verbs: []string{"get", "list"}}, - {APIGroups: []string{"networking.k8s.io"}, Resources: []string{"networkpolicies"}, Verbs: []string{"get", "list"}}, - {APIGroups: []string{"admissionregistration.k8s.io"}, Resources: []string{"mutatingwebhookconfigurations", "validatingwebhookconfigurations"}, Verbs: []string{"get", "list"}}, - {APIGroups: []string{"apps"}, Resources: []string{"deployments", "daemonsets", "statefulsets"}, Verbs: []string{"get", "list"}}, - {NonResourceURLs: []string{"/readyz", "/version", "/healthz"}, Verbs: []string{"get"}}, - }, + ObjectMeta: metav1.ObjectMeta{Name: name, Labels: roleLabels}, + Rules: validatorClusterRules(role), } - // Update-or-Create so a future CLI version's expanded rules replace the - // old set; AlreadyExists tolerance alone would silently keep stale rules. - if existing, err := client.RbacV1().ClusterRoles().Get(ctx, clusterValidatorName, metav1.GetOptions{}); err == nil { - existing.Rules = cr.Rules - existing.Labels = cr.Labels - if _, err := client.RbacV1().ClusterRoles().Update(ctx, existing, metav1.UpdateOptions{}); err != nil { - return fmt.Errorf("update cluster role: %w", err) - } - } else if apierrors.IsNotFound(err) { - if _, err := client.RbacV1().ClusterRoles().Create(ctx, cr, metav1.CreateOptions{}); err != nil && !apierrors.IsAlreadyExists(err) { - return fmt.Errorf("create cluster role: %w", err) - } - } else { - return fmt.Errorf("get cluster role: %w", err) + // Create only, for the same reason as the ServiceAccount above. The name is + // unique per run, so there are no stale rules from an older CLI to refresh + // and nothing legitimate to overwrite. + gotCR, err := client.RbacV1().ClusterRoles().Create(ctx, cr, metav1.CreateOptions{}) + if err != nil { + return fmt.Errorf("create cluster role: %w", err) } + created.add(kindClusterRole, gotCR) + subjects := []rbacv1.Subject{{Kind: rbacv1.ServiceAccountKind, Name: name, Namespace: clusterValidatorNamespace}} crb := &rbacv1.ClusterRoleBinding{ - ObjectMeta: metav1.ObjectMeta{Name: clusterValidatorName, Labels: labels}, - Subjects: []rbacv1.Subject{{ - Kind: rbacv1.ServiceAccountKind, - Name: clusterValidatorName, - Namespace: clusterValidatorNamespace, - }}, - RoleRef: rbacv1.RoleRef{ - APIGroup: rbacv1.GroupName, - Kind: "ClusterRole", - Name: clusterValidatorName, - }, + ObjectMeta: metav1.ObjectMeta{Name: name, Labels: roleLabels}, + Subjects: subjects, + RoleRef: rbacv1.RoleRef{APIGroup: rbacv1.GroupName, Kind: "ClusterRole", Name: name}, } - if _, err := client.RbacV1().ClusterRoleBindings().Create(ctx, crb, metav1.CreateOptions{}); err != nil && !apierrors.IsAlreadyExists(err) { + gotCRB, err := client.RbacV1().ClusterRoleBindings().Create(ctx, crb, metav1.CreateOptions{}) + if err != nil { return fmt.Errorf("create cluster role binding: %w", err) } + created.add(kindClusterRoleBinding, gotCRB) + + // The config ConfigMap is read by name in this namespace, so its grant is + // namespaced and limited to the names this run's Job can be pointed at, + // instead of every ConfigMap in the cluster. + r := &rbacv1.Role{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: clusterValidatorNamespace, Labels: roleLabels}, + Rules: []rbacv1.PolicyRule{{ + APIGroups: []string{""}, Resources: []string{"configmaps"}, Verbs: []string{"get"}, + ResourceNames: validatorConfigNames(role, runID), + }}, + } + gotRole, err := client.RbacV1().Roles(clusterValidatorNamespace).Create(ctx, r, metav1.CreateOptions{}) + if err != nil { + return fmt.Errorf("create role: %w", err) + } + created.add(kindRole, gotRole) + + rb := &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: clusterValidatorNamespace, Labels: roleLabels}, + Subjects: subjects, + RoleRef: rbacv1.RoleRef{APIGroup: rbacv1.GroupName, Kind: "Role", Name: name}, + } + gotRB, err := client.RbacV1().RoleBindings(clusterValidatorNamespace).Create(ctx, rb, metav1.CreateOptions{}) + if err != nil { + return fmt.Errorf("create role binding: %w", err) + } + created.add(kindRoleBinding, gotRB) return nil } +// validatorConfigNames are the ConfigMap names one run's Job can be pointed +// at: the no-config sentinel, and for the control-plane role the run's own. +func validatorConfigNames(role, runID string) []string { + if role == clusterValidatorControlPlaneRole { + return []string{clusterValidatorConfigRunName(runID), clusterValidatorNoConfigName} + } + return []string{clusterValidatorNoConfigName} +} + +// validatorClusterRules are one role's cluster-wide grants: exactly the calls +// the validator makes for that role when this CLI launches it, which is with +// VALIDATOR_PREFLIGHT set, enforcement disabled and no networkPolicies pairs. +// The validator's rbac_inventory.yaml lists every request it makes, by role, +// and a test holds these rules to that list less the grants only the summary +// write and the enforcement test use. Discovery needs no grant. +func validatorClusterRules(role string) []rbacv1.PolicyRule { + shared := []rbacv1.PolicyRule{ + // Admission webhook inventory, for both roles. + {APIGroups: []string{"admissionregistration.k8s.io"}, Resources: []string{ + "mutatingwebhookconfigurations", "validatingwebhookconfigurations", + }, Verbs: []string{"list"}}, + // CNI detection lists NetworkPolicies. The enforcement test, which + // writes them, is disabled in the CLI's ConfigMap. + {APIGroups: []string{"networking.k8s.io"}, Resources: []string{"networkpolicies"}, Verbs: []string{"list"}}, + {NonResourceURLs: []string{"/readyz", "/version"}, Verbs: []string{"get"}}, + } + if role != clusterValidatorControlPlaneRole { + return append([]rbacv1.PolicyRule{ + // Read-only, except that every run deletes the netpol-validation + // and node-to-node probe namespaces earlier runs left behind, and + // force-deletes the probe pods that keep one stuck Terminating. + {APIGroups: []string{""}, Resources: []string{"nodes"}, Verbs: []string{"list"}}, + {APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"list", "delete"}}, + {APIGroups: []string{""}, Resources: []string{"namespaces"}, Verbs: []string{"get", "list", "delete"}}, + // SMB CSI driver and its version, read from its Deployment. + {APIGroups: []string{"storage.k8s.io"}, Resources: []string{"csidrivers"}, Verbs: []string{"get"}}, + {APIGroups: []string{"apps"}, Resources: []string{"deployments"}, Verbs: []string{"get", "list"}}, + }, shared...) + } + return append([]rbacv1.PolicyRule{ + {APIGroups: []string{""}, Resources: []string{"nodes", "services", "events"}, Verbs: []string{"list"}}, + // The node-to-node probe creates and deletes its own namespace, + // DaemonSet and checker pods, reads each checker pod until it exits, + // and sweeps those of dead runs. + {APIGroups: []string{""}, Resources: []string{"namespaces"}, Verbs: []string{"get", "list", "create", + "delete"}}, + {APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"get", "list", "create", "delete"}}, + {APIGroups: []string{"apps"}, Resources: []string{"daemonsets"}, Verbs: []string{"list", "create", "delete"}}, + // The probe allows its own traffic in its namespace, so a default-deny + // policy does not read as an overlay fault. The namespace delete + // removes it. + {APIGroups: []string{"networking.k8s.io"}, Resources: []string{"networkpolicies"}, Verbs: []string{"create"}}, + // The default class is found by a list; the class the stack names + // (NVCF_STORAGE_CLASS) is read by name. + {APIGroups: []string{"storage.k8s.io"}, Resources: []string{"storageclasses"}, Verbs: []string{"get", "list"}}, + // Tier-1 and Tier-2 readiness, and the Envoy proxies behind the NVCF + // Gateways. The newest ReplicaSet dates a Deployment rollout that no + // progress deadline bounds. + {APIGroups: []string{"apps"}, Resources: []string{"deployments", "replicasets", "statefulsets"}, + Verbs: []string{"list"}}, + // The creation time of a rolling StatefulSet's or DaemonSet's update + // revision dates its rollout, which Tier-1 and Tier-2 bound. + {APIGroups: []string{"apps"}, Resources: []string{"controllerrevisions"}, Verbs: []string{"get"}}, + // The validator follows every NVCF route kind's parentRefs to learn + // which Gateways are NVCF's, and lists the GatewayClasses to tell + // which of those Gateways Envoy Gateway runs. + {APIGroups: []string{"gateway.networking.k8s.io"}, Resources: []string{ + "gateways", "gatewayclasses", "httproutes", "grpcroutes", "tcproutes", "udproutes", + }, Verbs: []string{"list"}}, + }, shared...) +} + func clusterValidatorLabels() map[string]string { return map[string]string{ "app.kubernetes.io/name": clusterValidatorAppLabel, - "app.kubernetes.io/managed-by": "nvcf-cli", + "app.kubernetes.io/managed-by": clusterValidatorManagedBy, "app.kubernetes.io/component": "preflight", } } -// Errors are swallowed: a stale Job is preferable to blocking the new run. -func sweepPriorClusterValidatorJobs(ctx context.Context, client kubernetes.Interface) { - selector := fmt.Sprintf("app.kubernetes.io/name=%s,app.kubernetes.io/managed-by=nvcf-cli", clusterValidatorAppLabel) - propagation := metav1.DeletePropagationBackground - _ = client.BatchV1().Jobs(clusterValidatorNamespace).DeleteCollection( - ctx, - metav1.DeleteOptions{PropagationPolicy: &propagation}, - metav1.ListOptions{LabelSelector: selector}, - ) +// clusterValidatorRunLabels are the labels for one run's objects: the managed +// labels, the role, the run ID, and the preserve marker when the run was asked +// to keep its artifacts (which the orphan sweeper then keeps for +// preservedValidatorTTL instead of orphanValidatorRBACTTL). +func clusterValidatorRunLabels(role, runID string, preserve bool) map[string]string { + l := clusterValidatorLabels() + if role != "" { + l[clusterValidatorRoleLabel] = role + } + if runID != "" { + l[clusterValidatorRunLabel] = runID + } + if preserve { + l[clusterValidatorPreserveLabel] = "true" + } + return l +} + +// clusterValidatorConfigLabels are the labels for one run's network-checks +// ConfigMap, the same as for the run's other objects, including the preserve +// marker when the run was asked to keep its artifacts. Without the marker the +// next run's orphan sweeper reclaims the ConfigMap once it is older than the +// TTL, and an operator re-running a Job they deliberately kept gets a +// validator that silently skips the configurable reachability checks. +func clusterValidatorConfigLabels(role, runID string, preserve bool) map[string]string { + return clusterValidatorRunLabels(role, runID, preserve) +} + +// clusterValidatorConfigRunName scopes the network-checks ConfigMap to one run. +// A shared name lets two overlapping commands overwrite each other's registry +// list, and lets the first to finish delete the config the other pod has not +// read yet. +func clusterValidatorConfigRunName(runID string) string { + if runID == "" { + return clusterValidatorConfigName + } + return clusterValidatorConfigName + "-" + runID +} + +// sweepOrphanClusterValidatorRBAC removes validator objects left behind by a +// run that died before its deferred cleanup (SIGKILL, OOM, lost terminal). +// +// Needed because the names are random per run: a fixed name self-healed by +// being reused, these would accumulate. Only objects carrying our labels and +// older than the TTL are removed, so a concurrent run is never disturbed. Age +// is measured against now, the apiserver's clock, never the local one: a +// workstation running ahead would otherwise see an overlapping run's fresh +// objects as stale. Objects of runID, the calling run, are never taken, nor a +// Secret named pullSecret, which the operator passed explicitly. +func sweepOrphanClusterValidatorRBAC( + ctx context.Context, client kubernetes.Interface, now time.Time, ttl time.Duration, runID, pullSecret string, +) { + // Derived from the same map the objects are stamped with, so the selector + // cannot drift from the labels. All three managed labels are required: the + // component label is part of what identifies these as ours. + opts := metav1.ListOptions{LabelSelector: labels.SelectorFromSet(clusterValidatorLabels()).String()} + + // stale applies the TTL for the object's kind of run. A --no-cleanup run's + // objects are kept longer, not forever: exempting them left a cluster-wide + // ClusterRole and an NGC-key Secret behind permanently. + stale := func(o metav1.Object) bool { + if runID != "" && o.GetLabels()[clusterValidatorRunLabel] == runID { + return false + } + limit := ttl + if o.GetLabels()[clusterValidatorPreserveLabel] == "true" { + limit = preservedValidatorTTL + } + ts := o.GetCreationTimestamp() + return ts.Time.Before(now.Add(-limit)) + } + + // Each reclaimable test requires the generated name as well as the labels + // and the age. Labels alone are public constants and can be copied onto + // anything, and this deletes cluster-scoped objects with errors swallowed. + // Network-check ConfigMaps and pull secrets carry their own name prefixes. + reclaimableConfig := func(o metav1.Object) bool { + return strings.HasPrefix(o.GetName(), clusterValidatorConfigName) && stale(o) + } + reclaimableSecret := func(o metav1.Object) bool { + return o.GetName() != pullSecret && strings.HasPrefix(o.GetName(), validatorPullSecretName) && stale(o) + } + // The bare legacy name is the fixed-name set released CLIs keep and reuse. + // Its labels no longer match this selector, but exclude it by name too so + // a released CLI's pod never loses its binding mid-run. + reclaimable := func(o metav1.Object) bool { + return strings.HasPrefix(o.GetName(), clusterValidatorName+"-") && stale(o) + } + + if l, err := client.RbacV1().ClusterRoleBindings().List(ctx, opts); err == nil { + for i := range l.Items { + if o := &l.Items[i]; reclaimable(o) { + _ = client.RbacV1().ClusterRoleBindings().Delete(ctx, o.Name, deleteExactly(o)) + } + } + } + rbs := client.RbacV1().RoleBindings(clusterValidatorNamespace) + if l, err := rbs.List(ctx, opts); err == nil { + for i := range l.Items { + if o := &l.Items[i]; reclaimable(o) { + _ = rbs.Delete(ctx, o.Name, deleteExactly(o)) + } + } + } + if l, err := client.RbacV1().ClusterRoles().List(ctx, opts); err == nil { + for i := range l.Items { + if o := &l.Items[i]; reclaimable(o) { + _ = client.RbacV1().ClusterRoles().Delete(ctx, o.Name, deleteExactly(o)) + } + } + } + roles := client.RbacV1().Roles(clusterValidatorNamespace) + if l, err := roles.List(ctx, opts); err == nil { + for i := range l.Items { + if o := &l.Items[i]; reclaimable(o) { + _ = roles.Delete(ctx, o.Name, deleteExactly(o)) + } + } + } + // Network-check ConfigMaps too. Run-scoping the name removed the accidental + // self-healing the fixed name gave us: a killed run used to leave exactly + // one object that the next run overwrote, and now each leaves its own, so + // under --wait they accumulate per poll. Same shape as the RBAC objects. + cms := client.CoreV1().ConfigMaps(clusterValidatorNamespace) + if l, err := cms.List(ctx, opts); err == nil { + for i := range l.Items { + if o := &l.Items[i]; reclaimableConfig(o) { + _ = cms.Delete(ctx, o.Name, deleteExactly(o)) + } + } + } + + // Pull secrets too: the deferred sweep is suppressed whenever the pod may + // still be running, and that is exactly the ImagePullBackOff path, so + // without this arm a Secret holding $oauthtoken:$NGC_API_KEY is the one + // object with no reclaim at all. + secrets := client.CoreV1().Secrets(clusterValidatorNamespace) + if l, err := secrets.List(ctx, opts); err == nil { + for i := range l.Items { + if o := &l.Items[i]; reclaimableSecret(o) { + _ = secrets.Delete(ctx, o.Name, deleteExactly(o)) + } + } + } + sa := client.CoreV1().ServiceAccounts(clusterValidatorNamespace) + if l, err := sa.List(ctx, opts); err == nil { + for i := range l.Items { + if o := &l.Items[i]; reclaimable(o) { + _ = sa.Delete(ctx, o.Name, deleteExactly(o)) + } + } + } + + // Jobs only need this for --no-cleanup runs: every other Job carries a + // deadline and a TTL. A preserved Job has neither, so without this arm it + // and its pod would stay forever. Background propagation removes the pod. + // A preserved run's Secret and ConfigMap are not owned by its Job, so the + // arms above reclaim them on the same TTL. + jobs := client.BatchV1().Jobs(clusterValidatorNamespace) + if l, err := jobs.List(ctx, opts); err == nil { + propagation := metav1.DeletePropagationBackground + for i := range l.Items { + o := &l.Items[i] + if o.Labels[clusterValidatorPreserveLabel] != "true" || !reclaimable(o) { + continue + } + del := deleteExactly(o) + del.PropagationPolicy = &propagation + _ = jobs.Delete(ctx, o.Name, del) + } + } } -// sweepManagedPullSecrets removes any docker-registry secrets in -// clusterValidatorNamespace that we previously created (mirrored from -// another namespace or minted from NGC_API_KEY). Called after the Job -// terminates so NGC credentials don't persist across preflight runs. +// clusterValidatorControlPlaneRole is the role value passed as VALIDATOR_ROLE +// when running against the control-plane cluster. Matches nvca's RoleControlPlane +// without importing that package. +const clusterValidatorControlPlaneRole = "control-plane" + +// clusterValidatorComputePlaneRole is the other VALIDATOR_ROLE value. Both are +// named so role-scoping logic reads the same on either side. +const clusterValidatorComputePlaneRole = "compute-plane" + +// clusterValidatorConfigName is the default ConfigMap name the validator binary +// looks for when VALIDATOR_CONFIG_NAME is empty. Must stay in sync with +// defaultConfigMapName in nvca/cmd/cluster-validator/main.go. +const clusterValidatorConfigName = "cluster-validator-network-checks" + +// clusterValidatorNoConfigName is a name no ConfigMap can have. VALIDATOR_CONFIG_NAME +// must be non-empty to suppress the validator's own default-name fallback, so +// "no config" has to be spelled as a name that resolves to nothing. The +// uppercase letters make it an invalid object name, so the apiserver refuses +// to create a ConfigMap under it: anyone able to create ConfigMaps here could +// otherwise feed every run critical endpoints and an enabled enforcement test. +const clusterValidatorNoConfigName = "cluster-validator-NO-CONFIG" + +// clusterValidatorRoleLabel scopes Job selectors to a single validator role. +const clusterValidatorRoleLabel = "nvcf.nvidia.com/validator-role" + +// clusterValidatorPreserveLabel marks objects an operator asked to keep with +// --no-cleanup. The orphan sweeper keeps them for preservedValidatorTTL instead +// of orphanValidatorRBACTTL: without the label a preserved run would be +// reclaimed after 30 minutes, and with no limit at all a kept ClusterRole and +// NGC-key Secret would stay forever. +const clusterValidatorPreserveLabel = "nvcf.nvidia.com/validator-preserve" + +// clusterValidatorRBACName returns the ServiceAccount / ClusterRole / +// ClusterRoleBinding name for one run of one role. +// +// The runID is random per run, which is what makes the name safe to create. +// A predictable name can be squatted: the managed labels are three public +// constants, so anyone who can create a ServiceAccount in the probe namespace +// could pre-create one carrying them, and a label check alone would then adopt +// it and bind it to the validator ClusterRole. With an unguessable name there +// is no collision to adopt, so the resources are only ever created by us. // -// Operator-supplied secrets via --cluster-validator-pull-secret aren't -// labeled by us and are skipped by the selector. Errors are swallowed: -// failing to clean up is preferable to failing the check itself. -func sweepManagedPullSecrets(ctx context.Context, client kubernetes.Interface) { - selector := fmt.Sprintf("app.kubernetes.io/name=%s,app.kubernetes.io/managed-by=nvcf-cli", clusterValidatorAppLabel) - _ = client.CoreV1().Secrets(clusterValidatorNamespace).DeleteCollection( - ctx, - metav1.DeleteOptions{}, - metav1.ListOptions{LabelSelector: selector}, - ) -} - -// IfNotPresent so locally-imported images (k3d image import, kind load) are -// reused. VALIDATOR_CONFIG_NAME is empty so the validator skips the -// configurable reachability/network-policy sections (ConfigMap support is a -// follow-up). +// Per-role naming also matters because ModeSplit runs both validators +// concurrently against contexts that can resolve to the same cluster. +func clusterValidatorRBACName(role, runID string) string { + name := clusterValidatorName + if role != "" { + name += "-" + role + } + if runID != "" { + name += "-" + runID + } + return name +} + +// newValidatorRunID returns an unguessable suffix for this run's RBAC names. +// crypto/rand, not math/rand: a predictable value would defeat the point. +func newValidatorRunID() (string, error) { + b := make([]byte, 5) + if _, err := cryptorand.Read(b); err != nil { + return "", fmt.Errorf("generating run id: %w", err) + } + return hex.EncodeToString(b), nil +} + +// controlPlaneValidatorEnforcementConfig follows the generated reachability +// section in the control-plane validator ConfigMap. // -// VALIDATOR_PREFLIGHT=true puts the validator in preflight mode: it runs the -// readiness checks and exits, but does NOT write its summary ConfigMap. The -// ConfigMap exists for the metrics path (the NVCA agent watches it and -// republishes on /metrics), which is meaningless here because preflight runs -// before NVCA is installed and our RBAC bootstrap grants no configmaps -// create/update. Tagging the invocation keeps preflight a clean no-op rather -// than emitting a confusing "failed to create summary ConfigMap" warning. -func buildClusterValidatorJob(name, image, pullSecret string, noCleanup bool) *batchv1.Job { +// No testImage: the validator also reads it for the node-to-node overlay +// probe, so a value here would override the validator's own default whenever +// --cluster-validator-probe-image is not set. +const controlPlaneValidatorEnforcementConfig = `enforcement: + # Disabled for preflight. VALIDATOR_PREFLIGHT only suppresses the summary + # write, so enforcement would still run: it creates netpol-validation + # namespaces, server and client pods and NetworkPolicies, and pulls + # busybox from Docker Hub. Doing that on a virgin or air-gapped cluster, + # before anything is installed and with no flag to turn it off, is not + # something a read-only readiness check should do. + enabled: false +` + +// ensureClusterValidatorConfig creates this run's network-check ConfigMap and +// records it in created. Create only: the name carries this run's ID, so an +// object already holding it was put there by someone else, and adopting it, +// with whatever labels it carries, would hand the validator their endpoints +// and enforcement settings and then delete their object. +func ensureClusterValidatorConfig( + ctx context.Context, client kubernetes.Interface, registries []RegistryEntry, role, runID string, preserve bool, + created runObjects, +) error { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterValidatorConfigRunName(runID), + Namespace: clusterValidatorNamespace, + Labels: clusterValidatorConfigLabels(role, runID, preserve), + }, + Data: map[string]string{"config.yaml": buildControlPlaneValidatorConfig(registries)}, + } + got, err := client.CoreV1().ConfigMaps(clusterValidatorNamespace).Create(ctx, cm, metav1.CreateOptions{}) + if err != nil { + return fmt.Errorf("create validator config ConfigMap: %w", err) + } + created.add(kindConfigMap, got) + return nil +} + +// buildControlPlaneValidatorConfig assembles the network-check ConfigMap YAML. +// The reachability endpoints are the registries the install pulls from, as +// EnumerateRegistries resolved them for the local credential check, and all of +// them are non-critical. A registry only the stack's charts come from is left +// out: helm pulls the charts on the operator's machine, not in the cluster. +// The validator dials them from a pod with no proxy support, but nodes pull +// through the container runtime, often via an HTTPS_PROXY or a registry +// mirror that pods cannot use: a failed in-pod dial is worth a warning, not +// proof that the nodes cannot pull. With no registries the section is +// omitted. +func buildControlPlaneValidatorConfig(registries []RegistryEntry) string { + var b strings.Builder + listed := map[string]bool{} + for _, reg := range registries { + host, port := parseRegistryHostPort(reg.Registry) + // A registry probed for two repository scopes is one endpoint. + if host == "" || listed[reg.Registry] || reg.ChartsOnly { + continue + } + listed[reg.Registry] = true + if b.Len() == 0 { + b.WriteString("reachability:\n endpoints:\n") + } + // Quote the values: they come from operator input and the stack + // values. An unquoted "[" or embedded newline would make the whole + // ConfigMap unparseable, and the validator then silently drops every + // reachability and enforcement check. + fmt.Fprintf(&b, + " - name: %q\n host: %q\n port: %d\n protocol: tcp+tls\n critical: false\n", + reg.Registry, host, port) + } + return b.String() + controlPlaneValidatorEnforcementConfig +} + +// parseRegistryHostPort splits a "host:port" string using net.SplitHostPort, +// which correctly handles IPv6 literals ([::1]:5000) and bare hostnames. +// Returns port 443 when no port is specified. Returns an empty host for +// anything that is not a bare host[:port], including a port that is not a +// number from 1 to 65535 and a trailing colon with no port ("nvcr.io:"). +func parseRegistryHostPort(s string) (host string, port int) { + s = strings.TrimSpace(s) + // Reject anything that is not a bare host[:port]. Callers interpolate the + // result straight into a URL and into the validator ConfigMap, and an empty + // host is the signal to skip the entry entirely. + if !isBareRegistryHost(s) { + return "", 0 + } + h, p, err := net.SplitHostPort(s) + if err != nil { + // No port present (e.g. "nvcr.io"). Strip IPv6 brackets: the value + // goes into the validator ConfigMap as a bare host, and the validator + // brackets it itself with net.JoinHostPort, so "[fd00::1]" would be + // dialled as "[[fd00::1]]:443" and reported unreachable. + return strings.TrimSuffix(strings.TrimPrefix(s, "["), "]"), 443 + } + if p == "" { + // Trailing colon with no port digit (e.g. "nvcr.io:") is the same typo. + return "", 0 + } + n, err := strconv.Atoi(p) + if err != nil || n <= 0 || n > 65535 { + // An explicit but unparseable port is a typo, not a request for 443. + // Silently probing a different endpoint than configured would report a + // result for something the operator never asked about. + return "", 0 + } + return h, n +} + +// buildClusterValidatorJob creates the validator Job. PullIfNotPresent reuses +// locally-imported images. VALIDATOR_PREFLIGHT=true skips the summary ConfigMap +// write. VALIDATOR_ROLE selects the check set (control-plane vs compute-plane). +// configName is the ConfigMap the validator reads: the one this run created, +// or clusterValidatorNoConfigName. +func buildClusterValidatorJob( + name, image, pullSecret, role, runID, configName string, noCleanup bool, env map[string]string, + tolerations ...corev1.Toleration, +) *batchv1.Job { backoff := int32(0) + grace := int64(clusterValidatorTerminationGrace / time.Second) + // Pod shape mirrors the chart's nvcaop.clusterValidatorJobSpec, which + // runs this same image. Two producers of one pod spec now exist in two + // languages, so they are kept deliberately in step: without the security + // context the Job is rejected outright by a namespace enforcing the + // PodSecurity "restricted" profile, and without the tolerations it never + // schedules on a cluster whose nodes all carry the control-plane taint. + // Either way waitForClusterValidatorJob burns its full budget and the run + // leaks, so these are correctness, not hardening. + runAsUser := int64(65534) + runAsNonRoot := true + readOnlyRoot := true + allowPrivEsc := false podSpec := corev1.PodSpec{ - ServiceAccountName: clusterValidatorName, - RestartPolicy: corev1.RestartPolicyNever, + ServiceAccountName: clusterValidatorRBACName(role, runID), + RestartPolicy: corev1.RestartPolicyNever, + TerminationGracePeriodSeconds: &grace, + Tolerations: append(clusterValidatorTolerations(), tolerations...), + SecurityContext: &corev1.PodSecurityContext{ + RunAsUser: &runAsUser, + RunAsGroup: &runAsUser, + FSGroup: &runAsUser, + RunAsNonRoot: &runAsNonRoot, + SeccompProfile: &corev1.SeccompProfile{Type: corev1.SeccompProfileTypeRuntimeDefault}, + }, Containers: []corev1.Container{{ Name: clusterValidatorContainer, Image: image, ImagePullPolicy: corev1.PullIfNotPresent, + Resources: clusterValidatorResources(), + SecurityContext: &corev1.SecurityContext{ + RunAsNonRoot: &runAsNonRoot, + ReadOnlyRootFilesystem: &readOnlyRoot, + AllowPrivilegeEscalation: &allowPrivEsc, + Capabilities: &corev1.Capabilities{Drop: []corev1.Capability{"ALL"}}, + }, Env: []corev1.EnvVar{ {Name: "VALIDATOR_CONFIG_NAMESPACE", Value: clusterValidatorNamespace}, - {Name: "VALIDATOR_CONFIG_NAME", Value: ""}, + // Always set. An empty value is NOT "no config": the validator + // falls back to its own default name, a ConfigMap anyone could + // create, and would run whatever it holds. + {Name: "VALIDATOR_CONFIG_NAME", Value: configName}, {Name: "VALIDATOR_PREFLIGHT", Value: "true"}, + {Name: "VALIDATOR_ROLE", Value: role}, + {Name: validatorTimeoutEnv, Value: validatorRunTimeout(clusterValidatorTimeout)}, }, }}, } + podSpec.Containers[0].Env = append(podSpec.Containers[0].Env, extraValidatorEnv(podSpec.Containers[0].Env, env)...) if pullSecret != "" { podSpec.ImagePullSecrets = []corev1.LocalObjectReference{{Name: pullSecret}} } @@ -301,17 +1560,29 @@ func buildClusterValidatorJob(name, image, pullSecret string, noCleanup bool) *b ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: clusterValidatorNamespace, - Labels: clusterValidatorLabels(), + Labels: clusterValidatorRunLabels(role, runID, noCleanup), }, Spec: batchv1.JobSpec{ BackoffLimit: &backoff, Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{Labels: clusterValidatorLabels()}, + ObjectMeta: metav1.ObjectMeta{Labels: clusterValidatorRunLabels(role, runID, false)}, Spec: podSpec, }, }, } if !noCleanup { + // Without a deadline a Job whose pod never finishes never becomes + // terminal, so its TTL never fires. The deadline plus the TTL remove + // the Job and its pod, and through ownerReferences its pull secret and + // ConfigMap. The ServiceAccount, roles and bindings are left to the + // deferred sweeps, or to a later run's orphan sweep. Sized above the + // runner's own timeout so it never truncates a wait that is still + // making progress; the runner sets it from the time it has left. A + // --no-cleanup Job gets neither, so the pod and its logs survive for + // debugging until the operator removes them or a later check's orphan + // sweep runs after preservedValidatorTTL. + activeDeadline := validatorActiveDeadlineSeconds(clusterValidatorTimeout) + job.Spec.ActiveDeadlineSeconds = &activeDeadline ttl := clusterValidatorTTLSeconds job.Spec.TTLSecondsAfterFinished = &ttl } @@ -324,21 +1595,41 @@ func buildClusterValidatorJob(name, image, pullSecret string, noCleanup bool) *b func waitForClusterValidatorJob(ctx context.Context, client kubernetes.Interface, jobName string) (*batchv1.Job, error) { ticker := time.NewTicker(clusterValidatorPollInterval) defer ticker.Stop() + getErrors := 0 + var pullFailingSince time.Time for { job, err := client.BatchV1().Jobs(clusterValidatorNamespace).Get(ctx, jobName, metav1.GetOptions{}) - if err != nil { + switch { + case err != nil: // If the deadline fired between ticker and Get, surface the wait // wording the select branch would have used. if ctxErr := ctx.Err(); ctxErr != nil { return nil, fmt.Errorf("waiting for job %s: %w", jobName, ctxErr) } - return nil, fmt.Errorf("get job %s: %w", jobName, err) - } - if job.Status.Succeeded > 0 || job.Status.Failed > 0 { + // A missing Job or a permission error cannot change; anything else + // is retried a few times before it decides the run. + getErrors++ + if apierrors.IsNotFound(err) || apierrors.IsForbidden(err) || apierrors.IsUnauthorized(err) || + getErrors >= validatorGetErrorLimit { + return nil, fmt.Errorf("get job %s: %w", jobName, err) + } + case jobFinished(job): return job, nil - } - if reason, msg := podPullFailureReason(ctx, client, jobName); reason != "" { - return job, fmt.Errorf("validator pod cannot pull image (%s): %s", reason, msg) + default: + getErrors = 0 + reason, msg := podPullFailureReason(ctx, client, jobName) + switch { + case reason == "": + pullFailingSince = time.Time{} + case reason == "InvalidImageName" || permanentPullFailure(msg): + // A malformed reference never pulls, nor does an image the + // registry does not have or will not serve to these credentials. + return job, &validatorImagePullError{reason: reason, message: msg} + case pullFailingSince.IsZero(): + pullFailingSince = time.Now() + case time.Since(pullFailingSince) >= validatorPullFailureGrace: + return job, &validatorImagePullError{reason: reason, message: msg} + } } select { case <-ctx.Done(): @@ -348,6 +1639,24 @@ func waitForClusterValidatorJob(ctx context.Context, client kubernetes.Interface } } +// permanentPullFailures are in the kubelet's message for a pull that no retry +// fixes: the image is not in the registry, or the credentials are refused. +var permanentPullFailures = []string{ + "not found", "manifest unknown", "unauthorized", "authentication required", "denied", +} + +// permanentPullFailure reports whether a pull error message says retrying +// cannot help. +func permanentPullFailure(message string) bool { + message = strings.ToLower(message) + for _, m := range permanentPullFailures { + if strings.Contains(message, m) { + return true + } + } + return false +} + // Returns ("", "") when no pull failure is observed; callers treat that // as "keep waiting". func podPullFailureReason(ctx context.Context, client kubernetes.Interface, jobName string) (reason, message string) { @@ -416,23 +1725,30 @@ func podNameForClusterValidatorJob(ctx context.Context, client kubernetes.Interf return pods.Items[0].Name, nil } -// Informational only; the canonical pass/fail signal is Job.Status.Succeeded. -// Returns -1 when the exit code can't be read. -func containerExitCode(ctx context.Context, client kubernetes.Interface, jobName string) int32 { +// containerTermination returns the validator container's exit code, -1 when +// it cannot be read or the container never ended by itself, and the reason +// the container or its pod ended when that says more than the code: an OOM +// kill or an eviction. Informational only; the canonical pass/fail signal is +// the Job's status. +func containerTermination(ctx context.Context, client kubernetes.Interface, jobName string) (int32, string) { podName, _ := podNameForClusterValidatorJob(ctx, client, jobName) if podName == "" { - return -1 + return -1, "" } pod, err := client.CoreV1().Pods(clusterValidatorNamespace).Get(ctx, podName, metav1.GetOptions{}) if err != nil { - return -1 + return -1, "" } + code, reason := int32(-1), "" for _, cs := range pod.Status.ContainerStatuses { if cs.Name == clusterValidatorContainer && cs.State.Terminated != nil { - return cs.State.Terminated.ExitCode + code, reason = cs.State.Terminated.ExitCode, cs.State.Terminated.Reason } } - return -1 + if reason == "" || reason == "Error" || reason == "Completed" { + reason = pod.Status.Reason + } + return code, reason } // Idempotent: running twice yields the same output. @@ -450,9 +1766,38 @@ func cleanValidatorOutput(raw string) string { return s + "\n" } -func kubectlLogsHint(jobName string) string { +// Pinned to the context the Job was created in: in ModeSplit the two roles +// run against different clusters, so a bare kubectl command resolves to +// whichever context is current and the hint reports "job not found". +func kubectlLogsHint(kubeContext, jobName string) string { if jobName == "" { return "" } - return fmt.Sprintf("kubectl logs -n %s job/%s --tail=-1", clusterValidatorNamespace, jobName) + return fmt.Sprintf("kubectl%s logs -n %s job/%s --tail=-1", + kubectlContextArg(kubeContext), clusterValidatorNamespace, jobName) +} + +// clusterValidatorTolerations mirrors the chart's tolerations so the Job can +// schedule on a cluster whose nodes all carry the control-plane taint, which +// is the normal shape for a dedicated control plane. +func clusterValidatorTolerations() []corev1.Toleration { + return []corev1.Toleration{ + {Key: "node-role.kubernetes.io/control-plane", Operator: corev1.TolerationOpExists, Effect: corev1.TaintEffectNoSchedule}, + {Key: "node-role.kubernetes.io/master", Operator: corev1.TolerationOpExists, Effect: corev1.TaintEffectNoSchedule}, + } +} + +// clusterValidatorResources mirrors the chart's defaults. Without requests the +// Job is rejected outright by a namespace carrying a LimitRange or a quota. +func clusterValidatorResources() corev1.ResourceRequirements { + return corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("100m"), + corev1.ResourceMemory: resource.MustParse("64Mi"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("200m"), + corev1.ResourceMemory: resource.MustParse("128Mi"), + }, + } } diff --git a/src/clis/nvcf-cli/internal/selfhosted/clustervalidator_lifecycle_test.go b/src/clis/nvcf-cli/internal/selfhosted/clustervalidator_lifecycle_test.go new file mode 100644 index 0000000000..4d3331d9f7 --- /dev/null +++ b/src/clis/nvcf-cli/internal/selfhosted/clustervalidator_lifecycle_test.go @@ -0,0 +1,2029 @@ +/* +SPDX-FileCopyrightText: Copyright (c) NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package selfhosted + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/fake" + typedcorev1 "k8s.io/client-go/kubernetes/typed/core/v1" + "k8s.io/client-go/rest" + ktesting "k8s.io/client-go/testing" +) + +// leftovers lists what one run left behind, by kind, other than its Job and +// pod (which the Job's deadline and TTL remove). +func leftovers(t *testing.T, client *fake.Clientset, runID string) map[string][]string { + t.Helper() + ctx := context.Background() + opts := metav1.ListOptions{LabelSelector: clusterValidatorRunLabel + "=" + runID} + out := map[string][]string{} + sas, err := client.CoreV1().ServiceAccounts(clusterValidatorNamespace).List(ctx, opts) + require.NoError(t, err) + for _, o := range sas.Items { + out["ServiceAccount"] = append(out["ServiceAccount"], o.Name) + } + crs, err := client.RbacV1().ClusterRoles().List(ctx, opts) + require.NoError(t, err) + for _, o := range crs.Items { + out["ClusterRole"] = append(out["ClusterRole"], o.Name) + } + crbs, err := client.RbacV1().ClusterRoleBindings().List(ctx, opts) + require.NoError(t, err) + for _, o := range crbs.Items { + out["ClusterRoleBinding"] = append(out["ClusterRoleBinding"], o.Name) + } + roles, err := client.RbacV1().Roles(clusterValidatorNamespace).List(ctx, opts) + require.NoError(t, err) + for _, o := range roles.Items { + out["Role"] = append(out["Role"], o.Name) + } + rbs, err := client.RbacV1().RoleBindings(clusterValidatorNamespace).List(ctx, opts) + require.NoError(t, err) + for _, o := range rbs.Items { + out["RoleBinding"] = append(out["RoleBinding"], o.Name) + } + secrets, err := client.CoreV1().Secrets(clusterValidatorNamespace).List(ctx, opts) + require.NoError(t, err) + for _, o := range secrets.Items { + out["Secret"] = append(out["Secret"], o.Name) + } + cms, err := client.CoreV1().ConfigMaps(clusterValidatorNamespace).List(ctx, opts) + require.NoError(t, err) + for _, o := range cms.Items { + out["ConfigMap"] = append(out["ConfigMap"], o.Name) + } + return out +} + +// lifecycleClient drives a Job through the given terminal behaviour. The Job +// gets a UID, as a real apiserver assigns one, so ownerReferences can be set. +func lifecycleClient(jobStatus func(name string) *batchv1.Job, waiting string) *fake.Clientset { + client := fake.NewSimpleClientset() + var jobName atomic.Value + jobName.Store("") + var jobDeleted atomic.Bool + var jobLabels atomic.Value + jobLabels.Store(map[string]string{}) + client.PrependReactor("create", "jobs", func(action ktesting.Action) (bool, runtime.Object, error) { + job := action.(ktesting.CreateAction).GetObject().(*batchv1.Job) + if job.UID == "" { + job.UID = types.UID("uid-" + job.Name) + } + jobName.Store(job.Name) + jobLabels.Store(job.Labels) + return false, nil, nil + }) + client.PrependReactor("get", "jobs", func(action ktesting.Action) (bool, runtime.Object, error) { + name := jobName.Load().(string) + if name == "" || action.(ktesting.GetAction).GetName() != name || jobStatus == nil || jobDeleted.Load() { + return false, nil, nil + } + job := jobStatus(name) + job.UID = types.UID("uid-" + name) + job.Labels = jobLabels.Load().(map[string]string) + return true, job, nil + }) + // The pod's phase follows the Job's: ended once the Job has finished, + // pending while it waits on its image, running otherwise. + client.PrependReactor("list", "pods", func(action ktesting.Action) (bool, runtime.Object, error) { + phase := corev1.PodRunning + switch name := jobName.Load().(string); { + case waiting != "": + phase = corev1.PodPending + case name != "" && jobStatus != nil && jobStatus(name).Status.Succeeded > 0: + phase = corev1.PodSucceeded + case name != "" && jobStatus != nil && jobStatus(name).Status.Failed > 0: + phase = corev1.PodFailed + } + ret, obj, err := podListReactor(waiting)(action) + if list, ok := obj.(*corev1.PodList); ok { + for i := range list.Items { + list.Items[i].Status.Phase = phase + } + } + return ret, obj, err + }) + // Deleting the Job takes its pod with it, as the garbage collector would; + // the fake clientset has none. + client.PrependReactor("delete", "jobs", func(ktesting.Action) (bool, runtime.Object, error) { + jobDeleted.Store(true) + return false, nil, nil + }) + client.PrependReactor("list", "pods", func(ktesting.Action) (bool, runtime.Object, error) { + if jobDeleted.Load() { + return true, &corev1.PodList{}, nil + } + return false, nil, nil + }) + client.PrependReactor("get", "pods", func(action ktesting.Action) (bool, runtime.Object, error) { + if action.GetSubresource() != "" { + return false, nil, nil + } + pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{ + Name: action.(ktesting.GetAction).GetName(), Namespace: clusterValidatorNamespace, + }} + if waiting != "" { + pod.Status.ContainerStatuses = []corev1.ContainerStatus{{ + Name: clusterValidatorContainer, + State: corev1.ContainerState{Waiting: &corev1.ContainerStateWaiting{Reason: waiting}}, + }} + } else { + pod.Status.ContainerStatuses = []corev1.ContainerStatus{{ + Name: clusterValidatorContainer, + State: corev1.ContainerState{Terminated: &corev1.ContainerStateTerminated{}}, + }} + } + return true, pod, nil + }) + return client +} + +func succeeded(name string) *batchv1.Job { + return &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: clusterValidatorNamespace}, + Status: batchv1.JobStatus{Succeeded: 1}, + } +} + +func running(name string) *batchv1.Job { + return &batchv1.Job{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: clusterValidatorNamespace}} +} + +// A clean run removes everything it created apart from the Job, which its TTL +// removes: the RBAC, the minted NGC pull secret and the network-check +// ConfigMap. +func TestRunClusterValidator_HappyPathLeavesNothingBehind(t *testing.T) { + t.Setenv("NGC_API_KEY", "key") + client := lifecycleClient(succeeded, "") + + res := runClusterValidator(context.Background(), client, "nvcr.io/nvidia/validator:1", + "", false, clusterValidatorControlPlaneRole, nil, nil) + require.NoError(t, res.Err) + require.NotEmpty(t, res.RunID) + assert.Empty(t, leftovers(t, client, res.RunID)) +} + +// A bootstrap that fails after the ServiceAccount exists must not abandon it. +func TestRunClusterValidator_FailedBootstrapLeavesNothingBehind(t *testing.T) { + client := fake.NewSimpleClientset() + client.PrependReactor("create", "clusterroles", func(ktesting.Action) (bool, runtime.Object, error) { + return true, nil, apierrors.NewForbidden( + schema.GroupResource{Group: rbacv1.GroupName, Resource: "clusterroles"}, "", nil) + }) + + res := runClusterValidator(context.Background(), client, "nvcr.io/nvidia/validator:1", + "", false, clusterValidatorComputePlaneRole, nil, nil) + require.Error(t, res.Err) + sas, err := client.CoreV1().ServiceAccounts(clusterValidatorNamespace).List(context.Background(), metav1.ListOptions{}) + require.NoError(t, err) + assert.Empty(t, sas.Items, "the ServiceAccount created before the failure must be removed") +} + +// Under --no-cleanup a failed bootstrap keeps what it created, so the result +// still carries the run ID the removal command selects on. +func TestRunClusterValidator_NoCleanupFailedBootstrapReportsTheRun(t *testing.T) { + client := fake.NewSimpleClientset() + client.PrependReactor("create", "clusterroles", func(ktesting.Action) (bool, runtime.Object, error) { + return true, nil, apierrors.NewForbidden( + schema.GroupResource{Group: rbacv1.GroupName, Resource: "clusterroles"}, "", nil) + }) + + res := runClusterValidator(context.Background(), client, "nvcr.io/nvidia/validator:1", + "", true, clusterValidatorComputePlaneRole, nil, nil) + require.Error(t, res.Err) + require.NotEmpty(t, res.RunID) + assert.Len(t, leftovers(t, client, res.RunID)["ServiceAccount"], 1) +} + +// A pull failure means the container never started, so nothing uses the RBAC, +// the NGC pull secret or the ConfigMap: the Job is deleted and all of them go +// now, instead of waiting 30 minutes for another run's orphan sweep. +func TestRunClusterValidator_PullFailureLeavesNothingBehind(t *testing.T) { + t.Setenv("NGC_API_KEY", "key") + client := lifecycleClient(running, "ImagePullBackOff") + + res := runClusterValidator(context.Background(), client, "nvcr.io/nvidia/validator:1", + "", false, clusterValidatorControlPlaneRole, nil, nil) + require.Error(t, res.Err) + assert.Empty(t, leftovers(t, client, res.RunID)) + created, deleted := "", "" + for _, a := range client.Actions() { + switch { + case a.GetVerb() == "create" && a.GetResource().Resource == "jobs": + created = a.(ktesting.CreateAction).GetObject().(*batchv1.Job).Name + case a.GetVerb() == "delete" && a.GetResource().Resource == "jobs": + deleted = a.(ktesting.DeleteAction).GetName() + } + } + require.NotEmpty(t, created) + assert.Equal(t, created, deleted, "the Job must be deleted so its pod stops retrying the pull") + assert.Empty(t, res.JobName, "no hint may point at the Job that was just deleted") +} + +// While the pod may still be running its RBAC must stay, but the Job owns the +// pull secret and ConfigMap so its deadline and TTL take them too. The +// validator's own timeout leaves it to its deadline; only the end of the run +// stops it. +func TestRunClusterValidator_RunningPodKeepsRBACAndJobOwnsArtifacts(t *testing.T) { + t.Setenv("NGC_API_KEY", "key") + prev := clusterValidatorTimeout + clusterValidatorTimeout = 200 * time.Millisecond + t.Cleanup(func() { clusterValidatorTimeout = prev }) + client := lifecycleClient(running, "") + + res := runClusterValidator(context.Background(), client, "nvcr.io/nvidia/validator:1", + "", false, clusterValidatorControlPlaneRole, nil, nil) + require.Error(t, res.Err) + assert.Contains(t, res.Err.Error(), "the validator did not finish within 200ms") + assert.Empty(t, jobDeletes(client), "the Job's deadline ends the pod; nothing stops it early") + assert.True(t, res.LeftBehind, "the caller prints the command that removes what was kept") + left := leftovers(t, client, res.RunID) + assert.Len(t, left["ServiceAccount"], 1, "a running pod must keep its RBAC") + require.Len(t, left["Secret"], 1) + require.Len(t, left["ConfigMap"], 1) + + secret, err := client.CoreV1().Secrets(clusterValidatorNamespace).Get(context.Background(), left["Secret"][0], metav1.GetOptions{}) + require.NoError(t, err) + require.Len(t, secret.OwnerReferences, 1) + assert.Equal(t, "Job", secret.OwnerReferences[0].Kind) + assert.Equal(t, res.JobName, secret.OwnerReferences[0].Name) + cm, err := client.CoreV1().ConfigMaps(clusterValidatorNamespace).Get(context.Background(), left["ConfigMap"][0], metav1.GetOptions{}) + require.NoError(t, err) + require.Len(t, cm.OwnerReferences, 1) + assert.Equal(t, res.JobName, cm.OwnerReferences[0].Name) +} + +// The deferred sweep removes only this run's Secret. A concurrent same-role +// run's Secret must survive it, or that run's pod fails its pull mid-flight. +func TestRunClusterValidator_SparesConcurrentSameRoleSecret(t *testing.T) { + t.Setenv("NGC_API_KEY", "key") + other := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: validatorPullSecretRunName(clusterValidatorComputePlaneRole, "other"), + Namespace: clusterValidatorNamespace, + Labels: clusterValidatorRunLabels(clusterValidatorComputePlaneRole, "other", false), + CreationTimestamp: metav1.Now(), + }, + Type: corev1.SecretTypeDockerConfigJson, + Data: map[string][]byte{corev1.DockerConfigJsonKey: []byte(`{"auths":{}}`)}, + } + client := lifecycleClient(succeeded, "") + _, err := client.CoreV1().Secrets(clusterValidatorNamespace).Create(context.Background(), other, metav1.CreateOptions{}) + require.NoError(t, err) + + res := runClusterValidator(context.Background(), client, "nvcr.io/nvidia/validator:1", + "", false, clusterValidatorComputePlaneRole, nil, nil) + require.NoError(t, res.Err) + _, err = client.CoreV1().Secrets(clusterValidatorNamespace).Get(context.Background(), other.Name, metav1.GetOptions{}) + assert.NoError(t, err, "another run's Secret must survive this run's sweep") +} + +// --no-cleanup keeps everything, labelled with the run so the printed command +// removes exactly that run. +func TestRunClusterValidator_NoCleanupKeepsRunAndPrintsTheCommand(t *testing.T) { + t.Setenv("NGC_API_KEY", "key") + client := lifecycleClient(succeeded, "") + + res := runClusterValidator(context.Background(), client, "nvcr.io/nvidia/validator:1", + "", true, clusterValidatorControlPlaneRole, nil, nil) + require.NoError(t, res.Err) + left := leftovers(t, client, res.RunID) + for _, kind := range []string{ + "ServiceAccount", "ClusterRole", "ClusterRoleBinding", "Role", "RoleBinding", "Secret", "ConfigMap", + } { + assert.Len(t, left[kind], 1, kind) + } + assert.True(t, res.Created) +} + +func stampAge(o metav1.Object, age time.Duration) { + o.SetCreationTimestamp(metav1.NewTime(time.Now().Add(-age))) +} + +// Preserved objects are kept for a day, not forever, and the orphan sweep +// reclaims a preserved Job too. Objects labelled by a released CLI, including +// its fixed legacy name, are never touched. +func TestSweepOrphanClusterValidatorRBAC_PreservedExpireAndLegacyIsSpared(t *testing.T) { + name := func(run string) string { return clusterValidatorRBACName(clusterValidatorControlPlaneRole, run) } + sa := func(n string, lbls map[string]string, age time.Duration) *corev1.ServiceAccount { + o := &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: n, Namespace: clusterValidatorNamespace, Labels: lbls}} + stampAge(o, age) + return o + } + job := func(n string, lbls map[string]string, age time.Duration) *batchv1.Job { + o := &batchv1.Job{ObjectMeta: metav1.ObjectMeta{Name: n, Namespace: clusterValidatorNamespace, Labels: lbls}} + stampAge(o, age) + return o + } + legacy := map[string]string{ + "app.kubernetes.io/name": clusterValidatorAppLabel, "app.kubernetes.io/managed-by": "nvcf-cli", + "app.kubernetes.io/component": "preflight", + } + client := fake.NewSimpleClientset( + sa(name("old-kept"), clusterValidatorRunLabels(clusterValidatorControlPlaneRole, "old-kept", true), 25*time.Hour), + sa(name("new-kept"), clusterValidatorRunLabels(clusterValidatorControlPlaneRole, "new-kept", true), time.Hour), + sa(clusterValidatorName, legacy, 1000*time.Hour), + job(clusterValidatorName+"-1", clusterValidatorRunLabels(clusterValidatorControlPlaneRole, "old-kept", true), 25*time.Hour), + job(clusterValidatorName+"-2", clusterValidatorRunLabels(clusterValidatorControlPlaneRole, "new-kept", true), time.Hour), + ) + + for _, run := range []struct { + id string + age time.Duration + }{{"old-kept", 25 * time.Hour}, {"new-kept", time.Hour}} { + lbls := clusterValidatorRunLabels(clusterValidatorControlPlaneRole, run.id, true) + objs := []metav1.Object{ + &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: validatorPullSecretRunName(clusterValidatorControlPlaneRole, + run.id), Namespace: clusterValidatorNamespace, Labels: lbls}}, + &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: clusterValidatorConfigRunName(run.id), + Namespace: clusterValidatorNamespace, + Labels: clusterValidatorConfigLabels(clusterValidatorControlPlaneRole, run.id, true)}}, + &rbacv1.ClusterRole{ObjectMeta: metav1.ObjectMeta{Name: name(run.id), Labels: lbls}}, + &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Name: name(run.id), Labels: lbls}}, + } + for _, o := range objs { + stampAge(o, run.age) + require.NoError(t, client.Tracker().Add(o.(runtime.Object))) + } + } + + sweepOrphanClusterValidatorRBAC(context.Background(), client, time.Now(), orphanValidatorRBACTTL, "", "") + + ctx := context.Background() + for _, run := range []struct { + id string + kept bool + }{{"old-kept", false}, {"new-kept", true}} { + _, secretErr := client.CoreV1().Secrets(clusterValidatorNamespace).Get(ctx, + validatorPullSecretRunName(clusterValidatorControlPlaneRole, run.id), metav1.GetOptions{}) + _, cmErr := client.CoreV1().ConfigMaps(clusterValidatorNamespace).Get(ctx, clusterValidatorConfigRunName(run.id), + metav1.GetOptions{}) + _, crErr := client.RbacV1().ClusterRoles().Get(ctx, name(run.id), metav1.GetOptions{}) + _, crbErr := client.RbacV1().ClusterRoleBindings().Get(ctx, name(run.id), metav1.GetOptions{}) + for kind, err := range map[string]error{"Secret": secretErr, "ConfigMap": cmErr, "ClusterRole": crErr, + "ClusterRoleBinding": crbErr} { + if run.kept { + assert.NoError(t, err, "a preserved %s younger than a day is kept", kind) + } else { + assert.True(t, apierrors.IsNotFound(err), "a preserved %s older than a day is reclaimed", kind) + } + } + } + _, err := client.CoreV1().ServiceAccounts(clusterValidatorNamespace).Get(ctx, name("old-kept"), metav1.GetOptions{}) + assert.True(t, apierrors.IsNotFound(err), "a preserved object older than a day is reclaimed") + _, err = client.CoreV1().ServiceAccounts(clusterValidatorNamespace).Get(ctx, name("new-kept"), metav1.GetOptions{}) + assert.NoError(t, err, "a preserved object younger than a day is kept") + _, err = client.CoreV1().ServiceAccounts(clusterValidatorNamespace).Get(ctx, clusterValidatorName, metav1.GetOptions{}) + assert.NoError(t, err, "a released CLI's fixed-name object is never touched") + _, err = client.BatchV1().Jobs(clusterValidatorNamespace).Get(ctx, clusterValidatorName+"-1", metav1.GetOptions{}) + assert.True(t, apierrors.IsNotFound(err), "a preserved Job older than a day is reclaimed") + _, err = client.BatchV1().Jobs(clusterValidatorNamespace).Get(ctx, clusterValidatorName+"-2", metav1.GetOptions{}) + assert.NoError(t, err) +} + +// Forwarded settings reach the container in a stable order, empty values are +// dropped, and nothing forwarded can override what the Job itself sets. +func TestBuildClusterValidatorJob_ForwardsEnvWithoutOverridingCore(t *testing.T) { + job := buildClusterValidatorJob("j", "img:1", "", clusterValidatorControlPlaneRole, "runid", + clusterValidatorNoConfigName, false, map[string]string{ + "NVCF_OPENBAO_NAMESPACE": "openbao", + "VALIDATOR_POST_INSTALL": "true", + "VALIDATOR_ROLE": "compute-plane", + "NVCF_GATEWAY_NAMES": "", + }) + env := map[string]string{} + var order []string + for _, e := range job.Spec.Template.Spec.Containers[0].Env { + env[e.Name] = e.Value + order = append(order, e.Name) + } + assert.Equal(t, "openbao", env["NVCF_OPENBAO_NAMESPACE"]) + assert.Equal(t, "true", env["VALIDATOR_POST_INSTALL"]) + assert.Equal(t, clusterValidatorControlPlaneRole, env["VALIDATOR_ROLE"], "a forwarded value must not override the role") + _, hasEmpty := env["NVCF_GATEWAY_NAMES"] + assert.False(t, hasEmpty, "empty values are not forwarded") + assert.Equal(t, []string{"VALIDATOR_CONFIG_NAMESPACE", "VALIDATOR_CONFIG_NAME", "VALIDATOR_PREFLIGHT", + "VALIDATOR_ROLE", "VALIDATOR_TIMEOUT", "NVCF_OPENBAO_NAMESPACE", "VALIDATOR_POST_INSTALL"}, order) +} + +// The extra tolerations reach the Job beside the control-plane ones it always +// carries, so a cluster whose nodes use other taints can schedule it. +func TestRunClusterValidator_ExtraTolerationsReachTheJob(t *testing.T) { + t.Setenv("NGC_API_KEY", "key") + client := lifecycleClient(succeeded, "") + var job *batchv1.Job + client.PrependReactor("create", "jobs", func(a ktesting.Action) (bool, runtime.Object, error) { + job = a.(ktesting.CreateAction).GetObject().(*batchv1.Job).DeepCopy() + return false, nil, nil + }) + extra := corev1.Toleration{Key: "dedicated", Operator: corev1.TolerationOpEqual, Value: "infra", + Effect: corev1.TaintEffectNoSchedule} + res := runClusterValidator(context.Background(), client, "nvcr.io/nvidia/validator:1", "", false, + clusterValidatorComputePlaneRole, nil, nil, extra) + require.NoError(t, res.Err) + require.NotNil(t, job) + assert.Equal(t, append(clusterValidatorTolerations(), extra), job.Spec.Template.Spec.Tolerations) +} + +// The control-plane validator receives the enumerated registries and the +// forwarded env; the compute-plane one gets the env but no registries. +func TestClusterValidatorCheck_PassesRegistriesAndEnv(t *testing.T) { + var got ClusterValidatorParams + cv := func(_ context.Context, p ClusterValidatorParams) ClusterValidatorResult { + got = p + return ClusterValidatorResult{Passed: true} + } + regs := []RegistryEntry{{Registry: "harbor.corp.example"}} + env := map[string]string{"VALIDATOR_POST_INSTALL": "true"} + clusterValidatorCheck(RoleConfig{ClusterValidator: cv, ClusterValidatorImage: "img:1", + ClusterValidatorRegistries: regs, ClusterValidatorEnv: env}, validatorRoleControlPlane).Run(context.Background()) + assert.Equal(t, regs, got.Registries) + assert.Equal(t, env, got.Env) +} + +// An image that predates validator roles runs the GPU checks on the control +// plane and fails. Without the role line in its transcript that is the image +// being too old, not the cluster failing, so it is a warning. +func TestClusterValidatorCheck_PreRoleImageIsAWarning(t *testing.T) { + run := func(logs string, passed bool) CheckResult { + cv := func(context.Context, ClusterValidatorParams) ClusterValidatorResult { + return ClusterValidatorResult{Passed: passed, ExitCode: 1, Logs: logs} + } + return clusterValidatorCheck(RoleConfig{ClusterValidator: cv, ClusterValidatorImage: "img:1"}, + validatorRoleControlPlane).Run(context.Background()) + } + + legacy := func(summaryRows ...string) string { + return "Starting NVCF cluster validation\n=== GPU Resources ===\nno GPUs found\n" + + "=== Validation Summary ===\nCheck Results:\n" + strings.Join(summaryRows, "\n") + + "\nCluster is NVCF-Not-Ready\n" + } + old := run(legacy("\u2713 Control Plane: Healthy", "\u2717 GPU Resources: Not Available"), false) + assert.Equal(t, SeverityWarning, old.Severity) + assert.Contains(t, old.Message, "does not support the control-plane checks") + + // The old image's checks common to both roles still see real failures, + // and those must not be downgraded with the GPU row. + readyz := run(legacy("\u2717 Control Plane: Unhealthy", "\u2717 GPU Resources: Not Available"), false) + assert.Equal(t, SeverityError, readyz.Severity) + assert.Contains(t, readyz.Message, "Control Plane: Unhealthy") + assert.NotContains(t, readyz.Message, "GPU Resources") + + noSummary := run("Starting NVCF cluster validation\n=== GPU Resources ===\nno GPUs found\n", false) + assert.Equal(t, SeverityError, noSummary.Severity, "without its summary nothing shows the rest passed") + + // A role-aware image that fails before printing its role line prints no + // GPU section either, so its failure must stand. + early := run("level=fatal msg=\"Failed to create Kubernetes client\"\n", false) + assert.Equal(t, SeverityError, early.Severity, "a missing role line alone is not proof of an old image") + + current := run("Starting NVCF cluster validation\nValidator role: control-plane\nTier-1: failed\n", false) + assert.Equal(t, SeverityError, current.Severity, "a role-aware image's failure is a real failure") + + unread := run("", false) + assert.Equal(t, SeverityError, unread.Severity, "an empty transcript proves nothing about the image") +} + +// An interrupt cancels the caller's context. The operator abandoned the run, +// so the Job is stopped and everything reclaimed now, rather than leaving a +// cluster-wide ClusterRole bound in default until a later orphan sweep. +func TestRunClusterValidator_InterruptLeavesNothingBehind(t *testing.T) { + t.Setenv("NGC_API_KEY", "key") + client := lifecycleClient(running, "") + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(200 * time.Millisecond) + cancel() + }() + + res := runClusterValidator(ctx, client, "nvcr.io/nvidia/validator:1", + "", false, clusterValidatorControlPlaneRole, nil, nil) + require.Error(t, res.Err) + assert.Empty(t, leftovers(t, client, res.RunID)) + deleted := false + for _, a := range client.Actions() { + if a.GetVerb() == "delete" && a.GetResource().Resource == "jobs" { + deleted = true + } + } + assert.True(t, deleted, "the Job must be stopped so its pod stops using the RBAC") +} + +// An apiserver that stops answering after the bootstrap fails must not hold +// the CLI open in the deferred sweeps: each one is bounded, and the run +// returns once they time out. +func TestRunClusterValidator_CleanupIsBoundedWhenTheAPIServerHangs(t *testing.T) { + prev := validatorCleanupTimeout + validatorCleanupTimeout = 500 * time.Millisecond + t.Cleanup(func() { validatorCleanupTimeout = prev }) + + var hang atomic.Bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if hang.Load() { + // The server notices a closed connection only once the body has + // been read, and the cleanup's deletes carry one. + _, _ = io.Copy(io.Discard, r.Body) + <-r.Context().Done() + return + } + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/clusterroles"): + // The bootstrap fails here, after its ServiceAccount exists, and + // the apiserver goes silent for everything after. + hang.Store(true) + w.WriteHeader(http.StatusForbidden) + _, _ = io.WriteString(w, `{"kind":"Status","apiVersion":"v1","status":"Failure","reason":"Forbidden","code":403}`) + case r.Method == http.MethodPost: + body, _ := io.ReadAll(r.Body) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write(body) + default: + w.WriteHeader(http.StatusInternalServerError) + _, _ = io.WriteString(w, `{"kind":"Status","apiVersion":"v1","status":"Failure","code":500}`) + } + })) + t.Cleanup(func() { + srv.CloseClientConnections() + srv.Close() + }) + // JSON, so the server can echo a created object back as the response. + client, err := kubernetes.NewForConfig(&rest.Config{ + Host: srv.URL, + ContentConfig: rest.ContentConfig{ContentType: "application/json"}, + }) + require.NoError(t, err) + + done := make(chan ClusterValidatorResult, 1) + go func() { + done <- runClusterValidator(context.Background(), client, "registry.example.com/validator:1", + "", false, clusterValidatorComputePlaneRole, nil, nil) + }() + select { + case res := <-done: + require.Error(t, res.Err) + assert.Contains(t, res.Err.Error(), "create cluster role", "the run must reach the hang, not fail earlier") + assert.True(t, hang.Load()) + case <-time.After(10 * time.Second): + t.Fatal("the deferred cleanup did not return against a hung apiserver") + } +} + +// A Job that succeeds with the validator's own warnings is not a clean pass. A +// rollout in progress is marked transient, so --wait keeps polling on it; any +// other warning is not, so --wait does not poll forever on it. +func TestClusterValidatorCheck_PassWithWarnings(t *testing.T) { + run := func(logs string) CheckResult { + cv := func(context.Context, ClusterValidatorParams) ClusterValidatorResult { + return ClusterValidatorResult{Passed: true, Logs: logs} + } + return clusterValidatorCheck(RoleConfig{ClusterValidator: cv, ClusterValidatorImage: "img:1"}, + validatorRoleControlPlane).Run(context.Background()) + } + base := "Validator role: control-plane\n" + + rolling := run(base + "Tier-2 StatefulSets: nats-system/nats: rolling update in progress (ready: 2/3)\n" + + "Cluster is NVCF-Ready (with warnings)\n") + assert.False(t, rolling.Passed) + assert.Equal(t, SeverityWarning, rolling.Severity) + assert.True(t, rolling.Transient) + + other := run(base + "Node-to-Node: not applicable (one Ready, schedulable node)\nCluster is NVCF-Ready (with warnings)\n") + assert.Equal(t, SeverityWarning, other.Severity) + assert.False(t, other.Transient, "a permanent warning must not keep --wait polling") + + clean := run(base + "Cluster is NVCF-Ready\n") + assert.True(t, clean.Passed) + assert.Equal(t, SeverityInfo, clean.Severity) +} + +// jobDeletes returns the DeleteOptions of every Job delete the client saw. +func jobDeletes(client *fake.Clientset) []metav1.DeleteOptions { + var out []metav1.DeleteOptions + for _, a := range client.Actions() { + if d, ok := a.(ktesting.DeleteActionImpl); ok && d.GetResource().Resource == "jobs" { + out = append(out, d.DeleteOptions) + } + } + return out +} + +// An interrupt stops the Job in the foreground, so its pod gets SIGTERM and +// its grace period while it still has the RBAC it needs to remove its own +// probe resources. Background propagation revoked that RBAC first. +func TestRunClusterValidator_InterruptStopsTheJobInTheForeground(t *testing.T) { + t.Setenv("NGC_API_KEY", "key") + client := lifecycleClient(running, "") + ctx, cancel := context.WithCancel(context.Background()) + client.PrependReactor("create", "jobs", func(ktesting.Action) (bool, runtime.Object, error) { + cancel() + return false, nil, nil + }) + res := runClusterValidator(ctx, client, "nvcr.io/nvidia/validator:1", + "", false, clusterValidatorComputePlaneRole, nil, nil) + require.Error(t, res.Err) + deletes := jobDeletes(client) + require.NotEmpty(t, deletes) + require.NotNil(t, deletes[0].PropagationPolicy) + assert.Equal(t, metav1.DeletePropagationForeground, *deletes[0].PropagationPolicy) + assert.Empty(t, leftovers(t, client, res.RunID), "the pod ended, so everything else goes") +} + +// If the stopped pod does not end within the bound, its RBAC stays rather than +// being pulled from under it, and the caller is told what was kept. +func TestRunClusterValidator_PodThatWillNotEndKeepsItsRBAC(t *testing.T) { + t.Setenv("NGC_API_KEY", "key") + client := lifecycleClient(running, "") + client.PrependReactor("list", "pods", func(action ktesting.Action) (bool, runtime.Object, error) { + sel := action.(ktesting.ListAction).GetListRestrictions().Labels.String() + return true, &corev1.PodList{Items: []corev1.Pod{{ + ObjectMeta: metav1.ObjectMeta{Name: "stuck", Namespace: clusterValidatorNamespace, + Labels: map[string]string{"job-name": strings.TrimPrefix(sel, "job-name=")}}, + Status: corev1.PodStatus{Phase: corev1.PodRunning}, + }}}, nil + }) + ctx, cancel := context.WithCancel(context.Background()) + client.PrependReactor("create", "jobs", func(ktesting.Action) (bool, runtime.Object, error) { + cancel() + return false, nil, nil + }) + res := runClusterValidator(ctx, client, "nvcr.io/nvidia/validator:1", + "", false, clusterValidatorComputePlaneRole, nil, nil) + require.Error(t, res.Err) + assert.True(t, res.LeftBehind) + assert.Len(t, leftovers(t, client, res.RunID)["ClusterRole"], 1) +} + +// On a plain timeout the Job's own deadline ends the pod shortly after. The +// run reads as the validator not finishing, not as failed checks: it keeps +// the partial transcript, and once the pod is gone its RBAC is swept. +func TestRunClusterValidator_DeadlineKillIsATimeout(t *testing.T) { + t.Setenv("NGC_API_KEY", "key") + var killed atomic.Bool + client := lifecycleClient(func(name string) *batchv1.Job { + if !killed.Load() { + return running(name) + } + job := running(name) + job.Status.Failed = 1 + job.Status.Conditions = []batchv1.JobCondition{{ + Type: batchv1.JobFailed, Status: corev1.ConditionTrue, Reason: batchv1.JobReasonDeadlineExceeded, + }} + return job + }, "") + // The deadline deletes the pod, so the re-read finds no transcript. + client.PrependReactor("list", "pods", func(ktesting.Action) (bool, runtime.Object, error) { + if killed.Load() { + return true, &corev1.PodList{}, nil + } + return false, nil, nil + }) + prevTimeout, prevGrace := clusterValidatorTimeout, validatorDeadlineGrace + clusterValidatorTimeout, validatorDeadlineGrace = 300*time.Millisecond, 5*time.Second + t.Cleanup(func() { clusterValidatorTimeout, validatorDeadlineGrace = prevTimeout, prevGrace }) + timer := time.AfterFunc(time.Second, func() { killed.Store(true) }) + t.Cleanup(func() { timer.Stop() }) + + res := runClusterValidator(context.Background(), client, "nvcr.io/nvidia/validator:1", + "", false, clusterValidatorComputePlaneRole, nil, nil) + require.EqualError(t, res.Err, "the validator did not finish within 300ms (stopped at its active deadline)") + assert.Equal(t, "fake logs\n", res.Logs, "the partial transcript read at the timeout is kept") + assert.False(t, res.LeftBehind) + assert.Empty(t, leftovers(t, client, res.RunID)["ClusterRole"], "the deadline ended the pod, so its RBAC goes") +} + +// Once the Job's pod has ended, its RBAC goes even if the Job never records +// the result. +func TestRunClusterValidator_TimeoutSweepsOnceThePodEnds(t *testing.T) { + t.Setenv("NGC_API_KEY", "key") + client := lifecycleClient(running, "") + var ended atomic.Bool + client.PrependReactor("list", "pods", func(action ktesting.Action) (bool, runtime.Object, error) { + if !ended.Load() { + return false, nil, nil + } + return podPhaseReactor(func() corev1.PodPhase { return corev1.PodFailed })(action) + }) + prev := clusterValidatorTimeout + clusterValidatorTimeout = 300 * time.Millisecond + t.Cleanup(func() { clusterValidatorTimeout = prev }) + timer := time.AfterFunc(time.Second, func() { ended.Store(true) }) + t.Cleanup(func() { timer.Stop() }) + + res := runClusterValidator(context.Background(), client, "nvcr.io/nvidia/validator:1", + "", false, clusterValidatorComputePlaneRole, nil, nil) + require.Error(t, res.Err) + assert.False(t, res.LeftBehind) + assert.Empty(t, leftovers(t, client, res.RunID)["ClusterRole"], "the pod ended, so its RBAC goes") +} + +// Under --no-cleanup the kept Secret and ConfigMap are not owned by the kept +// Job: re-running that Job by replacing it would garbage-collect them. A pull +// failure suspends the kept Job, so its pod stops retrying forever, and an +// interrupt keeps everything. +func TestRunClusterValidator_NoCleanupKeepsArtifactsUnowned(t *testing.T) { + t.Setenv("NGC_API_KEY", "key") + client := lifecycleClient(succeeded, "") + res := runClusterValidator(context.Background(), client, "nvcr.io/nvidia/validator:1", + "", true, clusterValidatorControlPlaneRole, nil, nil) + require.NoError(t, res.Err) + left := leftovers(t, client, res.RunID) + require.Len(t, left["Secret"], 1) + require.Len(t, left["ConfigMap"], 1) + sec, err := client.CoreV1().Secrets(clusterValidatorNamespace).Get(context.Background(), left["Secret"][0], metav1.GetOptions{}) + require.NoError(t, err) + assert.Empty(t, sec.OwnerReferences) + cm, err := client.CoreV1().ConfigMaps(clusterValidatorNamespace).Get(context.Background(), left["ConfigMap"][0], metav1.GetOptions{}) + require.NoError(t, err) + assert.Empty(t, cm.OwnerReferences) + + pulling := lifecycleClient(running, "ImagePullBackOff") + res = runClusterValidator(context.Background(), pulling, "nvcr.io/nvidia/validator:1", + "", true, clusterValidatorComputePlaneRole, nil, nil) + require.Error(t, res.Err) + assert.Empty(t, jobDeletes(pulling), "a kept Job is not deleted") + suspended := false + for _, a := range pulling.Actions() { + if p, ok := a.(ktesting.PatchAction); ok && p.GetResource().Resource == "jobs" && + strings.Contains(string(p.GetPatch()), `"suspend":true`) { + suspended = true + } + } + assert.True(t, suspended, "the kept Job must stop retrying the pull") + + interrupted := lifecycleClient(running, "") + ctx, cancel := context.WithCancel(context.Background()) + interrupted.PrependReactor("create", "jobs", func(ktesting.Action) (bool, runtime.Object, error) { + cancel() + return false, nil, nil + }) + res = runClusterValidator(ctx, interrupted, "nvcr.io/nvidia/validator:1", + "", true, clusterValidatorComputePlaneRole, nil, nil) + require.Error(t, res.Err) + assert.Empty(t, jobDeletes(interrupted), "--no-cleanup keeps the Job even on an interrupt") +} + +// The operator's own pull Secret is never made a dependent of the Job: only +// the Secret this run minted is, so a Job TTL cannot garbage-collect theirs. +func TestRunClusterValidator_OperatorPullSecretIsNeverOwned(t *testing.T) { + client := lifecycleClient(succeeded, "") + _, err := client.CoreV1().Secrets(clusterValidatorNamespace).Create(context.Background(), &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "my-pull", Namespace: clusterValidatorNamespace}, + Type: corev1.SecretTypeDockerConfigJson, + Data: map[string][]byte{corev1.DockerConfigJsonKey: []byte(`{"auths":{}}`)}, + }, metav1.CreateOptions{}) + require.NoError(t, err) + res := runClusterValidator(context.Background(), client, "nvcr.io/nvidia/validator:1", + "my-pull", false, clusterValidatorComputePlaneRole, nil, nil) + require.NoError(t, res.Err) + sec, err := client.CoreV1().Secrets(clusterValidatorNamespace).Get(context.Background(), "my-pull", metav1.GetOptions{}) + require.NoError(t, err) + assert.Empty(t, sec.OwnerReferences) +} + +// One failed Job read is not the Job's result, and a first pull error is what +// a transient registry blip looks like: both are retried. A malformed image +// reference never pulls, so it ends the wait at once. +func TestWaitForClusterValidatorJob_TransientErrorsAreRetried(t *testing.T) { + client := lifecycleClient(succeeded, "") + calls := 0 + client.PrependReactor("get", "jobs", func(ktesting.Action) (bool, runtime.Object, error) { + calls++ + if calls <= 2 { + return true, nil, apierrors.NewInternalError(fmt.Errorf("etcd timeout")) + } + return false, nil, nil + }) + _, err := client.BatchV1().Jobs(clusterValidatorNamespace).Create(context.Background(), + &batchv1.Job{ObjectMeta: metav1.ObjectMeta{Name: "j", Namespace: clusterValidatorNamespace}}, metav1.CreateOptions{}) + require.NoError(t, err) + job, err := waitForClusterValidatorJob(context.Background(), client, "j") + require.NoError(t, err) + assert.Equal(t, int32(1), job.Status.Succeeded) + + prev := validatorPullFailureGrace + validatorPullFailureGrace = time.Hour + t.Cleanup(func() { validatorPullFailureGrace = prev }) + pulling := lifecycleClient(running, "ErrImagePull") + _, err = pulling.BatchV1().Jobs(clusterValidatorNamespace).Create(context.Background(), + &batchv1.Job{ObjectMeta: metav1.ObjectMeta{Name: "j", Namespace: clusterValidatorNamespace}}, metav1.CreateOptions{}) + require.NoError(t, err) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, err = waitForClusterValidatorJob(ctx, pulling, "j") + var pullErr *validatorImagePullError + assert.False(t, errors.As(err, &pullErr), "a pull error inside the grace period is retried") + + invalid := lifecycleClient(running, "InvalidImageName") + _, err = invalid.BatchV1().Jobs(clusterValidatorNamespace).Create(context.Background(), + &batchv1.Job{ObjectMeta: metav1.ObjectMeta{Name: "j", Namespace: clusterValidatorNamespace}}, metav1.CreateOptions{}) + require.NoError(t, err) + _, err = waitForClusterValidatorJob(context.Background(), invalid, "j") + assert.True(t, errors.As(err, &pullErr), "a malformed reference never pulls") +} + +// A run that times out with a pod that will not end returns within +// ClusterValidatorRunCeiling, which the check's budget is sized from. The +// ceiling counts every wait the timeout path makes, so shortening one of them +// here cannot leave the budget short in production. +func TestClusterValidatorRunCeiling_CoversTheTimeoutPath(t *testing.T) { + t.Setenv("NGC_API_KEY", "key") + client := lifecycleClient(running, "") + client.PrependReactor("list", "pods", func(action ktesting.Action) (bool, runtime.Object, error) { + sel := action.(ktesting.ListAction).GetListRestrictions().Labels.String() + return true, &corev1.PodList{Items: []corev1.Pod{{ + ObjectMeta: metav1.ObjectMeta{Name: "stuck", Namespace: clusterValidatorNamespace, + Labels: map[string]string{"job-name": strings.TrimPrefix(sel, "job-name=")}}, + Status: corev1.PodStatus{Phase: corev1.PodRunning}, + }}}, nil + }) + // The deadline grace dominates every other bound in the ceiling, so a + // ceiling that leaves it out is short of the run, and a wait shorter than + // the grace ends before it. + prevTimeout, prevGrace, prevLogs, prevCleanup, prevMargin := clusterValidatorTimeout, + validatorDeadlineGrace, clusterValidatorLogFetchTimeout, validatorCleanupTimeout, validatorRunMargin + clusterValidatorTimeout, validatorDeadlineGrace, clusterValidatorLogFetchTimeout, validatorCleanupTimeout, + validatorRunMargin = 300*time.Millisecond, 3*time.Second, 100*time.Millisecond, 100*time.Millisecond, + 100*time.Millisecond + t.Cleanup(func() { + clusterValidatorTimeout, validatorDeadlineGrace, clusterValidatorLogFetchTimeout, validatorCleanupTimeout, + validatorRunMargin = prevTimeout, prevGrace, prevLogs, prevCleanup, prevMargin + }) + + start := time.Now() + res := runClusterValidator(context.Background(), client, "nvcr.io/nvidia/validator:1", + "", false, clusterValidatorComputePlaneRole, nil, nil) + elapsed := time.Since(start) + require.Error(t, res.Err) + assert.True(t, res.LeftBehind, "the pod never ended, so the whole grace was spent") + assert.GreaterOrEqual(t, elapsed, clusterValidatorTimeout+validatorDeadlineGrace, + "the run waited out the deadline grace, not a shorter wait") + assert.LessOrEqual(t, elapsed, ClusterValidatorRunCeiling()) +} + +// podPhaseReactor answers the Job's pod list with one pod in the phase phase() +// returns at the time of the call. +func podPhaseReactor(phase func() corev1.PodPhase) ktesting.ReactionFunc { + return func(action ktesting.Action) (bool, runtime.Object, error) { + jobName := strings.TrimPrefix(action.(ktesting.ListAction).GetListRestrictions().Labels.String(), "job-name=") + return true, &corev1.PodList{Items: []corev1.Pod{{ + ObjectMeta: metav1.ObjectMeta{Name: jobName + "-pod", Namespace: clusterValidatorNamespace, + Labels: map[string]string{"job-name": jobName}}, + Status: corev1.PodStatus{Phase: phase()}, + }}}, nil + } +} + +// A validator that finishes after the CLI's own timeout, while the CLI waits +// for the Job's deadline to end its pod, is graded on what it did. That wait +// is the deadline grace, not the shorter wait for a stopped Job. +func TestRunClusterValidator_LateFinishIsGraded(t *testing.T) { + t.Setenv("NGC_API_KEY", "key") + prevTimeout, prevStop, prevGrace := clusterValidatorTimeout, validatorStopTimeout, validatorDeadlineGrace + clusterValidatorTimeout, validatorStopTimeout, validatorDeadlineGrace = + 300*time.Millisecond, 300*time.Millisecond, 5*time.Second + t.Cleanup(func() { + clusterValidatorTimeout, validatorStopTimeout, validatorDeadlineGrace = prevTimeout, prevStop, prevGrace + }) + var finished atomic.Bool + client := lifecycleClient(func(name string) *batchv1.Job { + if finished.Load() { + return succeeded(name) + } + return running(name) + }, "") + client.PrependReactor("list", "pods", podPhaseReactor(func() corev1.PodPhase { + if finished.Load() { + return corev1.PodSucceeded + } + return corev1.PodRunning + })) + timer := time.AfterFunc(1500*time.Millisecond, func() { finished.Store(true) }) + t.Cleanup(func() { timer.Stop() }) + + res := runClusterValidator(context.Background(), client, "nvcr.io/nvidia/validator:1", + "", false, clusterValidatorControlPlaneRole, nil, nil) + require.NoError(t, res.Err) + assert.True(t, res.Passed) + assert.False(t, res.LeftBehind) + assert.NotEmpty(t, res.JobName) + assert.Empty(t, leftovers(t, client, res.RunID), "the pod ended, so the sweeps ran") +} + +// Failed Job reads end the wait, but not the run: once the pod has ended the +// Job is read again, and a validator that passed while its Job could not be +// read passes. +func TestRunClusterValidator_JobReadOutageIsGradedOnTheJob(t *testing.T) { + prev := clusterValidatorPollInterval + clusterValidatorPollInterval = 20 * time.Millisecond + t.Cleanup(func() { clusterValidatorPollInterval = prev }) + client := lifecycleClient(succeeded, "") + var failedReads atomic.Int32 + client.PrependReactor("get", "jobs", func(ktesting.Action) (bool, runtime.Object, error) { + if failedReads.Load() < int32(validatorGetErrorLimit) { + failedReads.Add(1) + return true, nil, apierrors.NewServiceUnavailable("the apiserver is restarting") + } + return false, nil, nil + }) + client.PrependReactor("list", "pods", podPhaseReactor(func() corev1.PodPhase { return corev1.PodSucceeded })) + + res := runClusterValidator(context.Background(), client, "nvcr.io/nvidia/validator:1", + "", false, clusterValidatorComputePlaneRole, nil, nil) + require.NoError(t, res.Err) + assert.True(t, res.Passed) + assert.EqualValues(t, validatorGetErrorLimit, failedReads.Load(), "the outage ended the wait") +} + +// Only consecutive failed reads end the wait: a good read in between starts +// the count again. +func TestWaitForClusterValidatorJob_OnlyConsecutiveReadErrorsEndTheWait(t *testing.T) { + prev := clusterValidatorPollInterval + clusterValidatorPollInterval = 10 * time.Millisecond + t.Cleanup(func() { clusterValidatorPollInterval = prev }) + client := fake.NewSimpleClientset() + calls := 0 + client.PrependReactor("get", "jobs", func(ktesting.Action) (bool, runtime.Object, error) { + calls++ + switch { + case calls > 3*validatorGetErrorLimit: + return true, succeeded("j"), nil + case calls%validatorGetErrorLimit == 0: + return true, running("j"), nil + } + return true, nil, apierrors.NewInternalError(fmt.Errorf("etcd timeout")) + }) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + job, err := waitForClusterValidatorJob(ctx, client, "j") + require.NoError(t, err) + assert.Equal(t, int32(1), job.Status.Succeeded) +} + +// A missing Job, or one this kubeconfig may not read, cannot change, so the +// first such read ends the wait. +func TestWaitForClusterValidatorJob_PermanentReadErrorsEndTheWaitAtOnce(t *testing.T) { + prev := clusterValidatorPollInterval + clusterValidatorPollInterval = 10 * time.Millisecond + t.Cleanup(func() { clusterValidatorPollInterval = prev }) + gr := schema.GroupResource{Group: batchv1.GroupName, Resource: "jobs"} + for _, readErr := range []error{ + apierrors.NewNotFound(gr, "j"), + apierrors.NewForbidden(gr, "j", errors.New("denied")), + apierrors.NewUnauthorized("expired token"), + } { + client := fake.NewSimpleClientset() + calls := 0 + client.PrependReactor("get", "jobs", func(ktesting.Action) (bool, runtime.Object, error) { + calls++ + return true, nil, readErr + }) + _, err := waitForClusterValidatorJob(context.Background(), client, "j") + require.Error(t, err) + assert.Equal(t, 1, calls, "%v is not retried", readErr) + } +} + +// A pull error no retry fixes is final at once. One that can clear, a +// registry outage or a DNS failure, waits out the grace. +func TestWaitForClusterValidatorJob_PermanentPullErrorsAreFinalAtOnce(t *testing.T) { + prevGrace, prevPoll := validatorPullFailureGrace, clusterValidatorPollInterval + validatorPullFailureGrace, clusterValidatorPollInterval = time.Hour, 10*time.Millisecond + t.Cleanup(func() { validatorPullFailureGrace, clusterValidatorPollInterval = prevGrace, prevPoll }) + for msg, final := range map[string]bool{ + `failed to resolve reference "nvcr.io/nvidia/validator:9": nvcr.io/nvidia/validator:9: not found`: true, + `failed to authorize: failed to fetch oauth token: 401 Unauthorized`: true, + `pull access denied, repository does not exist or may require authorization`: true, + `failed to resolve reference "nvcr.io/nvidia/validator:9": manifest unknown`: true, + `failed to fetch anonymous token: authentication required`: true, + `failed to do request: Head "https://nvcr.io/v2/nvidia/validator/manifests/9": 503 Service Unavailable`: false, + `dial tcp: lookup nvcr.io on 10.96.0.10:53: i/o timeout`: false, + } { + client := fake.NewSimpleClientset() + client.PrependReactor("get", "jobs", func(ktesting.Action) (bool, runtime.Object, error) { + return true, running("j"), nil + }) + client.PrependReactor("list", "pods", podPhaseReactor(func() corev1.PodPhase { return corev1.PodPending })) + client.PrependReactor("get", "pods", func(action ktesting.Action) (bool, runtime.Object, error) { + return true, &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: action.(ktesting.GetAction).GetName(), Namespace: clusterValidatorNamespace}, + Status: corev1.PodStatus{ContainerStatuses: []corev1.ContainerStatus{{ + Name: clusterValidatorContainer, + State: corev1.ContainerState{Waiting: &corev1.ContainerStateWaiting{Reason: "ErrImagePull", Message: msg}}, + }}}, + }, nil + }) + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + _, err := waitForClusterValidatorJob(ctx, client, "j") + cancel() + var pullErr *validatorImagePullError + assert.Equal(t, final, errors.As(err, &pullErr), msg) + } +} + +// A pod that cannot be listed may still be running: stopping its Job does not +// revoke its RBAC until the pod is seen to end, and the result says how to +// remove what was kept. +func TestRunClusterValidator_UnlistablePodKeepsItsRBAC(t *testing.T) { + prev := validatorStopTimeout + validatorStopTimeout = 300 * time.Millisecond + t.Cleanup(func() { validatorStopTimeout = prev }) + client := lifecycleClient(running, "ImagePullBackOff") + var deleted atomic.Bool + client.PrependReactor("delete", "jobs", func(ktesting.Action) (bool, runtime.Object, error) { + deleted.Store(true) + return false, nil, nil + }) + client.PrependReactor("list", "pods", func(ktesting.Action) (bool, runtime.Object, error) { + if deleted.Load() { + return true, nil, errors.New("connection refused") + } + return false, nil, nil + }) + + res := runClusterValidator(context.Background(), client, "nvcr.io/nvidia/validator:1", + "", false, clusterValidatorComputePlaneRole, nil, nil) + var pullErr *validatorImagePullError + require.ErrorAs(t, res.Err, &pullErr) + assert.True(t, res.LeftBehind) + assert.Len(t, leftovers(t, client, res.RunID)["ClusterRole"], 1, "the RBAC stays until the pod is seen to end") +} + +// A Job create that fails may still have been applied, the response lost. Its +// Job is stopped, and when its pod does not end the objects are kept and the +// result says how to remove them. Under --no-cleanup the Job is kept, like +// everything else the run made. +func TestRunClusterValidator_FailedCreateThatWasApplied(t *testing.T) { + prev := validatorStopTimeout + validatorStopTimeout = 300 * time.Millisecond + t.Cleanup(func() { validatorStopTimeout = prev }) + for _, noCleanup := range []bool{false, true} { + client := lifecycleClient(running, "") + client.PrependReactor("create", "jobs", func(action ktesting.Action) (bool, runtime.Object, error) { + job := action.(ktesting.CreateAction).GetObject().(*batchv1.Job) + require.NoError(t, client.Tracker().Add(job)) + return true, nil, apierrors.NewInternalError(errors.New("connection reset by peer")) + }) + client.PrependReactor("list", "pods", podPhaseReactor(func() corev1.PodPhase { return corev1.PodRunning })) + + res := runClusterValidator(context.Background(), client, "nvcr.io/nvidia/validator:1", + "", noCleanup, clusterValidatorComputePlaneRole, nil, nil) + require.ErrorContains(t, res.Err, "creating validator Job") + require.NotEmpty(t, res.RunID, "noCleanup=%v", noCleanup) + if noCleanup { + assert.Empty(t, jobDeletes(client), "--no-cleanup keeps the Job") + assert.False(t, res.LeftBehind, "the --no-cleanup command covers what was kept") + continue + } + assert.NotEmpty(t, jobDeletes(client), "the applied Job is stopped") + assert.True(t, res.LeftBehind, "its pod did not end, so its objects were kept") + assert.Len(t, leftovers(t, client, res.RunID)["ClusterRole"], 1) + } +} + +// A run whose budget ends while it waits out a Job read outage was cut short: +// the validator's result was still to come, so the error says the budget +// ended rather than reading as the validator's failure. +func TestRunClusterValidator_BudgetEndDuringAReadOutageIsCutShort(t *testing.T) { + prev := clusterValidatorPollInterval + clusterValidatorPollInterval = 20 * time.Millisecond + t.Cleanup(func() { clusterValidatorPollInterval = prev }) + client := lifecycleClient(running, "") + client.PrependReactor("get", "jobs", func(ktesting.Action) (bool, runtime.Object, error) { + return true, nil, apierrors.NewServiceUnavailable("the apiserver is restarting") + }) + client.PrependReactor("list", "pods", podPhaseReactor(func() corev1.PodPhase { return corev1.PodRunning })) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + res := runClusterValidator(ctx, client, "nvcr.io/nvidia/validator:1", + "", false, clusterValidatorComputePlaneRole, nil, nil) + require.ErrorIs(t, res.Err, context.DeadlineExceeded) + assert.ErrorContains(t, res.Err, "the apiserver is restarting", "the outage is still named") +} + +// legacyValidatorTranscript is what a validator that predates roles (v3.2.26) +// prints on a cluster with no GPUs, byte for byte from its printSummary: ANSI +// colours, the box around the verdict, and the failure line after the box, +// which carries the same mark as a failed row. +func legacyValidatorTranscript(controlPlaneHealthy bool) string { + const ( + blue, green, red, yellow, reset = "\x1b[34m", "\x1b[32m", "\x1b[31m", "\x1b[33m", "\x1b[0m" + check, cross, warn = "\u2713", "\u2717", "\u26a0" + ) + sep := blue + strings.Repeat("\u2501", 60) + reset + header := func(title string) []string { return []string{"", sep, blue + " " + title + reset, sep} } + edge := strings.Repeat("\u2550", 59) + controlPlane := green + check + " Control Plane: Healthy" + reset + if !controlPlaneHealthy { + controlPlane = red + cross + " Control Plane: Unhealthy" + reset + } + var lines []string + lines = append(lines, header("GPU Resources")...) + lines = append(lines, red+cross+" No GPU resources found in cluster"+reset) + lines = append(lines, header("Validation Summary")...) + lines = append(lines, + "Check Results:", + controlPlane, + green+check+" Worker Nodes: All Ready"+reset, + green+check+" Admission Webhooks: Mutating & Validating Supported"+reset, + green+check+" Network Policies: Supported"+reset, + green+check+" SMB CSI Driver: v1.16.0+ Installed"+reset, + red+cross+" GPU Resources: Not Available"+reset, + yellow+warn+" GPU Operator: Not Installed"+reset, + "", sep, "", + red+"\u2554"+edge+"\u2557"+reset, + red+"\u2551 "+cross+" Cluster is NVCF-Not-Ready "+cross+" \u2551"+reset, + red+"\u255a"+edge+"\u255d"+reset, + "", + red+cross+" Your cluster does not meet all requirements for NVCF workloads"+reset, + "", + "Validation completed at 2026-10-03 18:35:37 UTC", + ) + return strings.Join(lines, "\n") + "\n" +} + +// gradeWith runs the validator check for role on a stub that returns res. +func gradeWith(role string, res ClusterValidatorResult) CheckResult { + cv := func(context.Context, ClusterValidatorParams) ClusterValidatorResult { return res } + return clusterValidatorCheck(RoleConfig{ClusterValidator: cv, ClusterValidatorImage: "img:1", KubeContext: "ctx-a"}, + role).Run(context.Background()) +} + +// The narrow downgrade for an image that predates roles, against what such an +// image really prints. Its GPU failure on a CPU-only control plane is the +// image's doing, and the failure line it prints after the verdict box is not +// a row; any other failed row stands. On the compute plane nothing is +// downgraded: the old image ran the right checks there. +func TestClusterValidatorCheck_LegacyTranscript(t *testing.T) { + grade := func(role string, controlPlaneHealthy bool) CheckResult { + return gradeWith(role, ClusterValidatorResult{ExitCode: 1, Logs: cleanValidatorOutput( + legacyValidatorTranscript(controlPlaneHealthy))}) + } + gpuOnly := grade(validatorRoleControlPlane, true) + assert.Equal(t, SeverityWarning, gpuOnly.Severity, gpuOnly.Message) + assert.Contains(t, gpuOnly.Message, "does not support the control-plane checks") + + both := grade(validatorRoleControlPlane, false) + assert.Equal(t, SeverityError, both.Severity) + assert.Equal(t, "cluster-validator reported failures (Control Plane: Unhealthy); the image predates "+ + "validator roles, so only its checks common to both roles ran", both.Message) + + compute := grade(validatorRoleComputePlane, true) + assert.Equal(t, SeverityError, compute.Severity) + assert.Equal(t, "cluster-validator reported failures: GPU Resources: Not Available", compute.Message) +} + +// The legacy test needs both halves of its evidence: no role line, and the +// compute-plane check set's output. +func TestClusterValidatorCheck_LegacyNeedsNoRoleLineAndTheGPUSet(t *testing.T) { + roleAware := gradeWith(validatorRoleControlPlane, ClusterValidatorResult{ExitCode: 1, Logs: "Validator role: " + + "control-plane\n=== GPU Resources ===\nCheck Results:\n\u2717 GPU Resources: Not Available\n" + + "Cluster is NVCF-Not-Ready\n"}) + assert.Equal(t, SeverityError, roleAware.Severity, "a role-aware image's failure stands") + assert.NotContains(t, roleAware.Message, "predates") + + early := gradeWith(validatorRoleControlPlane, ClusterValidatorResult{ExitCode: 1, + Logs: "level=fatal msg=\"Failed to create Kubernetes client\"\n"}) + assert.Equal(t, SeverityError, early.Severity) + assert.NotContains(t, early.Message, "predates", "no GPU set, so nothing says the image is old") +} + +// A Job that succeeded is a clean pass only when its transcript shows the +// requested checks reaching their verdict. Otherwise warnings may be hidden: +// the row warns, names why, says how to read the transcript, and --wait polls +// again. +func TestClusterValidatorCheck_PassNeedsTheRoleLineAndTheVerdict(t *testing.T) { + for name, tc := range map[string]struct { + role string + res ClusterValidatorResult + want string + cleanOK bool + }{ + "empty transcript": {role: validatorRoleControlPlane, want: "its transcript was empty"}, + "no role line": {role: validatorRoleControlPlane, res: ClusterValidatorResult{ + Logs: "Starting NVCF cluster validation\nCluster is NVCF-Ready\n"}, want: "does not show the control-plane checks"}, + "other role": {role: validatorRoleControlPlane, res: ClusterValidatorResult{ + Logs: "Validator role: compute-plane\nCluster is NVCF-Ready\n"}, want: "does not show the control-plane checks"}, + "cut off before the verdict": {role: validatorRoleControlPlane, res: ClusterValidatorResult{ + Logs: "Validator role: control-plane\nTier-1: ok\n"}, want: "the transcript has no verdict"}, + "ready": {role: validatorRoleControlPlane, cleanOK: true}, + "legacy compute-plane": {role: validatorRoleComputePlane, cleanOK: true}, + } { + tc.res.Passed, tc.res.JobName = true, "job-1" + switch { + case name == "ready": + tc.res.Logs = "Validator role: control-plane\nCluster is NVCF-Ready\n" + case name == "legacy compute-plane": + tc.res.Logs = "=== GPU Resources ===\nCluster is NVCF-Ready\n" + } + r := gradeWith(tc.role, tc.res) + if tc.cleanOK { + assert.True(t, r.Passed, name) + assert.Equal(t, SeverityInfo, r.Severity, name) + continue + } + assert.False(t, r.Passed, name) + assert.Equal(t, SeverityWarning, r.Severity, name) + assert.True(t, r.Transient, "%s: --wait polls again", name) + assert.Contains(t, r.Message, "verdict could not be read", name) + assert.Contains(t, r.Message, tc.want, name) + assert.Contains(t, r.Message, "kubectl --context ctx-a logs -n default job/job-1", name) + } +} + +// The validator exits non-zero when the cluster is Not-Ready, so a Job that +// succeeded passes even when its transcript cannot be read. The row says the +// transcript was not read, so the check set that ran is unconfirmed, and how +// to read it. It is not transient: polling again would only start another +// validator Job that hits the same denial. +func TestClusterValidatorCheck_SucceededJobWithAnUnreadableTranscriptPasses(t *testing.T) { + for name, logs := range map[string]string{ + "nothing read": "", + "part of it read": "Validator role: control-plane\nTier-1: ok\n", + "only the run notes": "note: validator config not applied (forbidden); reachability checks may be skipped\n", + } { + r := gradeWith(validatorRoleControlPlane, ClusterValidatorResult{Passed: true, JobName: "job-1", Logs: logs, + LogsErr: errors.New(`pods "cv-1-abc" is forbidden: cannot get resource "pods/log"`)}) + assert.True(t, r.Passed, name) + assert.Equal(t, SeverityInfo, r.Severity, name) + assert.False(t, r.Transient, name) + assert.Contains(t, r.Message, "cluster-validator Job succeeded; its transcript could not be read", name) + assert.Contains(t, r.Message, `cannot get resource "pods/log"`, name) + assert.Contains(t, r.Message, "neither the checks it ran nor any warnings it reported are shown", name) + assert.Contains(t, r.Message, "kubectl --context ctx-a logs -n default job/job-1", name) + } + + failed := gradeWith(validatorRoleControlPlane, ClusterValidatorResult{ExitCode: 1, JobName: "job-1", + LogsErr: errors.New("connection refused to kubelet")}) + assert.True(t, failed.IsBlockingFailure(), "a Job that failed still fails") +} + +// A failed run names the rows that failed it, unobserved critical checks +// included. One that ended before its verdict, killed or evicted, says so +// instead of blaming the cluster's checks. +func TestClusterValidatorCheck_FailuresNameTheirRows(t *testing.T) { + failed := gradeWith(validatorRoleControlPlane, ClusterValidatorResult{ExitCode: 1, Logs: "Validator role: " + + "control-plane\nCheck Results:\n\u2713 Control Plane: Healthy\n" + + "\u2717 Tier-2 StatefulSets: nats-system/nats not ready\n" + + "\u26a0 Gateway API: Status Unknown (check did not run)\n" + + "\u26a0 Worker Nodes: 1 NotReady (non-blocking)\n" + + "\u2717 Cluster is NVCF-Not-Ready \u2717\n" + + "\u2717 Your cluster does not meet all requirements for NVCF workloads\n"}) + assert.Equal(t, SeverityError, failed.Severity) + assert.Equal(t, "cluster-validator reported failures: Tier-2 StatefulSets: nats-system/nats not ready; "+ + "Gateway API: Status Unknown (check did not run)", failed.Message) + + oom := gradeWith(validatorRoleControlPlane, ClusterValidatorResult{ExitCode: 137, Reason: "OOMKilled", + Logs: "Validator role: control-plane\nTier-1: ok\n"}) + assert.Equal(t, SeverityError, oom.Severity) + assert.Contains(t, oom.Message, "the validator exited 137 before printing a verdict (OOMKilled)") + + evicted := gradeWith(validatorRoleComputePlane, ClusterValidatorResult{ExitCode: -1, Reason: "Evicted"}) + assert.Contains(t, evicted.Message, "its pod ended before the validator printed a verdict (Evicted)") +} + +// The Job's status can trail its pod's by more than the poll: the pod has +// succeeded while the Job still reads as running. The run waits for the Job, +// the authority, and grades on it. +func TestRunClusterValidator_JobStatusTrailingThePodIsGraded(t *testing.T) { + t.Setenv("NGC_API_KEY", "key") + prevTimeout, prevGrace := clusterValidatorTimeout, validatorDeadlineGrace + clusterValidatorTimeout, validatorDeadlineGrace = 300*time.Millisecond, 6*time.Second + t.Cleanup(func() { clusterValidatorTimeout, validatorDeadlineGrace = prevTimeout, prevGrace }) + start := time.Now() + client := lifecycleClient(func(name string) *batchv1.Job { + if time.Since(start) > 2500*time.Millisecond { + return succeeded(name) + } + return running(name) + }, "") + client.PrependReactor("list", "pods", podPhaseReactor(func() corev1.PodPhase { + if time.Since(start) > 700*time.Millisecond { + return corev1.PodSucceeded + } + return corev1.PodRunning + })) + + res := runClusterValidator(context.Background(), client, "nvcr.io/nvidia/validator:1", + "", false, clusterValidatorComputePlaneRole, nil, nil) + require.NoError(t, res.Err) + assert.True(t, res.Passed) + assert.Empty(t, leftovers(t, client, res.RunID)) +} + +// Job reads that fail before the pod exists leave an empty pod list, which +// proves nothing: the pod may still be created. Its RBAC stays. +func TestRunClusterValidator_ReadOutageBeforeThePodExistsKeepsRBAC(t *testing.T) { + prevPoll, prevTimeout := clusterValidatorPollInterval, clusterValidatorTimeout + clusterValidatorPollInterval, clusterValidatorTimeout = 20*time.Millisecond, time.Second + t.Cleanup(func() { clusterValidatorPollInterval, clusterValidatorTimeout = prevPoll, prevTimeout }) + client := lifecycleClient(running, "") + client.PrependReactor("get", "jobs", func(ktesting.Action) (bool, runtime.Object, error) { + return true, nil, apierrors.NewServiceUnavailable("the apiserver is restarting") + }) + client.PrependReactor("list", "pods", func(ktesting.Action) (bool, runtime.Object, error) { + return true, &corev1.PodList{}, nil + }) + + res := runClusterValidator(context.Background(), client, "nvcr.io/nvidia/validator:1", + "", false, clusterValidatorComputePlaneRole, nil, nil) + require.Error(t, res.Err) + assert.True(t, res.LeftBehind) + assert.Len(t, leftovers(t, client, res.RunID)["ClusterRole"], 1) +} + +// Under --no-cleanup too, a validator that passed while its Job could not be +// read passes: the Job is read again once the wait ends. +func TestRunClusterValidator_NoCleanupReadOutageIsGradedOnTheJob(t *testing.T) { + prev := clusterValidatorPollInterval + clusterValidatorPollInterval = 20 * time.Millisecond + t.Cleanup(func() { clusterValidatorPollInterval = prev }) + client := lifecycleClient(succeeded, "") + var failedReads atomic.Int32 + client.PrependReactor("get", "jobs", func(ktesting.Action) (bool, runtime.Object, error) { + if failedReads.Add(1) <= int32(validatorGetErrorLimit) { + return true, nil, apierrors.NewServiceUnavailable("the apiserver is restarting") + } + return false, nil, nil + }) + + res := runClusterValidator(context.Background(), client, "nvcr.io/nvidia/validator:1", + "", true, clusterValidatorComputePlaneRole, nil, nil) + require.NoError(t, res.Err) + assert.True(t, res.Passed) +} + +// A pod being deleted may still be running its SIGTERM cleanup, and an empty +// pod list means the pods ended only once the Job will create none. +func TestValidatorPodsEnded(t *testing.T) { + now := metav1.Now() + pod := func(phase corev1.PodPhase, deleting bool) *corev1.Pod { + p := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "p-" + string(phase), Namespace: clusterValidatorNamespace, + Labels: map[string]string{"job-name": "j"}}, Status: corev1.PodStatus{Phase: phase}} + if deleting { + p.DeletionTimestamp = &now + p.Finalizers = []string{"batch.kubernetes.io/job-tracking"} + } + return p + } + job := func(mutate func(*batchv1.Job)) *batchv1.Job { + j := running("j") + mutate(j) + return j + } + for name, tc := range map[string]struct { + objects []runtime.Object + want bool + }{ + "ended": {objects: []runtime.Object{pod(corev1.PodFailed, false)}, want: true}, + "evicted, still terminating": {objects: []runtime.Object{pod(corev1.PodFailed, true)}}, + "running with a deletion mark": {objects: []runtime.Object{pod(corev1.PodRunning, true)}}, + "no pod, Job running": {objects: []runtime.Object{running("j")}}, + "no pod, Job gone": {want: true}, + "no pod, Job finished": {objects: []runtime.Object{job(func(j *batchv1.Job) { + j.Status.Failed = 1 + })}, want: true}, + "no pod, Job being deleted": {objects: []runtime.Object{job(func(j *batchv1.Job) { + j.DeletionTimestamp = &now + j.Finalizers = []string{"foregroundDeletion"} + })}, want: true}, + } { + client := fake.NewSimpleClientset(tc.objects...) + assert.Equal(t, tc.want, validatorPodsEnded(context.Background(), client, "j"), name) + } +} + +// An evicted or preempted pod counts as failed while it still runs its +// SIGTERM cleanup. Its RBAC stays until it ends, the result says how to +// remove it, and the row reads as the pod ending early, not failed checks. +func TestRunClusterValidator_EvictedPodKeepsItsRBAC(t *testing.T) { + prev := validatorStopTimeout + validatorStopTimeout = 300 * time.Millisecond + t.Cleanup(func() { validatorStopTimeout = prev }) + client := lifecycleClient(func(name string) *batchv1.Job { + j := running(name) + j.Status.Failed = 1 + return j + }, "") + now := metav1.Now() + client.PrependReactor("list", "pods", func(action ktesting.Action) (bool, runtime.Object, error) { + _, obj, err := podPhaseReactor(func() corev1.PodPhase { return corev1.PodRunning })(action) + list := obj.(*corev1.PodList) + list.Items[0].DeletionTimestamp = &now + return true, list, err + }) + client.PrependReactor("get", "pods", func(action ktesting.Action) (bool, runtime.Object, error) { + if action.GetSubresource() != "" { + return false, nil, nil + } + return true, &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: action.(ktesting.GetAction).GetName(), Namespace: clusterValidatorNamespace}, + Status: corev1.PodStatus{Phase: corev1.PodRunning, Reason: "Evicted"}, + }, nil + }) + + res := runClusterValidator(context.Background(), client, "nvcr.io/nvidia/validator:1", + "", false, clusterValidatorComputePlaneRole, nil, nil) + require.NoError(t, res.Err) + assert.False(t, res.Passed) + assert.True(t, res.LeftBehind) + assert.Len(t, leftovers(t, client, res.RunID)["ClusterRole"], 1) + assert.Equal(t, int32(-1), res.ExitCode) + assert.Equal(t, "Evicted", res.Reason) +} + +// The wait for a Job to finish retries failed reads, and ends at once when the +// Job and its pods are gone. +func TestAwaitValidatorJob(t *testing.T) { + client := fake.NewSimpleClientset(succeeded("j")) + reads := 0 + client.PrependReactor("get", "jobs", func(ktesting.Action) (bool, runtime.Object, error) { + if reads++; reads <= 2 { + return true, nil, apierrors.NewInternalError(errors.New("etcd timeout")) + } + return false, nil, nil + }) + job, podsDone, ended := awaitValidatorJob(context.Background(), client, "j", 10*time.Second) + require.NotNil(t, job, "a failed read is retried") + assert.True(t, podsDone) + assert.False(t, ended) + + start := time.Now() + job, podsDone, _ = awaitValidatorJob(context.Background(), fake.NewSimpleClientset(), "j", 10*time.Second) + assert.Nil(t, job) + assert.True(t, podsDone) + assert.Less(t, time.Since(start), time.Second, "a Job that is gone with its pods is not waited on") +} + +// The Job's active deadline counts from its create, so it is derived from +// what is left of the run's own timeout then. It ends the pod after that +// timeout, and with the pod's termination grace inside the wait for it. +func TestRunClusterValidator_ActiveDeadlineFollowsTheRunsTimeout(t *testing.T) { + assert.Positive(t, validatorDeadlineOffset, "the deadline must not cut short the run's own wait") + assert.LessOrEqual(t, validatorDeadlineOffset+clusterValidatorTerminationGrace, defaultValidatorDeadlineGrace, + "the pod must be gone before the CLI stops waiting for it") + offset := int64(validatorDeadlineOffset / time.Second) + assert.Equal(t, 8+offset, validatorActiveDeadlineSeconds(7200*time.Millisecond)) + + prev := clusterValidatorTimeout + clusterValidatorTimeout = 10 * time.Second + t.Cleanup(func() { clusterValidatorTimeout = prev }) + client := lifecycleClient(succeeded, "") + // A slow apiserver spends part of the timeout before the Job exists. + client.PrependReactor("create", "serviceaccounts", func(ktesting.Action) (bool, runtime.Object, error) { + time.Sleep(2 * time.Second) + return false, nil, nil + }) + var deadline int64 + var env []corev1.EnvVar + client.PrependReactor("create", "jobs", func(a ktesting.Action) (bool, runtime.Object, error) { + job := a.(ktesting.CreateAction).GetObject().(*batchv1.Job) + deadline = *job.Spec.ActiveDeadlineSeconds + env = job.Spec.Template.Spec.Containers[0].Env + return false, nil, nil + }) + res := runClusterValidator(context.Background(), client, "nvcr.io/nvidia/validator:1", + "", false, clusterValidatorComputePlaneRole, nil, map[string]string{validatorTimeoutEnv: "1h"}) + require.NoError(t, res.Err) + assert.LessOrEqual(t, deadline, 8+offset, "the deadline counts from the create, not the start of the run") + assert.Greater(t, deadline, offset) + + // The validator's own timeout counts from the same point and ends its + // checks before this run stops waiting, whatever the caller passed. + var timeouts []string + for _, e := range env { + if e.Name == validatorTimeoutEnv { + timeouts = append(timeouts, e.Value) + } + } + require.Len(t, timeouts, 1) + got, err := time.ParseDuration(timeouts[0]) + require.NoError(t, err) + assert.Positive(t, got) + assert.LessOrEqual(t, got, 4*time.Second, + "counted from the time left when the Job was created, not the run's full timeout") +} + +// Past VALIDATOR_TIMEOUT the Job's deadline leaves the pod time to be +// scheduled and pull its image, which the validator's timeout does not count, +// and to tear down the probe of a check that timeout cut short. With 90s for +// both, a pull over 30s and a slow teardown met the deadline, and the run +// reported a timeout instead of the validator's verdict. +func TestValidatorActiveDeadline_LeavesRoomPastTheValidatorTimeout(t *testing.T) { + for _, left := range []time.Duration{0, 7200 * time.Millisecond, 59 * time.Second, time.Minute, 5 * time.Minute} { + timeout, err := time.ParseDuration(validatorRunTimeout(left)) + require.NoError(t, err) + room := time.Duration(validatorActiveDeadlineSeconds(left))*time.Second - timeout + assert.GreaterOrEqual(t, room, validatorPodStartSlack+validatorTeardownBudget, "%s left", left) + } +} + +// VALIDATOR_TIMEOUT leaves the summary margin of a full run's wait, and half of +// a short one. +func TestValidatorRunTimeout(t *testing.T) { + assert.Equal(t, "270s", validatorRunTimeout(5*time.Minute)) + assert.Equal(t, "20s", validatorRunTimeout(40*time.Second)) + assert.Equal(t, "1s", validatorRunTimeout(0)) +} + +// Each term of the run ceiling, with its multiplier, pinned against a sum +// written out here: a term dropped from the formula leaves the budget short. +func TestClusterValidatorRunCeiling_Terms(t *testing.T) { + prevTimeout, prevGrace, prevLogs, prevCleanup, prevMargin := clusterValidatorTimeout, + validatorDeadlineGrace, clusterValidatorLogFetchTimeout, validatorCleanupTimeout, validatorRunMargin + t.Cleanup(func() { + clusterValidatorTimeout, validatorDeadlineGrace, clusterValidatorLogFetchTimeout, validatorCleanupTimeout, + validatorRunMargin = prevTimeout, prevGrace, prevLogs, prevCleanup, prevMargin + }) + clusterValidatorTimeout, validatorDeadlineGrace, clusterValidatorLogFetchTimeout, validatorCleanupTimeout, + validatorRunMargin = time.Second, 10*time.Second, 100*time.Second, 1000*time.Second, 10000*time.Second + assert.Equal(t, (1+10+4*100+3*1000+10000)*time.Second, ClusterValidatorRunCeiling()) +} + +// logsClient is a fake clientset whose pod logs come from a real client, so a +// test can change a transcript between reads. +type logsClient struct { + *fake.Clientset + logs kubernetes.Interface +} + +func (c logsClient) CoreV1() typedcorev1.CoreV1Interface { + return logsCoreV1{CoreV1Interface: c.Clientset.CoreV1(), logs: c.logs} +} + +type logsCoreV1 struct { + typedcorev1.CoreV1Interface + logs kubernetes.Interface +} + +func (c logsCoreV1) Pods(ns string) typedcorev1.PodInterface { + return logsPods{PodInterface: c.CoreV1Interface.Pods(ns), logs: c.logs.CoreV1().Pods(ns)} +} + +type logsPods struct { + typedcorev1.PodInterface + logs typedcorev1.PodInterface +} + +func (p logsPods) GetLogs(name string, opts *corev1.PodLogOptions) *rest.Request { + return p.logs.GetLogs(name, opts) +} + +// withLogs serves every pod log read on client from transcript(). +func withLogs(t *testing.T, client *fake.Clientset, transcript func() string) kubernetes.Interface { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain") + _, _ = io.WriteString(w, transcript()) + })) + t.Cleanup(srv.Close) + logs, err := kubernetes.NewForConfig(&rest.Config{Host: srv.URL}) + require.NoError(t, err) + return logsClient{Clientset: client, logs: logs} +} + +// A validator that finishes after the CLI's own timeout is graded on its +// whole transcript, read again once it has finished: the partial one read at +// the timeout holds neither its warnings nor its verdict. +func TestRunClusterValidator_LateFinishRereadsTheTranscript(t *testing.T) { + t.Setenv("NGC_API_KEY", "key") + prevTimeout, prevGrace := clusterValidatorTimeout, validatorDeadlineGrace + clusterValidatorTimeout, validatorDeadlineGrace = 300*time.Millisecond, 5*time.Second + t.Cleanup(func() { clusterValidatorTimeout, validatorDeadlineGrace = prevTimeout, prevGrace }) + var finished atomic.Bool + fakeClient := lifecycleClient(func(name string) *batchv1.Job { + if finished.Load() { + return succeeded(name) + } + return running(name) + }, "") + client := withLogs(t, fakeClient, func() string { + if finished.Load() { + return "Validator role: control-plane\nnats: rolling update in progress\n" + + "Cluster is NVCF-Ready (with warnings)\n" + } + return "Validator role: control-plane\n" + }) + timer := time.AfterFunc(time.Second, func() { finished.Store(true) }) + t.Cleanup(func() { timer.Stop() }) + + res := runValidatorJob(context.Background(), client, ClusterValidatorParams{ + Image: "nvcr.io/nvidia/validator:1", Role: clusterValidatorControlPlaneRole, + }) + require.NoError(t, res.Err) + assert.Contains(t, res.Logs, validatorReadyWithWarnings) + r := gradeWith(validatorRoleControlPlane, res) + assert.Equal(t, SeverityWarning, r.Severity) + assert.True(t, r.Transient, "the rollout keeps --wait polling") +} + +// A check's own timeout under a live budget is its result, not the budget +// cutting it short: exit 2, not 5. +func TestRunPreflight_ACheckOwnTimeoutIsNotCutShort(t *testing.T) { + cv := func(context.Context, ClusterValidatorParams) ClusterValidatorResult { + return ClusterValidatorResult{Err: fmt.Errorf("reading the Job: %w", context.DeadlineExceeded)} + } + results := RunPreflightForRole(context.Background(), PreflightConfig{}, RoleControlPlane, + RoleConfig{ClusterValidator: cv, ClusterValidatorImage: "img:1"}, &noopSink{}) + require.Len(t, results, 1) + assert.False(t, results[0].CutShort) + assert.True(t, results[0].IsBlockingFailure()) +} + +// A pull failure that clears restarts the grace: only a failure that lasts +// the whole grace is final. The production grace covers the kubelet's second +// pull retry. +func TestWaitForClusterValidatorJob_PullFailureThatClearsRestartsTheGrace(t *testing.T) { + assert.GreaterOrEqual(t, defaultValidatorPullFailureGrace, 60*time.Second) + prevGrace, prevPoll := validatorPullFailureGrace, clusterValidatorPollInterval + validatorPullFailureGrace, clusterValidatorPollInterval = 200*time.Millisecond, 20*time.Millisecond + t.Cleanup(func() { validatorPullFailureGrace, clusterValidatorPollInterval = prevGrace, prevPoll }) + client := fake.NewSimpleClientset() + client.PrependReactor("get", "jobs", func(ktesting.Action) (bool, runtime.Object, error) { + return true, running("j"), nil + }) + client.PrependReactor("list", "pods", podPhaseReactor(func() corev1.PodPhase { return corev1.PodPending })) + start := time.Now() + client.PrependReactor("get", "pods", func(action ktesting.Action) (bool, runtime.Object, error) { + pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: action.(ktesting.GetAction).GetName(), + Namespace: clusterValidatorNamespace}} + // Failing, then pulled for a moment, then failing again. + if since := time.Since(start); since < 120*time.Millisecond || since > 180*time.Millisecond { + pod.Status.ContainerStatuses = []corev1.ContainerStatus{{Name: clusterValidatorContainer, + State: corev1.ContainerState{Waiting: &corev1.ContainerStateWaiting{Reason: "ErrImagePull", + Message: "503 Service Unavailable"}}}} + } + return true, pod, nil + }) + ctx, cancel := context.WithTimeout(context.Background(), 340*time.Millisecond) + defer cancel() + _, err := waitForClusterValidatorJob(ctx, client, "j") + var pullErr *validatorImagePullError + assert.False(t, errors.As(err, &pullErr), "no single failure lasted the grace") +} + +// The removal command is safe to paste at any time: it looks at the pod, +// stops the Job in the foreground so the pod ends while it still has its +// RBAC, revokes the cluster-wide RBAC even if a namespaced delete fails, then +// deletes the rest. It names the context the run used. +func TestValidatorRemovalCommand(t *testing.T) { + sel := clusterValidatorRunLabel + "=r1" + assert.Equal(t, "kubectl --context ctx-a get pods -n default -l "+sel+"; "+ + "kubectl --context ctx-a delete -n default job -l "+sel+" --cascade=foreground --wait; "+ + "kubectl --context ctx-a delete clusterrolebinding,clusterrole -l "+sel+"; "+ + "kubectl --context ctx-a delete -n default rolebinding,role,serviceaccount,secret,configmap -l "+sel, + validatorRemovalCommand("ctx-a", "r1")) +} + +// In a single-cluster run no context is passed, so every hint names the +// current context the run used: pasted after a context switch, it still +// reaches that cluster. +func TestClusterValidatorCheck_HintsPinTheCurrentContext(t *testing.T) { + prev := currentKubeContextNameFn + currentKubeContextNameFn = func() string { return "kind-prod" } + t.Cleanup(func() { currentKubeContextNameFn = prev }) + var got ClusterValidatorParams + cv := func(_ context.Context, p ClusterValidatorParams) ClusterValidatorResult { + got = p + return ClusterValidatorResult{JobName: "job-1", RunID: "r1", Created: true, Err: errors.New("boom")} + } + r := clusterValidatorCheck(RoleConfig{ClusterValidator: cv, ClusterValidatorImage: "img:1", + ClusterValidatorNoCleanup: true}, validatorRoleControlPlane).Run(context.Background()) + assert.Equal(t, "kind-prod", got.KubeContext, "the run uses the context its hints name") + assert.Equal(t, validatorRemovalCommand("kind-prod", "r1"), r.Cleanup) + assert.Contains(t, r.Detail, "logs: kubectl --context kind-prod logs -n default job/job-1") + assert.Contains(t, r.Detail, "kept with --no-cleanup; remove with: "+r.Cleanup) +} + +// The removal command is printed whenever the run kept something, and only +// then: --no-cleanup with objects created, a pod that may still run, or a +// sweep that failed. Every run's command goes to the ledger as it starts. +func TestClusterValidatorCheck_CleanupRow(t *testing.T) { + for name, tc := range map[string]struct { + noCleanup bool + res ClusterValidatorResult + why string + }{ + "no-cleanup pass": { + noCleanup: true, + res: ClusterValidatorResult{Created: true, Passed: true}, + why: "kept with --no-cleanup", + }, + "no-cleanup interrupted": { + noCleanup: true, + res: ClusterValidatorResult{Created: true, Err: context.Canceled}, + why: "kept with --no-cleanup", + }, + "no-cleanup, nothing made": { + noCleanup: true, + res: ClusterValidatorResult{Err: errors.New("create service account: forbidden")}, + }, + "no-cleanup bootstrap fail": { + noCleanup: true, + res: ClusterValidatorResult{Created: true, Err: errors.New("create cluster role: forbidden")}, + why: "kept with --no-cleanup", + }, + "pod may run": { + res: ClusterValidatorResult{Created: true, LeftBehind: true, Err: errors.New("x")}, + why: "may still be running", + }, + "sweep failed": {res: ClusterValidatorResult{Created: true, Passed: true, SweepErr: errors.New("503 from apiserver")}, + why: "removing the run's objects failed (503 from apiserver)"}, + "clean": {res: ClusterValidatorResult{Created: true, Passed: true}}, + } { + ledger := &CleanupLedger{} + var during []string + cv := func(_ context.Context, p ClusterValidatorParams) ClusterValidatorResult { + p.OnStart("r1") + during = ledger.Outstanding() + res := tc.res + res.RunID = "r1" + return res + } + r := clusterValidatorCheck(RoleConfig{ClusterValidator: cv, ClusterValidatorImage: "img:1", KubeContext: "ctx-a", + ClusterValidatorNoCleanup: tc.noCleanup, ValidatorCleanup: ledger}, + validatorRoleComputePlane).Run(context.Background()) + cmd := validatorRemovalCommand("ctx-a", "r1") + assert.Equal(t, []string{cmd}, during, "%s: registered before the run creates anything", name) + if tc.why == "" { + assert.Empty(t, r.Cleanup, name) + assert.Empty(t, ledger.Outstanding(), name) + continue + } + assert.Equal(t, cmd, r.Cleanup, name) + assert.Contains(t, r.Detail, tc.why, name) + assert.Equal(t, []string{cmd}, ledger.Outstanding(), name) + } +} + +// A sweep that fails leaves the run's objects, so the result says so and the +// row carries the removal command. +func TestRunClusterValidator_FailedSweepIsReported(t *testing.T) { + t.Setenv("NGC_API_KEY", "key") + client := lifecycleClient(succeeded, "") + client.PrependReactor("delete", "clusterroles", func(ktesting.Action) (bool, runtime.Object, error) { + return true, nil, apierrors.NewServiceUnavailable("the apiserver is restarting") + }) + res := runClusterValidator(context.Background(), client, "nvcr.io/nvidia/validator:1", + "", false, clusterValidatorControlPlaneRole, nil, nil) + require.NoError(t, res.Err) + require.Error(t, res.SweepErr) + assert.Contains(t, res.SweepErr.Error(), "the apiserver is restarting") + assert.Len(t, leftovers(t, client, res.RunID)["ClusterRole"], 1) +} + +// Under --no-cleanup a kept Job whose pod cannot pull is suspended on every +// path, so the pod stops retrying: here the run is interrupted inside the +// pull grace. Its row points at the Job, not at logs its deleted pod never had. +func TestRunClusterValidator_NoCleanupSuspendsAPullingJobOnInterrupt(t *testing.T) { + prev := validatorPullFailureGrace + validatorPullFailureGrace = time.Hour + t.Cleanup(func() { validatorPullFailureGrace = prev }) + client := lifecycleClient(running, "ImagePullBackOff") + ctx, cancel := context.WithCancel(context.Background()) + timer := time.AfterFunc(500*time.Millisecond, cancel) + t.Cleanup(func() { timer.Stop() }) + + res := runClusterValidator(ctx, client, "nvcr.io/nvidia/validator:1", + "", true, clusterValidatorComputePlaneRole, nil, nil) + require.ErrorIs(t, res.Err, context.Canceled) + assert.True(t, jobSuspended(client)) + assert.True(t, res.Suspended) + assert.Empty(t, jobDeletes(client)) +} + +// jobSuspended reports whether the client saw the Job suspended. +func jobSuspended(client *fake.Clientset) bool { + for _, a := range client.Actions() { + if p, ok := a.(ktesting.PatchAction); ok && p.GetResource().Resource == "jobs" && + strings.Contains(string(p.GetPatch()), `"suspend":true`) { + return true + } + } + return false +} + +// A kept, unpullable Job is suspended, so its pod is deleted: the row says to +// describe the Job, not to read logs that never existed. A suspend that fails +// leaves the pod retrying, and the row says so. +func TestRunClusterValidator_NoCleanupPullFailure(t *testing.T) { + client := lifecycleClient(running, "ImagePullBackOff") + res := runClusterValidator(context.Background(), client, "nvcr.io/nvidia/validator:1", + "", true, clusterValidatorComputePlaneRole, nil, nil) + var pullErr *validatorImagePullError + require.ErrorAs(t, res.Err, &pullErr) + require.True(t, res.Suspended) + assert.Equal(t, "inspect: kubectl --context ctx-a describe -n default job/"+res.JobName, + clusterValidatorDetail("ctx-a", res)) + + denied := lifecycleClient(running, "ImagePullBackOff") + denied.PrependReactor("patch", "jobs", func(ktesting.Action) (bool, runtime.Object, error) { + return true, nil, apierrors.NewForbidden(schema.GroupResource{Group: batchv1.GroupName, Resource: "jobs"}, + "j", errors.New("cannot patch jobs")) + }) + res = runClusterValidator(context.Background(), denied, "nvcr.io/nvidia/validator:1", + "", true, clusterValidatorComputePlaneRole, nil, nil) + require.ErrorAs(t, res.Err, &pullErr) + assert.False(t, res.Suspended) + assert.Contains(t, res.Err.Error(), "suspending the kept Job failed, so its pod keeps retrying the pull") + assert.Contains(t, res.Err.Error(), "cannot patch jobs") +} + +// A create that fails under --no-cleanup may still have been applied: the +// kept Job is suspended, since nothing follows it and with no deadline a pod +// that cannot pull would retry forever. +func TestRunClusterValidator_NoCleanupFailedCreateIsSuspended(t *testing.T) { + client := lifecycleClient(running, "") + client.PrependReactor("create", "jobs", func(action ktesting.Action) (bool, runtime.Object, error) { + require.NoError(t, client.Tracker().Add(action.(ktesting.CreateAction).GetObject())) + return true, nil, apierrors.NewInternalError(errors.New("connection reset by peer")) + }) + res := runClusterValidator(context.Background(), client, "nvcr.io/nvidia/validator:1", + "", true, clusterValidatorComputePlaneRole, nil, nil) + require.ErrorContains(t, res.Err, "creating validator Job") + assert.True(t, jobSuspended(client)) + assert.Empty(t, jobDeletes(client)) +} + +// ctxPatchClient fails a Secret or ConfigMap patch whose context has ended, as +// a real client does; the fake clientset ignores contexts. +type ctxPatchClient struct{ *fake.Clientset } + +func (c ctxPatchClient) CoreV1() typedcorev1.CoreV1Interface { + return ctxPatchCoreV1{CoreV1Interface: c.Clientset.CoreV1()} +} + +type ctxPatchCoreV1 struct{ typedcorev1.CoreV1Interface } + +func (c ctxPatchCoreV1) Secrets(ns string) typedcorev1.SecretInterface { + return ctxPatchSecrets{SecretInterface: c.CoreV1Interface.Secrets(ns)} +} + +func (c ctxPatchCoreV1) ConfigMaps(ns string) typedcorev1.ConfigMapInterface { + return ctxPatchConfigMaps{ConfigMapInterface: c.CoreV1Interface.ConfigMaps(ns)} +} + +type ctxPatchSecrets struct{ typedcorev1.SecretInterface } + +func (s ctxPatchSecrets) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, + opts metav1.PatchOptions, sub ...string) (*corev1.Secret, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + return s.SecretInterface.Patch(ctx, name, pt, data, opts, sub...) +} + +type ctxPatchConfigMaps struct{ typedcorev1.ConfigMapInterface } + +func (c ctxPatchConfigMaps) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, + opts metav1.PatchOptions, sub ...string) (*corev1.ConfigMap, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + return c.ConfigMapInterface.Patch(ctx, name, pt, data, opts, sub...) +} + +// An interrupt right after the Job create must not leave the NGC-key Secret +// and the ConfigMap unowned: if the stopped pod does not end, the Job's TTL is +// then the only thing that removes them with it. +func TestRunClusterValidator_OwnershipSurvivesAnInterruptAfterTheCreate(t *testing.T) { + t.Setenv("NGC_API_KEY", "key") + prev := validatorStopTimeout + validatorStopTimeout = 200 * time.Millisecond + t.Cleanup(func() { validatorStopTimeout = prev }) + fakeClient := lifecycleClient(running, "") + fakeClient.PrependReactor("list", "pods", podPhaseReactor(func() corev1.PodPhase { return corev1.PodRunning })) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + fakeClient.PrependReactor("create", "jobs", func(ktesting.Action) (bool, runtime.Object, error) { + cancel() + return false, nil, nil + }) + + res := runValidatorJob(ctx, ctxPatchClient{fakeClient}, ClusterValidatorParams{ + Image: "nvcr.io/nvidia/validator:1", Role: clusterValidatorControlPlaneRole, + }) + require.ErrorIs(t, res.Err, context.Canceled) + require.True(t, res.LeftBehind) + left := leftovers(t, fakeClient, res.RunID) + require.Len(t, left["Secret"], 1) + require.Len(t, left["ConfigMap"], 1) + sec, err := fakeClient.CoreV1().Secrets(clusterValidatorNamespace).Get(ctx, left["Secret"][0], metav1.GetOptions{}) + require.NoError(t, err) + assert.Len(t, sec.OwnerReferences, 1) + cm, err := fakeClient.CoreV1().ConfigMaps(clusterValidatorNamespace).Get(ctx, + left["ConfigMap"][0], metav1.GetOptions{}) + require.NoError(t, err) + assert.Len(t, cm.OwnerReferences, 1) +} + +// An outage early in the run ends the wait early, but the validator still has +// the rest of its timeout: the wait for its result covers that as well as the +// deadline grace, so a validator that passes late in its timeout passes. +func TestRunClusterValidator_EarlyOutageThenLatePass(t *testing.T) { + t.Setenv("NGC_API_KEY", "key") + prevPoll, prevTimeout := clusterValidatorPollInterval, clusterValidatorTimeout + clusterValidatorPollInterval, clusterValidatorTimeout = 20*time.Millisecond, 3*time.Second + t.Cleanup(func() { clusterValidatorPollInterval, clusterValidatorTimeout = prevPoll, prevTimeout }) + start := time.Now() + late := func() bool { return time.Since(start) > validatorDeadlineGrace+500*time.Millisecond } + client := lifecycleClient(func(name string) *batchv1.Job { + if late() { + return succeeded(name) + } + return running(name) + }, "") + var failedReads atomic.Int32 + client.PrependReactor("get", "jobs", func(ktesting.Action) (bool, runtime.Object, error) { + if failedReads.Add(1) <= int32(validatorGetErrorLimit) { + return true, nil, apierrors.NewServiceUnavailable("the apiserver is restarting") + } + return false, nil, nil + }) + + res := runClusterValidator(context.Background(), client, "nvcr.io/nvidia/validator:1", + "", false, clusterValidatorComputePlaneRole, nil, nil) + require.NoError(t, res.Err) + assert.True(t, res.Passed) + assert.False(t, res.LeftBehind) +} + +// A pull Secret the run could not create is named when the pull then fails, +// rather than leaving only the registry's "unauthorized" to blame the key. +func TestRunClusterValidator_PullFailureNamesTheRefusedPullSecret(t *testing.T) { + t.Setenv("NGC_API_KEY", "key") + client := lifecycleClient(running, "ImagePullBackOff") + client.PrependReactor("create", "secrets", func(ktesting.Action) (bool, runtime.Object, error) { + return true, nil, apierrors.NewForbidden(corev1.Resource("secrets"), "x", errors.New("denied by policy")) + }) + + res := runClusterValidator(context.Background(), client, "nvcr.io/nvidia/validator:1", + "", false, clusterValidatorControlPlaneRole, nil, nil) + require.Error(t, res.Err) + assert.Contains(t, res.Err.Error(), "cannot pull image") + assert.Contains(t, res.Err.Error(), "the run's pull secret was not created") + assert.Contains(t, res.Err.Error(), "denied by policy") +} + +// A credential copied into the validator namespace is reported on the +// validator's row, with where it came from. +func TestClusterValidatorCheck_ReportsACopiedPullCredential(t *testing.T) { + client := lifecycleClient(succeeded, "") + cfg, err := buildDockerConfigJSON("nvcr.io", "$oauthtoken", "k") + require.NoError(t, err) + _, err = client.CoreV1().Secrets("vault-system").Create(context.Background(), &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "regcred", Namespace: "vault-system"}, + Type: corev1.SecretTypeDockerConfigJson, + Data: map[string][]byte{corev1.DockerConfigJsonKey: cfg}, + }, metav1.CreateOptions{}) + require.NoError(t, err) + + res := runClusterValidator(context.Background(), client, "nvcr.io/nvidia/validator:1", + "", false, clusterValidatorComputePlaneRole, nil, nil) + require.Len(t, res.Notes, 1) + assert.Contains(t, res.Notes[0], "copied the pull credential for nvcr.io from vault-system/regcred into default/") + + r := clusterValidatorCheck(RoleConfig{ + ClusterValidator: func(context.Context, ClusterValidatorParams) ClusterValidatorResult { return res }, + ClusterValidatorImage: "nvcr.io/nvidia/validator:1", KubeContext: "ctx", + }, clusterValidatorComputePlaneRole).Run(context.Background()) + assert.Contains(t, r.Detail, "copied the pull credential for nvcr.io from vault-system/regcred") +} diff --git a/src/clis/nvcf-cli/internal/selfhosted/clustervalidator_ownership_test.go b/src/clis/nvcf-cli/internal/selfhosted/clustervalidator_ownership_test.go new file mode 100644 index 0000000000..e1c7b06984 --- /dev/null +++ b/src/clis/nvcf-cli/internal/selfhosted/clustervalidator_ownership_test.go @@ -0,0 +1,431 @@ +/* +SPDX-FileCopyrightText: Copyright (c) NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package selfhosted + +import ( + "context" + "errors" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes/fake" + ktesting "k8s.io/client-go/testing" +) + +// stampCreates makes every Create return what an apiserver does: a UID, and a +// creationTimestamp on the apiserver's clock, which runs skew from the local +// one. It records each created object's UID by "/". +func stampCreates(client *fake.Clientset, skew time.Duration) *sync.Map { + uids := &sync.Map{} + client.PrependReactor("create", "*", func(a ktesting.Action) (bool, runtime.Object, error) { + o, ok := a.(ktesting.CreateAction).GetObject().(metav1.Object) + if !ok { + return false, nil, nil + } + uid := types.UID("uid-" + a.GetResource().Resource + "-" + o.GetName()) + o.SetUID(uid) + o.SetCreationTimestamp(metav1.NewTime(time.Now().Add(skew))) + uids.Store(a.GetResource().Resource+"/"+o.GetName(), uid) + return false, nil, nil + }) + return uids +} + +// jobEnv returns the env of the Job the run created. +func jobEnv(t *testing.T, client *fake.Clientset) map[string]string { + t.Helper() + for _, a := range client.Actions() { + if c, ok := a.(ktesting.CreateAction); ok && a.GetResource().Resource == "jobs" { + env := map[string]string{} + for _, e := range c.GetObject().(*batchv1.Job).Spec.Template.Spec.Containers[0].Env { + env[e.Name] = e.Value + } + return env + } + } + t.Fatal("the run created no Job") + return nil +} + +// Every delete a run makes is pinned to the UID its Create returned and to +// nothing else. A ResourceVersion precondition turns every delete of a Job, +// whose status its controller keeps writing, into a 409 the sweeps swallow. +func TestRunClusterValidator_DeletesArePinnedToTheCreatedUID(t *testing.T) { + t.Setenv("NGC_API_KEY", "key") + client := lifecycleClient(running, "ImagePullBackOff") + uids := stampCreates(client, 0) + + res := runClusterValidator(context.Background(), client, "nvcr.io/nvidia/validator:1", + "", false, clusterValidatorControlPlaneRole, []RegistryEntry{{Registry: "nvcr.io"}}, nil) + require.Error(t, res.Err) + + deleted := map[string]bool{} + for _, a := range client.Actions() { + d, ok := a.(ktesting.DeleteActionImpl) + if !ok { + continue + } + key := d.GetResource().Resource + "/" + d.Name + want, created := uids.Load(key) + require.True(t, created, "%s was deleted but not created by this run", key) + require.NotNil(t, d.DeleteOptions.Preconditions, key) + require.NotNil(t, d.DeleteOptions.Preconditions.UID, key) + assert.Equal(t, want, *d.DeleteOptions.Preconditions.UID, key) + assert.Nil(t, d.DeleteOptions.Preconditions.ResourceVersion, key) + deleted[d.GetResource().Resource] = true + } + for _, r := range []string{ + "jobs", "serviceaccounts", "clusterroles", "clusterrolebindings", "roles", "rolebindings", "secrets", + "configmaps", + } { + assert.True(t, deleted[r], "a pull failure removes the run's %s", r) + } +} + +// The Job is named per role and run, like the RBAC. A name from the clock +// alone collides across the two concurrent roles where the clock is coarse, +// and the loser then stopped the winner's live Job. +func TestRunClusterValidator_JobIsNamedPerRoleAndRun(t *testing.T) { + for _, role := range []string{clusterValidatorControlPlaneRole, clusterValidatorComputePlaneRole} { + client := lifecycleClient(succeeded, "") + res := runClusterValidator(context.Background(), client, "registry.example.com/validator:1", + "", false, role, nil, nil) + require.NoError(t, res.Err) + assert.Equal(t, clusterValidatorRBACName(role, res.RunID), res.JobName) + } +} + +// Another run's Job under this run's name is never stopped: not on +// AlreadyExists, which proves the name was taken, and not when a Create +// errored in a way that may have been applied, unless the Job read back +// carries this run's ID. +func TestRunClusterValidator_AnotherRunsJobUnderThisRunsNameSurvives(t *testing.T) { + for name, createErr := range map[string]func(string) error{ + "already exists": func(n string) error { + return apierrors.NewAlreadyExists(schema.GroupResource{Group: "batch", Resource: "jobs"}, n) + }, + "connection reset": func(string) error { return apierrors.NewInternalError(errors.New("connection reset")) }, + } { + t.Run(name, func(t *testing.T) { + client := fake.NewSimpleClientset() + client.PrependReactor("create", "jobs", func(a ktesting.Action) (bool, runtime.Object, error) { + job := a.(ktesting.CreateAction).GetObject().(*batchv1.Job) + other := job.DeepCopy() + other.Labels = clusterValidatorRunLabels(clusterValidatorComputePlaneRole, "otherrun", false) + other.UID = "uid-other" + require.NoError(t, client.Tracker().Add(other)) + return true, nil, createErr(job.Name) + }) + + // The other run's pod is still running. + client.PrependReactor("list", "pods", podPhaseReactor(func() corev1.PodPhase { return corev1.PodRunning })) + + res := runClusterValidator(context.Background(), client, "registry.example.com/validator:1", + "", false, clusterValidatorComputePlaneRole, nil, nil) + require.ErrorContains(t, res.Err, "creating validator Job") + assert.Empty(t, jobDeletes(client), "another run's Job must not be stopped") + assert.False(t, res.LeftBehind, "another run's pod does not keep this run's objects") + assert.Empty(t, leftovers(t, client, res.RunID)) + _, err := client.BatchV1().Jobs(clusterValidatorNamespace).Get(context.Background(), + clusterValidatorRBACName(clusterValidatorComputePlaneRole, res.RunID), metav1.GetOptions{}) + assert.NoError(t, err) + }) + // A kept run suspends its own Job when the create errored, never + // another run's under the name. + t.Run(name+", --no-cleanup", func(t *testing.T) { + client := fake.NewSimpleClientset() + client.PrependReactor("create", "jobs", func(a ktesting.Action) (bool, runtime.Object, error) { + job := a.(ktesting.CreateAction).GetObject().(*batchv1.Job) + other := job.DeepCopy() + other.Labels = clusterValidatorRunLabels(clusterValidatorComputePlaneRole, "otherrun", false) + other.UID = "uid-other" + require.NoError(t, client.Tracker().Add(other)) + return true, nil, createErr(job.Name) + }) + res := runClusterValidator(context.Background(), client, "registry.example.com/validator:1", + "", true, clusterValidatorComputePlaneRole, nil, nil) + require.ErrorContains(t, res.Err, "creating validator Job") + assert.False(t, jobSuspended(client), "another run's Job must not be suspended") + assert.NotContains(t, res.Err.Error(), "suspending the kept Job failed") + }) + } +} + +// A kept Job is suspended with its UID in the patch, so the apiserver refuses +// it if another object has taken the name since. +func TestSuspendValidatorJob_IsPinnedToTheJobsUID(t *testing.T) { + job := &batchv1.Job{ObjectMeta: metav1.ObjectMeta{Name: "j", Namespace: clusterValidatorNamespace, UID: "uid-mine"}} + client := fake.NewSimpleClientset(job) + require.NoError(t, suspendValidatorJob(client, "j", "run1", runObjects{kindJob: job})) + var patch string + for _, a := range client.Actions() { + if p, ok := a.(ktesting.PatchAction); ok && p.GetResource().Resource == "jobs" { + patch = string(p.GetPatch()) + } + } + assert.Contains(t, patch, `"uid":"uid-mine"`) + assert.Contains(t, patch, `"suspend":true`) +} + +// A bootstrap that hits AlreadyExists did not create that object, so the +// cleanup must not delete it, whatever labels it wears. Only what the run +// created goes. +func TestRunClusterValidator_RefusedBootstrapObjectSurvivesTheCleanup(t *testing.T) { + for name, lbls := range map[string]func(runID string) map[string]string{ + "unlabelled": func(string) map[string]string { return nil }, + "forged labels": func(runID string) map[string]string { + return clusterValidatorRunLabels(clusterValidatorComputePlaneRole, runID, false) + }, + } { + t.Run(name, func(t *testing.T) { + client := fake.NewSimpleClientset() + var runID string + client.PrependReactor("create", "clusterroles", func(a ktesting.Action) (bool, runtime.Object, error) { + cr := a.(ktesting.CreateAction).GetObject().(*rbacv1.ClusterRole) + runID = cr.Labels[clusterValidatorRunLabel] + squat := &rbacv1.ClusterRole{ObjectMeta: metav1.ObjectMeta{Name: cr.Name, Labels: lbls(runID)}} + require.NoError(t, client.Tracker().Add(squat)) + return true, nil, apierrors.NewAlreadyExists( + schema.GroupResource{Group: rbacv1.GroupName, Resource: "clusterroles"}, cr.Name) + }) + + res := runClusterValidator(context.Background(), client, "registry.example.com/validator:1", + "", false, clusterValidatorComputePlaneRole, nil, nil) + require.ErrorContains(t, res.Err, "bootstrapping validator RBAC") + name := clusterValidatorRBACName(clusterValidatorComputePlaneRole, runID) + _, err := client.RbacV1().ClusterRoles().Get(context.Background(), name, metav1.GetOptions{}) + assert.NoError(t, err, "the ClusterRole this run did not create must survive") + _, err = client.CoreV1().ServiceAccounts(clusterValidatorNamespace).Get(context.Background(), name, + metav1.GetOptions{}) + assert.True(t, apierrors.IsNotFound(err), "the ServiceAccount this run created is removed") + }) + } +} + +// A ConfigMap that appears under the run's name between the RBAC and the +// ConfigMap create, unlabelled or wearing forged managed labels, is neither +// adopted, owned, deleted nor read: the Job reads no ConfigMap at all. +func TestRunClusterValidator_PreExistingConfigMapIsNotAdoptedOrWired(t *testing.T) { + for name, lbls := range map[string]func(runID string) map[string]string{ + "unlabelled": func(string) map[string]string { return nil }, + "forged labels": func(runID string) map[string]string { + return clusterValidatorRunLabels(clusterValidatorControlPlaneRole, runID, false) + }, + } { + t.Run(name, func(t *testing.T) { + client := lifecycleClient(succeeded, "") + var cmName string + client.PrependReactor("create", "serviceaccounts", func(a ktesting.Action) (bool, runtime.Object, error) { + runID := a.(ktesting.CreateAction).GetObject().(*corev1.ServiceAccount).Labels[clusterValidatorRunLabel] + cmName = clusterValidatorConfigRunName(runID) + require.NoError(t, client.Tracker().Add(&corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: cmName, Namespace: clusterValidatorNamespace, + Labels: lbls(runID)}, + Data: map[string]string{"config.yaml": "enforcement:\n enabled: true\n"}, + })) + return false, nil, nil + }) + + res := runClusterValidator(context.Background(), client, "registry.example.com/validator:1", + "", false, clusterValidatorControlPlaneRole, []RegistryEntry{{Registry: "nvcr.io"}}, nil) + require.NoError(t, res.Err) + assert.Contains(t, res.Logs, "validator config not applied") + assert.Equal(t, clusterValidatorNoConfigName, jobEnv(t, client)["VALIDATOR_CONFIG_NAME"], + "a ConfigMap this run did not create is never wired into the Job") + + cm, err := client.CoreV1().ConfigMaps(clusterValidatorNamespace).Get(context.Background(), cmName, + metav1.GetOptions{}) + require.NoError(t, err, "the other ConfigMap must survive the run's cleanup") + assert.Equal(t, "enforcement:\n enabled: true\n", cm.Data["config.yaml"]) + assert.Empty(t, cm.OwnerReferences) + for _, a := range client.Actions() { + if a.GetResource().Resource == "configmaps" { + assert.NotContains(t, []string{"update", "patch", "delete"}, a.GetVerb()) + } + } + }) + } +} + +// Only the control-plane role reads a ConfigMap, and only one its run created. +// The compute role is pointed at a name no ConfigMap can have, never an empty +// value, which would let the validator fall back to its default name. +func TestRunClusterValidator_ConfigNamePerRole(t *testing.T) { + cp := lifecycleClient(succeeded, "") + res := runClusterValidator(context.Background(), cp, "registry.example.com/validator:1", + "", false, clusterValidatorControlPlaneRole, []RegistryEntry{{Registry: "nvcr.io"}}, nil) + require.NoError(t, res.Err) + assert.Equal(t, clusterValidatorConfigRunName(res.RunID), jobEnv(t, cp)["VALIDATOR_CONFIG_NAME"]) + + gpu := lifecycleClient(succeeded, "") + res = runClusterValidator(context.Background(), gpu, "registry.example.com/validator:1", + "", false, clusterValidatorComputePlaneRole, nil, nil) + require.NoError(t, res.Err) + assert.Equal(t, clusterValidatorNoConfigName, jobEnv(t, gpu)["VALIDATOR_CONFIG_NAME"]) +} + +// Every object a run creates carries its role, run and managed labels, the +// ConfigMap included, so the removal command and the per-role selectors see +// all of them. +func TestRunClusterValidator_EveryObjectCarriesRoleAndRunLabels(t *testing.T) { + t.Setenv("NGC_API_KEY", "key") + client := lifecycleClient(succeeded, "") + res := runClusterValidator(context.Background(), client, "nvcr.io/nvidia/validator:1", + "", true, clusterValidatorControlPlaneRole, []RegistryEntry{{Registry: "nvcr.io"}}, nil) + require.NoError(t, res.Err) + seen := 0 + for _, a := range client.Actions() { + c, ok := a.(ktesting.CreateAction) + if !ok { + continue + } + o := c.GetObject().(metav1.Object) + assert.Equal(t, clusterValidatorControlPlaneRole, o.GetLabels()[clusterValidatorRoleLabel], o.GetName()) + assert.Equal(t, res.RunID, o.GetLabels()[clusterValidatorRunLabel], o.GetName()) + assert.True(t, hasValidatorManagedLabels(o.GetLabels()), o.GetName()) + seen++ + } + assert.Equal(t, 8, seen, "RBAC (5), Secret, ConfigMap and Job") +} + +// The NGC-key pull Secret is minted only once the run can start a Job. A +// kubeconfig that cannot create the RBAC never gets the key written into the +// namespace. +func TestRunClusterValidator_DeniedBootstrapMintsNoPullSecret(t *testing.T) { + t.Setenv("NGC_API_KEY", "key") + client := fake.NewSimpleClientset() + client.PrependReactor("create", "clusterroles", func(ktesting.Action) (bool, runtime.Object, error) { + return true, nil, apierrors.NewForbidden( + schema.GroupResource{Group: rbacv1.GroupName, Resource: "clusterroles"}, "", nil) + }) + res := runClusterValidator(context.Background(), client, "nvcr.io/nvidia/validator:1", + "", true, clusterValidatorComputePlaneRole, nil, nil) + require.Error(t, res.Err) + for _, a := range client.Actions() { + assert.False(t, a.GetVerb() == "create" && a.GetResource().Resource == "secrets", + "no Secret may be created before the bootstrap succeeds") + } +} + +// The orphan sweep ages objects on the apiserver's clock. Here it runs ten +// hours behind the local clock, by which every object would look stale: only +// another run's object past the TTL on the apiserver's clock goes. The run's +// own objects and a Secret the operator named explicitly are never taken, +// however old, and the sweep runs before the Job is created. +func TestRunClusterValidator_OrphanSweepUsesTheServerClock(t *testing.T) { + const skew = -10 * time.Hour + serverAgo := func(age time.Duration) metav1.Time { return metav1.NewTime(time.Now().Add(skew - age)) } + cr := func(run string, age time.Duration) *rbacv1.ClusterRole { + return &rbacv1.ClusterRole{ObjectMeta: metav1.ObjectMeta{ + Name: clusterValidatorRBACName(clusterValidatorComputePlaneRole, run), + Labels: clusterValidatorRunLabels(clusterValidatorComputePlaneRole, run, false), + CreationTimestamp: serverAgo(age), + }} + } + stale, live := cr("stalerun", orphanValidatorRBACTTL+time.Minute), cr("liverun", orphanValidatorRBACTTL-time.Minute) + explicit := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{ + Name: validatorPullSecretRunName(clusterValidatorComputePlaneRole, "keptrun"), + Namespace: clusterValidatorNamespace, + Labels: clusterValidatorRunLabels(clusterValidatorComputePlaneRole, "keptrun", true), + // Past even the preserved TTL. + CreationTimestamp: serverAgo(preservedValidatorTTL + time.Hour), + }} + client := lifecycleClient(succeeded, "") + for _, o := range []runtime.Object{stale, live, explicit} { + require.NoError(t, client.Tracker().Add(o)) + } + // This run's ClusterRole reads as past even the preserved TTL: only the + // run ID spares it. Registered first, so it runs after stampCreates. + client.PrependReactor("create", "clusterroles", func(a ktesting.Action) (bool, runtime.Object, error) { + a.(ktesting.CreateAction).GetObject().(metav1.Object).SetCreationTimestamp( + serverAgo(preservedValidatorTTL + time.Hour)) + return false, nil, nil + }) + stampCreates(client, skew) + + res := runClusterValidator(context.Background(), client, "registry.example.com/validator:1", + explicit.Name, true, clusterValidatorComputePlaneRole, nil, nil) + require.NoError(t, res.Err) + + crs := client.RbacV1().ClusterRoles() + _, err := crs.Get(context.Background(), stale.Name, metav1.GetOptions{}) + assert.True(t, apierrors.IsNotFound(err), "another run's object past the TTL is reclaimed") + _, err = crs.Get(context.Background(), live.Name, metav1.GetOptions{}) + assert.NoError(t, err, "an overlapping run's object is younger than the TTL on the apiserver's clock") + _, err = crs.Get(context.Background(), clusterValidatorRBACName(clusterValidatorComputePlaneRole, res.RunID), + metav1.GetOptions{}) + assert.NoError(t, err, "the run never sweeps its own objects") + _, err = client.CoreV1().Secrets(clusterValidatorNamespace).Get(context.Background(), explicit.Name, + metav1.GetOptions{}) + assert.NoError(t, err, "an explicitly named pull Secret is never swept") + assert.Equal(t, explicit.Name, jobPullSecret(t, client)) + + order := []string{} + for _, a := range client.Actions() { + switch { + case a.GetVerb() == "delete" && a.GetResource().Resource == "clusterroles": + order = append(order, "sweep") + case a.GetVerb() == "create" && a.GetResource().Resource == "jobs": + order = append(order, "job") + } + } + assert.Equal(t, []string{"sweep", "job"}, order) +} + +// jobPullSecret is the pull Secret the run's Job references. +func jobPullSecret(t *testing.T, client *fake.Clientset) string { + t.Helper() + for _, a := range client.Actions() { + if c, ok := a.(ktesting.CreateAction); ok && a.GetResource().Resource == "jobs" { + var names []string + for _, s := range c.GetObject().(*batchv1.Job).Spec.Template.Spec.ImagePullSecrets { + names = append(names, s.Name) + } + return strings.Join(names, ",") + } + } + return "" +} + +// A live run's objects must outlast it on the apiserver's clock: the longest +// a run takes, and the longest its pod can outlive the run, stay below the +// orphan TTL. +func TestClusterValidatorRunCeiling_StaysBelowTheOrphanTTL(t *testing.T) { + prevStop, prevGrace := validatorStopTimeout, validatorDeadlineGrace + validatorStopTimeout, validatorDeadlineGrace = defaultValidatorStopTimeout, defaultValidatorDeadlineGrace + t.Cleanup(func() { validatorStopTimeout, validatorDeadlineGrace = prevStop, prevGrace }) + podLife := time.Duration(*buildClusterValidatorJob("j", "img:1", "", clusterValidatorComputePlaneRole, "r", + clusterValidatorNoConfigName, false, nil).Spec.ActiveDeadlineSeconds)*time.Second + + clusterValidatorTerminationGrace + assert.Less(t, max(ClusterValidatorRunCeiling(), podLife), orphanValidatorRBACTTL) + assert.Greater(t, validatorStopTimeout, clusterValidatorTerminationGrace, + "a stop waits out the pod's termination grace period") +} diff --git a/src/clis/nvcf-cli/internal/selfhosted/clustervalidator_test.go b/src/clis/nvcf-cli/internal/selfhosted/clustervalidator_test.go index 70ef4a6a91..770a1daef3 100644 --- a/src/clis/nvcf-cli/internal/selfhosted/clustervalidator_test.go +++ b/src/clis/nvcf-cli/internal/selfhosted/clustervalidator_test.go @@ -18,8 +18,15 @@ limitations under the License. package selfhosted import ( + "bytes" "context" + "errors" "fmt" + "io/fs" + "maps" + "os" + "path/filepath" + "slices" "strings" "sync/atomic" "testing" @@ -35,8 +42,10 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/validation" "k8s.io/client-go/kubernetes/fake" ktesting "k8s.io/client-go/testing" + "sigs.k8s.io/yaml" ) func jobSucceededReactor(name string) ktesting.ReactionFunc { @@ -85,8 +94,10 @@ func podListReactor(waitingReason string) ktesting.ReactionFunc { Namespace: clusterValidatorNamespace, Labels: map[string]string{"job-name": jobName}, }, + Status: corev1.PodStatus{Phase: corev1.PodSucceeded}, } if waitingReason != "" { + pod.Status.Phase = corev1.PodPending pod.Status.ContainerStatuses = []corev1.ContainerStatus{{ Name: clusterValidatorContainer, State: corev1.ContainerState{Waiting: &corev1.ContainerStateWaiting{ @@ -99,38 +110,52 @@ func podListReactor(waitingReason string) ktesting.ReactionFunc { } } -func hasActionPrefix(actions []ktesting.Action, verb, resource string) bool { - for _, a := range actions { - if a.GetVerb() == verb && a.GetResource().Resource == resource { - return true - } - } - return false -} - func TestRunClusterValidator_EmptyImage(t *testing.T) { // In normal flow the caller gates on a configured image, so this // branch is defensive. Verify it returns a clear error and makes // no API calls. client := fake.NewSimpleClientset() - res := runClusterValidator(context.Background(), client, "", "", false) + res := runClusterValidator(context.Background(), client, "", "", false, "", nil, nil) require.Error(t, res.Err) assert.Contains(t, res.Err.Error(), "image is empty") assert.False(t, res.Passed) assert.Empty(t, client.Actions(), "no API calls should be made when input validation fails") } -func TestClusterValidatorCheck_OrchestratorErrorStaysWarning(t *testing.T) { +// A validator that could not run checked nothing, so it fails the run rather +// than letting a readiness gate pass. The message names the opt-out. +func TestClusterValidatorCheck_OrchestratorErrorFails(t *testing.T) { cv := func(_ context.Context, _ ClusterValidatorParams) ClusterValidatorResult { - return ClusterValidatorResult{Err: fmt.Errorf("transient: API server unreachable")} + return ClusterValidatorResult{Err: fmt.Errorf("bootstrapping validator RBAC: forbidden")} } - r := clusterValidatorCheck(cv, "", "", "", false).Run(context.Background()) + r := clusterValidatorCheck(RoleConfig{ClusterValidator: cv}, "").Run(context.Background()) assert.False(t, r.Passed) - assert.Equal(t, "warning", r.Severity, - "transient orchestrator failures should not fail the overall preflight") + assert.Equal(t, SeverityError, r.Severity) + assert.True(t, r.IsBlockingFailure()) assert.Contains(t, r.Message, "cluster-validator did not complete") + assert.Contains(t, r.Message, "forbidden") + assert.Contains(t, r.Message, "--skip-cluster-validation") } +// driveJobToSuccess wires the reactors that make a created Job reach a +// Succeeded terminal state, mirroring TestRunClusterValidator_HappyPath. +func driveJobToSuccess(client *fake.Clientset) { + var jobName atomic.Value + jobName.Store("") + client.PrependReactor("create", "jobs", func(action ktesting.Action) (bool, runtime.Object, error) { + job := action.(ktesting.CreateAction).GetObject().(*batchv1.Job) + jobName.Store(job.Name) + return false, nil, nil + }) + client.PrependReactor("get", "jobs", func(action ktesting.Action) (bool, runtime.Object, error) { + name := jobName.Load().(string) + if name == "" { + return false, nil, nil + } + return jobSucceededReactor(name)(action) + }) + client.PrependReactor("list", "pods", podListReactor("")) +} func TestRunClusterValidator_HappyPath(t *testing.T) { client := fake.NewSimpleClientset() @@ -169,26 +194,19 @@ func TestRunClusterValidator_HappyPath(t *testing.T) { }, nil }) - res := runClusterValidator(context.Background(), client, "test-image:1.0", "", false) + res := runClusterValidator(context.Background(), client, "test-image:1.0", "", false, "", nil, nil) require.NoError(t, res.Err, "happy path must not surface an error") assert.True(t, res.Passed, "Succeeded>0 maps to Passed=true") assert.Equal(t, int32(0), res.ExitCode) assert.NotEmpty(t, res.JobName, "JobName must be populated so operators can read logs after the run") - // Sweep must run before create so the singleton invariant holds. - actions := client.Actions() - require.True(t, hasActionPrefix(actions, "delete-collection", "jobs"), - "runClusterValidator must sweep prior Jobs before creating the new one") - var sweepIdx, createIdx = -1, -1 - for i, a := range actions { - if a.GetVerb() == "delete-collection" && a.GetResource().Resource == "jobs" && sweepIdx == -1 { - sweepIdx = i - } - if a.GetVerb() == "create" && a.GetResource().Resource == "jobs" && createIdx == -1 { - createIdx = i - } + // No Job is deleted on a clean run: there is no sweep of earlier Jobs, + // which could only ever hit an overlapping run's live one. The orphan + // sweep lists Jobs but reclaims only preserved ones past their TTL. + for _, a := range client.Actions() { + assert.False(t, a.GetVerb() == "delete" && a.GetResource().Resource == "jobs", + "a clean run deletes no Job") } - assert.Less(t, sweepIdx, createIdx, "sweep must precede create in action sequence") } func TestRunClusterValidator_JobFailed(t *testing.T) { @@ -204,73 +222,318 @@ func TestRunClusterValidator_JobFailed(t *testing.T) { }) client.PrependReactor("list", "pods", podListReactor("")) - res := runClusterValidator(context.Background(), client, "test-image:1.0", "", false) + res := runClusterValidator(context.Background(), client, "test-image:1.0", "", false, "", nil, nil) require.NoError(t, res.Err, "a clean Passed=false verdict must not set Err") assert.False(t, res.Passed, "Failed>0 maps to Passed=false") assert.NotEmpty(t, res.JobName, "JobName must be populated on failure for kubectl-logs follow-up") } -func TestRunClusterValidator_RBACIdempotent(t *testing.T) { +// Each run mints an unguessable RBAC name, so there is no idempotence left to +// test: a name collision means something else already holds the name this run +// is about to bind cluster-wide permissions to. That must fail, not be adopted. +// (Replaces TestRunClusterValidator_RBACIdempotent, which seeded objects under +// the old fixed name and so never produced the AlreadyExists it described.) +func TestRunClusterValidator_RBACNameCollisionIsNotAdopted(t *testing.T) { client := fake.NewSimpleClientset() - // Seed the cluster with the SA, ClusterRole, and ClusterRoleBinding so - // each Create returns AlreadyExists. The runner must treat that as - // success and proceed to Job creation. - existing := []ktesting.ReactionFunc{ - alreadyExistsReactor("serviceaccounts", clusterValidatorName), - alreadyExistsReactor("clusterroles", clusterValidatorName), - alreadyExistsReactor("clusterrolebindings", clusterValidatorName), + client.PrependReactor("create", "clusterroles", func(action ktesting.Action) (bool, runtime.Object, error) { + name := action.(ktesting.CreateAction).GetObject().(*rbacv1.ClusterRole).Name + return true, nil, apierrors.NewAlreadyExists( + schema.GroupResource{Group: "rbac.authorization.k8s.io", Resource: "clusterroles"}, name) + }) + + res := runClusterValidator(context.Background(), client, "nvcr.io/x/validator:1", + "", false, clusterValidatorControlPlaneRole, nil, nil) + require.Error(t, res.Err, "a pre-existing object under our generated name must not be adopted") + assert.Contains(t, res.Err.Error(), "bootstrapping validator RBAC") +} + +// validatorRBACInventory is the validator's RBAC inventory, which lists every +// request it makes by check set. Bazel stages only this package's data, so +// there the test reads the checked-in copy instead. +var ( + validatorRBACInventory = filepath.Join("..", "..", "..", "..", "compute-plane-services", "nvca", "internal", + "clustervalidator", "rbac_inventory.yaml") + validatorRBACInventoryCopy = filepath.Join("testdata", "rbac_inventory.yaml") + + validatorRBACInventoryNotCompared = fmt.Sprintf("%s is not reachable from this test, so %s was not "+ + "compared with the validator's inventory: the RBAC tests ran against the copy alone", + validatorRBACInventory, validatorRBACInventoryCopy) +) + +// readValidatorRBACInventory returns the inventory at src, or the copy when +// src does not exist, and whether src was there to compare the copy with. +// When both exist they must be identical, so the copy cannot drift from the +// validator. +func readValidatorRBACInventory(src, copyPath string) (raw []byte, compared bool, err error) { + cp, err := os.ReadFile(copyPath) + if err != nil { + return nil, false, err } - verbs := []string{"create"} - resources := []string{"serviceaccounts", "clusterroles", "clusterrolebindings"} - for i, r := range existing { - client.PrependReactor(verbs[0], resources[i], r) + raw, err = os.ReadFile(src) + if errors.Is(err, fs.ErrNotExist) { + return cp, false, nil } + if err != nil { + return nil, false, err + } + if !bytes.Equal(raw, cp) { + return nil, false, fmt.Errorf("%s differs from %s: copy the validator's file over it", copyPath, src) + } + return raw, true, nil +} - var jobName atomic.Value - jobName.Store("") - client.PrependReactor("create", "jobs", func(action ktesting.Action) (bool, runtime.Object, error) { - jobName.Store(action.(ktesting.CreateAction).GetObject().(*batchv1.Job).Name) - return false, nil, nil - }) - client.PrependReactor("get", "jobs", func(action ktesting.Action) (bool, runtime.Object, error) { - return jobSucceededReactor(jobName.Load().(string))(action) - }) - client.PrependReactor("list", "pods", podListReactor("")) +// validatorInventoryRule is one rule of the validator's RBAC inventory. +type validatorInventoryRule struct { + Roles []string `json:"roles"` + Scope string `json:"scope"` + APIGroup string `json:"apiGroup"` + Resources []string `json:"resources"` + ResourceNames []string `json:"resourceNames"` + NonResourceURLs []string `json:"nonResourceURLs"` + Verbs []string `json:"verbs"` +} - res := runClusterValidator(context.Background(), client, "test-image:1.0", "", false) - require.NoError(t, res.Err, "AlreadyExists on RBAC bootstrap must be treated as success") - assert.True(t, res.Passed) +// inventoryGrants are the grants the inventory gives role, by scope, keyed as +// grantsOfRules keys them. A key maps to the resource names it is limited to, +// or to nil when it is not limited. +func inventoryGrants(rules []validatorInventoryRule, role string) map[string]map[string][]string { + grants := map[string]map[string][]string{"cluster": {}, "namespace": {}} + for _, r := range rules { + if !slices.Contains(r.Roles, role) { + continue + } + rule := rbacv1.PolicyRule{APIGroups: []string{r.APIGroup}, Resources: r.Resources, + NonResourceURLs: r.NonResourceURLs, Verbs: r.Verbs} + for _, k := range grantsOfRules([]rbacv1.PolicyRule{rule}) { + names, seen := grants[r.Scope][k] + switch { + case len(r.ResourceNames) == 0: + grants[r.Scope][k] = nil + case !seen || names != nil: + grants[r.Scope][k] = append(names, r.ResourceNames...) + } + } + } + return grants } -// Regression guard: a ClusterRole left over from an older CLI version must -// be refreshed with the current rule set instead of silently kept stale. -func TestRunClusterValidator_RBACRefreshesClusterRoleRules(t *testing.T) { - oldRules := []rbacv1.PolicyRule{ - {APIGroups: []string{""}, Resources: []string{"nodes"}, Verbs: []string{"get"}}, +const ( + omitEnforcement = "only the enforcement test makes it, and the CLI's ConfigMap disables that test" + omitSummary = "only the summary write makes it, and VALIDATOR_PREFLIGHT suppresses that write" +) + +// cliOmittedGrants are the inventory grants this CLI's roles leave out, each +// with why the validator never makes that request when the CLI launches it. +// Only the control-plane set runs the node-to-node probe, which creates and +// reads pods outside the enforcement test. +var cliOmittedGrants = map[string]map[string]string{ + clusterValidatorComputePlaneRole: { + "/namespaces:create": omitEnforcement, + "/pods:create": omitEnforcement, + "/pods:get": omitEnforcement, + "networking.k8s.io/networkpolicies:create": omitEnforcement, + "networking.k8s.io/networkpolicies:update": omitEnforcement, + "networking.k8s.io/networkpolicies:delete": omitEnforcement, + "/configmaps:create": omitSummary, + "/configmaps:update": omitSummary, + }, + clusterValidatorControlPlaneRole: { + "networking.k8s.io/networkpolicies:update": omitEnforcement, + "networking.k8s.io/networkpolicies:delete": omitEnforcement, + "/configmaps:create": omitSummary, + "/configmaps:update": omitSummary, + }, +} + +// grantsOfRules flattens rules into sorted "group/resource:verb" and +// "url:verb" entries. +func grantsOfRules(rules []rbacv1.PolicyRule) []string { + set := map[string]bool{} + for _, r := range rules { + for _, v := range r.Verbs { + for _, u := range r.NonResourceURLs { + set[u+":"+v] = true + } + for _, g := range r.APIGroups { + for _, res := range r.Resources { + set[g+"/"+res+":"+v] = true + } + } + } } - client := fake.NewSimpleClientset(&rbacv1.ClusterRole{ - ObjectMeta: metav1.ObjectMeta{Name: clusterValidatorName}, - Rules: oldRules, - }) + return slices.Sorted(maps.Keys(set)) +} - var jobName atomic.Value - jobName.Store("") - client.PrependReactor("create", "jobs", func(action ktesting.Action) (bool, runtime.Object, error) { - jobName.Store(action.(ktesting.CreateAction).GetObject().(*batchv1.Job).Name) - return false, nil, nil - }) - client.PrependReactor("get", "jobs", func(action ktesting.Action) (bool, runtime.Object, error) { - return jobSucceededReactor(jobName.Load().(string))(action) - }) - client.PrependReactor("list", "pods", podListReactor("")) +// assertGrants requires rules to grant exactly the keys of want, and a key +// the inventory limits to resource names only for some of those names. +func assertGrants(t *testing.T, want map[string][]string, rules []rbacv1.PolicyRule) { + t.Helper() + assert.Equal(t, slices.Sorted(maps.Keys(want)), grantsOfRules(rules)) + for _, r := range rules { + for _, k := range grantsOfRules([]rbacv1.PolicyRule{r}) { + if names := want[k]; names != nil { + assert.NotEmpty(t, r.ResourceNames, "the inventory grants %s only on %v", k, names) + assert.Subset(t, names, r.ResourceNames, "the inventory grants %s only on %v", k, names) + } + } + } +} + +// Where the validator's file is absent, as under Bazel, the RBAC tests read +// the copy alone. This test then reports SKIP with that reason instead of +// passing as though the copy had been compared with the validator's file. +func TestValidatorRBACInventoryCopy_IsComparedWithTheValidatorsFile(t *testing.T) { + _, compared, err := readValidatorRBACInventory(validatorRBACInventory, validatorRBACInventoryCopy) + require.NoError(t, err) + if !compared { + t.Skip(validatorRBACInventoryNotCompared) + } +} - res := runClusterValidator(context.Background(), client, "test-image:1.0", "", false) - require.NoError(t, res.Err) +// The copy is used only when the validator's file is absent, and must match +// it when both exist, so the test cannot keep passing against a stale copy. +func TestReadValidatorRBACInventory_CopyCannotDrift(t *testing.T) { + dir := t.TempDir() + src, cp := filepath.Join(dir, "src.yaml"), filepath.Join(dir, "copy.yaml") + require.NoError(t, os.WriteFile(cp, []byte("rules: []\n"), 0o600)) - got, err := client.RbacV1().ClusterRoles().Get(context.Background(), clusterValidatorName, metav1.GetOptions{}) + got, compared, err := readValidatorRBACInventory(src, cp) + require.NoError(t, err, "without the validator's file the copy is used") + assert.Equal(t, "rules: []\n", string(got)) + assert.False(t, compared, "a copy read without the validator's file was not compared with it") + + require.NoError(t, os.WriteFile(src, []byte("rules: []\n"), 0o600)) + got, compared, err = readValidatorRBACInventory(src, cp) require.NoError(t, err) - assert.Greater(t, len(got.Rules), len(oldRules), - "ClusterRole rules must be refreshed to the current set on each run, not kept stale") + assert.Equal(t, "rules: []\n", string(got)) + assert.True(t, compared) + + require.NoError(t, os.WriteFile(src, []byte("rules: [{}]\n"), 0o600)) + _, _, err = readValidatorRBACInventory(src, cp) + require.ErrorContains(t, err, "differs", "a copy that differs from the validator's file must fail") + + _, _, err = readValidatorRBACInventory(dir, cp) + require.Error(t, err, "a validator file that exists but cannot be read must not fall back to the copy") + + require.NoError(t, os.Remove(cp)) + _, _, err = readValidatorRBACInventory(src, cp) + require.Error(t, err, "the copy is required") +} + +// Each role's RBAC grants exactly what the validator's RBAC inventory gives +// that role, less the grants cliOmittedGrants names: nothing the validator +// requests when this CLI launches it is missing, which turns a critical row +// UNKNOWN, and nothing it never requests, such as NetworkPolicy updates, is +// granted. The ConfigMap read is a namespaced Role limited to the names this +// run's Job can be pointed at. +func TestValidatorRBAC_MatchesTheValidatorsInventory(t *testing.T) { + raw, compared, err := readValidatorRBACInventory(validatorRBACInventory, validatorRBACInventoryCopy) + require.NoError(t, err) + if !compared { + t.Log(validatorRBACInventoryNotCompared) + } + var inventory struct { + Rules []validatorInventoryRule `json:"rules"` + } + require.NoError(t, yaml.UnmarshalStrict(raw, &inventory)) + require.NotEmpty(t, inventory.Rules) + roles := map[string]bool{} + for _, r := range inventory.Rules { + require.Contains(t, []string{"cluster", "namespace"}, r.Scope, "%+v", r) + require.False(t, r.Scope == "namespace" && len(r.NonResourceURLs) > 0, + "a non-resource URL needs a cluster grant: %+v", r) + for _, role := range r.Roles { + roles[role] = true + } + } + assert.Equal(t, []string{clusterValidatorComputePlaneRole, clusterValidatorControlPlaneRole}, + slices.Sorted(maps.Keys(roles))) + + for role := range roles { + t.Run(role, func(t *testing.T) { + want := inventoryGrants(inventory.Rules, role) + for k := range cliOmittedGrants[role] { + _, cluster := want["cluster"][k] + _, namespaced := want["namespace"][k] + assert.True(t, cluster || namespaced, "%s is omitted but the inventory no longer grants it", k) + delete(want["cluster"], k) + delete(want["namespace"], k) + } + + client := fake.NewSimpleClientset() + created := runObjects{} + require.NoError(t, ensureClusterValidatorRBAC(context.Background(), client, role, "runid", false, created)) + name := clusterValidatorRBACName(role, "runid") + + cr, err := client.RbacV1().ClusterRoles().Get(context.Background(), name, metav1.GetOptions{}) + require.NoError(t, err) + assertGrants(t, want["cluster"], cr.Rules) + for _, r := range cr.Rules { + assert.Empty(t, r.ResourceNames) + assert.NotContains(t, r.Verbs, "*") + assert.NotContains(t, r.Resources, "*") + } + + r, err := client.RbacV1().Roles(clusterValidatorNamespace).Get(context.Background(), name, + metav1.GetOptions{}) + require.NoError(t, err) + assertGrants(t, want["namespace"], r.Rules) + for _, rule := range r.Rules { + assert.Equal(t, validatorConfigNames(role, "runid"), rule.ResourceNames) + } + rb, err := client.RbacV1().RoleBindings(clusterValidatorNamespace).Get(context.Background(), name, + metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, rbacv1.RoleRef{APIGroup: rbacv1.GroupName, Kind: "Role", Name: name}, rb.RoleRef) + assert.Equal(t, []rbacv1.Subject{{Kind: rbacv1.ServiceAccountKind, Name: name, + Namespace: clusterValidatorNamespace}}, rb.Subjects) + + for _, kind := range validatorRBACKinds { + assert.Contains(t, created, kind, "the cleanup deletes only what was recorded") + } + }) + } +} + +// The compute role's ConfigMap name cannot be created, so nobody able to +// create ConfigMaps in the namespace can hand every compute-plane run their +// endpoints and an enabled enforcement test. Only the control-plane role can +// read a ConfigMap of its own run. +func TestValidatorConfigNames_SentinelCannotExist(t *testing.T) { + assert.NotEmpty(t, validation.IsDNS1123Subdomain(clusterValidatorNoConfigName), + "the apiserver must refuse a ConfigMap under the no-config name") + assert.Equal(t, []string{clusterValidatorNoConfigName}, validatorConfigNames(clusterValidatorComputePlaneRole, "r")) + assert.Equal(t, []string{clusterValidatorConfigRunName("r"), clusterValidatorNoConfigName}, + validatorConfigNames(clusterValidatorControlPlaneRole, "r")) +} + +// rbacRuleCovers returns true when any PolicyRule in rules grants verb on +// resource within group. Wildcard verbs ("*") are treated as matching any verb. +func rbacRuleCovers(rules []rbacv1.PolicyRule, group, resource, verb string) bool { + for _, r := range rules { + if len(r.NonResourceURLs) > 0 { + continue // non-resource rules don't apply to API resources + } + if !strSliceContains(r.APIGroups, group) { + continue + } + if !strSliceContains(r.Resources, resource) { + continue + } + if strSliceContains(r.Verbs, verb) || strSliceContains(r.Verbs, "*") { + return true + } + } + return false +} + +func strSliceContains(ss []string, s string) bool { + for _, v := range ss { + if v == s { + return true + } + } + return false } func TestRunClusterValidator_ImagePullBackOffShortCircuits(t *testing.T) { @@ -317,13 +580,13 @@ func TestRunClusterValidator_ImagePullBackOffShortCircuits(t *testing.T) { }) start := time.Now() - res := runClusterValidator(context.Background(), client, "test-image:1.0", "", false) + res := runClusterValidator(context.Background(), client, "test-image:1.0", "", false, "", nil, nil) elapsed := time.Since(start) require.Error(t, res.Err, "ImagePullBackOff must short-circuit the wait with an error") assert.Contains(t, res.Err.Error(), "ImagePullBackOff") assert.Contains(t, res.Err.Error(), "cannot pull image") - assert.NotEmpty(t, res.JobName, "JobName must still be populated so the operator can describe the pod") + assert.Empty(t, res.JobName, "the Job is deleted on a pull failure, so no logs hint may name it") assert.Less(t, elapsed, clusterValidatorTimeout, "short-circuit must return well before the 5-minute timeout; otherwise the early-detect path is broken") } @@ -355,11 +618,10 @@ func TestRunClusterValidator_LogFetchSurvivesValidatorTimeout(t *testing.T) { client.PrependReactor("list", "pods", podListReactor("")) // Parent ctx stays alive for the entire run; only vctx expires. - res := runClusterValidator(context.Background(), client, "test-image:1.0", "", false) + res := runClusterValidator(context.Background(), client, "test-image:1.0", "", false, "", nil, nil) - require.Error(t, res.Err, "wait must surface the deadline-exceeded error") - assert.Contains(t, res.Err.Error(), "waiting for job", - "timeout path must report the wait failure verbatim") + require.Error(t, res.Err, "wait must surface the validator's own timeout") + assert.Contains(t, res.Err.Error(), "the validator did not finish within 100ms") assert.NotEmpty(t, res.JobName, "JobName must be populated so operators can run the kubectl-logs hint") @@ -408,13 +670,13 @@ func TestRunClusterValidator_ContextCanceled(t *testing.T) { time.Sleep(50 * time.Millisecond) cancel() }() - res := runClusterValidator(ctx, client, "test-image:1.0", "", false) + res := runClusterValidator(ctx, client, "test-image:1.0", "", false, "", nil, nil) require.Error(t, res.Err) assert.Contains(t, res.Err.Error(), "context") } func TestBuildClusterValidatorJobShape(t *testing.T) { - job := buildClusterValidatorJob("test-job", "img:1", "", false) + job := buildClusterValidatorJob("test-job", "img:1", "", "", "runid", clusterValidatorNoConfigName, false, nil) assert.Equal(t, "test-job", job.Name) assert.Equal(t, clusterValidatorNamespace, job.Namespace) @@ -431,13 +693,18 @@ func TestBuildClusterValidatorJobShape(t *testing.T) { "IfNotPresent so locally-imported images (k3d image import, kind load) are picked up without a registry pull") assert.Equal(t, corev1.RestartPolicyNever, job.Spec.Template.Spec.RestartPolicy, "validator is a one-shot command, not a long-running service") - assert.Equal(t, clusterValidatorName, job.Spec.Template.Spec.ServiceAccountName, - "pod must run under the CLI-bootstrapped SA, not the namespace default") + assert.Equal(t, clusterValidatorRBACName("", "runid"), job.Spec.Template.Spec.ServiceAccountName, + "pod must run under this run's bootstrapped SA, not the namespace default") labels := job.Labels assert.Equal(t, clusterValidatorAppLabel, labels["app.kubernetes.io/name"]) - assert.Equal(t, "nvcf-cli", labels["app.kubernetes.io/managed-by"]) + assert.NotEqual(t, "nvcf-cli", labels["app.kubernetes.io/managed-by"], + "released CLIs DeleteCollection on managed-by=nvcf-cli, so a shared value lets each version delete the other's objects") assert.Equal(t, "preflight", labels["app.kubernetes.io/component"]) + assert.Equal(t, "runid", labels[clusterValidatorRunLabel]) + assert.Equal(t, "runid", job.Spec.Template.Labels[clusterValidatorRunLabel]) + require.NotNil(t, job.Spec.ActiveDeadlineSeconds, + "without a deadline a Job whose pod never finishes never becomes terminal, so its TTL never fires") // The validator must run in preflight mode so it skips the summary // ConfigMap write (the metrics path needs the NVCA agent, which is not @@ -450,22 +717,37 @@ func TestBuildClusterValidatorJobShape(t *testing.T) { "preflight invocation must tag the validator so it does not attempt the metrics ConfigMap write") } +func TestBuildClusterValidatorJobShape_ValidatorRoleInEnv(t *testing.T) { + job := buildClusterValidatorJob("test-job", "img:1", "", clusterValidatorControlPlaneRole, "runid", + clusterValidatorNoConfigName, false, nil) + env := map[string]string{} + for _, e := range job.Spec.Template.Spec.Containers[0].Env { + env[e.Name] = e.Value + } + assert.Equal(t, clusterValidatorControlPlaneRole, env["VALIDATOR_ROLE"], + "VALIDATOR_ROLE must carry the role to the validator binary") +} + func TestBuildClusterValidatorJobShape_WithPullSecret(t *testing.T) { - job := buildClusterValidatorJob("test-job", "img:1", "nvcr-pull-secret", false) + job := buildClusterValidatorJob("test-job", "img:1", "nvcr-pull-secret", "", "runid", + clusterValidatorNoConfigName, false, nil) require.Len(t, job.Spec.Template.Spec.ImagePullSecrets, 1) assert.Equal(t, "nvcr-pull-secret", job.Spec.Template.Spec.ImagePullSecrets[0].Name) } func TestBuildClusterValidatorJobShape_NoPullSecret(t *testing.T) { - job := buildClusterValidatorJob("test-job", "img:1", "", false) + job := buildClusterValidatorJob("test-job", "img:1", "", "", "runid", clusterValidatorNoConfigName, false, nil) assert.Empty(t, job.Spec.Template.Spec.ImagePullSecrets, "empty pull-secret arg must not produce an empty-name ImagePullSecrets entry") } func TestBuildClusterValidatorJobShape_NoCleanup(t *testing.T) { - job := buildClusterValidatorJob("test-job", "img:1", "", true) + job := buildClusterValidatorJob("test-job", "img:1", "", "", "runid", clusterValidatorNoConfigName, true, nil) assert.Nil(t, job.Spec.TTLSecondsAfterFinished, "--no-cleanup must omit TTLSecondsAfterFinished so the Job persists for debugging") + assert.Nil(t, job.Spec.ActiveDeadlineSeconds, + "a deadline would kill the kept pod, and its logs, shortly after the CLI gives up") + assert.Equal(t, "true", job.Labels[clusterValidatorPreserveLabel]) } func TestCleanValidatorOutput_StripsANSI(t *testing.T) { @@ -509,30 +791,526 @@ func TestCleanValidatorOutput_EmptyInput(t *testing.T) { } func TestKubectlLogsHint(t *testing.T) { - got := kubectlLogsHint("nvcf-preflight-validator-12345") + got := kubectlLogsHint("", "nvcf-preflight-validator-12345") assert.Equal(t, "kubectl logs -n default job/nvcf-preflight-validator-12345 --tail=-1", got, ) } +// In ModeSplit the two roles run against different clusters, so the hint has +// to name the context the Job was actually created in. +func TestKubectlLogsHint_PinsContext(t *testing.T) { + got := kubectlLogsHint("gpu-ctx", "nvcf-preflight-validator-12345") + assert.Equal(t, + "kubectl --context gpu-ctx logs -n default job/nvcf-preflight-validator-12345 --tail=-1", + got, + ) +} + +func TestKubectlLogsHint_QuotesContext(t *testing.T) { + got := kubectlLogsHint("my ctx", "nvcf-preflight-validator-12345") + assert.Contains(t, got, "--context 'my ctx'", + "a context name with a space must be quoted so the pasted command does not split it") +} + func TestKubectlLogsHint_EmptyJob(t *testing.T) { - assert.Equal(t, "", kubectlLogsHint(""), + assert.Equal(t, "", kubectlLogsHint("gpu-ctx", ""), "empty jobName must produce empty hint so callers can compose detail without conditionals") } -func alreadyExistsReactor(resource, name string) ktesting.ReactionFunc { - gr := schema.GroupResource{Resource: resource} - return func(action ktesting.Action) (bool, runtime.Object, error) { - createAction, ok := action.(ktesting.CreateAction) - if !ok { - return false, nil, nil - } - meta, ok := createAction.GetObject().(metav1.Object) - if !ok || meta.GetName() != name { +// With no registries the reachability section is omitted rather than filled +// with a guess. +func TestBuildControlPlaneValidatorConfig_NoRegistries(t *testing.T) { + got := buildControlPlaneValidatorConfig(nil) + assert.NotContains(t, got, "reachability:") + assert.NotContains(t, got, "nvcr.io", "no hardcoded nvcr.io endpoint") + assert.Contains(t, got, "enforcement:", "enforcement block must be present") +} + +// The endpoints are exactly the enumerated registries, each non-critical +// whatever the local check's criticality. A mirrored install whose list has +// no nvcr.io must not be made to dial it: a fixed critical nvcr.io failed +// every such control plane. +func TestBuildControlPlaneValidatorConfig_EndpointsAreNotCritical(t *testing.T) { + got := buildControlPlaneValidatorConfig([]RegistryEntry{ + {Registry: "harbor.company.internal", Critical: false}, + {Registry: "registry.example:5000", Critical: true}, + }) + assert.NotContains(t, got, "nvcr.io") + assert.Contains(t, got, `host: "harbor.company.internal"`) + assert.Contains(t, got, `host: "registry.example"`) + assert.Contains(t, got, "port: 5000") + assert.Contains(t, got, "host: \"harbor.company.internal\"\n port: 443\n protocol: tcp+tls\n critical: false\n") + assert.Contains(t, got, "host: \"registry.example\"\n port: 5000\n protocol: tcp+tls\n critical: false\n", + "an in-pod dial is not evidence the nodes cannot pull, so no endpoint is critical") + assert.Less(t, strings.Index(got, "harbor.company.internal"), strings.Index(got, "enforcement:"), + "endpoints must appear before the enforcement block") + + // Nodes can pull nvcr.io through a runtime proxy or mirror that pods have + // no route to, so even an NGC install's nvcr.io is only a warning. + ngc := buildControlPlaneValidatorConfig([]RegistryEntry{{Registry: "nvcr.io", Critical: true}}) + assert.Contains(t, ngc, `host: "nvcr.io"`) + assert.NotContains(t, ngc, "critical: true") +} + +func TestBuildControlPlaneValidatorConfig_InvalidRegistrySkipped(t *testing.T) { + got := buildControlPlaneValidatorConfig([]RegistryEntry{{Registry: " "}, {Registry: "valid.registry.internal:5000"}}) + assert.Contains(t, got, "valid.registry.internal", "valid registry must appear") + assert.NotContains(t, got, "host: \"\"", "blank entry must not produce an empty host") +} + +func TestParseRegistryHostPort(t *testing.T) { + tests := []struct { + in string + wantHost string + wantPort int + }{ + {"nvcr.io:443", "nvcr.io", 443}, + {"harbor.company.internal:5000", "harbor.company.internal", 5000}, + {"registry.example.com", "registry.example.com", 443}, // no port -> 443 + {"", "", 0}, // empty -> skip + {" ", "", 0}, // blank -> skip + // IPv6: net.SplitHostPort handles bracketed literals correctly. + {"[::1]:5000", "::1", 5000}, + {"[2001:db8::1]:443", "2001:db8::1", 443}, + // An explicit but unusable port is a typo, not a request for 443: + // probing a different endpoint than configured reports a result for + // something the operator never asked about. + {"nvcr.io:", "", 0}, + {"nvcr.io:abc", "", 0}, + {"nvcr.io:0", "", 0}, + {"nvcr.io:70000", "", 0}, + } + for _, tt := range tests { + t.Run(tt.in, func(t *testing.T) { + h, p := parseRegistryHostPort(tt.in) + assert.Equal(t, tt.wantHost, h) + assert.Equal(t, tt.wantPort, p) + }) + } +} + +// The ConfigMap is create-only. One already holding the run's name was put +// there by someone else, unlabelled or wearing forged managed labels, and is +// neither updated nor recorded for this run's cleanup. +func TestEnsureClusterValidatorConfig_IsCreateOnly(t *testing.T) { + for name, lbls := range map[string]map[string]string{ + "unlabelled": {"owner": "operator"}, + "forged labels": clusterValidatorRunLabels(clusterValidatorControlPlaneRole, "runid", false), + } { + t.Run(name, func(t *testing.T) { + ctx := context.Background() + client := fake.NewSimpleClientset(&corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterValidatorConfigRunName("runid"), Namespace: clusterValidatorNamespace, Labels: lbls, + }, + Data: map[string]string{"config.yaml": "operator: content"}, + }) + created := runObjects{} + err := ensureClusterValidatorConfig(ctx, client, nil, clusterValidatorControlPlaneRole, "runid", false, + created) + require.Error(t, err) + assert.True(t, apierrors.IsAlreadyExists(errors.Unwrap(err)), err.Error()) + assert.NotContains(t, created, kindConfigMap) + + got, getErr := client.CoreV1().ConfigMaps(clusterValidatorNamespace).Get(ctx, + clusterValidatorConfigRunName("runid"), metav1.GetOptions{}) + require.NoError(t, getErr) + assert.Equal(t, "operator: content", got.Data["config.yaml"], "the other ConfigMap is untouched") + }) + } +} + +// The managed labels are three public constants, so a label check alone cannot +// establish ownership: anyone able to create a ServiceAccount in the probe +// namespace could stamp them. Safety comes from the name being unguessable, so +// there is no predictable object to pre-create and get bound to the validator's +// cluster-wide permissions. +func TestClusterValidatorRBACName_IsUnpredictableAndPerRun(t *testing.T) { + a, err := newValidatorRunID() + require.NoError(t, err) + b, err := newValidatorRunID() + require.NoError(t, err) + + assert.NotEqual(t, a, b, "each run must get a distinct identity") + assert.NotEmpty(t, a) + + const role = clusterValidatorControlPlaneRole + assert.NotEqual(t, clusterValidatorRBACName(role, a), clusterValidatorRBACName(role, b), + "RBAC names must differ per run") + assert.NotEqual(t, clusterValidatorRBACName(role, a), clusterValidatorRBACName("compute-plane", a), + "RBAC names must differ per role within a run") +} + +// Nothing pre-existing is adopted: creation is unconditional, so a squatted +// object surfaces as an error rather than being bound to our ClusterRole. +func TestEnsureClusterValidatorRBAC_DoesNotAdoptExistingObjects(t *testing.T) { + ctx := context.Background() + const role = clusterValidatorControlPlaneRole + const runID = "collide" + name := clusterValidatorRBACName(role, runID) + + // Forged managed labels: a label check alone would have adopted this. + client := fake.NewSimpleClientset(&corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, Namespace: clusterValidatorNamespace, Labels: clusterValidatorLabels(), + }, + }) + + err := ensureClusterValidatorRBAC(ctx, client, role, runID, false, nil) + require.Error(t, err, "a pre-existing object must not be adopted, forged labels or not") + + _, bindErr := client.RbacV1().ClusterRoleBindings().Get(ctx, name, metav1.GetOptions{}) + assert.True(t, apierrors.IsNotFound(bindErr), + "no binding may be created when the ServiceAccount was not created by this run") +} + +// Random names cannot self-heal by being reused next run, so leftovers from a +// killed run are reclaimed by age. Two things must survive: anything recent +// (it may belong to a run happening right now) and anything whose name is not +// one we generate, even when it carries our labels. Those labels are three +// public constants, so they can be copied onto anything. +func TestSweepOrphanClusterValidatorRBAC_RequiresNameLabelsAndAge(t *testing.T) { + ctx := context.Background() + old := metav1.NewTime(time.Now().Add(-2 * time.Hour)) + now := metav1.NewTime(time.Now()) + staleName := clusterValidatorRBACName(clusterValidatorControlPlaneRole, "deadbeef01") + freshName := clusterValidatorRBACName("compute-plane", "cafebabe02") + + client := fake.NewSimpleClientset( + &rbacv1.ClusterRole{ObjectMeta: metav1.ObjectMeta{ + Name: staleName, Labels: clusterValidatorLabels(), CreationTimestamp: old, + }}, + &rbacv1.ClusterRole{ObjectMeta: metav1.ObjectMeta{ + Name: freshName, Labels: clusterValidatorLabels(), CreationTimestamp: now, + }}, + // Our labels, but not a name we generate: an operator copying the + // labels onto their own ClusterRole must not have it deleted. + &rbacv1.ClusterRole{ObjectMeta: metav1.ObjectMeta{ + Name: "operator-owned-role", Labels: clusterValidatorLabels(), CreationTimestamp: old, + }}, + ) + + sweepOrphanClusterValidatorRBAC(ctx, client, time.Now(), orphanValidatorRBACTTL, "", "") + + _, err := client.RbacV1().ClusterRoles().Get(ctx, staleName, metav1.GetOptions{}) + assert.True(t, apierrors.IsNotFound(err), "a generated name past the TTL must be reclaimed") + + _, err = client.RbacV1().ClusterRoles().Get(ctx, freshName, metav1.GetOptions{}) + assert.NoError(t, err, "a recent one may belong to a concurrent run") + + _, err = client.RbacV1().ClusterRoles().Get(ctx, "operator-owned-role", metav1.GetOptions{}) + assert.NoError(t, err, "matching labels alone must not authorize deleting someone else's object") +} + +// An explicit but unparseable port is a typo. Silently probing 443 would report +// a result for an endpoint the operator never configured. +func TestParseRegistryHostPort_RejectsMalformedExplicitPort(t *testing.T) { + for _, in := range []string{"registry.example:abc", "registry.example:0", "registry.example:99999", "registry.example:"} { + host, port := parseRegistryHostPort(in) + assert.Empty(t, host, "%q must be rejected, not defaulted", in) + assert.Zero(t, port, "%q must not fall back to a port", in) + } + host, port := parseRegistryHostPort("registry.example") + assert.Equal(t, "registry.example", host, "no explicit port keeps the default") + assert.Equal(t, 443, port) +} + +// --no-cleanup must preserve the whole run, not just the Job: an operator who +// keeps the Job and re-runs the pod needs its pull secret to still exist. A +// later run, with or without --no-cleanup, leaves an earlier kept run alone. +func TestRunClusterValidator_NoCleanupKeepsPullSecretAndPriorJob(t *testing.T) { + t.Setenv("NGC_API_KEY", "key") + for _, later := range []bool{true, false} { + client := lifecycleClient(succeeded, "") + // The fake leaves creation times zero, which the orphan sweep reads + // as older than any TTL. A real apiserver stamps them. + client.PrependReactor("create", "*", func(a ktesting.Action) (bool, runtime.Object, error) { + if o, ok := a.(ktesting.CreateAction).GetObject().(metav1.Object); ok { + o.SetCreationTimestamp(metav1.Now()) + } return false, nil, nil - } - return true, nil, apierrors.NewAlreadyExists(gr, name) + }) + first := runClusterValidator(context.Background(), client, "nvcr.io/x/validator:1", + "", true, clusterValidatorControlPlaneRole, nil, nil) + require.NoError(t, first.Err) + kept := leftovers(t, client, first.RunID) + require.Len(t, kept["Secret"], 1, "the first run keeps its pull secret") + + runClusterValidator(context.Background(), client, "nvcr.io/x/validator:1", + "", later, clusterValidatorControlPlaneRole, nil, nil) + assert.Equal(t, kept, leftovers(t, client, first.RunID), "later --no-cleanup=%v", later) + _, err := client.BatchV1().Jobs(clusterValidatorNamespace).Get(context.Background(), first.JobName, + metav1.GetOptions{}) + assert.NoError(t, err, "the kept Job survives a later run") } } +// The Job runs the same image as the chart's CronJob, so it needs the same pod +// shape. Without the security context a namespace enforcing the PodSecurity +// "restricted" profile rejects it at admission; without the tolerations it +// never schedules on a cluster whose nodes all carry the control-plane taint. +// Either way the wait burns its full budget and the run leaks. +func TestBuildClusterValidatorJob_MatchesChartPodShape(t *testing.T) { + job := buildClusterValidatorJob("j", "nvcr.io/x/validator:1", "", + clusterValidatorControlPlaneRole, "runid", clusterValidatorNoConfigName, false, nil) + spec := job.Spec.Template.Spec + + require.NotNil(t, spec.SecurityContext, "pod security context is required under restricted") + require.NotNil(t, spec.SecurityContext.RunAsUser) + assert.Equal(t, int64(65534), *spec.SecurityContext.RunAsUser, "chart runs this image as 65534") + require.NotNil(t, spec.SecurityContext.RunAsNonRoot) + assert.True(t, *spec.SecurityContext.RunAsNonRoot) + require.NotNil(t, spec.SecurityContext.SeccompProfile) + assert.Equal(t, corev1.SeccompProfileTypeRuntimeDefault, spec.SecurityContext.SeccompProfile.Type) + + require.Len(t, spec.Containers, 1) + sc := spec.Containers[0].SecurityContext + require.NotNil(t, sc, "container security context is required under restricted") + require.NotNil(t, sc.RunAsNonRoot) + assert.True(t, *sc.RunAsNonRoot) + require.NotNil(t, sc.AllowPrivilegeEscalation) + assert.False(t, *sc.AllowPrivilegeEscalation) + require.NotNil(t, sc.Capabilities) + assert.Equal(t, []corev1.Capability{"ALL"}, sc.Capabilities.Drop) + + // An interrupted validator removes its probe before it exits; a shorter + // grace period lets the kubelet kill it with the probe namespace left. + require.NotNil(t, spec.TerminationGracePeriodSeconds) + assert.Greater(t, time.Duration(*spec.TerminationGracePeriodSeconds)*time.Second, validatorTeardownBudget) + + var keys []string + for _, tol := range spec.Tolerations { + keys = append(keys, tol.Key) + } + assert.Contains(t, keys, "node-role.kubernetes.io/control-plane") + assert.Contains(t, keys, "node-role.kubernetes.io/master") + + assert.False(t, spec.Containers[0].Resources.Requests.Cpu().IsZero(), + "a namespace with a LimitRange or quota rejects a pod with no requests") +} + +// An ImagePullBackOff Job never reaches a terminal state on its own, so +// TTLSecondsAfterFinished never fires and the Job, its cluster-wide RBAC and +// its NGC-derived pull secret persist forever. The deferred sweeps are +// suppressed on that path by design, so the deadline is the only reclaim. +func TestBuildClusterValidatorJob_SetsActiveDeadline(t *testing.T) { + job := buildClusterValidatorJob("j", "nvcr.io/x/validator:1", "", + clusterValidatorControlPlaneRole, "runid", clusterValidatorNoConfigName, false, nil) + require.NotNil(t, job.Spec.ActiveDeadlineSeconds, + "a Job with no deadline cannot terminate itself on a pull failure") + assert.Greater(t, *job.Spec.ActiveDeadlineSeconds, int64(clusterValidatorTimeout/time.Second), + "the deadline must outlast the runner's own wait so it never truncates a live run") +} + +// The pull secret holds $oauthtoken:$NGC_API_KEY, and the deferred sweep is +// skipped whenever the pod may still be running, which is the pull-failure +// path. The orphan sweeper is its only other reclaim. +func TestSweepOrphanClusterValidatorRBAC_ReclaimsStalePullSecret(t *testing.T) { + old := metav1.NewTime(time.Now().Add(-time.Hour)) + stale := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{ + Name: validatorPullSecretRunName(clusterValidatorControlPlaneRole, "runid"), + Namespace: clusterValidatorNamespace, + Labels: clusterValidatorLabels(), + CreationTimestamp: old, + }} + client := fake.NewSimpleClientset(stale) + sweepOrphanClusterValidatorRBAC(context.Background(), client, time.Now(), orphanValidatorRBACTTL, "", "") + + _, err := client.CoreV1().Secrets(clusterValidatorNamespace).Get( + context.Background(), stale.Name, metav1.GetOptions{}) + assert.True(t, apierrors.IsNotFound(err), "a stale managed pull secret must be reclaimed") +} + +// A fresh secret may belong to a concurrent run, and an operator-supplied one +// carries neither our labels nor our name. +func TestSweepOrphanClusterValidatorRBAC_SparesFreshAndUnmanagedSecrets(t *testing.T) { + fresh := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{ + Name: validatorPullSecretRunName(clusterValidatorControlPlaneRole, "runid"), + Namespace: clusterValidatorNamespace, + Labels: clusterValidatorLabels(), + CreationTimestamp: metav1.NewTime(time.Now()), + }} + operator := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{ + Name: "operator-pull", + Namespace: clusterValidatorNamespace, + Labels: clusterValidatorLabels(), + CreationTimestamp: metav1.NewTime(time.Now().Add(-time.Hour)), + }} + client := fake.NewSimpleClientset(fresh, operator) + sweepOrphanClusterValidatorRBAC(context.Background(), client, time.Now(), orphanValidatorRBACTTL, "", "") + + for _, name := range []string{fresh.Name, operator.Name} { + _, err := client.CoreV1().Secrets(clusterValidatorNamespace).Get( + context.Background(), name, metav1.GetOptions{}) + assert.NoError(t, err, "%s must survive the orphan sweep", name) + } +} + +// The preflight ConfigMap must not switch on active NetworkPolicy enforcement. +// VALIDATOR_PREFLIGHT only suppresses the summary write, so enforcement would +// still create namespaces, pods and NetworkPolicies and pull busybox from +// Docker Hub, on a cluster where nothing is installed yet. +func TestBuildControlPlaneValidatorConfig_EnforcementDisabledForPreflight(t *testing.T) { + cfg := buildControlPlaneValidatorConfig(nil) + idx := strings.Index(cfg, "enforcement:") + require.GreaterOrEqual(t, idx, 0, "the enforcement block must be present") + assert.Contains(t, cfg[idx:], "enabled: false", + "a read-only readiness check must not mutate the cluster or need Docker Hub") +} + +// With enforcement off the ConfigMap names no probe image, so the validator's +// own default, or NVCF_N2N_PROBE_IMAGE, picks the overlay probe image. +func TestBuildControlPlaneValidatorConfig_LeavesTheProbeImageToTheValidator(t *testing.T) { + cfg := buildControlPlaneValidatorConfig([]RegistryEntry{{Registry: "nvcr.io"}}) + assert.NotContains(t, cfg, "testImage") +} + +// --no-cleanup must outlast the orphan TTL. Without a preserve marker the +// orphan sweeper reclaims a deliberately kept run after 30 minutes, which +// makes the flag mean "keep for 30 minutes". +func TestSweepOrphanClusterValidatorRBAC_SparesPreservedObjects(t *testing.T) { + labels := clusterValidatorLabels() + labels[clusterValidatorPreserveLabel] = "true" + old := metav1.NewTime(time.Now().Add(-time.Hour)) + client := fake.NewSimpleClientset( + &rbacv1.ClusterRole{ObjectMeta: metav1.ObjectMeta{ + Name: clusterValidatorName + "-control-plane-abc", Labels: labels, CreationTimestamp: old, + }}, + &corev1.Secret{ObjectMeta: metav1.ObjectMeta{ + Name: validatorPullSecretRunName(clusterValidatorControlPlaneRole, "runid"), + Namespace: clusterValidatorNamespace, Labels: labels, CreationTimestamp: old, + }}, + ) + sweepOrphanClusterValidatorRBAC(context.Background(), client, time.Now(), orphanValidatorRBACTTL, "", "") + + _, err := client.RbacV1().ClusterRoles().Get(context.Background(), + clusterValidatorName+"-control-plane-abc", metav1.GetOptions{}) + assert.NoError(t, err, "a preserved ClusterRole must survive the orphan sweep") + _, err = client.CoreV1().Secrets(clusterValidatorNamespace).Get(context.Background(), + validatorPullSecretRunName(clusterValidatorControlPlaneRole, "runid"), metav1.GetOptions{}) + assert.NoError(t, err, "a preserved pull secret must survive the orphan sweep") +} + +// The preserve marker has to be applied at creation, not just honoured by the +// sweeper, or the mechanism is inert. +func TestEnsureClusterValidatorRBAC_LabelsPreservedRun(t *testing.T) { + client := fake.NewSimpleClientset() + require.NoError(t, ensureClusterValidatorRBAC(context.Background(), client, + clusterValidatorControlPlaneRole, "abc123", true, nil)) + + cr, err := client.RbacV1().ClusterRoles().Get(context.Background(), + clusterValidatorRBACName(clusterValidatorControlPlaneRole, "abc123"), metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, "true", cr.Labels[clusterValidatorPreserveLabel], + "--no-cleanup must mark its objects so the orphan sweeper spares them") +} + +func TestEnsureClusterValidatorRBAC_NoPreserveLabelByDefault(t *testing.T) { + client := fake.NewSimpleClientset() + require.NoError(t, ensureClusterValidatorRBAC(context.Background(), client, + clusterValidatorControlPlaneRole, "abc123", false, nil)) + + cr, err := client.RbacV1().ClusterRoles().Get(context.Background(), + clusterValidatorRBACName(clusterValidatorControlPlaneRole, "abc123"), metav1.GetOptions{}) + require.NoError(t, err) + assert.NotContains(t, cr.Labels, clusterValidatorPreserveLabel, + "an ordinary run must stay reclaimable") +} + +// Run-scoping the ConfigMap name removed the accidental self-healing the fixed +// name gave us: a killed run used to leave exactly one object that the next run +// overwrote, and now each leaves its own. The deferred sweep is suppressed on +// the pull-failure path, so the orphan sweeper is the only reclaim. +func TestSweepOrphanClusterValidatorRBAC_ReclaimsStaleConfigMap(t *testing.T) { + old := metav1.NewTime(time.Now().Add(-time.Hour)) + stale := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{ + Name: clusterValidatorConfigRunName("deadbeef01"), + Namespace: clusterValidatorNamespace, + Labels: clusterValidatorLabels(), + CreationTimestamp: old, + }} + client := fake.NewSimpleClientset(stale) + sweepOrphanClusterValidatorRBAC(context.Background(), client, time.Now(), orphanValidatorRBACTTL, "", "") + + _, err := client.CoreV1().ConfigMaps(clusterValidatorNamespace).Get( + context.Background(), stale.Name, metav1.GetOptions{}) + assert.True(t, apierrors.IsNotFound(err), + "a per-run ConfigMap left by a killed run must be reclaimed") +} + +func TestSweepOrphanClusterValidatorRBAC_SparesFreshAndUnmanagedConfigMaps(t *testing.T) { + fresh := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{ + Name: clusterValidatorConfigRunName("aaaaaaaa01"), + Namespace: clusterValidatorNamespace, + Labels: clusterValidatorLabels(), + CreationTimestamp: metav1.NewTime(time.Now()), + }} + operator := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{ + Name: "operator-owned", + Namespace: clusterValidatorNamespace, + Labels: clusterValidatorLabels(), + CreationTimestamp: metav1.NewTime(time.Now().Add(-time.Hour)), + }} + client := fake.NewSimpleClientset(fresh, operator) + sweepOrphanClusterValidatorRBAC(context.Background(), client, time.Now(), orphanValidatorRBACTTL, "", "") + + for _, name := range []string{fresh.Name, operator.Name} { + _, err := client.CoreV1().ConfigMaps(clusterValidatorNamespace).Get( + context.Background(), name, metav1.GetOptions{}) + assert.NoError(t, err, "%s must survive the orphan sweep", name) + } +} + +// --no-cleanup must preserve the ConfigMap as well as the Job and its RBAC. +// Without the marker the next run's orphan sweeper reclaims it after the TTL, +// and an operator re-running the Job they kept gets a validator that silently +// skips the configurable reachability and enforcement checks. +func TestEnsureClusterValidatorConfig_MarksPreservedRun(t *testing.T) { + client := fake.NewSimpleClientset() + require.NoError(t, ensureClusterValidatorConfig(context.Background(), client, nil, + clusterValidatorControlPlaneRole, "runid", true, nil)) + + cm, err := client.CoreV1().ConfigMaps(clusterValidatorNamespace).Get( + context.Background(), clusterValidatorConfigRunName("runid"), metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, "true", cm.Labels[clusterValidatorPreserveLabel]) + + // And the orphan sweeper honours it even once it is older than the TTL. + cm.CreationTimestamp = metav1.NewTime(time.Now().Add(-time.Hour)) + _, err = client.CoreV1().ConfigMaps(clusterValidatorNamespace).Update( + context.Background(), cm, metav1.UpdateOptions{}) + require.NoError(t, err) + sweepOrphanClusterValidatorRBAC(context.Background(), client, time.Now(), orphanValidatorRBACTTL, "", "") + + _, err = client.CoreV1().ConfigMaps(clusterValidatorNamespace).Get( + context.Background(), clusterValidatorConfigRunName("runid"), metav1.GetOptions{}) + assert.NoError(t, err, "a preserved ConfigMap must survive the orphan sweep") +} + +func TestEnsureClusterValidatorConfig_NoPreserveMarkerByDefault(t *testing.T) { + client := fake.NewSimpleClientset() + require.NoError(t, ensureClusterValidatorConfig(context.Background(), client, nil, + clusterValidatorControlPlaneRole, "runid", false, nil)) + + cm, err := client.CoreV1().ConfigMaps(clusterValidatorNamespace).Get( + context.Background(), clusterValidatorConfigRunName("runid"), metav1.GetOptions{}) + require.NoError(t, err) + assert.NotContains(t, cm.Labels, clusterValidatorPreserveLabel, + "an ordinary run must stay reclaimable") +} + +// --no-cleanup has to outlast the run that set it. The run itself already skips +// the prior-Job sweep, but without a marker on the Job the next ordinary run of +// the same role deletes it, while its ConfigMap, RBAC and pull secret survive. +func TestBuildClusterValidatorJob_MarksPreservedRun(t *testing.T) { + kept := buildClusterValidatorJob("j", "nvcr.io/x/v:1", "", + clusterValidatorControlPlaneRole, "runid", clusterValidatorNoConfigName, true, nil) + assert.Equal(t, "true", kept.Labels[clusterValidatorPreserveLabel]) + + ordinary := buildClusterValidatorJob("j", "nvcr.io/x/v:1", "", + clusterValidatorControlPlaneRole, "runid", clusterValidatorNoConfigName, false, nil) + assert.NotContains(t, ordinary.Labels, clusterValidatorPreserveLabel, + "an ordinary run must stay sweepable") +} diff --git a/src/clis/nvcf-cli/internal/selfhosted/credential_scope_test.go b/src/clis/nvcf-cli/internal/selfhosted/credential_scope_test.go new file mode 100644 index 0000000000..840e7c71f4 --- /dev/null +++ b/src/clis/nvcf-cli/internal/selfhosted/credential_scope_test.go @@ -0,0 +1,400 @@ +/* +SPDX-FileCopyrightText: Copyright (c) NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package selfhosted + +import ( + "context" + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" +) + +// newEntitledNGC accepts each password in access for the repositories under +// the orgs listed for it; "" stands for every repository. +// unavailableToken, listed as a password's org, has the fake token service +// answer that password 503. +const unavailableToken = "!unavailable" + +func newEntitledNGC(t *testing.T, access map[string][]string) *fakeNGC { + t.Helper() + f := &fakeNGC{} + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/token": + _, pass, _ := r.BasicAuth() + f.mu.Lock() + f.sent = append(f.sent, pass) + failing := f.failing > 0 + f.failing-- + f.mu.Unlock() + if failing { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + scope := r.URL.Query().Get("scope") + repo := strings.TrimSuffix(strings.TrimPrefix(scope, "repository:"), ":pull") + if scope != "" && !strings.Contains(repo, "/") { + http.Error(w, "malformed token scope", http.StatusBadRequest) + return + } + orgs, known := access[pass] + if slices.Contains(orgs, unavailableToken) { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + if !known { + w.WriteHeader(http.StatusUnauthorized) + return + } + if scope != "" && !slices.ContainsFunc(orgs, func(org string) bool { + return org == "" || strings.HasPrefix(repo, org+"/") + }) { + w.WriteHeader(http.StatusForbidden) + return + } + _, _ = w.Write([]byte(`{"token":"t"}`)) + case r.Header.Get("Authorization") == "Bearer t" && strings.HasSuffix(r.URL.Path, "/tags/list"): + _, _ = w.Write([]byte(`{"tags":["1.0.0","1.2.0"]}`)) + default: + bearerChallenge(w, "https://"+ngcKeyRegistryHost+"/token") + } + })) + t.Cleanup(srv.Close) + tr := srv.Client().Transport.(*http.Transport).Clone() + // The test certificate names example.com, not nvcr.io. + tr.TLSClientConfig.ServerName = "example.com" + tr.DialContext = func(ctx context.Context, network, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, network, srv.Listener.Addr().String()) + } + prev := http.DefaultTransport + http.DefaultTransport = tr + t.Cleanup(func() { http.DefaultTransport = prev }) + return f +} + +// mintedPassword returns the password of the one registry entry in a Secret +// this CLI minted. +func mintedPassword(t *testing.T, s *corev1.Secret) string { + t.Helper() + require.Equal(t, corev1.SecretTypeDockercfg, s.Type) + var entries map[string]dockerConfigAuth + require.NoError(t, json.Unmarshal(s.Data[corev1.DockerConfigKey], &entries)) + require.Len(t, entries, 1) + for _, e := range entries { + _, pass, ok := decodeDockerConfigAuth(e) + require.True(t, ok) + return pass + } + return "" +} + +// mintPullSecret resolves the validator's pull secret for image in an empty +// cluster and returns the password it was minted with. +func mintPullSecret(ctx context.Context, t *testing.T, client *fake.Clientset, image, role string) string { + t.Helper() + name, _, err := resolveValidatorPullSecret(ctx, client, "", image, role, "runid", false, nil) + require.NoError(t, err) + s, err := client.CoreV1().Secrets(clusterValidatorNamespace).Get(ctx, name, metav1.GetOptions{}) + require.NoError(t, err) + return mintedPassword(t, s) +} + +// Split mode's roles mint their pull secrets in parallel, possibly before the +// registry row has run. Each still gets the credential nvcr.io accepts for +// the image, and the scope is settled once for both. +func TestResolveValidatorPullSecret_BothRolesMintTheAcceptedCredential(t *testing.T) { + ngc := newFakeNGC(t, "good-key") + dockerHome(t, inlineDockerConfig(t, "nvcr.io", "$oauthtoken", "rotated-out", "")) + t.Setenv("NGC_API_KEY", "good-key") + ctx := WithRegistryCredentials(context.Background(), NewRegistryCredentials(false)) + + roles := []string{clusterValidatorControlPlaneRole, clusterValidatorComputePlaneRole} + clients := []*fake.Clientset{fake.NewSimpleClientset(), fake.NewSimpleClientset()} + names, errs := make([]string, len(roles)), make([]error, len(roles)) + var wg sync.WaitGroup + for i, role := range roles { + wg.Add(1) + go func() { + defer wg.Done() + names[i], _, errs[i] = resolveValidatorPullSecret(ctx, clients[i], "", "nvcr.io/nvidia/cv:1.0.0", + role, "runid", false, nil) + }() + } + wg.Wait() + for i, client := range clients { + require.NoError(t, errs[i], roles[i]) + s, err := client.CoreV1().Secrets(clusterValidatorNamespace).Get(ctx, names[i], metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, "good-key", mintedPassword(t, s), roles[i]) + } + sentLogin := 0 + for _, p := range ngc.passwords() { + if p == "rotated-out" { + sentLogin++ + } + } + assert.Equal(t, 1, sentLogin, "the scope is settled once, with the other role waiting on it") +} + +// A docker login with no access to an org gives way to the NGC key for that +// org alone. The validator's pull secret keeps the login, which reaches the +// validator's org, and the 403 is never reported as a rejected login. +func TestRegistryCredentials_NoAccessMovesOnlyThatScope(t *testing.T) { + newEntitledNGC(t, map[string][]string{"login-pass": {"nvidia"}, "key-pass": {"orgb"}}) + dockerHome(t, inlineDockerConfig(t, "nvcr.io", "$oauthtoken", "login-pass", "")) + t.Setenv("NGC_API_KEY", "key-pass") + ctx := WithRegistryCredentials(context.Background(), NewRegistryCredentials(false)) + + stack := RegistryEntry{Registry: "nvcr.io", RepoHint: "orgb/team", Critical: true} + r := registryCredentialCheck(probeRegistryCredential, stack, "nvcr.io/orgb/team", false, nil).Run(ctx) + assert.True(t, r.Passed, r.Message) + assert.Equal(t, SeverityInfo, r.Severity) + assert.Contains(t, r.Message, "has no access to nvcr.io/orgb/team, so the run sends NGC_API_KEY for it") + assert.NotContains(t, r.Message, "rejected") + + assert.Equal(t, "login-pass", mintPullSecret(ctx, t, fake.NewSimpleClientset(), "nvcr.io/nvidia/cv:1.0.0", + clusterValidatorComputePlaneRole), "the validator's org is the login's") +} + +// Before install, a docker login with no access to the scope helm pulls the +// stack's charts from fails that row, though the NGC key reaches it: helm on +// this machine pulls the charts with the docker login. It is reported as no +// access, never as a rejected login. After install, and on a scope that holds +// no charts, the key serving the scope is enough. +func TestRegistryCredentialCheck_NoAccessToTheChartScopeFailsBeforeInstall(t *testing.T) { + newEntitledNGC(t, map[string][]string{"login-pass": {"nvidia"}, "key-pass": {"orgb"}}) + dockerHome(t, inlineDockerConfig(t, "nvcr.io", "$oauthtoken", "login-pass", "")) + t.Setenv("NGC_API_KEY", "key-pass") + + for name, tc := range map[string]struct { + charts, postInstall, passed bool + }{ + "chart scope, before install": {charts: true}, + "chart scope, after install": {charts: true, postInstall: true, passed: true}, + "no charts there, before install": {passed: true}, + } { + ctx := WithRegistryCredentials(context.Background(), NewRegistryCredentials(false)) + entry := RegistryEntry{Registry: "nvcr.io", RepoHint: "orgb/team", Critical: true, Charts: tc.charts} + r := registryCredentialCheck(probeRegistryCredential, entry, "nvcr.io/orgb/team", tc.postInstall, + clusterHolding("", nil, nil)).Run(ctx) + assert.Equal(t, tc.passed, r.Passed, name+": "+r.Message) + assert.Contains(t, r.Message, "has no access to nvcr.io/orgb/team", name) + assert.NotContains(t, r.Message, "rejected", name) + if tc.passed { + assert.Equal(t, SeverityInfo, r.Severity, name) + continue + } + assert.Equal(t, SeverityError, r.Severity, name) + assert.Error(t, r.Err, name) + assert.Contains(t, r.Message, "helm on this machine pulls the stack's charts from nvcr.io/orgb/team "+ + "with the docker login, so the install cannot pull them", name) + assert.Equal(t, "login-pass", mintPullSecret(ctx, t, fake.NewSimpleClientset(), "nvcr.io/nvidia/cv:1.0.0", + clusterValidatorComputePlaneRole), "the validator's org keeps the login") + } +} + +// Before a local control-plane install the NGC key goes first, since up mints +// the cluster's pull secrets from it, so settling never sends the docker +// login. helm on this machine still pulls the stack's charts with it, so the +// chart scope's row judges it anyway, and grades it as where the login goes +// first: rejected (401) or with no access to the scope (403), it fails the +// critical row. The key keeps serving tag discovery and the validator's pull +// secret, a row for another scope never sends the login, and a login that +// works, or none at all, leaves the row passing on the key. +func TestRegistryCredentialCheck_KeyFirstStillJudgesTheChartScopesLogin(t *testing.T) { + const charts = "helm on this machine pulls the stack's charts from nvcr.io/orgb/team with the docker login, " + + "so the install cannot pull them" + for name, tc := range map[string]struct { + login string + config string + access map[string][]string + message string + warning bool + }{ + "rejected login": {login: "rotated-out", access: map[string][]string{"key-pass": {""}}, + message: "was rejected, and docker and helm on this machine still send it"}, + "login the registry gave no answer for": {login: "login-pass", + access: map[string][]string{"key-pass": {""}, "login-pass": {unavailableToken}}, + message: "could not verify the docker login helm on this machine pulls the charts from", warning: true}, + "unreadable docker config": {config: "{", access: map[string][]string{"key-pass": {""}}, + message: "could not verify the docker login helm on this machine pulls the charts from", warning: true}, + "login with no access": {login: "login-pass", + access: map[string][]string{"key-pass": {""}, "login-pass": {"nvidia"}}, + message: "has no access to nvcr.io/orgb/team, so the run sends NGC_API_KEY for it"}, + "login with access": {login: "login-pass", + access: map[string][]string{"key-pass": {""}, "login-pass": {"orgb"}}}, + "no docker login": {access: map[string][]string{"key-pass": {""}}}, + } { + t.Run(name, func(t *testing.T) { + withTempCacheDir(t) + ngc := newEntitledNGC(t, tc.access) + config := `{}` + switch { + case tc.config != "": + config = tc.config + case tc.login != "": + config = inlineDockerConfig(t, "nvcr.io", "$oauthtoken", tc.login, "") + } + dockerHome(t, config) + t.Setenv("NGC_API_KEY", "key-pass") + ctx := WithRegistryCredentials(context.Background(), NewRegistryCredentials(true)) + + chartRow := RegistryEntry{Registry: "nvcr.io", RepoHint: "orgb/team", Critical: true, Charts: true} + r := registryCredentialCheck(probeRegistryCredential, chartRow, "nvcr.io/orgb/team", false, nil).Run(ctx) + switch { + case tc.message == "": + assert.True(t, r.Passed, r.Message) + assert.Equal(t, SeverityInfo, r.Severity) + case tc.warning: + assert.False(t, r.Passed, r.Message) + assert.Equal(t, SeverityWarning, r.Severity, "the login could not be judged, which is no pass") + assert.Contains(t, r.Message, tc.message) + assert.Contains(t, r.Message, "credentials from NGC_API_KEY valid") + default: + assert.False(t, r.Passed, r.Message) + assert.Equal(t, SeverityError, r.Severity) + assert.Error(t, r.Err) + assert.Contains(t, r.Message, tc.message) + assert.Contains(t, r.Message, "credentials from NGC_API_KEY valid") + assert.Contains(t, r.Message, charts) + } + + loginSends := func() int { + n := 0 + for _, p := range ngc.passwords() { + if p != "key-pass" { + n++ + } + } + return n + } + byChartRow := loginSends() + if tc.login == "" { + assert.Zero(t, byChartRow) + } else if !tc.warning { + assert.Equal(t, 1, byChartRow, "the chart row sends the docker login once") + } + + other := RegistryEntry{Registry: "nvcr.io", RepoHint: "nvidia/cv", Critical: true} + r = registryCredentialCheck(probeRegistryCredential, other, "nvcr.io", false, nil).Run(ctx) + assert.True(t, r.Passed, r.Message) + assert.Equal(t, "nvcr.io: credentials valid", r.Message) + got, ok := ResolveLatestValidatorTag(ctx, "nvcr.io/nvidia/cv") + require.True(t, ok) + assert.Equal(t, "nvcr.io/nvidia/cv:1.2.0", got) + assert.Equal(t, "key-pass", mintPullSecret(ctx, t, fake.NewSimpleClientset(), "nvcr.io/orgb/team/cv:1.0.0", + clusterValidatorControlPlaneRole), "the pull secret keeps the key, which up mints from") + + assert.Equal(t, byChartRow, loginSends(), "only the chart scope's row sends the docker login") + }) + } +} + +// With the entitlements the other way round, each row passes on the +// credential that reaches its org, whichever row runs first, and tag +// discovery for the validator image uses the one that reaches the image. +func TestRegistryCredentials_EachScopeKeepsTheCredentialThatReachesIt(t *testing.T) { + withTempCacheDir(t) + newEntitledNGC(t, map[string][]string{"login-pass": {"orgb"}, "key-pass": {"nvidia"}}) + dockerHome(t, inlineDockerConfig(t, "nvcr.io", "$oauthtoken", "login-pass", "")) + t.Setenv("NGC_API_KEY", "key-pass") + ctx := WithRegistryCredentials(context.Background(), NewRegistryCredentials(false)) + + got, ok := ResolveLatestValidatorTag(ctx, "nvcr.io/nvidia/cv") + require.True(t, ok) + assert.Equal(t, "nvcr.io/nvidia/cv:1.2.0", got) + for _, e := range []RegistryEntry{ + {Registry: "nvcr.io", RepoHint: "nvidia/cv", Critical: true}, + {Registry: "nvcr.io", RepoHint: "orgb/team", Critical: true}, + } { + r := registryCredentialCheck(probeRegistryCredential, e, e.Registry+"/"+e.RepoHint, false, nil).Run(ctx) + assert.True(t, r.Passed, r.Message) + assert.NotContains(t, r.Message, "rejected") + assert.NotContains(t, r.Message, "generate a new NGC API key") + } + assert.Equal(t, "key-pass", mintPullSecret(ctx, t, fake.NewSimpleClientset(), "nvcr.io/nvidia/cv:1.0.0", + clusterValidatorControlPlaneRole)) +} + +// A registry fault that leaves a scope's login unjudged is not kept: the next +// lookup asks again and moves to the key once nvcr.io rejects the login. +func TestRegistryCredentials_AnUnjudgedScopeIsSettledAgain(t *testing.T) { + prevBackoff := registryRetryBackoff + registryRetryBackoff = time.Millisecond + t.Cleanup(func() { registryRetryBackoff = prevBackoff }) + ngc := newFakeNGC(t, "good-key") + ngc.failing = registryRetryAttempts + dockerHome(t, inlineDockerConfig(t, "nvcr.io", "$oauthtoken", "rotated-out", "")) + t.Setenv("NGC_API_KEY", "good-key") + rc := NewRegistryCredentials(false) + + got := rc.lookup(context.Background(), "nvcr.io", "nvidia/cv") + assert.Equal(t, "rotated-out", got.cred.pass, "an unjudged login stays for this caller") + got = rc.lookup(context.Background(), "nvcr.io", "nvidia/cv") + assert.Equal(t, "good-key", got.cred.pass) + require.NotNil(t, got.rejectedLogin) +} + +// After install a refused credential is no longer called this machine's +// alone without looking. An NGC key the cluster's pull secret also holds, as +// up mints it for the local environment, and a docker login, which the +// cluster's pull secret is often made from, are errors. Only a key the +// cluster's pull secrets do not hold warns. +func TestRegistryCredentialCheck_AfterInstallLooksInTheCluster(t *testing.T) { + newFakeNGC(t, "nothing-matches") + entry := RegistryEntry{Registry: "nvcr.io", RepoHint: "nvidia/cv", Critical: true} + inCluster := func(pass string) ClusterPullSecretChecker { + client := fake.NewSimpleClientset(dockerConfigSecret("nvcr-pull-secret", "nvcf", + mustDockerConfigJSON(t, "nvcr.io", "$oauthtoken", pass))) + return func(ctx context.Context, registry, repo, user, secret string) (string, error) { + return clusterPullSecretHolding(ctx, client, registry, repo, user, secret) + } + } + grade := func(cluster ClusterPullSecretChecker) CheckResult { + ctx := WithRegistryCredentials(context.Background(), NewRegistryCredentials(false)) + return registryCredentialCheck(probeRegistryCredential, entry, "nvcr.io", true, cluster).Run(ctx) + } + + dockerHome(t, `{}`) + t.Setenv("NGC_API_KEY", "revoked-key") + r := grade(inCluster("revoked-key")) + assert.Equal(t, SeverityError, r.Severity, r.Message) + assert.Contains(t, r.Message, "the cluster's pull secret nvcf/nvcr-pull-secret holds the same key") + r = grade(inCluster("current-key")) + assert.Equal(t, SeverityWarning, r.Severity, r.Message) + assert.Contains(t, r.Message, "affects only this machine") + + dockerHome(t, inlineDockerConfig(t, "nvcr.io", "$oauthtoken", "rotated-out", "")) + t.Setenv("NGC_API_KEY", "") + r = grade(inCluster("current-key")) + assert.False(t, r.Passed) + assert.Equal(t, SeverityError, r.Severity, r.Message) + assert.NotContains(t, r.Message, "affects only this machine") +} diff --git a/src/clis/nvcf-cli/internal/selfhosted/credhelper_test.go b/src/clis/nvcf-cli/internal/selfhosted/credhelper_test.go new file mode 100644 index 0000000000..010894903c --- /dev/null +++ b/src/clis/nvcf-cli/internal/selfhosted/credhelper_test.go @@ -0,0 +1,330 @@ +/* +SPDX-FileCopyrightText: Copyright (c) NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package selfhosted + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "runtime" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// A registry may send several WWW-Authenticate headers. The Bearer one is +// answered wherever it appears; reading only the first reported a registry +// answering "Negotiate" then "Bearer" as a clean skip. +func TestSelectAuthChallenge(t *testing.T) { + bearer := `Bearer realm="https://auth.example/token",service="registry"` + assert.Equal(t, bearer, selectAuthChallenge([]string{"Negotiate", bearer})) + assert.Equal(t, `Basic realm="r"`, selectAuthChallenge([]string{`Basic realm="r"`})) + assert.Equal(t, "", selectAuthChallenge(nil)) +} + +// inlineDockerConfig builds a config.json with one inline credential at run +// time. A literal auths blob trips secret scanners even with fake values. +func inlineDockerConfig(t *testing.T, registry, user, pass, credsStore string) string { + t.Helper() + cfg := map[string]any{"auths": map[string]any{ + registry: map[string]string{"username": user, "password": pass}, + }} + if credsStore != "" { + cfg["credsStore"] = credsStore + } + b, err := json.Marshal(cfg) + require.NoError(t, err) + return string(b) +} + +// dockerHome writes ~/.docker/config.json under a temporary HOME and puts a +// fake docker-credential- for each helper on PATH, each answering with +// its own secret. +func dockerHome(t *testing.T, config string, helpers ...string) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("shell-script credential helpers") + } + home := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(home, ".docker"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(home, ".docker", "config.json"), []byte(config), 0o600)) + t.Setenv("HOME", home) + bin := t.TempDir() + for _, h := range helpers { + script := "#!/bin/sh\n[ \"$1\" = get ] || exit 1\nread server\n" + + "printf '{\"ServerURL\":\"%s\",\"Username\":\"u-" + h + "\",\"Secret\":\"s-" + h + "\"}' \"$server\"\n" + require.NoError(t, os.WriteFile(filepath.Join(bin, "docker-credential-"+h), []byte(script), 0o755)) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + prev := credentialHelperTimeout + credentialHelperTimeout = time.Minute + t.Cleanup(func() { credentialHelperTimeout = prev }) +} + +// credsFor reads registry's credential from the docker config. +func credsFor(t *testing.T, registry string) (string, string, bool) { + t.Helper() + cred, ok, _ := credsFromDockerConfig(context.Background(), registry) + return cred.user, cred.pass, ok +} + +// Docker Desktop leaves only an empty auths entry and keeps the login in +// credsStore, so a working `docker login` must be read through the helper. +func TestCredsFromDockerConfig_UsesCredsStore(t *testing.T) { + dockerHome(t, `{"auths":{"nvcr.io":{}},"credsStore":"store"}`, "store") + u, p, ok := credsFor(t, "nvcr.io") + require.True(t, ok) + assert.Equal(t, "u-store", u) + assert.Equal(t, "s-store", p) +} + +// A per-registry credHelpers entry wins over the global store. +func TestCredsFromDockerConfig_CredHelpersBeatCredsStore(t *testing.T) { + dockerHome(t, `{"credsStore":"store","credHelpers":{"nvcr.io":"ngc"}}`, "store", "ngc") + u, _, ok := credsFor(t, "nvcr.io") + require.True(t, ok) + assert.Equal(t, "u-ngc", u) +} + +// Inline credentials are read when no store is configured. +func TestCredsFromDockerConfig_InlineAuthWithoutAStore(t *testing.T) { + dockerHome(t, inlineDockerConfig(t, "nvcr.io", "x", "y", "")) + u, p, ok := credsFor(t, "nvcr.io") + require.True(t, ok) + assert.Equal(t, "x", u) + assert.Equal(t, "y", p) +} + +// With a store configured docker never reads an inline password, so a stale +// one left in the file must not win over the store. +func TestCredsFromDockerConfig_StoreBeatsStaleInlineAuth(t *testing.T) { + dockerHome(t, inlineDockerConfig(t, "nvcr.io", "old", "stale", "store"), "store") + u, p, ok := credsFor(t, "nvcr.io") + require.True(t, ok) + assert.Equal(t, "u-store", u) + assert.Equal(t, "s-store", p) +} + +// Docker Hub logins live under the legacy index key, whatever host name the +// image uses. +func TestCredsFromDockerConfig_DockerHubKey(t *testing.T) { + dockerHome(t, inlineDockerConfig(t, "https://index.docker.io/v1/", "hub", "pw", "")) + for _, host := range []string{"docker.io", "index.docker.io", "registry-1.docker.io"} { + u, _, ok := credsFor(t, host) + require.True(t, ok, host) + assert.Equal(t, "hub", u) + } +} + +// The timeout holds even when the helper leaves a child holding stdout open. +func TestCredsFromHelper_TimeoutIsEnforced(t *testing.T) { + dockerHome(t, `{"credsStore":"hang"}`) + bin := t.TempDir() + script := "#!/bin/sh\nsleep 30 &\nsleep 30\n" + require.NoError(t, os.WriteFile(filepath.Join(bin, "docker-credential-hang"), []byte(script), 0o755)) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + credentialHelperTimeout = 500 * time.Millisecond + + start := time.Now() + _, _, ok := credsFor(t, "nvcr.io") + assert.False(t, ok) + assert.Less(t, time.Since(start), 10*time.Second, "the helper call must not outlive its timeout by the child's lifetime") +} + +// A helper that floods stdout is refused rather than buffered without bound. +func TestCredsFromHelper_OutputIsCapped(t *testing.T) { + dockerHome(t, `{"credsStore":"flood"}`) + bin := t.TempDir() + // Valid JSON padded past the cap, so only the cap can refuse it. + script := "#!/bin/sh\nprintf '{\"Username\":\"u\",\"Secret\":\"s\"}'\nhead -c 200000 /dev/zero | tr '\\0' ' '\n" + require.NoError(t, os.WriteFile(filepath.Join(bin, "docker-credential-flood"), []byte(script), 0o755)) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + _, _, ok := credsFor(t, "nvcr.io") + assert.False(t, ok) +} + +// The helper name comes from a file; anything that is not a plain helper +// suffix is refused rather than put on an exec path. +func TestCredsFromDockerConfig_RefusesUnsafeHelperName(t *testing.T) { + // The helper exists on PATH, so only the name check can refuse it. + dockerHome(t, `{"credsStore":"bad name"}`, "bad name") + _, _, ok := credsFor(t, "nvcr.io") + assert.False(t, ok) +} + +// Several challenges can share one header, comma-separated. +func TestSelectAuthChallenge_JoinedInOneHeader(t *testing.T) { + got := selectAuthChallenge([]string{`Negotiate, Bearer realm="https://auth.example/token",service="r"`}) + assert.Equal(t, `Bearer realm="https://auth.example/token",service="r"`, got) + assert.Equal(t, `Basic realm="bearer thing"`, selectAuthChallenge([]string{`Basic realm="bearer thing"`}), + "a quoted word is not a challenge") +} + +// Before a local install the NGC key goes first for nvcr.io: up mints its +// pull secrets from it. Anywhere else the docker login goes first, the one +// docker and a pull secret minted from it use, and the key is the fallback. +// Other registries never get the key. +func TestRegistryCredentials_NGCKeyOrder(t *testing.T) { + dockerHome(t, `{"auths":{"nvcr.io":{}},"credsStore":"store"}`, "store") + t.Setenv("NGC_API_KEY", "env-key") + ctx := context.Background() + + cred, ok, err := readCredential(ctx, NewRegistryCredentials(true), "nvcr.io") + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, "env-key", cred.pass) + assert.True(t, cred.ngcKey) + assert.Equal(t, "NGC_API_KEY", cred.source) + + cred, ok, _ = readCredential(ctx, NewRegistryCredentials(false), "nvcr.io") + require.True(t, ok) + assert.Equal(t, "u-store", cred.user, "the docker login goes first where up does not mint from the key") + assert.False(t, cred.ngcKey) + + dockerHome(t, `{}`) + cred, ok, _ = readCredential(ctx, NewRegistryCredentials(false), "nvcr.io") + require.True(t, ok) + assert.Equal(t, "env-key", cred.pass, "without a docker login the key is the fallback") + + for _, reg := range []string{"quay.io", "example.nvcr.io", "nvcr.io:443@attacker.example", "nvcr.io.attacker.example"} { + for _, prefer := range []bool{true, false} { + cred, _, _ := readCredential(ctx, NewRegistryCredentials(prefer), reg) + assert.NotEqual(t, "env-key", cred.pass, "the NGC key is never sent to %s", reg) + } + } +} + +// readCredential is rc's credential for registry before any scope is +// settled. +func readCredential(ctx context.Context, rc *RegistryCredentials, registry string) (registryCredential, bool, error) { + got := rc.read(ctx, registry) + return got.cred, got.ok, got.err +} + +// A docker login the registry answers 401 gives way to the NGC key for that +// scope. Where the key goes first, or for a registry that is not nvcr.io, +// there is nothing behind the credential, and lookup never asks the registry. +func TestRegistryCredentials_RejectedLoginGivesWayForTheScope(t *testing.T) { + ngc := newFakeNGC(t, "env-key") + dockerHome(t, `{"auths":{"nvcr.io":{}},"credsStore":"store"}`, "store") + t.Setenv("NGC_API_KEY", "env-key") + ctx := context.Background() + + got := NewRegistryCredentials(false).lookup(ctx, "nvcr.io", "nvidia/cv") + require.NoError(t, got.err) + require.True(t, got.ok) + assert.Equal(t, "env-key", got.cred.pass) + require.NotNil(t, got.rejectedLogin) + assert.Equal(t, "docker-credential-store", got.rejectedLogin.source) + assert.Nil(t, got.noAccessLogin) + assert.Equal(t, []string{"s-store"}, ngc.passwords()) + + got = NewRegistryCredentials(true).lookup(ctx, "nvcr.io", "nvidia/cv") + assert.Equal(t, "env-key", got.cred.pass) + assert.Nil(t, got.rejectedLogin) + got = NewRegistryCredentials(false).lookup(ctx, "quay.io", "jetstack/a") + assert.Equal(t, "s-store", got.cred.pass, "the key is never sent to another registry") + assert.Equal(t, []string{"s-store"}, ngc.passwords(), "nothing was left to settle") +} + +// docker reads its config from $DOCKER_CONFIG when that is set, as CI +// runners do. +func TestCredsFromDockerConfig_HonorsDOCKER_CONFIG(t *testing.T) { + dockerHome(t, `{}`) + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "config.json"), + []byte(inlineDockerConfig(t, "harbor.example.com", "ci", "pw", "")), 0o600)) + t.Setenv("DOCKER_CONFIG", dir) + u, p, ok := credsFor(t, "harbor.example.com") + require.True(t, ok) + assert.Equal(t, "ci", u) + assert.Equal(t, "pw", p) +} + +// docker matches a key with a scheme or a /v1/ path by its hostname. +func TestCredsFromDockerConfig_NormalizesKeys(t *testing.T) { + for _, key := range []string{"https://harbor.example.com", "harbor.example.com/v1/", "http://harbor.example.com/"} { + dockerHome(t, inlineDockerConfig(t, key, "u", "p", "")) + _, _, ok := credsFor(t, "harbor.example.com") + assert.True(t, ok, key) + } + dockerHome(t, `{"credHelpers":{"https://harbor.example.com":"ngc"}}`, "ngc") + u, _, ok := credsFor(t, "harbor.example.com") + require.True(t, ok) + assert.Equal(t, "u-ngc", u, "a credHelpers key is matched the same way") +} + +// A helper call ends with its caller's context, so tag discovery and the +// registry probe keep their own bounds and stop on an interrupt. +func TestCredsFromHelper_EndsWithTheCallersContext(t *testing.T) { + dockerHome(t, `{"credsStore":"hang"}`) + bin := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(bin, "docker-credential-hang"), []byte("#!/bin/sh\nsleep 30\n"), 0o755)) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + start := time.Now() + _, ok, err := credsFromDockerConfig(ctx, "nvcr.io") + assert.False(t, ok) + assert.Error(t, err, "a helper that did not answer is reported, not read as no credential") + assert.Less(t, time.Since(start), 5*time.Second) +} + +// A helper's "not found" answer is no credential, not a failure; any other +// failure is reported. +func TestCredsFromHelper_NotFoundIsNoCredential(t *testing.T) { + dockerHome(t, `{"credsStore":"empty"}`) + bin := t.TempDir() + script := "#!/bin/sh\necho 'credentials not found in native keychain'\nexit 1\n" + require.NoError(t, os.WriteFile(filepath.Join(bin, "docker-credential-empty"), []byte(script), 0o755)) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + _, ok, err := credsFromDockerConfig(context.Background(), "nvcr.io") + assert.False(t, ok) + assert.NoError(t, err) + + dockerHome(t, `{"credsStore":"missing-helper"}`) + _, ok, err = credsFromDockerConfig(context.Background(), "nvcr.io") + assert.False(t, ok) + assert.ErrorContains(t, err, "docker-credential-missing-helper") +} + +// One run reads a registry's credential once, however many checks and +// scopes ask. +func TestRegistryCredentials_ResolvesOncePerRun(t *testing.T) { + dockerHome(t, `{"credsStore":"count"}`) + bin := t.TempDir() + calls := filepath.Join(t.TempDir(), "calls") + script := "#!/bin/sh\necho x >> '" + calls + "'\nprintf '{\"Username\":\"u\",\"Secret\":\"s\"}'\n" + require.NoError(t, os.WriteFile(filepath.Join(bin, "docker-credential-count"), []byte(script), 0o755)) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + + rc := NewRegistryCredentials(false) + ctx := WithRegistryCredentials(context.Background(), rc) + for _, repo := range []string{"nvcf/a", "nvcf/b", "nvcf/a"} { + got := registryCredentialsFrom(ctx).lookup(ctx, "harbor.example.com", repo) + require.NoError(t, got.err) + require.True(t, got.ok) + } + body, err := os.ReadFile(calls) + require.NoError(t, err) + assert.Equal(t, "x\n", string(body), "the helper runs once") +} diff --git a/src/clis/nvcf-cli/internal/selfhosted/dockercreds.go b/src/clis/nvcf-cli/internal/selfhosted/dockercreds.go new file mode 100644 index 0000000000..793839bf2d --- /dev/null +++ b/src/clis/nvcf-cli/internal/selfhosted/dockercreds.go @@ -0,0 +1,574 @@ +/* +SPDX-FileCopyrightText: Copyright (c) NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package selfhosted + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io/fs" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "sync" + "time" +) + +// ngcKeyRegistryHost is the one registry NGC_API_KEY is for: the registry up +// mints its pull secrets for from that key. isNGCRegistry also accepts other +// NVIDIA hosts (staging NGC, internal registries), and those take their own +// logins, never the NGC key. +const ngcKeyRegistryHost = "nvcr.io" + +// isNGCKeyRegistry reports whether NGC_API_KEY may be sent to registry. +func isNGCKeyRegistry(registry string) bool { + if !isBareRegistryHost(registry) { + return false + } + host := registry + if h, _, err := net.SplitHostPort(registry); err == nil { + host = h + } + return strings.EqualFold(host, ngcKeyRegistryHost) +} + +// registryCredential is a local credential for one registry. +type registryCredential struct { + user, pass string + // source says where it was read: an environment variable, the docker + // config file, or a docker-credential- helper. + source string + // ngcKey marks the NGC API key read from the environment. + ngcKey bool +} + +// RegistryCredentials resolves the local credential for a registry the way +// docker does, once per registry per run, and settles which credential the run +// uses for each repository scope on it. The credential probe, tag discovery +// and the validator's pull secret all read the settled one, so the credential +// the local row checks is the one the validator Job is given. +type RegistryCredentials struct { + // preferNGCKey puts the NGC API key ahead of the docker config for + // nvcr.io. That is right only where up mints its pull secrets from the + // key; anywhere else the docker login is what docker and the cluster use, + // and the key is sent for a scope only when there is no login, or the + // registry rejects the login or gives it no access to that scope. Either + // way the row for the stack's chart scope judges the docker login, which + // helm pulls the charts with: see judgeLogin. + preferNGCKey bool + + mu sync.Mutex + cache map[string]credentialLookup + scopes map[credentialScope]*scopeSettlement +} + +type credentialLookup struct { + cred registryCredential + ok bool + err error + // fallback is the credential that may take cred's place for a scope: the + // NGC key behind a docker login for nvcr.io. + fallback *registryCredential +} + +// credentialScope is one repository scope on a registry. nvcr.io entitles a +// credential per org and team, so a docker login and the NGC key can each +// reach a scope the other cannot. +type credentialScope struct{ registry, repo string } + +// scopeSettlement holds one scope's settled credential. lock admits one +// caller at a time, so the callers that arrive while it settles wait for its +// result instead of each probing the registry. +type scopeSettlement struct { + lock chan struct{} + settled bool + result settledCredential +} + +// settledCredential is the credential the run uses for one repository scope. +type settledCredential struct { + cred registryCredential + ok bool + err error + // rejectedLogin is the docker login the registry answered 401 for this + // scope, before the NGC key took its place. + rejectedLogin *registryCredential + // noAccessLogin is a docker login the registry gave no access to this + // scope, a 403, before the NGC key took its place. Its access, not the + // login itself, is what the registry refused. + noAccessLogin *registryCredential + // fallbackFailure says why the NGC key, tried for a scope the docker + // login had no access to, did not take its place. + fallbackFailure string + // loginUnjudged says why judgeLogin could not judge the docker login + // helm pulls the charts with: its config could not be read, or the + // registry gave no answer about it. + loginUnjudged string +} + +// NewRegistryCredentials returns a per-run credential resolver. +func NewRegistryCredentials(preferNGCKey bool) *RegistryCredentials { + return &RegistryCredentials{preferNGCKey: preferNGCKey, cache: map[string]credentialLookup{}, + scopes: map[credentialScope]*scopeSettlement{}} +} + +type registryCredentialsKey struct{} + +// WithRegistryCredentials returns ctx carrying rc, which every registry +// credential lookup made under ctx uses. +func WithRegistryCredentials(ctx context.Context, rc *RegistryCredentials) context.Context { + return context.WithValue(ctx, registryCredentialsKey{}, rc) +} + +// registryCredentialsFrom returns the resolver ctx carries, or a fresh one +// that prefers the NGC key, the order up mints from. +func registryCredentialsFrom(ctx context.Context) *RegistryCredentials { + if rc, ok := ctx.Value(registryCredentialsKey{}).(*RegistryCredentials); ok && rc != nil { + return rc + } + return NewRegistryCredentials(true) +} + +// read returns registry's local credentials as docker and the environment +// hold them, before any scope is settled. ok is false when there is none; err +// reports a credential store that could not be read, which is not the same as +// no credential. A read the caller's ctx cut short is not cached. +func (rc *RegistryCredentials) read(ctx context.Context, registry string) credentialLookup { + key := strings.ToLower(registry) + rc.mu.Lock() + defer rc.mu.Unlock() + if hit, ok := rc.cache[key]; ok { + return hit + } + found := rc.resolve(ctx, registry) + if ctx.Err() == nil { + rc.cache[key] = found + } + return found +} + +// lookup returns the credential the run uses for repo on registry. +// +// Where nvcr.io has a docker login with the NGC key behind it, the scope is +// settled first: a 401 to the login moves the scope to the key, and a 403, +// no access to the scope, tries the key for it. Each scope is settled once, +// and callers for it wait for that result, so the row, tag discovery and the +// validator's pull secret get one credential. Another scope is settled on its +// own. A result the caller's ctx cut short, or that a fault of the registry +// left open, is not kept. +func (rc *RegistryCredentials) lookup(ctx context.Context, registry, repo string) settledCredential { + return rc.settle(ctx, registry, repo, nil) +} + +// lookupChallenged is lookup for a caller that already holds the registry's +// WWW-Authenticate challenge, so settling does not ask /v2/ for it again. +func (rc *RegistryCredentials) lookupChallenged( + ctx context.Context, registry, repo, challenge string, +) settledCredential { + return rc.settle(ctx, registry, repo, &challenge) +} + +func (rc *RegistryCredentials) settle(ctx context.Context, registry, repo string, challenge *string) settledCredential { + read := rc.read(ctx, registry) + if !read.ok || read.fallback == nil { + return settledCredential{cred: read.cred, ok: read.ok, err: read.err} + } + s := rc.settlement(registry, repo) + select { + case s.lock <- struct{}{}: + case <-ctx.Done(): + return settledCredential{err: ctx.Err()} + } + defer func() { <-s.lock }() + if s.settled { + return s.result + } + result, decided := settleScope(ctx, registry, repo, challenge, read.cred, *read.fallback) + if decided && ctx.Err() == nil { + s.settled, s.result = true, result + } + return result +} + +func (rc *RegistryCredentials) settlement(registry, repo string) *scopeSettlement { + key := credentialScope{registry: strings.ToLower(registry), repo: repo} + rc.mu.Lock() + defer rc.mu.Unlock() + s, ok := rc.scopes[key] + if !ok { + s = &scopeSettlement{lock: make(chan struct{}, 1)} + rc.scopes[key] = s + } + return s +} + +// credentialSettleTimeout bounds the registry round trips that settle one +// scope. A var so tests can shorten it. +var credentialSettleTimeout = registryProbeTimeout + +// settleScope decides between a docker login and the NGC key behind it for +// repo, asking /v2/ for the challenge when the caller has none. Only a 401 +// says the login is rejected. A 403 says it has no access to repo, and the +// key is tried for repo alone. decided is false for an answer that says +// nothing about either credential. +func settleScope( + ctx context.Context, registry, repo string, challenge *string, login, key registryCredential, +) (settledCredential, bool) { + sctx, cancel := context.WithTimeout(ctx, credentialSettleTimeout) + defer cancel() + client := newRegistryHTTPClient(credentialSettleTimeout) + keep := settledCredential{cred: login, ok: true} + if challenge == nil { + resp, err := doRegistryRequest(sctx, client, func() (*http.Request, error) { + return http.NewRequestWithContext(sctx, http.MethodGet, "https://"+registry+"/v2/", nil) + }) + if err != nil { + return keep, false + } + resp.Body.Close() + switch resp.StatusCode { + case http.StatusOK: + // Anyone may read /v2/, which tells nothing about either credential. + return keep, true + case http.StatusUnauthorized: + default: + return keep, false + } + got := selectAuthChallenge(resp.Header.Values("Www-Authenticate")) + challenge = &got + } + if scheme := authChallengeScheme(*challenge); scheme != "" && !strings.EqualFold(scheme, "Bearer") { + return keep, true + } + _, err := exchangeBearerToken(sctx, client, registry, repo, *challenge, &login) + var te *tokenExchangeError + switch { + case err == nil: + return keep, true + case !errors.As(err, &te): + return keep, false + case te.refused: + return keep, true + case te.credentialed && te.status == http.StatusUnauthorized: + return settledCredential{cred: key, ok: true, rejectedLogin: &login}, true + case !te.credentialed || te.status != http.StatusForbidden: + return keep, false + } + _, err = exchangeBearerToken(sctx, client, registry, repo, *challenge, &key) + switch { + case err == nil: + return settledCredential{cred: key, ok: true, noAccessLogin: &login}, true + case errors.As(err, &te) && (te.rejected() || te.refused): + keep.fallbackFailure = key.source + " was tried for it too: " + te.msg + return keep, true + } + return keep, false +} + +// judgeLogin returns settled with the docker login judged for repo where the +// NGC key went first, so settling never sent the login: a 401 marks it +// rejected and a 403 marks it without access to repo, as settleScope does. +// The credential the run uses for repo stays the key. A scope helm on this +// machine pulls the stack's charts from needs this: helm sends the docker +// login whatever order the run sends credentials in. No docker login besides +// the key leaves settled as it was; a docker config that cannot be read, or +// an answer that says nothing about the login, sets loginUnjudged. +func (rc *RegistryCredentials) judgeLogin( + ctx context.Context, registry, repo, challenge string, settled settledCredential, +) settledCredential { + if !rc.preferNGCKey || !settled.ok || !settled.cred.ngcKey { + return settled + } + login, ok, err := credsFromDockerConfig(ctx, registry) + if err != nil { + settled.loginUnjudged = err.Error() + return settled + } + if !ok || login.pass == settled.cred.pass { + return settled + } + jctx, cancel := context.WithTimeout(ctx, credentialSettleTimeout) + defer cancel() + _, err = exchangeBearerToken(jctx, newRegistryHTTPClient(credentialSettleTimeout), registry, repo, challenge, + &login) + var te *tokenExchangeError + switch { + case err == nil: + case errors.As(err, &te) && te.rejected() && te.status == http.StatusUnauthorized: + settled.rejectedLogin = &login + case errors.As(err, &te) && te.rejected(): + settled.noAccessLogin = &login + default: + settled.loginUnjudged = err.Error() + } + return settled +} + +func (rc *RegistryCredentials) resolve(ctx context.Context, registry string) credentialLookup { + var fromKey *registryCredential + if isNGCKeyRegistry(registry) { + if name, key := firstSetEnv(ngcAPIKeyEnvNames...); key != "" { + fromKey = ®istryCredential{user: "$oauthtoken", pass: key, source: name, ngcKey: true} + } + } + if fromKey != nil && rc.preferNGCKey { + return credentialLookup{cred: *fromKey, ok: true} + } + cred, ok, err := credsFromDockerConfig(ctx, registry) + switch { + case ok: + if fromKey != nil && fromKey.pass == cred.pass { + fromKey = nil + } + return credentialLookup{cred: cred, ok: true, fallback: fromKey} + case fromKey != nil: + return credentialLookup{cred: *fromKey, ok: true} + } + return credentialLookup{err: err} +} + +// firstSetEnv returns the first of names set to a non-empty value, and that +// value. +func firstSetEnv(names ...string) (string, string) { + for _, name := range names { + if v := os.Getenv(name); v != "" { + return name, v + } + } + return "", "" +} + +// dockerConfigPath is where docker reads its config: $DOCKER_CONFIG, else +// ~/.docker. +func dockerConfigPath() (string, error) { + if dir := os.Getenv("DOCKER_CONFIG"); dir != "" { + return filepath.Join(dir, "config.json"), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".docker", "config.json"), nil +} + +// dockerConfigAuth is one auths entry of a docker config. +type dockerConfigAuth struct { + Auth string `json:"auth"` + Username string `json:"username"` + Password string `json:"password"` +} + +func credsFromDockerConfig(ctx context.Context, registry string) (registryCredential, bool, error) { + path, err := dockerConfigPath() + if err != nil { + return registryCredential{}, false, nil + } + body, err := os.ReadFile(path) + if errors.Is(err, fs.ErrNotExist) { + return registryCredential{}, false, nil + } + if err != nil { + return registryCredential{}, false, fmt.Errorf("read %s: %w", path, err) + } + var doc struct { + Auths map[string]dockerConfigAuth `json:"auths"` + CredsStore string `json:"credsStore"` + CredHelpers map[string]string `json:"credHelpers"` + } + if err := json.Unmarshal(body, &doc); err != nil { + return registryCredential{}, false, fmt.Errorf("parse %s: %w", path, err) + } + // Docker's order: a per-registry credHelpers entry, else credsStore, and + // only with neither configured the inline auths entry. With a store + // configured docker never reads an inline password, so an old one left in + // the file must not win here either: that sent a stale key while `docker + // pull` worked. + key := dockerConfigKey(registry) + helper, ok := doc.CredHelpers[key] + if !ok { + for k, h := range doc.CredHelpers { + if sameDockerHost(k, key) { + helper = h + break + } + } + } + if helper == "" { + helper = doc.CredsStore + } + if helper != "" { + return credsFromHelper(ctx, helper, key) + } + entry, ok := doc.Auths[key] + if !ok { + // Docker matches a legacy key, with a scheme or a /v1/ path, by its + // hostname. + for k, e := range doc.Auths { + if sameDockerHost(k, key) { + entry, ok = e, true + break + } + } + } + if !ok { + return registryCredential{}, false, nil + } + user, pass, ok := decodeDockerConfigAuth(entry) + if !ok { + return registryCredential{}, false, nil + } + return registryCredential{user: user, pass: pass, source: "docker config " + path}, true, nil +} + +func decodeDockerConfigAuth(entry dockerConfigAuth) (string, string, bool) { + if entry.Username != "" && entry.Password != "" { + return entry.Username, entry.Password, true + } + if entry.Auth == "" { + return "", "", false + } + raw, err := base64.StdEncoding.DecodeString(entry.Auth) + if err != nil { + return "", "", false + } + user, pass, ok := strings.Cut(string(raw), ":") + return user, pass, ok +} + +// dockerHubConfigKey is the key docker stores Docker Hub logins under. +const dockerHubConfigKey = "https://index.docker.io/v1/" + +// dockerHubRegistry is the host Docker Hub images are pulled from. +const dockerHubRegistry = "registry-1.docker.io" + +// dockerConfigKey maps a registry host to the key docker uses for it in +// config.json. Docker Hub's hosts all share one legacy key; every other +// registry is keyed by its host. +func dockerConfigKey(registry string) string { + if isDockerHubHost(registry) { + return dockerHubConfigKey + } + return registry +} + +func isDockerHubHost(host string) bool { + switch strings.ToLower(host) { + case "docker.io", "index.docker.io", dockerHubRegistry: + return true + } + return false +} + +// dockerHostname reduces a docker config key to its host the way docker's +// file store does: scheme and anything from the first '/' dropped, with +// Docker Hub's hosts folded into one. +func dockerHostname(key string) string { + key = strings.TrimPrefix(strings.TrimPrefix(key, "https://"), "http://") + host, _, _ := strings.Cut(key, "/") + host = strings.ToLower(host) + if isDockerHubHost(host) { + return "docker.io" + } + return host +} + +func sameDockerHost(a, b string) bool { + return dockerHostname(a) == dockerHostname(b) +} + +// credentialHelperName matches the docker-credential- suffixes docker +// accepts. Anything else is refused rather than put on an exec path. +var credentialHelperName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*$`) + +// credentialHelperTimeout bounds one helper call; a helper waiting on a +// keychain prompt must not stall preflight. A var so tests can allow for a +// loaded machine starting a freshly written helper script. +var credentialHelperTimeout = 5 * time.Second + +// credsFromHelper asks docker-credential- for registry's credential +// using the credential-helper protocol docker uses: "get" with the server URL +// on stdin, JSON {"Username","Secret"} on stdout. The call ends with ctx, so +// it stays inside the caller's time bound and stops on an interrupt. +func credsFromHelper(ctx context.Context, helper, serverURL string) (registryCredential, bool, error) { + if !credentialHelperName.MatchString(helper) { + return registryCredential{}, false, fmt.Errorf("refusing credential helper name %q", helper) + } + hctx, cancel := context.WithTimeout(ctx, credentialHelperTimeout) + defer cancel() + name := "docker-credential-" + helper + cmd := exec.CommandContext(hctx, name, "get") + cmd.Stdin = strings.NewReader(serverURL) + // Without WaitDelay a helper that leaves a child holding stdout open keeps + // Output waiting past the timeout. The output cap keeps a misbehaving + // helper from filling memory; a credential is a few hundred bytes. + cmd.WaitDelay = time.Second + var out cappedBuffer + cmd.Stdout = &out + if err := cmd.Run(); err != nil { + // The protocol's answer for a registry the store has nothing for. + if strings.Contains(out.buf.String(), "credentials not found") { + return registryCredential{}, false, nil + } + if hctx.Err() != nil { + return registryCredential{}, false, fmt.Errorf("%s did not answer within %s", name, credentialHelperTimeout) + } + return registryCredential{}, false, fmt.Errorf("%s: %w", name, err) + } + if out.overflow { + return registryCredential{}, false, fmt.Errorf("%s wrote more than %d bytes", name, credentialHelperOutputLimit) + } + var cred struct { + Username string `json:"Username"` + Secret string `json:"Secret"` + } + if err := json.Unmarshal(out.buf.Bytes(), &cred); err != nil { + return registryCredential{}, false, fmt.Errorf("%s: decode its answer: %w", name, err) + } + if cred.Secret == "" { + return registryCredential{}, false, nil + } + return registryCredential{user: cred.Username, pass: cred.Secret, source: name}, true, nil +} + +// credentialHelperOutputLimit caps what a credential helper may write. +const credentialHelperOutputLimit = 64 << 10 + +// cappedBuffer keeps at most credentialHelperOutputLimit bytes and records +// whether more were written. +type cappedBuffer struct { + buf bytes.Buffer + overflow bool +} + +func (c *cappedBuffer) Write(p []byte) (int, error) { + if room := credentialHelperOutputLimit - c.buf.Len(); len(p) > room { + c.overflow = true + if room > 0 { + c.buf.Write(p[:room]) + } + return len(p), nil + } + return c.buf.Write(p) +} diff --git a/src/clis/nvcf-cli/internal/selfhosted/helm_runtime.go b/src/clis/nvcf-cli/internal/selfhosted/helm_runtime.go index 7af90e8492..6441e14149 100644 --- a/src/clis/nvcf-cli/internal/selfhosted/helm_runtime.go +++ b/src/clis/nvcf-cli/internal/selfhosted/helm_runtime.go @@ -71,7 +71,7 @@ func checkHelmRuntimeCompatibility(ctx context.Context, helmfileSpec, helmSpec B r := CheckResult{ ID: "local-host-tools-helm-runtime", Category: "local-host-tools", - Severity: "error", + Severity: SeverityError, HintURL: "https://github.com/helmfile/helmfile#installation", } _, helmfileVersion, err := probeBinarySpecVersion(ctx, helmfileSpec) @@ -93,7 +93,7 @@ func checkHelmRuntimeCompatibility(ctx context.Context, helmfileSpec, helmSpec B return r } r.Passed = true - r.Severity = "info" + r.Severity = SeverityInfo r.Detail = string(mode) r.Message = fmt.Sprintf("Helm %s with helmfile %s uses %s mode", helmVersion, helmfileVersion, mode) return r diff --git a/src/clis/nvcf-cli/internal/selfhosted/inotify_probe.go b/src/clis/nvcf-cli/internal/selfhosted/inotify_probe.go index 75fd86bdcf..d712e820a9 100644 --- a/src/clis/nvcf-cli/internal/selfhosted/inotify_probe.go +++ b/src/clis/nvcf-cli/internal/selfhosted/inotify_probe.go @@ -18,25 +18,29 @@ limitations under the License. package selfhosted import ( + "cmp" "context" "fmt" "io" "slices" "strconv" "strings" + "sync" "time" "golang.org/x/sync/errgroup" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" "k8s.io/client-go/kubernetes" ) const ( - // inotifyProbeImage is the image used by the per-node probe pod. Pinned - // to match the documented inotify-tuner DaemonSet so customers do not need - // to mirror an extra image. + // inotifyProbeImage is the per-node probe pod's image when no probe + // image is configured. Pinned to match the documented inotify-tuner + // DaemonSet so customers do not need to mirror an extra image. inotifyProbeImage = "busybox:1.36" // inotifyProbeNamespace is where probe pods are created. "default" exists @@ -46,6 +50,13 @@ const ( // inotifyProbeContainer is the container name inside the probe pod. inotifyProbeContainer = "probe" + // inotifyProbeAppLabel names the probe's pods apart from the validator's. + inotifyProbeAppLabel = "nvcf-inotify-probe" + + // inotifyProbeRole is the role the probe's pods are labelled with: only + // the compute-plane checks run it. + inotifyProbeRole = clusterValidatorComputePlaneRole + // inotifyProbeShellCmd reads both sysctls. Each cat runs independently // so a missing /proc entry on one path doesn't drop the other value; // the surviving integer reaches the parser, which errors clearly with @@ -53,10 +64,6 @@ const ( // and discarding all diagnostic info. inotifyProbeShellCmd = "cat /host/proc/sys/fs/inotify/max_user_instances; cat /host/proc/sys/fs/inotify/max_user_watches" - // perNodePodTimeout caps the create+wait+logs sequence for one node. - // Image pull on a cold node can take ~30s; pod schedule + cat is sub-second. - perNodePodTimeout = 90 * time.Second - // podPollInterval is how often we poll pod status while waiting for the // probe container to terminate. podPollInterval = 1 * time.Second @@ -95,8 +102,16 @@ const ( // PSA-restricted clusters where pod create is denied for unprivileged // users, customers may need to apply the documented node-inotify-tuner // DaemonSet manually before this probe will succeed. -func NewInotifyProber() NodeInotifyProber { - return func(ctx context.Context, kubeContext string) ([]NodeInotifyLimits, error) { +// +// image replaces the default busybox image, for clusters that pull from a +// mirror; empty keeps the default. +// +// The prober remembers what it learned of each node across its calls, the +// polls of one --wait: a node found within the limits is not probed again, and +// the nodes no call has a result for are probed first, so each poll gets +// further than the one before. +func NewInotifyProber(image string) NodeInotifyProber { + return newInotifyProber(image, func(kubeContext string) (kubernetes.Interface, error) { restCfg, err := loadKubeConfig(kubeContext) if err != nil { return nil, fmt.Errorf("building kubeconfig: %w", err) @@ -116,10 +131,37 @@ func NewInotifyProber() NodeInotifyProber { if err != nil { return nil, fmt.Errorf("building kubernetes client: %w", err) } - return probeAllNodes(ctx, client) + return client, nil + }) +} + +// newInotifyProber is NewInotifyProber with the client for a kube context +// from connect. +func newInotifyProber(image string, connect func(kubeContext string) (kubernetes.Interface, error)) NodeInotifyProber { + mem := &inotifyProbeMemory{} + return func(ctx context.Context, kubeContext string) ([]NodeInotifyLimits, error) { + client, err := connect(kubeContext) + if err != nil { + return nil, err + } + return probeAllNodes(ctx, client, image, mem) } } +// perNodePodTimeout caps the create+wait+logs sequence for one node. Image +// pull on a cold node can take ~30s; pod schedule + cat is sub-second. A var so +// tests can shorten it. +var perNodePodTimeout = 90 * time.Second + +// inotifyProbeBudget bounds the whole probe, inside the time the check sets +// aside for the checks that run before the validator. A var so tests can +// shorten it. +var inotifyProbeBudget = 100 * time.Second + +// probePodCleanupTimeout bounds the deletion of a probe pod, which runs after +// the probe's own deadline. The probe share leaves room for it. +var probePodCleanupTimeout = 10 * time.Second + // probeAllNodes is the testable core: it takes a kubernetes.Interface so // callers can inject fake.NewSimpleClientset. It probes nodes in parallel // with a small concurrency cap so image-pull latency doesn't blow the @@ -131,7 +173,22 @@ func NewInotifyProber() NodeInotifyProber { // nodeInotifyCheck can always inspect partial results, and limit // violations on some nodes are never dropped when other nodes are // concurrently unreachable. -func probeAllNodes(ctx context.Context, client kubernetes.Interface) ([]NodeInotifyLimits, error) { +// +// mem holds what earlier calls learned of the nodes, and takes what this one +// learns; nil starts from nothing. +func probeAllNodes( + ctx context.Context, client kubernetes.Interface, image string, mem *inotifyProbeMemory, +) ([]NodeInotifyLimits, error) { + if mem == nil { + mem = &inotifyProbeMemory{} + } + // Nodes are probed probeConcurrency at a time, so on a cluster whose + // probe pods stall each wave takes perNodePodTimeout: unbounded, the probe + // ran past the time the check sets aside for the checks before the + // validator, and cut the validator short. A node not reached in time is + // reported as not probed. + ctx, cancel := context.WithTimeout(ctx, inotifyProbeBudget) + defer cancel() nodes, err := client.CoreV1().Nodes().List(ctx, metav1.ListOptions{}) if err != nil { return nil, fmt.Errorf("listing nodes: %w", err) @@ -139,17 +196,34 @@ func probeAllNodes(ctx context.Context, client kubernetes.Interface) ([]NodeInot if len(nodes.Items) == 0 { return nil, nil } - results := make([]NodeInotifyLimits, len(nodes.Items)) + runID, err := newValidatorRunID() + if err != nil { + return nil, err + } + run := &probeRun{id: runID} + // Each pod is deleted once its node is probed, but a Create the budget or + // an interrupt cut off may still have been applied, and then no per-node + // delete runs. This reclaims those by the run's label on every return. + probeDeadline, _ := ctx.Deadline() + defer func() { run.reclaim(client, probeDeadline) }() + // Unschedulable nodes won't have control plane components deployed on them. + schedulable := slices.DeleteFunc(nodes.Items, func(n corev1.Node) bool { return n.Spec.Unschedulable }) + results := make([]NodeInotifyLimits, len(schedulable)) eg, egCtx := errgroup.WithContext(ctx) eg.SetLimit(probeConcurrency) - for i, n := range nodes.Items { - // Unschedulable nodes won't have control plane components deployed on them. - if n.Spec.Unschedulable { + for _, i := range mem.plan(schedulable, results) { + node := &schedulable[i] + // No pod runs on a node that is not Ready, so its probe would only + // wait out its bound, and a wave of such nodes the probe's budget. + if why := nodeNotReady(node); why != "" { + results[i] = mem.settle(node, NodeInotifyLimits{NodeName: node.Name, + Err: fmt.Errorf("node %s is not Ready (%s), so no probe pod can run there", node.Name, why)}, + probeFinished) continue } - i, nodeName := i, n.Name eg.Go(func() error { - results[i] = probeOneNode(egCtx, client, nodeName) + res, stop := probeOneNode(egCtx, client, run, node.Name, image) + results[i] = mem.settle(node, res, stop) // Per-node failures are encoded in results[i].Err; never // propagate them as the errgroup's error, since that would // cancel sibling probes still in flight. @@ -157,64 +231,226 @@ func probeAllNodes(ctx context.Context, client kubernetes.Interface) ([]NodeInot }) } _ = eg.Wait() - // Remove any results from skipped nodes. - results = slices.DeleteFunc(results, func(item NodeInotifyLimits) bool { - return item.Err == nil && item.NodeName == "" - }) return results, nil } +// nodeNotReady returns why node's Ready condition is not True, or "" when it +// is, or when the node reports no Ready condition. +func nodeNotReady(node *corev1.Node) string { + for _, c := range node.Status.Conditions { + if c.Type == corev1.NodeReady && c.Status != corev1.ConditionTrue { + return strings.TrimSpace(cmp.Or(c.Reason, "Ready="+string(c.Status))) + } + } + return "" +} + +// probeStop says whether the probe's budget stopped a node's probe. +type probeStop int + +const ( + // probeFinished is a probe that ran to its end: the node was measured, or + // failed on its own, such as an image it could not pull or its own bound. + probeFinished probeStop = iota + // probeNotStarted is a node the budget ran out before. + probeNotStarted + // probeStoppedPartWay is a node the budget ran out on while it was probed. + probeStoppedPartWay +) + // probeOneNode creates, waits on, and tears down a single probe pod, returning -// the parsed limits or a per-node error. -func probeOneNode(ctx context.Context, client kubernetes.Interface, nodeName string) NodeInotifyLimits { +// the parsed limits or a per-node error, and whether the probe's budget, +// rather than the node, stopped it. +func probeOneNode( + ctx context.Context, client kubernetes.Interface, run *probeRun, nodeName, image string, +) (NodeInotifyLimits, probeStop) { res := NodeInotifyLimits{NodeName: nodeName} + if ctx.Err() != nil { + res.Err = fmt.Errorf("not probed: the inotify probe's %s budget ran out", inotifyProbeBudget) + return res, probeNotStarted + } + // ctx is the whole probe's, so a failure once it ended is the budget's, + // not the node's. It is told apart here, before the pod's cleanup. + failed := func(err error) (NodeInotifyLimits, probeStop) { + res.Err = err + if ctx.Err() != nil { + return res, probeStoppedPartWay + } + return res, probeFinished + } pctx, cancel := context.WithTimeout(ctx, perNodePodTimeout) defer cancel() - pod, err := client.CoreV1().Pods(inotifyProbeNamespace).Create(pctx, buildInotifyProbePod(nodeName), metav1.CreateOptions{}) + pod, err := createProbePod(pctx, client, nodeName, run.id, image) if err != nil { - res.Err = fmt.Errorf("create probe pod on node %s: %w", nodeName, err) - return res + return failed(fmt.Errorf("create probe pod on node %s: %w", nodeName, err)) } - defer cleanupProbePod(client, pod.Name) + run.created(pod) + defer cleanupProbePod(client, pod) if err := waitForPodTerminal(pctx, client, pod.Name); err != nil { - res.Err = fmt.Errorf("waiting for probe pod on node %s: %w", nodeName, err) - return res + return failed(fmt.Errorf("waiting for probe pod on node %s: %w", nodeName, err)) } logs, err := fetchPodLogs(pctx, client, pod.Name) if err != nil { - res.Err = fmt.Errorf("fetching probe logs from node %s: %w", nodeName, err) - return res + return failed(fmt.Errorf("fetching probe logs from node %s: %w", nodeName, err)) } instances, watches, err := parseInotifyOutput(logs) if err != nil { res.Err = fmt.Errorf("parsing inotify output from node %s: %w", nodeName, err) - return res + return res, probeFinished } res.MaxUserInstances = instances res.MaxUserWatches = watches + return res, probeFinished +} + +// inotifyProbeMemory is what one prober learned of each node over its calls. +type inotifyProbeMemory struct { + mu sync.Mutex + nodes map[string]*nodeProbeRecord // by node name and UID +} + +type nodeProbeRecord struct { + // last is the node's latest probe that ran to its end, if any. + last *NodeInotifyLimits + // stoppedPartWay is set once the budget ran out on a probe of the node. + stoppedPartWay bool +} + +func nodeProbeKey(node *corev1.Node) string { return node.Name + "/" + string(node.UID) } + +// plan fills in results for the nodes an earlier call found within the +// limits, which are not probed again, and returns the others in the order to +// probe them: first the nodes no call has a result for, those the budget +// stopped part way ahead of the rest, so they get a whole turn; then the nodes +// whose last result was a failure or below the limits, which a new probe may +// change. +func (m *inotifyProbeMemory) plan(nodes []corev1.Node, results []NodeInotifyLimits) []int { + m.mu.Lock() + defer m.mu.Unlock() + var stopped, fresh, again []int + for i := range nodes { + rec := m.nodes[nodeProbeKey(&nodes[i])] + switch { + case rec == nil: + fresh = append(fresh, i) + case rec.last != nil && rec.last.withinLimits(): + results[i] = *rec.last + case rec.last != nil: + again = append(again, i) + default: + stopped = append(stopped, i) + } + } + return slices.Concat(stopped, fresh, again) +} + +// settle records how a node's probe ended and returns the node's result for +// this call. Only a node without a result that the budget stopped is out of +// budget: a later call, which probes such nodes first, may reach it. A node +// that failed on its own, or the budget stopped part way a second time +// although it went first, is not: a later call is unlikely to do better. +func (m *inotifyProbeMemory) settle(node *corev1.Node, res NodeInotifyLimits, stop probeStop) NodeInotifyLimits { + m.mu.Lock() + defer m.mu.Unlock() + if m.nodes == nil { + m.nodes = map[string]*nodeProbeRecord{} + } + key := nodeProbeKey(node) + rec := m.nodes[key] + if rec == nil { + rec = &nodeProbeRecord{} + } + switch { + case stop == probeFinished: + rec.last = &res + case rec.last != nil: + // The budget stopped a new probe of a node an earlier call has a + // result for: that result stands. + return *rec.last + case stop == probeNotStarted: + res.OutOfBudget = true + if !rec.stoppedPartWay { + // Nothing to record: the node is still one no call has reached. + return res + } + case !rec.stoppedPartWay: + res.OutOfBudget = true + rec.stoppedPartWay = true + default: + rec.last = &res + } + m.nodes[key] = rec return res } +// createProbePod creates the probe pod for nodeName. A ResourceQuota on cpu or +// memory in the namespace rejects a pod that states no requests or limits for +// them, so on that rejection the pod is created once more with small ones. +func createProbePod( + ctx context.Context, client kubernetes.Interface, nodeName, runID, image string, +) (*corev1.Pod, error) { + pods := client.CoreV1().Pods(inotifyProbeNamespace) + pod := buildInotifyProbePod(nodeName, runID, image) + created, err := pods.Create(ctx, pod, metav1.CreateOptions{}) + if !quotaRequiresResources(err) { + return created, err + } + // A quota may cover limits as well as requests. Equal values keep the pod + // Guaranteed. + pod.Spec.Containers[0].Resources = corev1.ResourceRequirements{ + Requests: inotifyProbeQuotaResources(), + Limits: inotifyProbeQuotaResources(), + } + return pods.Create(ctx, pod, metav1.CreateOptions{}) +} + +// quotaRequiresResources reports a create a ResourceQuota refused because the +// pod states no request or limit for a resource the quota covers. +func quotaRequiresResources(err error) bool { + return apierrors.IsForbidden(err) && strings.Contains(err.Error(), "must specify") +} + +// inotifyProbeQuotaResources are the probe container's requests, and its +// limits, in a namespace whose quota requires them. +func inotifyProbeQuotaResources() corev1.ResourceList { + return corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("50m"), + corev1.ResourceMemory: resource.MustParse("32Mi"), + } +} + +// inotifyProbeLabels are the labels on every probe pod of one run: the +// validator's managed labels under the probe's own name, the role, and the +// run ID. With an empty runID they select every run's probe pods. +func inotifyProbeLabels(runID string) map[string]string { + l := clusterValidatorRunLabels(inotifyProbeRole, runID, false) + l["app.kubernetes.io/name"] = inotifyProbeAppLabel + return l +} + // buildInotifyProbePod constructs a Pod that runs once on the named node and // prints the two inotify sysctls to stdout. The container is unprivileged // and runs in the default PID namespace: /proc/sys/fs/inotify/max_user_* // are kernel-wide sysctls and are world-readable through the host's procfs -// via the read-only /host hostPath mount. -func buildInotifyProbePod(nodeName string) *corev1.Pod { +// via the read-only /host hostPath mount, so the container also runs as a +// non-root user with no capabilities. An empty image selects the default. +func buildInotifyProbePod(nodeName, runID, image string) *corev1.Pod { + if image == "" { + image = inotifyProbeImage + } hostPathDir := corev1.HostPathDirectory + nobody := int64(65534) + runAsNonRoot, readOnlyRoot, allowPrivEsc := true, true, false return &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{ - GenerateName: "nvcf-inotify-probe-", + GenerateName: inotifyProbeAppLabel + "-", Namespace: inotifyProbeNamespace, - Labels: map[string]string{ - "app.kubernetes.io/name": "nvcf-inotify-probe", - "app.kubernetes.io/managed-by": "nvcf-cli", - }, + Labels: inotifyProbeLabels(runID), }, Spec: corev1.PodSpec{ NodeName: nodeName, @@ -222,10 +458,27 @@ func buildInotifyProbePod(nodeName string) *corev1.Pod { Tolerations: []corev1.Toleration{ {Operator: corev1.TolerationOpExists}, }, + SecurityContext: &corev1.PodSecurityContext{ + RunAsUser: &nobody, + RunAsGroup: &nobody, + }, Containers: []corev1.Container{{ Name: inotifyProbeContainer, - Image: inotifyProbeImage, + Image: image, Command: []string{"sh", "-c", inotifyProbeShellCmd}, + // No requests: the pod is pinned with NodeName, so the + // kubelet admits it against the node's free capacity, and + // any request has it rejected OutOfcpu or OutOfmemory on a + // fully requested node, the busiest ones. No limits either: + // a limit without a request sets the request to it. + // createProbePod adds both where a quota requires them. + SecurityContext: &corev1.SecurityContext{ + RunAsNonRoot: &runAsNonRoot, + ReadOnlyRootFilesystem: &readOnlyRoot, + AllowPrivilegeEscalation: &allowPrivEsc, + Capabilities: &corev1.Capabilities{Drop: []corev1.Capability{"ALL"}}, + SeccompProfile: &corev1.SeccompProfile{Type: corev1.SeccompProfileTypeRuntimeDefault}, + }, VolumeMounts: []corev1.VolumeMount{{ Name: "host", MountPath: "/host", @@ -244,7 +497,9 @@ func buildInotifyProbePod(nodeName string) *corev1.Pod { // waitForPodTerminal polls the pod until its phase is Succeeded or Failed. // The probe container is `cat`, so success is the normal exit path; Failed -// surfaces image-pull errors, scheduling rejections, etc. +// surfaces scheduling rejections and the like. A container that cannot start, +// such as one whose image cannot be pulled, leaves the pod Pending, so that +// ends the wait too rather than running out the node's bound. func waitForPodTerminal(ctx context.Context, client kubernetes.Interface, podName string) error { ticker := time.NewTicker(podPollInterval) defer ticker.Stop() @@ -259,6 +514,9 @@ func waitForPodTerminal(ctx context.Context, client kubernetes.Interface, podNam case corev1.PodFailed: return fmt.Errorf("probe pod %s failed: %s", podName, podFailureMessage(pod)) } + if containerCannotStart(pod) { + return fmt.Errorf("probe pod %s cannot start: %s", podName, podFailureMessage(pod)) + } select { case <-ctx.Done(): return ctx.Err() @@ -267,6 +525,22 @@ func waitForPodTerminal(ctx context.Context, client kubernetes.Interface, podNam } } +// containerCannotStart reports a container the kubelet is waiting on for a +// reason it does not get past by itself: an image it cannot pull, or a +// container it cannot configure. +func containerCannotStart(pod *corev1.Pod) bool { + for _, cs := range pod.Status.ContainerStatuses { + if w := cs.State.Waiting; w != nil { + switch w.Reason { + case "ErrImagePull", "ImagePullBackOff", "ErrImageNeverPull", "InvalidImageName", + "CreateContainerConfigError": + return true + } + } + } + return false +} + // podFailureMessage extracts a useful single-line failure reason from a // terminated pod's container status (or falls back to the pod's status // message/reason). @@ -308,23 +582,84 @@ func fetchPodLogs(ctx context.Context, client kubernetes.Interface, podName stri return string(b), nil } -// cleanupProbePod best-effort deletes the probe pod. Uses a background -// context so the pod is cleaned up even when the caller's context was -// canceled mid-probe. -func cleanupProbePod(client kubernetes.Interface, podName string) { - delCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) +// cleanupProbePod best-effort deletes the probe pod this run created, pinned +// to its UID. Uses a background context so the pod is cleaned up even when +// the caller's context was canceled mid-probe. +func cleanupProbePod(client kubernetes.Interface, pod *corev1.Pod) { + delCtx, cancel := context.WithTimeout(context.Background(), probePodCleanupTimeout) defer cancel() + deleteProbePod(delCtx, client, pod) +} + +// deleteProbePod deletes one probe pod at once, pinned to its UID. Errors are +// swallowed: the caller already has its result, and a leaked probe pod is +// preferable to an error that masks the real check outcome. +func deleteProbePod(ctx context.Context, client kubernetes.Interface, pod *corev1.Pod) { + opts := deleteExactly(pod) grace := podDeleteGrace - err := client.CoreV1().Pods(inotifyProbeNamespace).Delete(delCtx, podName, metav1.DeleteOptions{ - GracePeriodSeconds: &grace, - }) + opts.GracePeriodSeconds = &grace + err := client.CoreV1().Pods(inotifyProbeNamespace).Delete(ctx, pod.Name, opts) if err != nil && !apierrors.IsNotFound(err) { - // Swallow; the caller already has its result and a leaked probe pod - // is preferable to an error that masks the real check outcome. _ = err } } +// probeRun is one probe's identity and what it learned of the apiserver's +// clock from the pods it created. +type probeRun struct { + id string + mu sync.Mutex + // serverNow is the latest creationTimestamp among this run's pods. + serverNow time.Time +} + +func (r *probeRun) created(pod *corev1.Pod) { + r.mu.Lock() + defer r.mu.Unlock() + if ts := pod.CreationTimestamp.Time; ts.After(r.serverNow) { + r.serverNow = ts + } +} + +// reclaim deletes, on a fresh bounded context, every probe pod still carrying +// this run's ID, and every other run's probe pod older than +// orphanValidatorRBACTTL on the apiserver's clock: a run killed outright, or +// interrupted twice, leaves pods that mount the host's root and tolerate +// every taint, and nothing else removes them. Another run's younger pods may +// belong to a probe still in progress, so they are kept. +// +// It ends within probePodCleanupTimeout of the probe's deadline, the room the +// probe share leaves for the cleanup after it, which the per-node deletes may +// already have used. +func (r *probeRun) reclaim(client kubernetes.Interface, probeDeadline time.Time) { + start := time.Now() + if !probeDeadline.IsZero() && probeDeadline.Before(start) { + start = probeDeadline + } + ctx, cancel := context.WithDeadline(context.Background(), start.Add(probePodCleanupTimeout)) + defer cancel() + pods, err := client.CoreV1().Pods(inotifyProbeNamespace).List(ctx, metav1.ListOptions{ + LabelSelector: labels.SelectorFromSet(inotifyProbeLabels("")).String(), + }) + if err != nil { + return + } + r.mu.Lock() + now := r.serverNow + r.mu.Unlock() + for i := range pods.Items { + pod := &pods.Items[i] + if !strings.HasPrefix(pod.Name, inotifyProbeAppLabel+"-") { + continue + } + mine := pod.Labels[clusterValidatorRunLabel] == r.id + stale := !now.IsZero() && pod.CreationTimestamp.Time.Before(now.Add(-orphanValidatorRBACTTL)) + if mine || stale { + deleteProbePod(ctx, client, pod) + } + } +} + // parseInotifyOutput extracts max_user_instances then max_user_watches from // the two-line probe-container stdout. Tolerates leading/trailing whitespace // and ignores any non-numeric lines so the parse is robust to stray container diff --git a/src/clis/nvcf-cli/internal/selfhosted/inotify_probe_test.go b/src/clis/nvcf-cli/internal/selfhosted/inotify_probe_test.go index 6cc13c3410..0d52c8680b 100644 --- a/src/clis/nvcf-cli/internal/selfhosted/inotify_probe_test.go +++ b/src/clis/nvcf-cli/internal/selfhosted/inotify_probe_test.go @@ -19,16 +19,30 @@ package selfhosted import ( "context" + "errors" "fmt" + "io" + "net/http" + "net/http/httptest" + "path" + "strings" + "sync" + "sync/atomic" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes/fake" + "k8s.io/client-go/rest" ktesting "k8s.io/client-go/testing" ) @@ -39,7 +53,7 @@ func fakeNode(name string) *corev1.Node { func TestProbeAllNodes_EmptyCluster(t *testing.T) { client := fake.NewSimpleClientset() - results, err := probeAllNodes(context.Background(), client) + results, err := probeAllNodes(context.Background(), client, "", nil) require.NoError(t, err) assert.Empty(t, results, "no nodes → no per-node results") } @@ -49,7 +63,7 @@ func TestProbeAllNodes_ListNodesError(t *testing.T) { client.PrependReactor("list", "nodes", func(_ ktesting.Action) (bool, runtime.Object, error) { return true, nil, fmt.Errorf("forbidden: nodes") }) - _, err := probeAllNodes(context.Background(), client) + _, err := probeAllNodes(context.Background(), client, "", nil) require.Error(t, err) assert.Contains(t, err.Error(), "listing nodes") assert.Contains(t, err.Error(), "forbidden") @@ -62,21 +76,165 @@ func TestProbeAllNodes_PodCreateErrorSurfacesPerNode(t *testing.T) { client.PrependReactor("create", "pods", func(_ ktesting.Action) (bool, runtime.Object, error) { return true, nil, fmt.Errorf("forbidden: pod create denied") }) - results, err := probeAllNodes(context.Background(), client) + results, err := probeAllNodes(context.Background(), client, "", nil) require.NoError(t, err, "list succeeded so the overall probe should not return an error") require.Len(t, results, 2) for _, r := range results { require.Error(t, r.Err, "per-node create failure must surface as NodeInotifyLimits.Err") assert.Contains(t, r.Err.Error(), "create probe pod") assert.Contains(t, r.Err.Error(), r.NodeName) + assert.False(t, r.OutOfBudget, "the node's own failure, not the budget's") } } +// The probe is bounded as a whole. Nodes are probed probeConcurrency at a +// time, so on a cluster whose probe pods never finish each wave took +// perNodePodTimeout, and the probe cut short the validator that runs after it. +// Nodes the budget did not reach are reported as not probed, and every pod +// created is removed. +func TestProbeAllNodes_StallingPodsAreBoundedByTheProbeBudget(t *testing.T) { + prev := inotifyProbeBudget + inotifyProbeBudget = 500 * time.Millisecond + t.Cleanup(func() { inotifyProbeBudget = prev }) + const extra = 3 + var nodes []runtime.Object + for i := 0; i < probeConcurrency+extra; i++ { + nodes = append(nodes, fakeNode(fmt.Sprintf("node-%02d", i))) + } + client := fake.NewSimpleClientset(nodes...) + var created atomic.Int32 + client.PrependReactor("create", "pods", func(a ktesting.Action) (bool, runtime.Object, error) { + // The fake clientset does not honour generateName. The pods never + // reach a terminal phase, like pods that cannot pull or schedule. + pod := a.(ktesting.CreateAction).GetObject().(*corev1.Pod) + pod.Name = fmt.Sprintf("%s%d", pod.GenerateName, created.Add(1)) + return false, nil, nil + }) + + start := time.Now() + results, err := probeAllNodes(context.Background(), client, "", nil) + require.NoError(t, err) + assert.Less(t, time.Since(start), 5*time.Second, "the probe outlived its budget") + require.Len(t, results, probeConcurrency+extra) + notProbed := 0 + for _, r := range results { + require.Error(t, r.Err, r.NodeName) + assert.True(t, r.OutOfBudget, "%s: stopped in flight or never reached, a later run may probe it", r.NodeName) + if strings.Contains(r.Err.Error(), "not probed: the inotify probe's 500ms budget ran out") { + notProbed++ + } + } + assert.Equal(t, extra, notProbed, "the nodes past the first wave are never reached") + pods, err := client.CoreV1().Pods(inotifyProbeNamespace).List(context.Background(), metav1.ListOptions{}) + require.NoError(t, err) + assert.Empty(t, pods.Items, "every probe pod created is removed") + assert.EqualValues(t, probeConcurrency, created.Load()) +} + +// A probe pod whose image cannot be pulled, as on an air-gapped cluster with no +// probe image configured, stays Pending. Each node waited out its own bound, +// so a poll settled only the first wave and the budget cut the rest short, +// on every --wait poll of a large cluster. Such a node now fails at once, and +// one poll settles every node, none of them a node a later poll may reach. +func TestProbeAllNodes_UnpullableImageFailsEachNodeAtOnce(t *testing.T) { + shortenInotifyProbe(t, 600*time.Millisecond, 400*time.Millisecond) + const nodes = 5 * probeConcurrency + var objs []runtime.Object + for i := 0; i < nodes; i++ { + objs = append(objs, fakeNode(fmt.Sprintf("node-%02d", i))) + } + client := fake.NewSimpleClientset(objs...) + var created atomic.Int32 + client.PrependReactor("create", "pods", func(a ktesting.Action) (bool, runtime.Object, error) { + pod := a.(ktesting.CreateAction).GetObject().(*corev1.Pod) + pod.Name = fmt.Sprintf("%s%d", pod.GenerateName, created.Add(1)) + pod.Status.Phase = corev1.PodPending + pod.Status.ContainerStatuses = []corev1.ContainerStatus{{Name: inotifyProbeContainer, + State: corev1.ContainerState{Waiting: &corev1.ContainerStateWaiting{ + Reason: "ImagePullBackOff", Message: `Back-off pulling image "busybox:1.36"`}}}} + return false, nil, nil + }) + + results, err := probeAllNodes(context.Background(), client, "", nil) + require.NoError(t, err) + require.Len(t, results, nodes) + for _, r := range results { + require.Error(t, r.Err, r.NodeName) + assert.Contains(t, r.Err.Error(), "cannot start: ImagePullBackOff", r.NodeName) + assert.False(t, r.OutOfBudget, "%s failed on its own", r.NodeName) + } + assert.EqualValues(t, nodes, created.Load()) + row := nodeInotifyCheck(func(context.Context, string) ([]NodeInotifyLimits, error) { return results, nil }, ""). + Run(context.Background()) + assert.Equal(t, SeverityWarning, row.Severity) + assert.False(t, row.Transient, "--wait has nothing to wait for") + pods, err := client.CoreV1().Pods(inotifyProbeNamespace).List(context.Background(), metav1.ListOptions{}) + require.NoError(t, err) + assert.Empty(t, pods.Items, "every probe pod created is removed") +} + +// No pod runs on a node that is not Ready, so its probe pod only waited out +// its bound. Such a node is reported without a pod. A node with no Ready +// condition is probed as before. +func TestProbeAllNodes_NodeNotReadyIsNotProbed(t *testing.T) { + shortenInotifyProbe(t, 600*time.Millisecond, 400*time.Millisecond) + var objs []runtime.Object + for i := 0; i < 3*probeConcurrency; i++ { + n := fakeNode(fmt.Sprintf("down-%02d", i)) + n.Status.Conditions = []corev1.NodeCondition{{Type: corev1.NodeReady, Status: corev1.ConditionUnknown, + Reason: "NodeStatusUnknown"}} + objs = append(objs, n) + } + objs = append(objs, fakeNode("plain")) + client := fake.NewSimpleClientset(objs...) + var createdOn []string + var mu sync.Mutex + client.PrependReactor("create", "pods", func(a ktesting.Action) (bool, runtime.Object, error) { + pod := a.(ktesting.CreateAction).GetObject().(*corev1.Pod) + mu.Lock() + createdOn = append(createdOn, pod.Spec.NodeName) + mu.Unlock() + return true, nil, errors.New("refused") + }) + + results, err := probeAllNodes(context.Background(), client, "", nil) + require.NoError(t, err) + require.Len(t, results, 3*probeConcurrency+1) + for _, r := range results { + require.Error(t, r.Err, r.NodeName) + assert.False(t, r.OutOfBudget, r.NodeName) + if r.NodeName != "plain" { + assert.Contains(t, r.Err.Error(), "node "+r.NodeName+" is not Ready (NodeStatusUnknown)") + } + } + assert.Equal(t, []string{"plain"}, createdOn, "only the node with no Ready condition gets a pod") +} + +// Only a container the kubelet does not get past by itself ends the wait +// early; one still being created, or an image still being pulled, does not. +func TestContainerCannotStart(t *testing.T) { + for reason, want := range map[string]bool{ + "ErrImagePull": true, "ImagePullBackOff": true, "InvalidImageName": true, "ErrImageNeverPull": true, + "CreateContainerConfigError": true, "ContainerCreating": false, "PodInitializing": false, "": false, + } { + pod := &corev1.Pod{Status: corev1.PodStatus{ContainerStatuses: []corev1.ContainerStatus{{ + State: corev1.ContainerState{Waiting: &corev1.ContainerStateWaiting{Reason: reason}}}}}} + assert.Equal(t, want, containerCannotStart(pod), reason) + } + assert.False(t, containerCannotStart(&corev1.Pod{}), "no status yet") +} + func TestBuildInotifyProbePodShape(t *testing.T) { - pod := buildInotifyProbePod("node-x") + pod := buildInotifyProbePod("node-x", "run1", "") assert.Equal(t, inotifyProbeNamespace, pod.Namespace) assert.Equal(t, "nvcf-inotify-probe-", pod.GenerateName, "GenerateName lets the API server assign a unique suffix") + // The run and role labels let a run reclaim exactly its own pods, and the + // managed-by value is this CLI's, not the one released CLIs delete by. + assert.Equal(t, "run1", pod.Labels[clusterValidatorRunLabel]) + assert.Equal(t, clusterValidatorComputePlaneRole, pod.Labels[clusterValidatorRoleLabel]) + assert.Equal(t, clusterValidatorManagedBy, pod.Labels["app.kubernetes.io/managed-by"]) + assert.Equal(t, inotifyProbeAppLabel, pod.Labels["app.kubernetes.io/name"]) assert.Empty(t, pod.Name, "explicit Name conflicts with GenerateName") assert.Equal(t, "node-x", pod.Spec.NodeName, "must pin to the target node") assert.False(t, pod.Spec.HostPID, @@ -98,4 +256,430 @@ func TestBuildInotifyProbePodShape(t *testing.T) { require.Len(t, pod.Spec.Volumes, 1) require.NotNil(t, pod.Spec.Volumes[0].HostPath) assert.Equal(t, "/", pod.Spec.Volumes[0].HostPath.Path) + + // Pinned with NodeName, the pod is admitted against the node's free + // capacity: a request has it rejected on a fully requested node. A limit + // alone would become the request. + assert.Empty(t, c.Resources.Requests) + assert.Empty(t, c.Resources.Limits) + require.NotNil(t, pod.Spec.SecurityContext) + require.NotNil(t, pod.Spec.SecurityContext.RunAsUser) + assert.NotZero(t, *pod.Spec.SecurityContext.RunAsUser, "the sysctls are world-readable, so root is not needed") + require.NotNil(t, c.SecurityContext) + assert.True(t, *c.SecurityContext.RunAsNonRoot) + assert.True(t, *c.SecurityContext.ReadOnlyRootFilesystem) + assert.False(t, *c.SecurityContext.AllowPrivilegeEscalation) + assert.Equal(t, []corev1.Capability{"ALL"}, c.SecurityContext.Capabilities.Drop) +} + +// The configured probe image replaces the Docker Hub default, so a cluster +// that pulls from a mirror can run the probe. +func TestBuildInotifyProbePod_UsesTheConfiguredImage(t *testing.T) { + pod := buildInotifyProbePod("node-x", "run1", "mirror.example/busybox:1.36") + assert.Equal(t, "mirror.example/busybox:1.36", pod.Spec.Containers[0].Image) +} + +// probePodsClient serves one node whose probe pods finish at once with the +// given output. Creates get a name, a UID and a creationTimestamp on the +// apiserver's clock, which here runs serverSkew from the local one. +func probePodsClient(t *testing.T, serverSkew time.Duration, objs ...runtime.Object) *fake.Clientset { + t.Helper() + client := fake.NewSimpleClientset(append([]runtime.Object{fakeNode("node-a")}, objs...)...) + var n atomic.Int32 + client.PrependReactor("create", "pods", func(a ktesting.Action) (bool, runtime.Object, error) { + pod := a.(ktesting.CreateAction).GetObject().(*corev1.Pod) + pod.Name = fmt.Sprintf("%s%d", pod.GenerateName, n.Add(1)) + pod.UID = types.UID("uid-" + pod.Name) + pod.CreationTimestamp = metav1.NewTime(time.Now().Add(serverSkew)) + pod.Status.Phase = corev1.PodSucceeded + return false, nil, nil + }) + return client +} + +// probePod is a probe pod of another run, created age ago on a clock skewed +// by serverSkew. +func probePod(name, runID string, serverSkew, age time.Duration) *corev1.Pod { + return &corev1.Pod{ObjectMeta: metav1.ObjectMeta{ + Name: name, Namespace: inotifyProbeNamespace, Labels: inotifyProbeLabels(runID), + CreationTimestamp: metav1.NewTime(time.Now().Add(serverSkew - age)), + }} +} + +// A probe deletes exactly its own pods, by UID, and leaves an overlapping +// probe's pods alone. Another run's pod past the orphan TTL on the +// apiserver's clock is reclaimed, whatever the local clock says: here the +// apiserver runs ten hours behind, so by the local clock every pod is stale. +func TestProbeAllNodes_ReclaimsOnlyItsOwnAndStalePods(t *testing.T) { + const skew = -10 * time.Hour + live := probePod("nvcf-inotify-probe-live", "otherrun", skew, orphanValidatorRBACTTL-time.Minute) + stale := probePod("nvcf-inotify-probe-stale", "deadrun", skew, orphanValidatorRBACTTL+time.Minute) + // Probe labels on a name the probe never generates. + foreign := probePod("operator-pod", "deadrun", skew, 2*orphanValidatorRBACTTL) + client := probePodsClient(t, skew, live, stale, foreign) + + _, err := probeAllNodes(context.Background(), client, "", nil) + require.NoError(t, err) + + pods, err := client.CoreV1().Pods(inotifyProbeNamespace).List(context.Background(), metav1.ListOptions{}) + require.NoError(t, err) + var left []string + for _, p := range pods.Items { + left = append(left, p.Name) + } + assert.ElementsMatch(t, []string{live.Name, foreign.Name}, left, + "the probe's own pod and the stale orphan go; a live probe's pod and a foreign one stay") + for _, a := range client.Actions() { + d, ok := a.(ktesting.DeleteActionImpl) + if !ok || d.GetResource().Resource != "pods" { + continue + } + require.NotNil(t, d.DeleteOptions.Preconditions, d.Name) + require.NotNil(t, d.DeleteOptions.Preconditions.UID, d.Name) + assert.Nil(t, d.DeleteOptions.Preconditions.ResourceVersion, d.Name) + } +} + +// A Create the budget or an interrupt cut off may still have been applied. +// No per-node delete runs for it, so the probe reclaims it by its run label. +func TestProbeAllNodes_ReclaimsAPodWhoseCreateErrored(t *testing.T) { + client := fake.NewSimpleClientset(fakeNode("node-a")) + client.PrependReactor("create", "pods", func(a ktesting.Action) (bool, runtime.Object, error) { + pod := a.(ktesting.CreateAction).GetObject().(*corev1.Pod).DeepCopy() + pod.Name = pod.GenerateName + "applied" + require.NoError(t, client.Tracker().Add(pod)) + return true, nil, context.DeadlineExceeded + }) + + results, err := probeAllNodes(context.Background(), client, "", nil) + require.NoError(t, err) + require.Len(t, results, 1) + require.Error(t, results[0].Err) + pods, err := client.CoreV1().Pods(inotifyProbeNamespace).List(context.Background(), metav1.ListOptions{}) + require.NoError(t, err) + assert.Empty(t, pods.Items, "the applied pod mounts the host's root and must not be left behind") +} + +// The reclaim after a probe ends within probePodCleanupTimeout of the probe's +// deadline, the room the probe share leaves for it, however slow the +// apiserver. Bounded by the longer sweep timeout, it ate into the time sized +// for the validator. +func TestProbeRun_ReclaimEndsWithinTheCleanupRoom(t *testing.T) { + prevPod, prevSweep := probePodCleanupTimeout, validatorCleanupTimeout + probePodCleanupTimeout, validatorCleanupTimeout = 300*time.Millisecond, time.Minute + t.Cleanup(func() { probePodCleanupTimeout, validatorCleanupTimeout = prevPod, prevSweep }) + srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + <-r.Context().Done() + })) + t.Cleanup(func() { + srv.CloseClientConnections() + srv.Close() + }) + client, err := kubernetes.NewForConfig(&rest.Config{Host: srv.URL, QPS: -1}) + require.NoError(t, err) + run := &probeRun{id: "r1"} + + start := time.Now() + run.reclaim(client, start.Add(time.Hour)) + assert.Less(t, time.Since(start), 5*time.Second, "a probe that ended early still bounds its reclaim") + + start = time.Now() + run.reclaim(client, start.Add(-probePodCleanupTimeout)) + assert.Less(t, time.Since(start), probePodCleanupTimeout, + "the per-node deletes used the room after the deadline, so the reclaim gets none") +} + +// quotaClient serves one node in a namespace whose ResourceQuota on cpu and +// memory rejects a pod that does not state them, as the admission plugin +// words it. Admitted pods finish at once; every create is recorded. +func quotaClient(t *testing.T, retryErr error) (*fake.Clientset, *[]corev1.ResourceRequirements) { + t.Helper() + client := fake.NewSimpleClientset(fakeNode("node-a")) + var creates []corev1.ResourceRequirements + var n atomic.Int32 + client.PrependReactor("create", "pods", func(a ktesting.Action) (bool, runtime.Object, error) { + pod := a.(ktesting.CreateAction).GetObject().(*corev1.Pod) + res := pod.Spec.Containers[0].Resources + creates = append(creates, res) + if len(res.Requests) == 0 && len(res.Limits) == 0 { + return true, nil, apierrors.NewForbidden(schema.GroupResource{Resource: "pods"}, pod.GenerateName, + errors.New("failed quota: compute: must specify limits.cpu for: probe; limits.memory for: probe; "+ + "requests.cpu for: probe; requests.memory for: probe")) + } + if retryErr != nil { + return true, nil, retryErr + } + pod.Name = fmt.Sprintf("%s%d", pod.GenerateName, n.Add(1)) + pod.Status.Phase = corev1.PodSucceeded + return false, nil, nil + }) + return client, &creates +} + +// A namespace whose quota requires requests and limits rejects the probe pod, +// which states none. The pod is created once more with small, equal requests +// and limits, so the node is probed rather than reported as not probed. +func TestProbeAllNodes_StatesResourcesWhenAQuotaRequiresThem(t *testing.T) { + client, creates := quotaClient(t, nil) + results, err := probeAllNodes(context.Background(), client, "", nil) + require.NoError(t, err) + require.Len(t, results, 1) + require.Len(t, *creates, 2, "one retry, with resources") + retry := (*creates)[1] + for _, name := range []corev1.ResourceName{corev1.ResourceCPU, corev1.ResourceMemory} { + req, lim := retry.Requests[name], retry.Limits[name] + assert.False(t, req.IsZero(), name) + assert.Zero(t, req.Cmp(lim), "%s: equal requests and limits", name) + } + if results[0].Err != nil { + assert.NotContains(t, results[0].Err.Error(), "create probe pod", "the retry was admitted") + } + pods, err := client.CoreV1().Pods(inotifyProbeNamespace).List(context.Background(), metav1.ListOptions{}) + require.NoError(t, err) + assert.Empty(t, pods.Items, "the retried pod is removed like any other") +} + +// The retry happens once, and only for a quota that requires resources: its +// own rejection is the node's error, and any other refusal is not retried. +func TestProbeAllNodes_QuotaRetryIsOnceAndOnlyForMissingResources(t *testing.T) { + client, creates := quotaClient(t, apierrors.NewForbidden(schema.GroupResource{Resource: "pods"}, "p", + errors.New("exceeded quota: compute, requested: requests.cpu=50m, used: requests.cpu=4, "+ + "limited: requests.cpu=4"))) + results, err := probeAllNodes(context.Background(), client, "", nil) + require.NoError(t, err) + require.Len(t, results, 1) + assert.Len(t, *creates, 2) + require.Error(t, results[0].Err) + assert.Contains(t, results[0].Err.Error(), "exceeded quota") + + denied := fake.NewSimpleClientset(fakeNode("node-a")) + calls := 0 + denied.PrependReactor("create", "pods", func(ktesting.Action) (bool, runtime.Object, error) { + calls++ + return true, nil, apierrors.NewForbidden(schema.GroupResource{Resource: "pods"}, "p", + errors.New(`violates PodSecurity "restricted:latest": hostPath volumes`)) + }) + results, err = probeAllNodes(context.Background(), denied, "", nil) + require.NoError(t, err) + require.Len(t, results, 1) + assert.Equal(t, 1, calls, "a refusal for any other reason is not retried") + assert.Contains(t, results[0].Err.Error(), "PodSecurity") +} + +// probeCluster is a fake cluster of nodes node-00, node-01, and so on, for a +// prober called several times, as the polls of one --wait call it. A pod that +// finishes logs logs(node, call), served after delay; one that does not stays +// pending, like a pod that cannot pull its image or a node that is NotReady. +type probeCluster struct { + client kubernetes.Interface + mu sync.Mutex + call int + nodeOf map[string]string // probe pod -> node + // created is the node of each probe pod created, per call. + created [][]string +} + +func newProbeCluster( + t *testing.T, nodes int, finishes bool, delay time.Duration, logs func(node string, call int) string, +) *probeCluster { + t.Helper() + pc := &probeCluster{nodeOf: map[string]string{}} + var objs []runtime.Object + for i := 0; i < nodes; i++ { + n := fakeNode(fmt.Sprintf("node-%02d", i)) + n.UID = types.UID("uid-" + n.Name) + objs = append(objs, n) + } + client := fake.NewSimpleClientset(objs...) + var seq atomic.Int32 + client.PrependReactor("create", "pods", func(a ktesting.Action) (bool, runtime.Object, error) { + pod := a.(ktesting.CreateAction).GetObject().(*corev1.Pod) + pod.Name = fmt.Sprintf("%s%d", pod.GenerateName, seq.Add(1)) + if finishes { + pod.Status.Phase = corev1.PodSucceeded + } + pc.mu.Lock() + pc.nodeOf[pod.Name] = pod.Spec.NodeName + pc.created[len(pc.created)-1] = append(pc.created[len(pc.created)-1], pod.Spec.NodeName) + pc.mu.Unlock() + return false, nil, nil + }) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(delay) + pc.mu.Lock() + node, call := pc.nodeOf[path.Base(path.Dir(r.URL.Path))], pc.call + pc.mu.Unlock() + w.Header().Set("Content-Type", "text/plain") + _, _ = io.WriteString(w, logs(node, call)) + })) + t.Cleanup(srv.Close) + logsFrom, err := kubernetes.NewForConfig(&rest.Config{Host: srv.URL, QPS: -1}) + require.NoError(t, err) + pc.client = logsClient{Clientset: client, logs: logsFrom} + return pc +} + +// probe is one call of prober, the prober's next poll. +func (pc *probeCluster) probe(t *testing.T, prober NodeInotifyProber) []NodeInotifyLimits { + t.Helper() + pc.mu.Lock() + pc.call++ + pc.created = append(pc.created, nil) + pc.mu.Unlock() + limits, err := prober(context.Background(), "test") + require.NoError(t, err) + return limits +} + +func (pc *probeCluster) prober() NodeInotifyProber { + return newInotifyProber("", func(string) (kubernetes.Interface, error) { return pc.client, nil }) +} + +func withinLimitsLog(string, int) string { return "8192\n524288\n" } + +// shortenInotifyProbe sets the probe's budget and each node's own bound. +func shortenInotifyProbe(t *testing.T, budget, perNode time.Duration) { + t.Helper() + prevBudget, prevNode := inotifyProbeBudget, perNodePodTimeout + inotifyProbeBudget, perNodePodTimeout = budget, perNode + t.Cleanup(func() { inotifyProbeBudget, perNodePodTimeout = prevBudget, prevNode }) +} + +// Nodes whose probe pods cannot finish hit their own bound, a failure of the +// node a later poll cannot clear. On a cluster with more such nodes than one +// wave, the budget ran out on the next wave the same way on every poll, so +// --wait never ended. A node the budget stopped is probed first on the next +// poll, where it hits its own bound too, and that poll is not transient. +func TestInotifyProber_NodesThatCannotFinishStopKeepingTheWaitGoing(t *testing.T) { + shortenInotifyProbe(t, 600*time.Millisecond, 400*time.Millisecond) + const extra = 4 + pc := newProbeCluster(t, probeConcurrency+extra, false, 0, withinLimitsLog) + prober := pc.prober() + + first := pc.probe(t, prober) + require.Len(t, first, probeConcurrency+extra) + var cut []string + for _, l := range first { + require.Error(t, l.Err, l.NodeName) + if l.OutOfBudget { + cut = append(cut, l.NodeName) + } + } + assert.Len(t, cut, extra, "only the nodes the budget stopped may be reached later: %v", first) + + second := pc.probe(t, prober) + require.Len(t, second, probeConcurrency+extra) + for _, l := range second { + require.Error(t, l.Err, l.NodeName) + assert.False(t, l.OutOfBudget, "%s: every node has now failed on its own", l.NodeName) + } + require.GreaterOrEqual(t, len(pc.created[1]), probeConcurrency) + assert.Subset(t, pc.created[1][:probeConcurrency], cut, "the nodes the budget stopped go in the first wave") + + row := nodeInotifyCheck(func(context.Context, string) ([]NodeInotifyLimits, error) { return second, nil }, ""). + Run(context.Background()) + assert.Equal(t, SeverityWarning, row.Severity) + assert.False(t, row.Transient, "--wait has nothing left to wait for") +} + +// A cluster with more nodes than one poll's budget reaches is measured over +// several polls: a node found within the limits is not probed again, and the +// nodes no poll has a result for go first. Before, every poll started over in +// the same order, and on a large cluster never reached the tail. +func TestInotifyProber_ReachesEveryNodeOfALargeClusterOverPolls(t *testing.T) { + shortenInotifyProbe(t, 500*time.Millisecond, 90*time.Second) + const nodes = 120 + pc := newProbeCluster(t, nodes, true, 40*time.Millisecond, withinLimitsLog) + prober := pc.prober() + + within := map[string]bool{} + for poll := 0; poll < 8 && len(within) < nodes; poll++ { + limits := pc.probe(t, prober) + require.Len(t, limits, nodes) + for _, node := range pc.created[poll] { + assert.False(t, within[node], "%s was within the limits on an earlier poll and was probed again", node) + } + for _, l := range limits { + if l.Err == nil && l.withinLimits() { + within[l.NodeName] = true + } else { + assert.True(t, l.OutOfBudget, "%s: %v", l.NodeName, l.Err) + } + } + } + assert.Len(t, within, nodes, "every node is measured within a few polls") + assert.Greater(t, len(pc.created), 1, "the cluster is larger than one poll reaches") +} + +// A node below the limits is probed again on the next poll, so limits raised +// while --wait polls are seen; its earlier reading is kept only while the +// budget stops the new probe. +func TestInotifyProber_ProbesANodeBelowTheLimitsAgain(t *testing.T) { + pc := newProbeCluster(t, 2, true, 0, func(node string, call int) string { + if node == "node-01" && call == 1 { + return "128\n524288\n" + } + return "8192\n524288\n" + }) + prober := pc.prober() + + first := pc.probe(t, prober) + require.Len(t, first, 2) + second := pc.probe(t, prober) + require.Len(t, second, 2) + assert.Equal(t, []string{"node-01"}, pc.created[1], "only the node below the limits is probed again") + for _, l := range second { + assert.True(t, l.withinLimits(), "%s: %+v", l.NodeName, l) + } +} + +// How each way a probe ends carries over to the next poll: what it reports +// now, and where the node goes in the next poll's order. +func TestInotifyProbeMemory_CarriesEachOutcomeToTheNextPoll(t *testing.T) { + node := func(name string) corev1.Node { + return corev1.Node{ObjectMeta: metav1.ObjectMeta{Name: name, UID: types.UID("uid-" + name)}} + } + cut := errors.New("waiting for probe pod: context deadline exceeded") + notReached := errors.New("not probed") + pulled := errors.New("ErrImagePull") + within := NodeInotifyLimits{MaxUserInstances: 8192, MaxUserWatches: 524288} + below := NodeInotifyLimits{MaxUserInstances: 128, MaxUserWatches: 524288} + nodes := []corev1.Node{node("ok"), node("low"), node("own"), node("cut"), node("unreached"), node("new")} + + mem := &inotifyProbeMemory{} + got := map[string]NodeInotifyLimits{} + settle := func(i int, res NodeInotifyLimits, stop probeStop) { + res.NodeName = nodes[i].Name + got[nodes[i].Name] = mem.settle(&nodes[i], res, stop) + } + settle(0, within, probeFinished) + settle(1, below, probeFinished) + settle(2, NodeInotifyLimits{Err: pulled}, probeFinished) + settle(3, NodeInotifyLimits{Err: cut}, probeStoppedPartWay) + settle(4, NodeInotifyLimits{Err: notReached}, probeNotStarted) + for name, out := range map[string]bool{"ok": false, "low": false, "own": false, "cut": true, "unreached": true} { + assert.Equal(t, out, got[name].OutOfBudget, name) + } + + results := make([]NodeInotifyLimits, len(nodes)) + order := mem.plan(nodes, results) + var names []string + for _, i := range order { + names = append(names, nodes[i].Name) + } + assert.Equal(t, []string{"cut", "unreached", "new", "low", "own"}, names, + "stopped part way first, then nodes never reached, then nodes a new probe may change") + assert.True(t, results[0].withinLimits(), "a node within the limits is kept, not probed again") + + // Next poll: the budget stops every probe it makes. + settle(1, NodeInotifyLimits{Err: notReached}, probeNotStarted) + settle(2, NodeInotifyLimits{Err: cut}, probeStoppedPartWay) + settle(3, NodeInotifyLimits{Err: cut}, probeStoppedPartWay) + settle(4, NodeInotifyLimits{Err: notReached}, probeNotStarted) + assert.Equal(t, below.MaxUserInstances, got["low"].MaxUserInstances, "its earlier reading stands") + assert.Equal(t, pulled, got["own"].Err, "its earlier failure stands") + assert.False(t, got["low"].OutOfBudget) + assert.False(t, got["own"].OutOfBudget) + assert.False(t, got["cut"].OutOfBudget, "stopped part way again although it went first") + assert.True(t, got["unreached"].OutOfBudget, "still never reached") } diff --git a/src/clis/nvcf-cli/internal/selfhosted/main_test.go b/src/clis/nvcf-cli/internal/selfhosted/main_test.go new file mode 100644 index 0000000000..d5d5b02cfd --- /dev/null +++ b/src/clis/nvcf-cli/internal/selfhosted/main_test.go @@ -0,0 +1,55 @@ +/* +SPDX-FileCopyrightText: Copyright (c) NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package selfhosted + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +// TestMain points HOME at an empty directory so no test reads the developer's +// ~/.docker/config.json, or runs a credential helper it names. Tests that need +// a docker config write their own under a temporary HOME. HELM_DRIVER and the +// NGC key variables are cleared too: an exported sql driver turns off the +// stale-namespace probe's no-release signal, which the probe tests assert on. +func TestMain(m *testing.M) { + home, err := os.MkdirTemp("", "selfhosted-test-home-") + if err != nil { + panic(err) + } + _ = os.Setenv("HOME", home) + // client-go resolved ~/.kube/config at init, before the swap above. + _ = os.Setenv("KUBECONFIG", filepath.Join(home, ".kube", "config")) + _ = os.Unsetenv("HELM_DRIVER") + // A docker config or an NGC key exported on the machine running the + // tests would otherwise be the credential every registry test sends. + _ = os.Unsetenv("DOCKER_CONFIG") + for _, name := range ngcAPIKeyEnvNames { + _ = os.Unsetenv(name) + } + // The fake clientset has no controllers, so a pod never ends on its own: + // keep the waits for that short. Tests of the waits set their own. + validatorStopTimeout = 2 * time.Second + validatorDeadlineGrace = 2 * time.Second + validatorPullFailureGrace = 0 + code := m.Run() + _ = os.RemoveAll(home) + os.Exit(code) +} diff --git a/src/clis/nvcf-cli/internal/selfhosted/miniapiserver_test.go b/src/clis/nvcf-cli/internal/selfhosted/miniapiserver_test.go new file mode 100644 index 0000000000..770a8e63c0 --- /dev/null +++ b/src/clis/nvcf-cli/internal/selfhosted/miniapiserver_test.go @@ -0,0 +1,392 @@ +/* +SPDX-FileCopyrightText: Copyright (c) NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package selfhosted + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" +) + +// miniAPIServer is an in-memory apiserver over real HTTP, for what the fake +// clientset cannot show: request contexts and calls that hang. A request whose +// context is already cancelled never reaches it, which is the point. It +// stores what is created, lists it back, and records deletes. +type miniAPIServer struct { + t *testing.T + mu sync.Mutex + objects map[string]map[string]any // collection path -> name -> object + deletes []string // "/" + // hangDeletes holds every DELETE until the client gives up. + hangDeletes atomic.Bool + // jobCreated is closed on the first Job create. + jobCreated chan struct{} + onceJob sync.Once + // runningJob, when set, is the status every Job read returns. + runningJob bool + // podLogs, when set, gives each Job a running pod whose log is podLogs. + podLogs string +} + +func newMiniAPIServer(t *testing.T) (*miniAPIServer, kubernetes.Interface) { + t.Helper() + m := &miniAPIServer{t: t, objects: map[string]map[string]any{}, jobCreated: make(chan struct{}), runningJob: true} + srv := httptest.NewServer(http.HandlerFunc(m.serve)) + t.Cleanup(func() { + srv.CloseClientConnections() + srv.Close() + }) + client, err := kubernetes.NewForConfig(&rest.Config{ + Host: srv.URL, + ContentConfig: rest.ContentConfig{ContentType: "application/json"}, + // No client-side throttling: a run's requests then take as long as + // the server makes them, and a short run deadline is not spent in + // the client's rate limiter. + QPS: -1, + }) + require.NoError(t, err) + return m, client +} + +func (m *miniAPIServer) deleted() []string { + m.mu.Lock() + defer m.mu.Unlock() + return append([]string(nil), m.deletes...) +} + +func (m *miniAPIServer) serve(w http.ResponseWriter, r *http.Request) { + path := strings.TrimSuffix(r.URL.Path, "/") + if r.Method == http.MethodGet && strings.HasSuffix(path, "/log") { + w.Header().Set("Content-Type", "text/plain") + _, _ = io.WriteString(w, m.podLogs) + return + } + w.Header().Set("Content-Type", "application/json") + collection, name := path, "" + if i := strings.LastIndex(path, "/"); i > 0 && isNamedPath(path) { + collection, name = path[:i], path[i+1:] + } + m.mu.Lock() + defer m.mu.Unlock() + switch r.Method { + case http.MethodPost: + body, _ := io.ReadAll(r.Body) + var obj map[string]any + _ = json.Unmarshal(body, &obj) + meta, _ := obj["metadata"].(map[string]any) + n, _ := meta["name"].(string) + meta["uid"] = "uid-" + n + if m.objects[collection] == nil { + m.objects[collection] = map[string]any{} + } + m.objects[collection][n] = obj + if strings.HasSuffix(collection, "/jobs") { + m.onceJob.Do(func() { close(m.jobCreated) }) + if m.podLogs != "" { + pods := podsCollection(collection) + if m.objects[pods] == nil { + m.objects[pods] = map[string]any{} + } + m.objects[pods][n+"-pod"] = map[string]any{ + "apiVersion": "v1", "kind": "Pod", + "metadata": map[string]any{"name": n + "-pod", "labels": map[string]any{"job-name": n}}, + "status": map[string]any{"phase": "Running"}, + } + } + } + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(obj) + case http.MethodGet: + if name == "" { + items := []any{} + for _, o := range m.objects[collection] { + items = append(items, o) + } + // A typed client decodes only its own list kind. + _ = json.NewEncoder(w).Encode(map[string]any{ + "apiVersion": listAPIVersion(collection), "kind": listKinds[collection[strings.LastIndex(collection, "/")+1:]], + "items": items, + }) + return + } + obj, ok := m.objects[collection][name] + if !ok { + notFound(w) + return + } + _ = json.NewEncoder(w).Encode(obj) + case http.MethodDelete: + if m.hangDeletes.Load() { + // The server notices a closed connection only once the body has + // been read, so read it before waiting on the request context. + _, _ = io.Copy(io.Discard, r.Body) + m.mu.Unlock() + <-r.Context().Done() + m.mu.Lock() + return + } + // Preconditions are enforced as the apiserver does. Stored objects + // carry no resourceVersion, so a delete pinned to one conflicts, as a + // real one does whenever the object changed since it was read. + var opts struct { + Preconditions *struct { + UID *string `json:"uid"` + ResourceVersion *string `json:"resourceVersion"` + } `json:"preconditions"` + } + _ = json.NewDecoder(r.Body).Decode(&opts) + if obj, ok := m.objects[collection][name].(map[string]any); ok && opts.Preconditions != nil { + meta, _ := obj["metadata"].(map[string]any) + uid, _ := meta["uid"].(string) + if (opts.Preconditions.UID != nil && *opts.Preconditions.UID != uid) || + opts.Preconditions.ResourceVersion != nil { + w.WriteHeader(http.StatusConflict) + _, _ = io.WriteString(w, + `{"kind":"Status","apiVersion":"v1","status":"Failure","reason":"Conflict","code":409}`) + return + } + } + m.deletes = append(m.deletes, collection+"/"+name) + delete(m.objects[collection], name) + if strings.HasSuffix(collection, "/jobs") { + // The Job's pods go with it, as the garbage collector would. + delete(m.objects, podsCollection(collection)) + } + _ = json.NewEncoder(w).Encode(map[string]any{"kind": "Status", "apiVersion": "v1", "status": "Success"}) + default: + // PATCH and PUT: accept and echo nothing interesting. + _ = json.NewEncoder(w).Encode(map[string]any{}) + } +} + +// podsCollection is the pod collection in the namespace of a Job collection. +func podsCollection(jobs string) string { + _, ns, _ := strings.Cut(jobs, "/namespaces/") + return "/api/v1/namespaces/" + strings.TrimSuffix(ns, "/jobs") + "/pods" +} + +// listKinds maps a collection's resource to the kind a typed client decodes. +var listKinds = map[string]string{ + "jobs": "JobList", "pods": "PodList", "secrets": "SecretList", "configmaps": "ConfigMapList", + "serviceaccounts": "ServiceAccountList", "clusterroles": "ClusterRoleList", + "clusterrolebindings": "ClusterRoleBindingList", "namespaces": "NamespaceList", + "roles": "RoleList", "rolebindings": "RoleBindingList", +} + +// listAPIVersion is the group version in a collection path: "v1" under /api, +// "/" under /apis. +func listAPIVersion(collection string) string { + parts := strings.Split(strings.Trim(collection, "/"), "/") + if len(parts) >= 3 && parts[0] == "apis" { + return parts[1] + "/" + parts[2] + } + return "v1" +} + +// isNamedPath reports whether path names one object rather than a collection: +// its last segment follows a resource segment. +func isNamedPath(path string) bool { + parts := strings.Split(strings.Trim(path, "/"), "/") + resources := map[string]bool{ + "jobs": true, "pods": true, "secrets": true, "configmaps": true, "serviceaccounts": true, + "clusterroles": true, "clusterrolebindings": true, "namespaces": true, "roles": true, "rolebindings": true, + } + return len(parts) >= 2 && resources[parts[len(parts)-2]] +} + +func notFound(w http.ResponseWriter) { + w.WriteHeader(http.StatusNotFound) + _, _ = io.WriteString(w, `{"kind":"Status","apiVersion":"v1","status":"Failure","reason":"NotFound","code":404}`) +} + +// runInterrupted starts a run against m, cancels it once the Job exists, and +// returns the result. +func runInterrupted(t *testing.T, client kubernetes.Interface, m *miniAPIServer) ClusterValidatorResult { + t.Helper() + return runInterruptedAs(t, client, m, "registry.example.com/validator:1", clusterValidatorComputePlaneRole, nil) +} + +// runInterruptedAs is runInterrupted with the image, role and registries the +// run is given. +func runInterruptedAs( + t *testing.T, client kubernetes.Interface, m *miniAPIServer, image, role string, registries []RegistryEntry, +) ClusterValidatorResult { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan ClusterValidatorResult, 1) + go func() { + done <- runClusterValidator(ctx, client, image, "", false, role, registries, nil) + }() + select { + case <-m.jobCreated: + case <-time.After(30 * time.Second): + t.Fatal("the run never created its Job") + } + cancel() + select { + case res := <-done: + return res + case <-time.After(60 * time.Second): + t.Fatal("the interrupted run did not return") + } + return ClusterValidatorResult{} +} + +// On an interrupt the cleanup requests really go out, for the NGC-key pull +// Secret and the ConfigMap as well as the RBAC. A cleanup context derived from +// the cancelled run context sends none, which the fake clientset cannot show +// because it ignores contexts. +func TestRunClusterValidator_InterruptSendsCleanupOverTheWire(t *testing.T) { + t.Setenv("NGC_API_KEY", "key") + m, client := newMiniAPIServer(t) + res := runInterruptedAs(t, client, m, "nvcr.io/nvidia/validator:1", clusterValidatorControlPlaneRole, + []RegistryEntry{{Registry: "nvcr.io"}}) + require.Error(t, res.Err) + + got := strings.Join(m.deleted(), " ") + for _, want := range []string{ + "/jobs/", "/clusterrolebindings/", "/clusterroles/", "/rolebindings/", "/roles/", "/serviceaccounts/", + "/secrets/", "/configmaps/", + } { + assert.Contains(t, got, want) + } +} + +// The check's budget running out ends the run as an interrupt does: the Job +// is stopped and everything swept, not left to its deadline with its RBAC +// kept. The transcript is read first, on a fresh context; a read on the run's +// expired context never leaves the client. +func TestRunClusterValidator_BudgetEndStopsTheJobAndKeepsTheTranscript(t *testing.T) { + t.Setenv("NGC_API_KEY", "key") + m, client := newMiniAPIServer(t) + m.podLogs = "Validator role: control-plane\npartial transcript\n" + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + start := time.Now() + res := runClusterValidator(ctx, client, "nvcr.io/nvidia/validator:1", "", false, + clusterValidatorControlPlaneRole, []RegistryEntry{{Registry: "nvcr.io"}}, nil) + require.ErrorIs(t, res.Err, context.DeadlineExceeded, "the budget cut it short; the validator did not fail") + assert.Less(t, time.Since(start), time.Second+validatorDeadlineGrace, "it did not wait for the Job's deadline") + assert.Contains(t, res.Logs, "partial transcript") + assert.False(t, res.LeftBehind) + assert.Empty(t, res.JobName, "the Job is gone, so no hint may point at it") + got := strings.Join(m.deleted(), " ") + for _, want := range []string{ + "/jobs/", "/clusterrolebindings/", "/clusterroles/", "/rolebindings/", "/roles/", "/serviceaccounts/", + "/secrets/", "/configmaps/", + } { + assert.Contains(t, got, want) + } +} + +// An interrupt while the run waits, after the validator's own timeout, for the +// Job's deadline to end the pod is acted on at once: the Job is stopped and +// everything swept, instead of the wait running out first. +func TestRunClusterValidator_InterruptDuringTheDeadlineGraceStopsTheJob(t *testing.T) { + prevTimeout, prevGrace := clusterValidatorTimeout, validatorDeadlineGrace + clusterValidatorTimeout, validatorDeadlineGrace = 300*time.Millisecond, time.Minute + t.Cleanup(func() { clusterValidatorTimeout, validatorDeadlineGrace = prevTimeout, prevGrace }) + m, client := newMiniAPIServer(t) + m.podLogs = "Validator role: compute-plane\n" + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan ClusterValidatorResult, 1) + go func() { + done <- runClusterValidator(ctx, client, "registry.example.com/validator:1", "", false, + clusterValidatorComputePlaneRole, nil, nil) + }() + select { + case <-m.jobCreated: + case res := <-done: + t.Fatalf("the run ended before it created its Job: %v", res.Err) + } + time.Sleep(clusterValidatorTimeout + time.Second) + cancel() + + select { + case res := <-done: + require.ErrorContains(t, res.Err, "the validator did not finish within") + assert.False(t, res.LeftBehind) + case <-time.After(10 * time.Second): + t.Fatal("the interrupt was not acted on during the wait for the Job's deadline") + } + got := strings.Join(m.deleted(), " ") + for _, want := range []string{"/jobs/", "/clusterrolebindings/", "/clusterroles/", "/serviceaccounts/"} { + assert.Contains(t, got, want) + } +} + +// A delete that never answers does not hold the CLI open: every cleanup call +// is bounded. +func TestRunClusterValidator_HungDeletesAreBounded(t *testing.T) { + prev := validatorCleanupTimeout + validatorCleanupTimeout = 500 * time.Millisecond + t.Cleanup(func() { validatorCleanupTimeout = prev }) + m, client := newMiniAPIServer(t) + m.hangDeletes.Store(true) + + start := time.Now() + res := runInterrupted(t, client, m) + require.Error(t, res.Err) + assert.Less(t, time.Since(start), 25*time.Second) +} + +// A control-plane run that times out once its pod has ended makes all three +// deferred sweeps in turn, each on its own bounded context. With every DELETE +// hanging, the run still returns within ClusterValidatorRunCeiling, which the +// check's budget is sized from. +func TestClusterValidatorRunCeiling_CoversHungSweeps(t *testing.T) { + prevTimeout, prevGrace, prevLogs, prevCleanup, prevMargin := clusterValidatorTimeout, + validatorDeadlineGrace, clusterValidatorLogFetchTimeout, validatorCleanupTimeout, validatorRunMargin + clusterValidatorTimeout, validatorDeadlineGrace, clusterValidatorLogFetchTimeout, validatorCleanupTimeout, + validatorRunMargin = 3*time.Second, time.Second, 500*time.Millisecond, time.Second, 200*time.Millisecond + t.Cleanup(func() { + clusterValidatorTimeout, validatorDeadlineGrace, clusterValidatorLogFetchTimeout, validatorCleanupTimeout, + validatorRunMargin = prevTimeout, prevGrace, prevLogs, prevCleanup, prevMargin + }) + // An NGC image with NGC_API_KEY mints a pull Secret, and a registry makes + // the ConfigMap, so each of the three sweeps has an object to delete. + t.Setenv("NGC_API_KEY", "key") + m, client := newMiniAPIServer(t) + m.hangDeletes.Store(true) + + start := time.Now() + res := runClusterValidator(context.Background(), client, "nvcr.io/nvidia/validator:1", + "", false, clusterValidatorControlPlaneRole, []RegistryEntry{{Registry: "nvcr.io"}}, nil) + elapsed := time.Since(start) + require.Error(t, res.Err) + require.Contains(t, res.Err.Error(), "the validator did not finish within", + "the run reached the Job wait and timed out there") + assert.GreaterOrEqual(t, elapsed, validatorDeferredSweeps*validatorCleanupTimeout, + "every sweep ran and hung until its bound") + assert.LessOrEqual(t, elapsed, ClusterValidatorRunCeiling()) +} diff --git a/src/clis/nvcf-cli/internal/selfhosted/orchestrator_errors_test.go b/src/clis/nvcf-cli/internal/selfhosted/orchestrator_errors_test.go index a3b2e0e184..1bd771a614 100644 --- a/src/clis/nvcf-cli/internal/selfhosted/orchestrator_errors_test.go +++ b/src/clis/nvcf-cli/internal/selfhosted/orchestrator_errors_test.go @@ -422,9 +422,9 @@ func TestRetryAfterFromError_Parsing(t *testing.T) { {"rate limit: Retry-After: 30", 30}, {"Retry-After: 120s", 120}, {"retry-after: 5", 5}, - {"no header here", 5}, // default - {"Retry-After: ", 5}, // no digits → default - {"Retry-After: 0", 5}, // zero → default + {"no header here", 5}, // default + {"Retry-After: ", 5}, // no digits -> default + {"Retry-After: 0", 5}, // zero -> default {"Retry-After: 300 ok", 300}, // stops at first non-digit } for _, tc := range cases { diff --git a/src/clis/nvcf-cli/internal/selfhosted/output.go b/src/clis/nvcf-cli/internal/selfhosted/output.go index c0fdf3d919..10368fd024 100644 --- a/src/clis/nvcf-cli/internal/selfhosted/output.go +++ b/src/clis/nvcf-cli/internal/selfhosted/output.go @@ -23,6 +23,8 @@ import ( "io" "sort" "strings" + + "nvcf-cli/internal/selfhosted/severity" ) // RenderText writes linkerd-style green/red/warn checkmarks grouped by category @@ -64,7 +66,7 @@ func RenderJSON(w io.Writer, results []CheckResult) { } for _, r := range results { out.Checks = append(out.Checks, checkOut{ - ID: r.ID, Category: r.Category, Severity: r.Severity, + ID: r.ID, Category: r.Category, Severity: string(r.Severity), Passed: r.Passed, Message: r.Message, HintURL: r.HintURL, }) } @@ -74,10 +76,10 @@ func RenderJSON(w io.Writer, results []CheckResult) { } func mark(r CheckResult) string { - switch { - case r.Passed: + switch severity.Of(r.Passed, r.Severity) { + case severity.Pass: return "✓" - case r.Severity == "warning": + case severity.Warn: return "‼" default: return "×" @@ -98,10 +100,10 @@ func statusGlyph(status string) string { func overallStatus(results []CheckResult) string { hasWarn := false for _, r := range results { - if !r.Passed && r.Severity == "error" { - return "error" + if r.IsBlockingFailure() { + return string(SeverityError) } - if !r.Passed && r.Severity == "warning" { + if r.IsWarning() { hasWarn = true } } diff --git a/src/clis/nvcf-cli/internal/selfhosted/preflight.go b/src/clis/nvcf-cli/internal/selfhosted/preflight.go index 1d820eb91b..65690b7607 100644 --- a/src/clis/nvcf-cli/internal/selfhosted/preflight.go +++ b/src/clis/nvcf-cli/internal/selfhosted/preflight.go @@ -18,19 +18,26 @@ limitations under the License. package selfhosted import ( + "cmp" "context" + "errors" "fmt" "net/http" "os" "os/exec" "path/filepath" "regexp" + "slices" + "sort" "strings" + "sync" "time" "github.com/Masterminds/semver/v3" + corev1 "k8s.io/api/core/v1" "nvcf-cli/internal/selfhosted/progress" + "nvcf-cli/internal/selfhosted/severity" ) const ( @@ -44,19 +51,65 @@ const ( binaryVersionMessage = "%s %s on PATH (%s required)" ) +// Severity is a CheckResult's severity, graded by severity.Of. +type Severity = severity.Severity + +const ( + SeverityInfo = severity.Info + SeverityWarning = severity.Warning + SeverityError = severity.Error +) + +// IsBlockingFailure reports whether the result fails the run. The exit code, +// the final event, the category counts, the summaries and the dashboard all +// grade with severity.Of, so they cannot disagree. +func (r CheckResult) IsBlockingFailure() bool { + return severity.Of(r.Passed, r.Severity) == severity.Fail +} + +// IsWarning reports a miss that does not fail the run. +func (r CheckResult) IsWarning() bool { + return severity.Of(r.Passed, r.Severity) == severity.Warn +} + +// CountResults tallies results by the one rule every count uses. +func CountResults(results []CheckResult) (passed, failed, warned int) { + for _, r := range results { + switch severity.Of(r.Passed, r.Severity) { + case severity.Pass: + passed++ + case severity.Fail: + failed++ + default: + warned++ + } + } + return passed, failed, warned +} + // One row in the linkerd-style output. Logs is internal-only and is not // forwarded into the CheckCompleted JSONL wire event, so it never leaks // into the stable JSON contract. type CheckResult struct { ID string Category string - Severity string // "info" | "warning" | "error" + Severity Severity Passed bool Message string Detail string // optional: short version string or extra context (M+8.11) HintURL string - Err error // populated only when the check itself failed to execute + Err error // populated only when the check itself failed to execute Logs string // optional: full check transcript for --show-logs; not emitted to JSON + // Transient marks a warning expected to clear by itself, such as a + // rollout in progress. --wait keeps polling while one remains. + Transient bool + // CutShort marks a check the run's time budget stopped: it never started, + // or ran out of time while it ran. Such a result is no finding about the + // cluster, so the verdict is a timeout. + CutShort bool + // Cleanup is the command that removes what the check left in the + // cluster. Its row is reported even when the run is interrupted. + Cleanup string } // BinarySpec defines a tool that must be on PATH and a version constraint. @@ -75,7 +128,7 @@ func checkBinary(ctx context.Context, s BinarySpec) CheckResult { r := CheckResult{ ID: "local-host-tools-" + s.Name, Category: "local-host-tools", - Severity: "error", + Severity: SeverityError, HintURL: s.HintURL, } path, err := s.LookPath(s.Name) @@ -195,6 +248,35 @@ func runVersionCmdOnce(ctx context.Context, path string, args []string, re *rege type PreflightConfig struct { LocalOnly bool Tools []BinarySpec + // ToolsAdvisory grades a missing or unsupported local tool as a warning. + // Checking an installed stack runs no helm, helmfile or kubectl binary; + // only the install does. + ToolsAdvisory bool + + // Registries is the list of container registries to credential-check. + // When empty, the registry-credentials category is omitted entirely. + // Populated by the cmd layer from EnumerateRegistries. + Registries []RegistryEntry + + // RegistryChecker validates credentials for one registry. Nil skips the + // category. Production wires NewRegistryCredentialChecker; tests pass fakes. + RegistryChecker RegistryCredentialChecker + + // Interrupted reports an interrupt. The run's context cannot: once the + // check's budget has ended it, a later interrupt leaves it unchanged, and + // an interrupted run reports no rows for checks it never finished. + Interrupted func() bool + + // RegistryPostInstall marks a run whose registries belong to a plane it + // checks as installed. The cluster then pulls with its own pull secrets, + // so an NGC key a registry rejects is a warning where + // RegistryClusterSecrets shows that no pull secret holds it, and a docker + // login the NGC key replaced passes where none holds the login. + RegistryPostInstall bool + // RegistryClusterSecrets looks for a refused credential in the pull + // secrets of the clusters a post-install run checks. Nil leaves that + // unknown. + RegistryClusterSecrets ClusterPullSecretChecker } // DefaultTools returns the kubectl/helmfile/helm specs with version floors @@ -252,8 +334,16 @@ type Role int const ( RoleLocalOnly Role = iota // shared local-host tools only; no kubectl contact - RoleControlPlane // shared + Gateway API CRDs + default StorageClass - RoleComputePlane // shared + GPU operator + GPU node labels + (optional) SIS reachability + RoleControlPlane // shared + gateway/StorageClass/LB checks via cluster-validator + RoleComputePlane // shared + SIS reachability, node inotify limits and the validator, each opt-in +) + +// Validator role strings passed as VALIDATOR_ROLE to the cluster-validator Job. +// These must match the constants in the nvca cluster-validator binary's +// clustervalidator package (RoleControlPlane / RoleComputePlane). +const ( + validatorRoleControlPlane = "control-plane" + validatorRoleComputePlane = "compute-plane" ) // String returns a human-readable name for the role. @@ -281,16 +371,95 @@ type RoleConfig struct { // constructed). Production wires NewInotifyProber; tests pass fakes. InotifyProber NodeInotifyProber - // Nil skips the cluster-validator check. The cmd layer emits a stderr - // notice when the operator explicitly opted out; the check schema - // stays clean so JSON consumers don't see a misleading "passed=true" - // row for a probe that never actually ran. + // Nil skips the cluster-validator check; the caller reports why in + // Skipped. ClusterValidator ClusterValidator ClusterValidatorImage string ClusterValidatorPullSecret string ClusterValidatorNoCleanup bool + + // StaleNamespaceProber detects NVCF stack namespaces that are being + // deleted, hold a Helm release left mid-operation, or hold what a removed + // install leaves behind. Nil skips the check; production wires + // NewStaleNamespaceProber; tests pass fakes. + StaleNamespaceProber StaleNamespaceProber + + // StackDir is the resolved stack checkout. When set, the namespaces to + // probe are read from its helmfile.d rather than the static fallback list, + // so the check follows the stack instead of drifting from it. + StackDir string + + // StackEnv is the helmfile environment whose values file, layered over + // StackDir's environments/base.yaml, decides which gated releases the + // stack installs, and so which of their namespaces are probed. + StackEnv string + + // ExtraStaleNamespaces are merged into this role's stale-namespace scan. + // In ModeSingle both roles share one cluster, so only one of them runs the + // probe to avoid emitting the same check ID twice; the other role's + // namespaces are passed here instead of being dropped. The two roles probe + // disjoint lists, so without this the skipped role's namespaces are never + // scanned on the one topology where they sit on the same cluster. + ExtraStaleNamespaces []string + + // ClusterValidatorRegistries are the registries the control-plane + // validator probes for reachability: the EnumerateRegistries result the + // local credential check also uses. Ignored for the compute-plane validator. + ClusterValidatorRegistries []RegistryEntry + + // ClusterValidatorEnv is passed to the validator container for both roles. + ClusterValidatorEnv map[string]string + // ClusterValidatorTolerations are added to the validator Job's + // control-plane tolerations. + ClusterValidatorTolerations []corev1.Toleration + + // ClusterValidatorUnresolvedImage is a configured validator image without + // a tag whose latest tag could not be discovered. The validator does not + // run, and the role reports that as a failed check rather than nothing. + ClusterValidatorUnresolvedImage string + + // ValidatorCleanup records the removal command of every validator run + // that may leave objects in the cluster. Nil records nothing. + ValidatorCleanup *CleanupLedger + + // Skipped are the checks the run's flags or configuration leave out. Each + // is still reported as a row, so the stream says what was not checked. + Skipped []SkippedCheck +} + +// SkippedCheck is a check the run does not make. +type SkippedCheck struct { + Category string + ID string + Message string + // Warn reports a check the run was expected to make but lacked an input + // for. An explicit opt-out passes at info, as a registry this CLI cannot + // probe does. + Warn bool +} + +func skippedCheck(s SkippedCheck) binaryCheckSpec { + return binaryCheckSpec{ + ID: s.ID, + HumanLabel: s.Message, + static: true, + Run: func(context.Context) CheckResult { + if s.Warn { + return CheckResult{ID: s.ID, Severity: SeverityWarning, Message: s.Message} + } + return CheckResult{ID: s.ID, Severity: SeverityInfo, Passed: true, Message: s.Message} + }, + } } +// The check categories a run reports. +const ( + CategoryLocalHostTools = "local-host-tools" + CategoryRegistryCredentials = "registry-credentials" + CategoryControlPlane = "control-plane-cluster" + CategoryComputePlane = "compute-plane-cluster" +) + // categorySpec groups a set of checks under a named category. Categories run // in declaration order so the bubbletea ModeCheck dashboard renders them // top-to-bottom predictably. @@ -306,6 +475,20 @@ type binaryCheckSpec struct { ID string HumanLabel string Run func(ctx context.Context) CheckResult + // ownBudget runs the check on the run's budget, which sizes it by its own + // ceiling, instead of inside the probe share. + ownBudget bool + // static marks a row whose result is known without any work, so the + // budget cannot cut it short. + static bool + // worst is the most severe grade the check gives a miss, which a row the + // budget cuts short keeps: not knowing the outcome is no worse than the + // worst outcome. Empty is an error. + worst Severity +} + +func (s binaryCheckSpec) worstSeverity() Severity { + return cmp.Or(s.worst, SeverityError) } // buildCategories converts a PreflightConfig into the ordered list of @@ -318,18 +501,35 @@ func buildCategories(cfg PreflightConfig, role Role, rc RoleConfig) []categorySp out = append(out, *local) } - if cfg.LocalOnly || role == RoleLocalOnly { - return out + // Registry credential check runs from the operator's machine - no cluster + // contact needed. RunPreflightForRole is called once per role, so the + // caller clears Registries on every role but one to avoid duplicate + // check_started/check_completed events. Deduplicating on the role here + // instead would drop the check entirely when the control plane does not + // run, which is the case for a compute-plane-only invocation. + if len(cfg.Registries) > 0 && cfg.RegistryChecker != nil { + out = append(out, buildRegistryCredentialCategory(cfg)) } - switch role { - case RoleControlPlane: - out = append(out, controlPlaneCheckCategory(rc)) - case RoleComputePlane: - out = append(out, computePlaneCheckCategory(rc)) + if !cfg.LocalOnly { + switch role { + case RoleControlPlane: + out = append(out, controlPlaneCheckCategory(rc)) + case RoleComputePlane: + out = append(out, computePlaneCheckCategory(rc)) + } } - return out + for _, s := range rc.Skipped { + i := slices.IndexFunc(out, func(c categorySpec) bool { return c.name == s.Category }) + if i < 0 { + i = len(out) + out = append(out, categorySpec{name: s.Category, role: role}) + } + out[i].checks = append(out[i].checks, skippedCheck(s)) + } + // A category with no rows would report a clean tally for nothing checked. + return slices.DeleteFunc(out, func(c categorySpec) bool { return len(c.checks) == 0 }) } // buildLocalHostCategory builds the shared local-host-tools category from cfg. @@ -351,39 +551,70 @@ func buildLocalHostCategory(cfg PreflightConfig) *categorySpec { if len(checks) == 0 { return nil } - return &categorySpec{name: "local-host-tools", role: RoleLocalOnly, checks: checks} + if cfg.ToolsAdvisory { + for i := range checks { + checks[i].Run = advisoryTool(checks[i].Run) + checks[i].worst = SeverityWarning + } + } + return &categorySpec{name: CategoryLocalHostTools, role: RoleLocalOnly, checks: checks} } -// controlPlaneCheckCategory returns the placeholder cluster-side checks for -// control plane: Gateway API CRDs, default StorageClass. Real probes land -// when M3 ships; for now they emit an "info" CheckResult so the renderer -// matrix is observable. -func controlPlaneCheckCategory(_ RoleConfig) categorySpec { - return categorySpec{ - name: "control-plane-cluster", - role: RoleControlPlane, - checks: []binaryCheckSpec{ - placeholderCheck("gateway-api-crds", "checking Gateway API CRDs…", "Gateway API CRD probe — pending M3 cluster-side preflight"), - placeholderCheck("default-storageclass", "checking default StorageClass…", "Default StorageClass probe — pending M3 cluster-side preflight"), - }, +// advisoryTool grades a tool check's miss as a warning. +func advisoryTool(run func(context.Context) CheckResult) func(context.Context) CheckResult { + return func(ctx context.Context) CheckResult { + r := run(ctx) + if !r.Passed { + r.Severity = SeverityWarning + r.Message += "; only an install needs it" + } + return r } } -// computePlaneCheckCategory returns the placeholder compute-plane checks. -// Conditionally adds an SIS reachability probe when RoleConfig.SISURL is set, -// a node-inotify-limits probe when RoleConfig.InotifyProber is set, and a -// containerized cluster-validator probe when RoleConfig.ClusterValidator is -// set. Each conditional probe is opt-in via its own RoleConfig field so -// callers that opted out via a --skip-* flag simply leave the corresponding -// field nil/empty and the check is omitted from the category entirely. +// controlPlaneCheckCategory returns the cluster-side checks for the control +// plane. The stale-namespace check runs first so a leftover namespace from a +// prior partial teardown is surfaced before any other cluster work. +func controlPlaneCheckCategory(rc RoleConfig) categorySpec { + cat := categorySpec{ + name: CategoryControlPlane, + role: RoleControlPlane, + checks: []binaryCheckSpec{}, + } + // Stale namespace check runs first so leftover namespaces surface + // before any other cluster work. + if rc.StaleNamespaceProber != nil { + cat.checks = append(cat.checks, + staleNamespaceCheck(rc.StaleNamespaceProber, rc.KubeContext, + mergeNamespaces(resolveStackNamespaces(rc.StackDir, rc.StackEnv, nvcfControlPlaneNamespaces), + rc.ExtraStaleNamespaces))) + } + // Containerized cluster-validator probe for control-plane checks + // (Gateway API CRDs, Envoy Gateway, StorageClass, external LB, + // node-to-node overlay, and reachability to rc's registries). + if rc.ClusterValidator != nil { + cat.checks = append(cat.checks, clusterValidatorCheck(rc, validatorRoleControlPlane)) + } else if rc.ClusterValidatorUnresolvedImage != "" { + cat.checks = append(cat.checks, unresolvedValidatorCheck(rc.ClusterValidatorUnresolvedImage)) + } + return cat +} + +// computePlaneCheckCategory returns the compute-plane checks. SIS, inotify, +// and cluster-validator probes are opt-in via their RoleConfig fields; a nil +// or empty field omits the corresponding check from the category. func computePlaneCheckCategory(rc RoleConfig) categorySpec { cat := categorySpec{ - name: "compute-plane-cluster", + name: CategoryComputePlane, role: RoleComputePlane, - checks: []binaryCheckSpec{ - placeholderCheck("gpu-operator", "checking GPU operator…", "GPU operator probe — pending M+10 cluster-side preflight"), - placeholderCheck("gpu-node-labels", "checking GPU node labels…", "GPU node-label probe — pending M+10 cluster-side preflight"), - }, + } + // Stale namespace check runs first so leftover namespaces from a prior + // partial teardown surface before any other cluster work. + if rc.StaleNamespaceProber != nil { + cat.checks = append(cat.checks, + staleNamespaceCheck(rc.StaleNamespaceProber, rc.KubeContext, + mergeNamespaces(resolveStackNamespaces(rc.StackDir, rc.StackEnv, nvcfComputePlaneNamespaces), + rc.ExtraStaleNamespaces))) } if rc.SISURL != "" { cat.checks = append(cat.checks, sisReachabilityCheck(rc.SISURL)) @@ -392,13 +623,9 @@ func computePlaneCheckCategory(rc RoleConfig) categorySpec { cat.checks = append(cat.checks, nodeInotifyCheck(rc.InotifyProber, rc.KubeContext)) } if rc.ClusterValidator != nil { - cat.checks = append(cat.checks, clusterValidatorCheck( - rc.ClusterValidator, - rc.KubeContext, - rc.ClusterValidatorImage, - rc.ClusterValidatorPullSecret, - rc.ClusterValidatorNoCleanup, - )) + cat.checks = append(cat.checks, clusterValidatorCheck(rc, validatorRoleComputePlane)) + } else if rc.ClusterValidatorUnresolvedImage != "" { + cat.checks = append(cat.checks, unresolvedValidatorCheck(rc.ClusterValidatorUnresolvedImage)) } return cat } @@ -422,6 +649,18 @@ type NodeInotifyLimits struct { MaxUserInstances int64 MaxUserWatches int64 Err error + // OutOfBudget marks an Err caused by the probe's budget running out + // before this node's probe finished, on a node no earlier probe has a + // result for. A later poll, which probes such nodes first, may reach it. + // A node that failed on its own, such as one whose probe pod could not + // pull its image or start in time, is not out of budget. + OutOfBudget bool +} + +// withinLimits reports a node measured at or above NVCA's minimums. +func (l NodeInotifyLimits) withinLimits() bool { + return l.Err == nil && l.MaxUserInstances >= minInotifyMaxUserInstances && + l.MaxUserWatches >= minInotifyMaxUserWatches } // NodeInotifyProber returns one NodeInotifyLimits per cluster node, or a @@ -441,29 +680,31 @@ func nodeInotifyCheck(prober NodeInotifyProber, kubeContext string) binaryCheckS Run: func(ctx context.Context) CheckResult { r := CheckResult{ ID: id, - Severity: "error", + Severity: SeverityError, HintURL: inotifyHintURL, } limits, err := prober(ctx, kubeContext) if err != nil { - r.Severity = "warning" + r.Severity = SeverityWarning r.Message = "node inotify probe failed: " + err.Error() r.Err = err return r } if len(limits) == 0 { - r.Severity = "warning" - r.Passed = true - r.Message = "no nodes returned by inotify probe; skipping" + // A probe that checked nothing is not a pass. + r.Severity = SeverityWarning + r.Message = "no schedulable nodes were probed for inotify limits" return r } var failing, probeErrs []string + outOfBudget := false for _, l := range limits { if l.Err != nil { probeErrs = append(probeErrs, fmt.Sprintf("%s: %v", l.NodeName, l.Err)) + outOfBudget = outOfBudget || l.OutOfBudget continue } - if l.MaxUserInstances < minInotifyMaxUserInstances || l.MaxUserWatches < minInotifyMaxUserWatches { + if !l.withinLimits() { failing = append(failing, fmt.Sprintf( "%s (max_user_instances=%d/%d, max_user_watches=%d/%d)", l.NodeName, @@ -489,11 +730,15 @@ func nodeInotifyCheck(prober NodeInotifyProber, kubeContext string) binaryCheckS return r } if len(probeErrs) > 0 { - r.Severity = "warning" + r.Severity = SeverityWarning r.Message = fmt.Sprintf( "could not probe inotify limits on %d node(s): %s", len(probeErrs), strings.Join(probeErrs, "; "), ) + // A node the budget did not reach may be below the limits, + // so --wait polls again: the next poll keeps the nodes found + // within the limits and probes the unreached ones first. + r.Transient = outOfBudget return r } r.Passed = true @@ -503,72 +748,731 @@ func nodeInotifyCheck(prober NodeInotifyProber, kubeContext string) binaryCheckS } } -// Severity mapping: runner errors (RBAC/pull/timeout) -> warning, since -// they're operator-fixable infra issues; validator Passed=false -> -// error (real check failures); Passed=true -> info. -func clusterValidatorCheck(cv ClusterValidator, kubeContext, image, pullSecret string, noCleanup bool) binaryCheckSpec { +// unresolvedValidatorCheck reports a configured validator that cannot run +// because its image has no tag and the latest one could not be discovered. +// The kubelet would pull such a reference as :latest, which the validator +// repository does not publish. It fails like any validator that could not +// run: nothing was validated. +func unresolvedValidatorCheck(image string) binaryCheckSpec { const id = "cluster-validator" + return binaryCheckSpec{ + ID: id, + HumanLabel: "resolving the cluster-validator image...", + static: true, + Run: func(context.Context) CheckResult { + return CheckResult{ + ID: id, + Severity: SeverityError, + HintURL: clusterValidatorHintURL, + Message: "cluster-validator not run: could not resolve a tag for " + image + + "; pin cluster_validator_image to a tag, or pass --skip-cluster-validation", + } + }, + } +} + +// clusterValidatorCheck runs the validator Job for role. A validator that +// could not run (RBAC denied, image pull failure, timeout) is an error like a +// validator failure: nothing was checked, and a readiness gate must not pass +// on that. --skip-cluster-validation is the explicit opt-out. Only the +// control-plane validator probes rc's registries. +func clusterValidatorCheck(rc RoleConfig, role string) binaryCheckSpec { + const id = "cluster-validator" + registries := rc.ClusterValidatorRegistries + if role != validatorRoleControlPlane { + registries = nil + } return binaryCheckSpec{ ID: id, HumanLabel: "running cluster-validator probe…", + ownBudget: true, Run: func(ctx context.Context) CheckResult { r := CheckResult{ ID: id, HintURL: clusterValidatorHintURL, } - result := cv(ctx, ClusterValidatorParams{ + // Every hint names the context the run used, so it stays right + // after the current context changes. + kubeContext := effectiveKubeContext(rc.KubeContext) + result := rc.ClusterValidator(ctx, ClusterValidatorParams{ KubeContext: kubeContext, - Image: image, - PullSecret: pullSecret, - NoCleanup: noCleanup, + Image: rc.ClusterValidatorImage, + PullSecret: rc.ClusterValidatorPullSecret, + NoCleanup: rc.ClusterValidatorNoCleanup, + Role: role, + Registries: registries, + Env: rc.ClusterValidatorEnv, + Tolerations: rc.ClusterValidatorTolerations, + OnStart: func(runID string) { + rc.ValidatorCleanup.started(runID, validatorRemovalCommand(kubeContext, runID)) + }, }) r.Logs = result.Logs - r.Detail = clusterValidatorDetail(result.JobName) + r.Detail = strings.Join(append(result.Notes, clusterValidatorDetail(kubeContext, result)), "; ") + r.Detail = strings.TrimSuffix(r.Detail, "; ") + if why := validatorKeptReason(rc.ClusterValidatorNoCleanup, result); why != "" { + r.Cleanup = validatorRemovalCommand(kubeContext, result.RunID) + r.Detail = strings.TrimPrefix(r.Detail+"; ", "; ") + why + ": " + r.Cleanup + } + rc.ValidatorCleanup.finished(result.RunID, r.Cleanup) - if result.Err != nil { - r.Severity = "warning" - r.Message = "cluster-validator did not complete: " + result.Err.Error() + if errors.Is(result.Err, context.Canceled) { + r.Severity = SeverityWarning + r.Message = "cluster-validator was interrupted" r.Err = result.Err return r } - r.Passed = result.Passed - if result.Passed { - r.Severity = "info" - r.Message = "cluster passed cluster-validator built-in checks" + if result.Err != nil { + r.Severity = SeverityError + r.Message = "cluster-validator did not complete, so the cluster was not validated: " + + result.Err.Error() + "; fix the cause, or pass --skip-cluster-validation to run without it" + r.Err = result.Err return r } - r.Severity = "error" - r.Message = fmt.Sprintf("cluster-validator reported failures (exit code %d)", result.ExitCode) + gradeValidatorTranscript(&r, result, role, kubeContext) return r }, } } -func clusterValidatorDetail(jobName string) string { - hint := kubectlLogsHint(jobName) +// gradeValidatorTranscript grades a validator that ran to its end on what its +// transcript shows, not on the Job's status alone. +func gradeValidatorTranscript(r *CheckResult, result ClusterValidatorResult, role, kubeContext string) { + // An image that predates role support ignores VALIDATOR_ROLE and runs + // the compute-plane GPU checks, which fail on a CPU-only control + // plane. The latest tag can be such an image until a release with + // role support is published, so report it as the image being too + // old rather than as the cluster failing. That needs positive + // evidence of the legacy check set, not just a missing role line: a + // role-aware image that fails before printing it (say, building its + // Kubernetes client) must still fail the run. + if role == validatorRoleControlPlane && isLegacyComputePlaneTranscript(result.Logs) { + // Downgrade only the failure the old image causes by itself: its + // GPU check on a CPU-only control plane. Its checks common to both + // roles (the control plane's /readyz, admission webhooks, critical + // registry reachability) still see real failures, and those fail. + if others := legacyCriticalFailures(result.Logs); len(others) > 0 { + r.Severity = SeverityError + r.Message = "cluster-validator reported failures (" + strings.Join(others, "; ") + + "); the image predates validator roles, so only its checks common to both roles ran" + return + } + r.Severity = SeverityWarning + r.Message = "cluster-validator image does not support the control-plane checks (it ran the " + + "compute-plane set); use an image from an NVCA release that supports validator roles, " + + "or pass --skip-cluster-validation" + return + } + if !result.Passed { + r.Severity = SeverityError + if !strings.Contains(result.Logs, validatorVerdictNotReady) { + // It ended before its verdict: a crash, an OOM kill or an + // eviction, not the cluster failing a check. + r.Message = "cluster-validator did not complete, so the cluster was not validated: " + + validatorEndedEarly(result) + return + } + r.Message = fmt.Sprintf("cluster-validator reported failures (exit code %d)", result.ExitCode) + if rows := failedValidatorRows(result.Logs, ""); len(rows) > 0 { + r.Message = "cluster-validator reported failures: " + strings.Join(rows, "; ") + } + return + } + // The validator exits non-zero when the cluster is Not-Ready, so a Job + // that succeeded is its verdict even when the transcript cannot be read, + // say with pods/log denied. It passes; its warnings, and the role line + // that shows which check set ran, are unseen. + if result.LogsErr != nil && !strings.Contains(result.Logs, validatorVerdictReady) { + r.Passed = true + r.Severity = SeverityInfo + r.Message = "cluster-validator Job succeeded; its transcript could not be read (" + result.LogsErr.Error() + + "), so neither the checks it ran nor any warnings it reported are shown" + if hint := kubectlLogsHint(kubeContext, result.JobName); hint != "" { + r.Message += "; read it with: " + hint + } + return + } + // Info needs proof the requested checks ran and passed: the role line, + // and the verdict. A transcript that stops short of its verdict can hide + // warnings, so it is not a clean pass, and --wait polls again. + if why := unreadVerdict(result, role); why != "" { + r.Severity = SeverityWarning + r.Transient = true + r.Message = "cluster-validator Job succeeded but its verdict could not be read (" + why + ")" + if hint := kubectlLogsHint(kubeContext, result.JobName); hint != "" { + r.Message += "; read it with: " + hint + } + return + } + if strings.Contains(result.Logs, validatorReadyWithWarnings) { + // The Job succeeding is not a clean pass when the validator + // itself reported warnings. A rollout in progress is expected + // to clear, so --wait keeps polling on it. + r.Severity = SeverityWarning + r.Transient = isRolloutTranscript(result.Logs) + r.Message = "cluster-validator passed with warnings; run with --show-logs for details" + if r.Transient { + r.Message = "cluster-validator passed with a rollout in progress; --wait polls until it completes" + } + return + } + r.Passed = true + r.Severity = SeverityInfo + r.Message = "cluster passed cluster-validator built-in checks" +} + +// unreadVerdict says why a successful run's transcript does not show the +// requested check set reaching its verdict, or "" when it does. A validator +// that predates roles always runs the compute-plane set. +func unreadVerdict(result ClusterValidatorResult, role string) string { + logs := result.Logs + ranRole := strings.Contains(logs, validatorRoleMarker+role) || + (role == validatorRoleComputePlane && isLegacyComputePlaneTranscript(logs)) + switch { + case strings.TrimSpace(logs) == "": + return "its transcript was empty" + case !ranRole: + return "the transcript does not show the " + role + " checks" + case !strings.Contains(logs, validatorVerdictReady): + return "the transcript has no verdict" + } + return "" +} + +// validatorEndedEarly describes a validator that failed before its verdict. +func validatorEndedEarly(result ClusterValidatorResult) string { + msg := "its pod ended before the validator printed a verdict" + if result.ExitCode >= 0 { + msg = fmt.Sprintf("the validator exited %d before printing a verdict", result.ExitCode) + } + if result.Reason != "" { + msg += " (" + result.Reason + ")" + } + if result.LogsErr != nil { + msg += "; its transcript could not be read: " + result.LogsErr.Error() + } + return msg +} + +// validatorRoleMarker prefixes the line a role-aware validator prints naming +// the check set it runs. Must match RoleMarker in +// nvca/internal/clustervalidator/validator.go. +const validatorRoleMarker = "Validator role: " + +// The verdict lines printSummary prints. The with-warnings line contains the +// plain one. Must match printSummary in nvca/internal/clustervalidator. +const ( + validatorVerdictReady = "Cluster is NVCF-Ready" + validatorReadyWithWarnings = "NVCF-Ready (with warnings)" + validatorVerdictNotReady = "Cluster is NVCF-Not-Ready" +) + +// validatorRolloutMarkers are in the validator's warnings for a Deployment or +// StatefulSet rollout in progress (checks.go, Tier-1 and Tier-2). +var validatorRolloutMarkers = []string{"rollout in progress", "rolling update in progress", "mid-rollout"} + +func isRolloutTranscript(logs string) bool { + for _, m := range validatorRolloutMarkers { + if strings.Contains(logs, m) { + return true + } + } + return false +} + +// validatorSummaryStart, validatorFailIcon and validatorWarnIcon locate the +// failed and unobserved rows the validator prints in its summary. Must match +// printSummary and the print helpers in nvca/internal/clustervalidator. +const ( + validatorSummaryStart = "Check Results:" + validatorFailIcon = "\u2717" + validatorWarnIcon = "\u26a0" + validatorStatusUnknown = "Status Unknown" +) + +// failedValidatorRows returns the summary rows of a transcript that failed a +// critical check, or that report a check as Status Unknown, leaving out rows +// that start with skip when skip is set. The rows end at the verdict: the line +// after it carries the failure mark too. +func failedValidatorRows(logs, skip string) []string { + i := strings.LastIndex(logs, validatorSummaryStart) + if i < 0 { + return nil + } + var out []string + for _, line := range strings.Split(logs[i:], "\n") { + if strings.Contains(line, "Cluster is") { + break + } + _, row, failed := strings.Cut(line, validatorFailIcon) + if !failed { + if !strings.Contains(line, validatorStatusUnknown) { + continue + } + row = strings.TrimPrefix(strings.TrimSpace(line), validatorWarnIcon) + } + if row = strings.TrimSpace(row); row != "" && (skip == "" || !strings.HasPrefix(row, skip)) { + out = append(out, row) + } + } + return out +} + +// legacyCriticalFailures returns the failed critical summary rows of a +// pre-role transcript other than GPU Resources, the one row the compute-plane +// set fails on any CPU-only control plane. A transcript without a summary +// returns a row saying so: nothing shows the run was otherwise clean. +func legacyCriticalFailures(logs string) []string { + if !strings.Contains(logs, validatorSummaryStart) { + return []string{"the validator printed no summary"} + } + return failedValidatorRows(logs, legacyComputePlaneHeader) +} + +// legacyComputePlaneHeader is a section header only the compute-plane check +// set prints. A validator that predates roles always runs that set. +const legacyComputePlaneHeader = "GPU Resources" + +// isLegacyComputePlaneTranscript reports whether logs come from a validator +// that ignored the requested control-plane role: no role line, and the +// compute-plane check set's output instead. +func isLegacyComputePlaneTranscript(logs string) bool { + return !strings.Contains(logs, validatorRoleMarker) && strings.Contains(logs, legacyComputePlaneHeader) +} + +// validatorKeptReason says why a run's objects are still in the cluster, or +// "" when nothing it created was kept. +func validatorKeptReason(noCleanup bool, result ClusterValidatorResult) string { + switch { + case result.RunID == "": + return "" + case result.SweepErr != nil: + return "removing the run's objects failed (" + result.SweepErr.Error() + "); remove them with" + case result.LeftBehind: + // The pod could still be running, so its RBAC was kept rather than + // pulled out from under it. + return "the validator pod may still be running, so its objects were kept; once it ends, remove them with" + case noCleanup && result.Created: + return "kept with --no-cleanup; remove with" + } + return "" +} + +// validatorRemovalCommand removes everything one validator run created, in an +// order that is safe at any time: look at the pod, stop the Job in the +// foreground so its pod ends while it still has its RBAC, revoke the +// cluster-wide RBAC, then delete the rest. Joined with ';', so a failed step +// does not skip the RBAC. Everything the run created carries its run label. +func validatorRemovalCommand(kubeContext, runID string) string { + sel := clusterValidatorRunLabel + "=" + runID + kubectl := "kubectl" + kubectlContextArg(kubeContext) + return strings.Join([]string{ + fmt.Sprintf("%s get pods -n %s -l %s", kubectl, clusterValidatorNamespace, sel), + fmt.Sprintf("%s delete -n %s job -l %s --cascade=foreground --wait", kubectl, clusterValidatorNamespace, sel), + fmt.Sprintf("%s delete clusterrolebinding,clusterrole -l %s", kubectl, sel), + fmt.Sprintf("%s delete -n %s rolebinding,role,serviceaccount,secret,configmap -l %s", kubectl, + clusterValidatorNamespace, sel), + }, "; ") +} + +// CleanupLedger records, across the validator runs of one command, the +// removal command of every run that may leave objects in a cluster: runs in +// progress, and runs that kept objects. Safe for concurrent use; its methods +// do nothing on a nil ledger. +type CleanupLedger struct { + mu sync.Mutex + running map[string]string + kept []string +} + +func (l *CleanupLedger) started(runID, command string) { + if l == nil { + return + } + l.mu.Lock() + defer l.mu.Unlock() + if l.running == nil { + l.running = map[string]string{} + } + l.running[runID] = command +} + +// finished records that a run returned, keeping its removal command when it +// left objects behind. +func (l *CleanupLedger) finished(runID, command string) { + if l == nil { + return + } + l.mu.Lock() + defer l.mu.Unlock() + delete(l.running, runID) + if command != "" && !slices.Contains(l.kept, command) { + l.kept = append(l.kept, command) + } +} + +// Outstanding returns the removal commands of the runs that kept objects, +// then of the runs still in progress. +func (l *CleanupLedger) Outstanding() []string { + if l == nil { + return nil + } + l.mu.Lock() + defer l.mu.Unlock() + out := slices.Clone(l.kept) + running := make([]string, 0, len(l.running)) + for _, c := range l.running { + running = append(running, c) + } + sort.Strings(running) + return append(out, running...) +} + +// clusterValidatorDetail points at the validator's transcript, or for a kept +// Job that was suspended, whose pod is gone, at the Job. +func clusterValidatorDetail(kubeContext string, result ClusterValidatorResult) string { + if result.Suspended && result.JobName != "" { + return fmt.Sprintf("inspect: kubectl%s describe -n %s job/%s", + kubectlContextArg(kubeContext), clusterValidatorNamespace, result.JobName) + } + hint := kubectlLogsHint(kubeContext, result.JobName) if hint == "" { return "" } return "logs: " + hint } -// placeholderCheck returns a binaryCheckSpec that emits a passing "info" -// CheckResult with the given message. Used until M3/M+10 cluster-side probes ship. -func placeholderCheck(id, label, message string) binaryCheckSpec { +// buildRegistryCredentialCategory returns the registry-credentials category +// that probes each configured registry for reachability and valid credentials. +// A registry listed twice, for two repository scopes, gets one row per scope. +// Its row with no scope, else its first, is labeled by the registry alone, and +// each other row adds its scope. A chart source of one segment can list the +// row with no scope after a scoped one. +func buildRegistryCredentialCategory(cfg PreflightConfig) categorySpec { + cat := categorySpec{ + name: CategoryRegistryCredentials, + role: RoleLocalOnly, // runs regardless of cluster role + checks: make([]binaryCheckSpec, 0, len(cfg.Registries)), + } + bare := map[string]int{} + for i, reg := range cfg.Registries { + if j, ok := bare[reg.Registry]; !ok || (reg.RepoHint == "" && cfg.Registries[j].RepoHint != "") { + bare[reg.Registry] = i + } + } + for i, reg := range cfg.Registries { + label := reg.Registry + if bare[reg.Registry] != i { + label += "/" + reg.RepoHint + } + cat.checks = append(cat.checks, registryCredentialCheck(cfg.RegistryChecker, reg, label, + cfg.RegistryPostInstall, cfg.RegistryClusterSecrets)) + } + return cat +} + +// registryCredentialCheck returns a binaryCheckSpec that probes one registry. +// Only a credential the registry refused can fail: at error severity for a +// critical registry, at warning for any other. That includes a docker login +// nvcr.io rejected even where the NGC key it gave way to works, before and +// after install: docker and helm on this machine still send it. Before +// install it also includes a docker login with no access to the scope helm +// pulls the stack's charts from, though the key reaches it, and that scope's +// docker login is judged even where the run sends the key first. After +// install a refused NGC key is a warning only where the cluster's pull +// secrets are read and none holds it. Everything else the probe can report, +// from an unreachable registry to a missing local credential, is a warning or +// an informational pass. +func registryCredentialCheck( + checker RegistryCredentialChecker, entry RegistryEntry, label string, postInstall bool, + clusterSecrets ClusterPullSecretChecker, +) binaryCheckSpec { + id := "registry-cred-" + label + severity := SeverityWarning + if entry.Critical { + severity = SeverityError + } + // heldBy looks for cred in the cluster's pull secrets. + heldBy := func(ctx context.Context, cred registryCredential) (string, error) { + if clusterSecrets == nil { + return "", errors.New("no cluster to read them from") + } + return clusterSecrets(ctx, entry.Registry, entry.RepoHint, cred.user, cred.pass) + } + charts := "; helm on this machine pulls the stack's charts from " + scopeLabel(entry.Registry, entry.RepoHint) + + " with the docker login, so the install cannot pull them" return binaryCheckSpec{ ID: id, - HumanLabel: label, - Run: func(_ context.Context) CheckResult { - return CheckResult{ - ID: id, - Severity: "info", - Passed: true, - Message: message, + HumanLabel: fmt.Sprintf("checking credentials for %s...", label), + worst: severity, + Run: func(ctx context.Context) CheckResult { + r := CheckResult{ID: id, Severity: severity} + err := checker(ctx, entry.Registry, entry.RepoHint, entry.Critical, entry.Charts && !postInstall) + if err == nil { + r.Passed = true + r.Severity = SeverityInfo + r.Message = label + ": credentials valid" + return r + } + var outcome registryProbeOutcome + if !errors.As(err, &outcome) { + // The run's context ended mid-probe: nothing was learned. + r.Message = label + ": " + err.Error() + r.Err = err + return r + } + r.Message = label + ": " + outcome.detail + switch outcome.kind { + case probeSkipped: + r.Passed = true + r.Severity = SeverityInfo + r.Message = label + ": skipped (" + outcome.detail + ")" + case probeOtherCredential: + if !postInstall && entry.Charts { + r.Err = err + r.Message += charts + "; log in with a credential that has pull access to it" + break + } + r.Passed = true + r.Severity = SeverityInfo + case probeAnonymous, probeNotVerified: + r.Passed = true + r.Severity = SeverityInfo + case probeNoCredential, probeUnverifiable: + r.Severity = SeverityWarning + r.Err = err + case probeLoginRejected: + r.Err = err + if !postInstall { + if entry.Charts { + r.Message += charts + " until it is renewed" + } + break + } + switch holder, cmpErr := heldBy(ctx, outcome.refused); { + case holder != "": + r.Message += "; the cluster's pull secret " + holder + " holds that docker login, " + + "so the cluster cannot pull with it either" + case cmpErr != nil: + r.Message += "; could not check whether the cluster's pull secrets hold that docker login: " + + cmpErr.Error() + } + case probeRejected: + r.Err = err + if !postInstall || !outcome.keyOnly { + break + } + switch holder, cmpErr := heldBy(ctx, outcome.refused); { + case holder != "": + r.Message += "; the cluster's pull secret " + holder + " holds the same key, " + + "so the cluster cannot pull with it either" + case cmpErr != nil: + r.Message += "; could not check whether the cluster's pull secrets hold the same key: " + + cmpErr.Error() + default: + r.Severity = SeverityWarning + r.Message += "; no pull secret the cluster holds for " + entry.Registry + + " uses it, so this affects only this machine" + } } + return r }, } } +// mergeNamespaces returns the union of namespace lists, order-stable and +// de-duplicated, without the namespaces NVCA creates at runtime. +func mergeNamespaces(lists ...[]string) []string { + var out []string + seen := make(map[string]bool) + for _, list := range lists { + for _, ns := range list { + if ns == "" || seen[ns] || runtimeOwnedNamespaces[ns] { + continue + } + seen[ns] = true + out = append(out, ns) + } + } + return out +} + +// staleNamespaceCheck detects NVCF namespaces stuck Terminating, holding a +// Helm release left mid-operation, or left holding install data after a +// partial teardown. Only a namespace stuck Terminating is an error; the +// others, and a probe that could not read the cluster, are warnings. +func staleNamespaceCheck(prober StaleNamespaceProber, kubeContext string, namespaces []string) binaryCheckSpec { + const id = "stale-namespaces" + return binaryCheckSpec{ + ID: id, + HumanLabel: "checking for stale NVCF namespaces...", + Run: func(ctx context.Context) CheckResult { + r := CheckResult{ID: id, Severity: SeverityWarning} + // Resolve the context before probing, then probe that exact name. + // Resolving afterwards leaves a window where the current-context + // changes in between, which would have the hints act on a + // cluster other than the one that was read. + probedContext := effectiveKubeContext(kubeContext) + stale, err := prober(ctx, probedContext, namespaces) + var unreachable *ClusterUnreachableError + if errors.As(err, &unreachable) { + // Nothing about this cluster was checked, so nothing passes. + r.Severity = SeverityError + r.Message = unreachable.Error() + r.Err = err + return r + } + if len(stale) == 0 { + if err != nil { + r.Err = err + r.Message = "stale namespace probe failed: " + err.Error() + return r + } + r.Severity = SeverityInfo + r.Passed = true + r.Message = "no stale NVCF namespaces detected" + return r + } + // Every hint names the context that was actually probed, above. + // In split mode the two callers pass different contexts, and with + // no context flag the probe followed the current-context. Either + // way the name goes into the command, because the current-context + // can change between reading the output and pasting it. + kctl := "kubectl" + kubectlContextArg(probedContext) + parts := make([]string, 0, len(stale)) + var hints []string + for _, ns := range stale { + part := ns.Name + " (" + ns.Reason + if ns.Detail != "" { + part += ": " + ns.Detail + } + parts = append(parts, part+")") + switch ns.Reason { + case StaleStuckTerminating: + // Positive evidence found before a later read failed + // still blocks the run. + r.Severity = SeverityError + case StaleTerminating: + // A normal deletion finishes by itself; --wait polls it. + r.Transient = true + } + hints = append(hints, staleNamespaceHints(ns, kctl, probedContext)...) + } + found := fmt.Sprintf("%d stale namespace(s) detected: %s. %s", + len(stale), strings.Join(parts, ", "), strings.Join(hints, "; ")) + r.Message = found + if err != nil { + r.Message += ". Additionally could not probe: " + err.Error() + } + // A stuck namespace is a finding however the scan ended: graded + // as cut short it would exit 5. Short of one, a scan the budget + // stopped is cut short, since a namespace it did not reach could + // be stuck, and it keeps the namespaces it found. + if r.Severity != SeverityError { + r.Err = err + if cutShortBy(ctx, err) { + r.CutShort = true + r.Message = cutShortMessage + "; found before then: " + found + } + } + r.Transient = r.Transient && r.Severity == SeverityWarning + return r + }, + } +} + +// staleNamespaceHints returns the commands for one reported namespace, one +// per namespace with the real name substituted. A `` placeholder is not +// pasteable: the shell reads `<` and `>` as redirection. kubectl also keeps +// only the last -n it sees, so a joined command inspects only one namespace. +// +// Every kind is enumerated, not `get all`: that omits PVCs, Secrets, +// ConfigMaps and custom resources, so after a partial teardown it reports +// "No resources found" over the Cassandra volumes and the OpenBao unseal +// Secret. +func staleNamespaceHints(ns StaleNamespace, kctl, kubeContext string) []string { + inspect := fmt.Sprintf("%s api-resources --verbs=list --namespaced -o name | "+ + "xargs -n1 %s get -n %s --show-kind --ignore-not-found", kctl, kctl, ns.Name) + conditions := fmt.Sprintf("%s get ns %s -o jsonpath='{.status.conditions}'", kctl, ns.Name) + switch ns.Reason { + case StaleStuckTerminating: + // Inspect first, force last. A namespace usually stays Terminating + // because an object inside it still has a finalizer; clearing the + // namespace's own finalizers skips that object's cleanup and can + // orphan what it manages, such as a cloud load balancer or volume. + // spec.finalizers is writable only through the /finalize + // subresource: a plain patch is silently reverted by the + // apiserver's namespace strategy. + return []string{ + fmt.Sprintf("see why %s is held in Terminating: %s", ns.Name, conditions), + fmt.Sprintf("find what is holding %s in Terminating: %s", ns.Name, inspect), + fmt.Sprintf("only if nothing inside %s can be cleaned up, force-clear its finalizers: "+ + "%s get ns %s -o json | jq '.spec.finalizers=[]' | "+ + "%s replace --raw /api/v1/namespaces/%s/finalize -f -", + ns.Name, kctl, ns.Name, kctl, ns.Name), + } + case StaleTerminating: + return []string{fmt.Sprintf("%s is still being deleted; rerun the check once it is gone, "+ + "or see what it is waiting on: %s", ns.Name, conditions)} + case StaleReleaseMidOperation: + // An install or teardown still running leaves the same records, so + // the hint reads the history before anything is changed. + helm := "helm" + helmContextArg(kubeContext) + var hints []string + for _, release := range ns.Releases { + hints = append(hints, fmt.Sprintf("if no install or teardown is running, read the history of %s "+ + "with %s history %s -n %s, then finish it with helm rollback or helm uninstall", + release, helm, release, ns.Name)) + } + return hints + default: + // An observation, not a remediation. A namespace with no Helm + // release is not necessarily stale: an operator who installs a + // gated component the documented upstream way, or via Argo, owns a + // healthy namespace with no owner=helm object. + return []string{fmt.Sprintf("%s holds volume claims or workload objects but no Helm release; inspect: %s", + ns.Name, inspect)} + } +} + +// helmContextArg renders the probed context as a --kube-context flag, or "" +// when no explicit context was given. +func helmContextArg(kubeContext string) string { + if kubeContext == "" { + return "" + } + return " --kube-context " + shellQuoteArg(kubeContext) +} + +// kubectlContextArg renders the probed context as a --context flag for the +// remediation hints, or "" when no explicit context was given so the hint stays +// readable for a single-cluster setup. The value is shell-quoted: a context +// name is operator-supplied and arbitrary. +func kubectlContextArg(kubeContext string) string { + if kubeContext == "" { + return "" + } + return " --context " + shellQuoteArg(kubeContext) +} + +// shellQuoteArg makes s safe to paste into a shell. Unquoted when it holds only +// characters no shell treats specially, so the common case stays legible. +func shellQuoteArg(s string) string { + if s == "" { + return "''" + } + safe := strings.IndexFunc(s, func(r rune) bool { + return !(r == '-' || r == '_' || r == '.' || r == '/' || r == ':' || r == '@' || r == '=' || + (r >= '0' && r <= '9') || + (r >= 'A' && r <= 'Z') || + (r >= 'a' && r <= 'z')) + }) == -1 + if safe { + return s + } + return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'" +} + // sisReachabilityCheck does an HTTP GET on /v1/health and asserts < 5xx. // Tagged so the renderer groups it under "compute-plane-cluster". func sisReachabilityCheck(sisURL string) binaryCheckSpec { @@ -580,7 +1484,7 @@ func sisReachabilityCheck(sisURL string) binaryCheckSpec { req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(sisURL, "/")+"/v1/health", nil) if err != nil { return CheckResult{ - ID: "sis-reachability", Severity: "error", Passed: false, + ID: "sis-reachability", Severity: SeverityError, Passed: false, Message: "SIS request build failed: " + err.Error(), HintURL: "https://docs.nvidia.com/nvcf/self-hosted/troubleshooting#sis-reachability", } @@ -588,21 +1492,22 @@ func sisReachabilityCheck(sisURL string) binaryCheckSpec { resp, err := cli.Do(req) if err != nil { return CheckResult{ - ID: "sis-reachability", Severity: "error", Passed: false, + ID: "sis-reachability", Severity: SeverityError, Passed: false, Message: "SIS unreachable: " + err.Error(), HintURL: "https://docs.nvidia.com/nvcf/self-hosted/troubleshooting#sis-reachability", + Err: err, } } defer resp.Body.Close() if resp.StatusCode >= 500 { return CheckResult{ - ID: "sis-reachability", Severity: "error", Passed: false, + ID: "sis-reachability", Severity: SeverityError, Passed: false, Message: fmt.Sprintf("SIS returned %d", resp.StatusCode), HintURL: "https://docs.nvidia.com/nvcf/self-hosted/troubleshooting#sis-reachability", } } return CheckResult{ - ID: "sis-reachability", Severity: "info", Passed: true, + ID: "sis-reachability", Severity: SeverityInfo, Passed: true, Message: fmt.Sprintf("SIS reachable (%d)", resp.StatusCode), } }, @@ -626,50 +1531,174 @@ func RunPreflightStreaming(ctx context.Context, cfg PreflightConfig, sink progre return runPreflightImpl(ctx, cfg, RoleLocalOnly, RoleConfig{}, sink) } +// checkProbeShare bounds the checks a role runs before its validator, and is +// the whole budget of a role without one. A var so tests can shorten it. +var checkProbeShare = 2 * time.Minute + +// CheckProbeShare is the time each role sets aside for the checks before its +// validator. RunPreflightForRole enforces it, so however slow those probes are +// the validator still gets the time the run's budget sized it for. +func CheckProbeShare() time.Duration { return checkProbeShare } + +// localCheckShare bounds the checks that run on this machine: the local tools +// and the registry probes. They get their own share so a slow registry cannot +// spend the time set aside for the cluster checks. A var so tests can shorten +// it. +var localCheckShare = 90 * time.Second + +// LocalCheckShare is the time each role invocation sets aside for the checks +// that run on this machine, before its cluster checks. +func LocalCheckShare() time.Duration { return localCheckShare } + func runPreflightImpl(ctx context.Context, cfg PreflightConfig, role Role, rc RoleConfig, sink progress.EventSink) []CheckResult { var all []CheckResult + interrupted := func() bool { + return errors.Is(ctx.Err(), context.Canceled) || (cfg.Interrupted != nil && cfg.Interrupted()) + } + localCtx, cancelLocal := context.WithTimeout(ctx, localCheckShare) + defer cancelLocal() + // The cluster share starts with the first cluster category, so the local + // checks before it cannot spend it. The probes end early enough for a + // probe pod's cleanup, which outlives its probe, to finish inside it. + clusterShare := &lazyTimeout{parent: ctx, d: checkProbeShare - probePodCleanupTimeout} + defer clusterShare.stop() categories := buildCategories(cfg, role, rc) for _, cat := range categories { + shareCtx := localCtx + if cat.role != RoleLocalOnly { + shareCtx = clusterShare.get() + } catStart := time.Now() - var passed, failed int + var catResults []CheckResult for _, spec := range cat.checks { - if ctx.Err() != nil { + checkCtx := shareCtx + if spec.ownBudget { + checkCtx = ctx + } + var res CheckResult + switch { + case interrupted(): + // Interrupted: the command reports the interrupt, not rows. return all + case checkCtx.Err() != nil && !spec.static: + // The budget ran out before this check started. Dropping it + // let the partial set grade as the verdict, so a validator + // that never ran could pass the gate. + res = CheckResult{ + ID: spec.ID, Severity: spec.worstSeverity(), CutShort: true, + Message: "not run: the check's time budget ran out before it started", + Err: checkCtx.Err(), + } + default: + _ = sink.Emit(ctx, progress.CheckStarted{ + Category: cat.name, + ID: spec.ID, + Message: spec.HumanLabel, + }) + res = spec.Run(checkCtx) + if interrupted() { + // Cut short by the interrupt, so its result is not a + // finding; the cancelled final event says what happened. + // A row that says how to remove what the check left in + // the cluster is still reported: nothing else will. + if res.Cleanup != "" { + res.Category = cat.name + all = append(all, res) + emitCheckCompleted(sink, cat.name, res) + } + return all + } + res = normaliseResult(checkCtx, res, spec.worstSeverity()) } - _ = sink.Emit(ctx, progress.CheckStarted{ - Category: cat.name, - ID: spec.ID, - Message: spec.HumanLabel, - }) - res := spec.Run(ctx) res.Category = cat.name all = append(all, res) - _ = sink.Emit(ctx, progress.CheckCompleted{ - Category: cat.name, - ID: res.ID, - Passed: res.Passed, - Severity: res.Severity, - Message: res.Message, - Detail: res.Detail, - HintURL: res.HintURL, - }) - if res.Passed { - passed++ - } else { - failed++ - } + emitCheckCompleted(sink, cat.name, res) + catResults = append(catResults, res) } + passed, failed, warned := CountResults(catResults) _ = sink.Emit(ctx, progress.CategoryCompleted{ - Category: cat.name, - PassedCount: passed, - FailedCount: failed, - DurationSec: time.Since(catStart).Seconds(), + Category: cat.name, + PassedCount: passed, + FailedCount: failed, + WarningCount: warned, + DurationSec: time.Since(catStart).Seconds(), }) } return all } +// lazyTimeout is a context whose timeout starts on first use. +type lazyTimeout struct { + parent context.Context + d time.Duration + ctx context.Context + cancel context.CancelFunc +} + +func (l *lazyTimeout) get() context.Context { + if l.ctx == nil { + l.ctx, l.cancel = context.WithTimeout(l.parent, l.d) + } + return l.ctx +} + +func (l *lazyTimeout) stop() { + if l.cancel != nil { + l.cancel() + } +} + +// cutShortMessage starts the message of a row the budget stopped while it ran. +const cutShortMessage = "cut short: the check's time budget ran out before it finished" + +// cutShortBy reports a miss caused by the spent budget that ctx carries, +// rather than by a bound of the check's own. +func cutShortBy(ctx context.Context, err error) bool { + return errors.Is(ctx.Err(), context.DeadlineExceeded) && errors.Is(err, context.DeadlineExceeded) +} + +// normaliseResult applies the rules every row shares. A pass is info, whatever +// severity the check set before it knew the outcome. A miss caused by the +// spent budget is cut short: no finding either way, graded at worst, the +// check's most severe grade. A check whose own bound fired keeps its result. +func normaliseResult(ctx context.Context, res CheckResult, worst Severity) CheckResult { + if res.Passed { + res.Severity = SeverityInfo + return res + } + if !res.CutShort && !cutShortBy(ctx, res.Err) { + return res + } + if !res.CutShort { + // Replaced, not wrapped: the check's own advice is about a failure + // it did not get to observe. A check that kept what it found + // before the budget ran out marked the row itself. + res.Message = cutShortMessage + } + res.CutShort = true + res.Severity = worst + res.Transient = res.Transient && worst == SeverityWarning + return res +} + +// emitCheckCompleted reports one check's result. It uses a fresh context: the +// run's own may have ended, and the row must still reach the stream. +func emitCheckCompleted(sink progress.EventSink, category string, res CheckResult) { + _ = sink.Emit(context.Background(), progress.CheckCompleted{ + Category: category, + ID: res.ID, + Passed: res.Passed, + Severity: res.Severity, + Message: res.Message, + Detail: res.Detail, + HintURL: res.HintURL, + Transient: res.Transient, + CutShort: res.CutShort, + Cleanup: res.Cleanup, + }) +} + // noopSink is a progress.EventSink that discards all events. Used by RunPreflight // to delegate to RunPreflightStreaming without streaming overhead for callers that // only need the final result slice. @@ -678,11 +1707,22 @@ type noopSink struct{} func (*noopSink) Emit(context.Context, progress.Event) error { return nil } func (*noopSink) Close() error { return nil } -// RunPreflight executes local-host (and optionally cluster-side) pre-flight -// checks and returns the result slice. It is a thin wrapper around -// RunPreflightStreaming with a no-op sink, kept for orchestrator callers -// (runUpPreflight in cmd/self_hosted_up.go) that do not need streaming. -// Cluster-side checks land in M3 (need a kubectl client wired up). +// RunPreflight executes the role-independent pre-flight checks (local tools +// and registry credentials) and returns the result slice. It is a thin +// wrapper around RunPreflightStreaming with a no-op sink, kept for +// orchestrator callers (runUpPreflight in cmd/self_hosted_up.go) that do not +// need streaming. func RunPreflight(ctx context.Context, cfg PreflightConfig) []CheckResult { return RunPreflightStreaming(ctx, cfg, &noopSink{}) } + +// ControlPlaneStaleNamespaces and ComputePlaneStaleNamespaces expose each +// role's stale-namespace list so the cmd layer can merge the two when a single +// cluster hosts both roles and only one probe runs. +func ControlPlaneStaleNamespaces(stackDir, env string) []string { + return resolveStackNamespaces(stackDir, env, nvcfControlPlaneNamespaces) +} + +func ComputePlaneStaleNamespaces(stackDir, env string) []string { + return resolveStackNamespaces(stackDir, env, nvcfComputePlaneNamespaces) +} diff --git a/src/clis/nvcf-cli/internal/selfhosted/preflight_test.go b/src/clis/nvcf-cli/internal/selfhosted/preflight_test.go index 262075dff8..abeee45a4e 100644 --- a/src/clis/nvcf-cli/internal/selfhosted/preflight_test.go +++ b/src/clis/nvcf-cli/internal/selfhosted/preflight_test.go @@ -19,12 +19,15 @@ package selfhosted import ( "context" + "errors" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" + "strings" "testing" + "time" "github.com/Masterminds/semver/v3" "github.com/stretchr/testify/assert" @@ -289,7 +292,7 @@ func TestRunPreflightStreaming_EmitsEvents(t *testing.T) { require.True(t, ok, "event[3] must be CheckCompleted") assert.Equal(t, "local-host-tools-helmfile", cc1.ID) assert.False(t, cc1.Passed) - assert.Equal(t, "error", cc1.Severity) + assert.Equal(t, SeverityError, cc1.Severity) assert.Equal(t, "https://github.com/helmfile/helmfile#installation", cc1.HintURL) catDone, ok := sink.events[4].(progress.CategoryCompleted) @@ -405,10 +408,13 @@ func TestRunPreflightForRole_LocalOnly(t *testing.T) { assert.Len(t, res, 4, "expected 3 tool results plus helm runtime compatibility") } -func TestRunPreflightForRole_ControlPlaneAddsClusterCategory(t *testing.T) { +// A category with no rows would report a clean tally for nothing checked, so +// it is not reported. A role always has rows: its probes, or the skipped rows +// that say why they did not run. +func TestRunPreflightForRole_EmptyCategoryIsNotReported(t *testing.T) { sink := &captureSink{} cfg := PreflightConfig{Tools: []BinarySpec{passingToolSpec("kubectl", "1.30.0")}} - res := RunPreflightForRole(context.Background(), cfg, RoleControlPlane, RoleConfig{KubeContext: "admin@cp"}, sink) + RunPreflightForRole(context.Background(), cfg, RoleControlPlane, RoleConfig{KubeContext: "admin@cp"}, sink) seen := map[string]bool{} for _, e := range sink.events { @@ -417,14 +423,27 @@ func TestRunPreflightForRole_ControlPlaneAddsClusterCategory(t *testing.T) { } } assert.True(t, seen["local-host-tools"], "expected local-host-tools category") - assert.True(t, seen["control-plane-cluster"], "expected control-plane-cluster category") + assert.False(t, seen["control-plane-cluster"], "an empty category must not report a tally") +} + +func TestRunPreflightForRole_ControlPlaneWithValidatorAddsClusterValidatorCheck(t *testing.T) { + sink := &captureSink{} + cfg := PreflightConfig{Tools: []BinarySpec{passingToolSpec("kubectl", "1.30.0")}} + cv := func(_ context.Context, p ClusterValidatorParams) ClusterValidatorResult { + return ClusterValidatorResult{Passed: true} + } + res := RunPreflightForRole(context.Background(), cfg, RoleControlPlane, RoleConfig{ + KubeContext: "admin@cp", + ClusterValidator: cv, + ClusterValidatorImage: "nvcf-validator:1.0", + }, sink) var gotCheckIDs []string for _, r := range res { gotCheckIDs = append(gotCheckIDs, r.ID) } - assert.Contains(t, gotCheckIDs, "gateway-api-crds") - assert.Contains(t, gotCheckIDs, "default-storageclass") + assert.Contains(t, gotCheckIDs, "cluster-validator", + "cluster-validator check must appear when ClusterValidator is configured for control-plane role") } func TestRunPreflightForRole_ComputePlaneWithoutSISURL(t *testing.T) { @@ -436,8 +455,9 @@ func TestRunPreflightForRole_ComputePlaneWithoutSISURL(t *testing.T) { for _, r := range res { gotIDs = append(gotIDs, r.ID) } - assert.Contains(t, gotIDs, "gpu-operator") - assert.Contains(t, gotIDs, "gpu-node-labels") + // No row passes for a check that does not run. + assert.NotContains(t, gotIDs, "gpu-operator") + assert.NotContains(t, gotIDs, "gpu-node-labels") assert.NotContains(t, gotIDs, "sis-reachability") // no SISURL → not added } @@ -537,7 +557,7 @@ func TestNodeInotifyCheck_OneNodeBelowMinimum(t *testing.T) { got := findResult(res, "node-inotify-limits") require.NotNil(t, got) assert.False(t, got.Passed, "below-minimum node must fail the check") - assert.Equal(t, "error", got.Severity) + assert.Equal(t, SeverityError, got.Severity) assert.Contains(t, got.Message, "node-b") assert.Contains(t, got.Message, "max_user_instances=128/8192") assert.NotContains(t, got.Message, "node-a", "node above minimum should not be listed") @@ -579,7 +599,7 @@ func TestNodeInotifyCheck_LimitViolationsBeatProbeErrors(t *testing.T) { got := findResult(res, "node-inotify-limits") require.NotNil(t, got) assert.False(t, got.Passed, "limit violation must fail the check") - assert.Equal(t, "error", got.Severity, + assert.Equal(t, SeverityError, got.Severity, "limit violations outrank probe errors and must surface as error, not warning") assert.Contains(t, got.Message, "node-b", "non-compliant node must appear in message") assert.Contains(t, got.Message, "max_user_instances=128/8192") @@ -602,9 +622,45 @@ func TestNodeInotifyCheck_PerNodeProbeError(t *testing.T) { got := findResult(res, "node-inotify-limits") require.NotNil(t, got) assert.False(t, got.Passed, "per-node probe error must not silently pass") - assert.Equal(t, "warning", got.Severity, "probe failures degrade to warning, not error") + assert.Equal(t, SeverityWarning, got.Severity, "probe failures degrade to warning, not error") assert.Contains(t, got.Message, "node-b") assert.Contains(t, got.Message, "forbidden") + assert.False(t, got.Transient, "a denied create does not clear by itself") +} + +// A node the probe's budget did not reach may be below the limits, so the +// warning is transient and --wait polls again. A limit violation found beside +// it is an error, never transient. +func TestNodeInotifyCheck_NodesTheBudgetDidNotReachAreTransient(t *testing.T) { + notReached := NodeInotifyLimits{NodeName: "node-b", OutOfBudget: true, + Err: errors.New("not probed: the inotify probe's 1m40s budget ran out")} + ok := NodeInotifyLimits{NodeName: "node-a", MaxUserInstances: 8192, MaxUserWatches: 524288} + low := NodeInotifyLimits{NodeName: "node-a", MaxUserInstances: 128, MaxUserWatches: 524288} + for name, tc := range map[string]struct { + limits []NodeInotifyLimits + wantSeverity Severity + wantTransient bool + }{ + "not reached": { + limits: []NodeInotifyLimits{ok, notReached}, + wantSeverity: SeverityWarning, + wantTransient: true, + }, + "not reached beside a violation": { + limits: []NodeInotifyLimits{low, notReached}, + wantSeverity: SeverityError, + }, + } { + res := RunPreflightForRole(context.Background(), PreflightConfig{}, RoleComputePlane, RoleConfig{ + InotifyProber: func(context.Context, string) ([]NodeInotifyLimits, error) { return tc.limits, nil }, + }, &captureSink{}) + got := findResult(res, "node-inotify-limits") + require.NotNil(t, got, name) + assert.False(t, got.Passed, name) + assert.Equal(t, tc.wantSeverity, got.Severity, name) + assert.Equal(t, tc.wantTransient, got.Transient, name) + assert.Contains(t, got.Message, "node-b: not probed", name) + } } func TestNodeInotifyCheck_ClusterWideProbeError(t *testing.T) { @@ -619,7 +675,7 @@ func TestNodeInotifyCheck_ClusterWideProbeError(t *testing.T) { got := findResult(res, "node-inotify-limits") require.NotNil(t, got) assert.False(t, got.Passed) - assert.Equal(t, "warning", got.Severity) + assert.Equal(t, SeverityWarning, got.Severity) assert.ErrorIs(t, got.Err, probeErr) } @@ -654,3 +710,586 @@ func TestParseInotifyOutput(t *testing.T) { require.Error(t, err) }) } + +// In ModeSingle one cluster hosts both roles and only one stale-namespace +// probe runs, so the skipped role's namespaces must be merged in. Dropping +// them means a kai-scheduler or nvca-operator namespace wedged Terminating is +// reported as "no stale NVCF namespaces detected". +func TestStaleNamespaceCheck_MergesTheOtherRolesNamespaces(t *testing.T) { + var probed []string + prober := func(_ context.Context, _ string, namespaces []string) ([]StaleNamespace, error) { + probed = namespaces + return nil, nil + } + rc := RoleConfig{ + StaleNamespaceProber: prober, + ExtraStaleNamespaces: ComputePlaneStaleNamespaces("", ""), + } + cat := controlPlaneCheckCategory(rc) + require.NotEmpty(t, cat.checks) + cat.checks[0].Run(context.Background()) + + for _, ns := range []string{"nvcf", "vault-system"} { + assert.Contains(t, probed, ns, "the control-plane list must still be covered") + } + assert.Contains(t, probed, "nvca-operator", "the compute-plane list must be merged in, not dropped") +} + +func TestMergeNamespaces_DedupesAndKeepsOrder(t *testing.T) { + got := mergeNamespaces([]string{"a", "b"}, []string{"b", "c", ""}) + assert.Equal(t, []string{"a", "b", "c"}, got) + assert.Nil(t, mergeNamespaces(nil, nil)) + assert.Equal(t, []string{"a"}, mergeNamespaces([]string{"nvcf-backend", "a"}, []string{"nvca-system"}), + "namespaces NVCA creates at runtime hold live work and are never probed") +} + +// Every count derives from one rule: only an error-severity miss fails; any +// other miss is a warning. +func TestCountResults_OneRule(t *testing.T) { + p, f, w := CountResults([]CheckResult{ + {Passed: true, Severity: SeverityInfo}, + {Passed: false, Severity: SeverityError}, + {Passed: false, Severity: SeverityWarning}, + {Passed: false, Severity: SeverityInfo}, + }) + assert.Equal(t, 1, p) + assert.Equal(t, 1, f) + assert.Equal(t, 2, w) +} + +// When the budget runs out, the checks not yet started are error rows rather +// than silently dropped, so a partial run cannot grade as a pass. An +// interrupt ends the run without them: the command reports the interrupt. +func TestRunPreflight_BudgetSpentChecksAreNotRunErrors(t *testing.T) { + cfg := PreflightConfig{Tools: []BinarySpec{passingToolSpec("kubectl", "1.30.0"), passingToolSpec("helm", "3.15.0")}} + + expired, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) + defer cancel() + res := RunPreflightForRole(expired, cfg, RoleLocalOnly, RoleConfig{}, &captureSink{}) + require.NotEmpty(t, res) + for _, r := range res { + assert.True(t, r.IsBlockingFailure(), r.ID) + assert.True(t, r.CutShort, r.ID) + assert.Contains(t, r.Message, "not run", r.ID) + } + + cancelled, stop := context.WithCancel(context.Background()) + stop() + assert.Empty(t, RunPreflightForRole(cancelled, cfg, RoleLocalOnly, RoleConfig{}, &captureSink{})) + + // A check cut short by the interrupt is not a finding either. + midRun, stopMidRun := context.WithCancel(context.Background()) + defer stopMidRun() + interrupting := passingToolSpec("kubectl", "1.30.0") + interrupting.Version = func(context.Context, string) (*semver.Version, error) { + stopMidRun() + return nil, context.Canceled + } + cfg = PreflightConfig{Tools: []BinarySpec{interrupting}} + assert.Empty(t, RunPreflightForRole(midRun, cfg, RoleLocalOnly, RoleConfig{}, &captureSink{})) +} + +// A check the budget stopped while it ran is cut short: no finding either way. +// One that had its result, a pass or its own failure, keeps it even when the +// budget ran out right after. +func TestRunPreflight_CutShortOnlyWhenTheBudgetStoppedTheCheck(t *testing.T) { + spec := func(name string, version func(ctx context.Context) (*semver.Version, error)) BinarySpec { + s := passingToolSpec(name, "1.30.0") + s.Version = func(ctx context.Context, _ string) (*semver.Version, error) { return version(ctx) } + return s + } + for name, tc := range map[string]struct { + version func(ctx context.Context) (*semver.Version, error) + cutShort bool + passed bool + }{ + "stopped by the budget": { + version: func(ctx context.Context) (*semver.Version, error) { <-ctx.Done(); return nil, ctx.Err() }, + cutShort: true, + }, + "its own failure": { + version: func(ctx context.Context) (*semver.Version, error) { + <-ctx.Done() + return nil, errors.New("exec format error") + }, + }, + "passed as the budget ran out": { + version: func(ctx context.Context) (*semver.Version, error) { + <-ctx.Done() + return semver.MustParse("1.30.0"), nil + }, + passed: true, + }, + } { + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + res := RunPreflightForRole(ctx, PreflightConfig{Tools: []BinarySpec{spec("kubectl", tc.version)}}, + RoleLocalOnly, RoleConfig{}, &captureSink{}) + cancel() + require.Len(t, res, 1, name) + assert.Equal(t, tc.cutShort, res[0].CutShort, name) + assert.Equal(t, tc.passed, res[0].Passed, name) + if tc.cutShort { + assert.True(t, strings.HasPrefix(res[0].Message, "cut short: "), name) + } + } +} + +// A row that says how to remove what a check left in the cluster is reported +// even when the run is interrupted, as an event too, since nothing else will +// say it. An interrupted row without one is not a finding and is dropped. +func TestRunPreflight_InterruptKeepsTheCleanupRow(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + sink := &captureSink{} + rc := RoleConfig{ + KubeContext: "ctx-a", + ClusterValidatorImage: "nvcr.io/nvidia/validator:1", + ClusterValidator: func(context.Context, ClusterValidatorParams) ClusterValidatorResult { + cancel() + return ClusterValidatorResult{RunID: "run1", Err: context.Canceled, LeftBehind: true} + }, + } + res := RunPreflightForRole(ctx, PreflightConfig{}, RoleControlPlane, rc, sink) + require.Len(t, res, 1) + assert.Equal(t, "cluster-validator", res[0].ID) + assert.Equal(t, SeverityWarning, res[0].Severity) + assert.Equal(t, "cluster-validator was interrupted", res[0].Message) + assert.Contains(t, res[0].Cleanup, clusterValidatorRunLabel+"=run1") + assert.Contains(t, res[0].Cleanup, "--context ctx-a") + var emitted []progress.CheckCompleted + for _, e := range sink.events { + if cc, ok := e.(progress.CheckCompleted); ok { + emitted = append(emitted, cc) + } + } + require.Len(t, emitted, 1) + assert.Contains(t, emitted[0].Detail, res[0].Cleanup) + + dropped, stop := context.WithCancel(context.Background()) + defer stop() + rc.ClusterValidator = func(context.Context, ClusterValidatorParams) ClusterValidatorResult { + stop() + return ClusterValidatorResult{RunID: "run2", Err: context.Canceled} + } + assert.Empty(t, RunPreflightForRole(dropped, PreflightConfig{}, RoleControlPlane, rc, &captureSink{})) +} + +// A configured validator whose tag could not be resolved does not run, and +// each role says so as a failed row: a note on stderr is lost to a consumer +// of the JSON stream, and nothing was validated. +func TestRunPreflight_UnresolvedValidatorImageFailsEachRole(t *testing.T) { + rc := RoleConfig{ClusterValidatorUnresolvedImage: "nvcr.io/nvidia/nvcf-byoc/cluster-validator"} + for _, role := range []Role{RoleControlPlane, RoleComputePlane} { + res := RunPreflightForRole(context.Background(), PreflightConfig{}, role, rc, &captureSink{}) + var row *CheckResult + for i := range res { + if res[i].ID == "cluster-validator" { + row = &res[i] + } + } + require.NotNil(t, row, role) + assert.True(t, row.IsBlockingFailure(), role) + assert.Contains(t, row.Message, "could not resolve a tag for nvcr.io/nvidia/nvcf-byoc/cluster-validator", role) + assert.Contains(t, row.Message, "--skip-cluster-validation", role) + } +} + +// completedRows returns the CheckCompleted events a sink received. +func completedRows(sink *captureSink) []progress.CheckCompleted { + var out []progress.CheckCompleted + for _, e := range sink.events { + if cc, ok := e.(progress.CheckCompleted); ok { + out = append(out, cc) + } + } + return out +} + +// Every passing row is info on the wire, whatever severity the check set +// before it knew the outcome: a consumer that triages on severity error must +// not flag a healthy run. +func TestRunPreflight_PassingRowsAreInfo(t *testing.T) { + sink := &captureSink{} + cfg := PreflightConfig{Tools: []BinarySpec{passingToolSpec("kubectl", "1.30.0"), passingToolSpec("helm", "3.15.0")}} + rc := RoleConfig{ + InotifyProber: func(context.Context, string) ([]NodeInotifyLimits, error) { + return []NodeInotifyLimits{{NodeName: "n1", MaxUserInstances: 8192, MaxUserWatches: 524288}}, nil + }, + } + RunPreflightForRole(context.Background(), cfg, RoleComputePlane, rc, sink) + rows := completedRows(sink) + require.Len(t, rows, 3) + for _, row := range rows { + assert.True(t, row.Passed, row.ID) + assert.Equal(t, SeverityInfo, row.Severity, row.ID) + } +} + +// A probe that checked no node is not a pass. +func TestNodeInotifyCheck_NothingProbedIsNotAPass(t *testing.T) { + spec := nodeInotifyCheck(func(context.Context, string) ([]NodeInotifyLimits, error) { return nil, nil }, "") + r := spec.Run(context.Background()) + assert.False(t, r.Passed) + assert.Equal(t, SeverityWarning, r.Severity) + assert.Contains(t, r.Message, "no schedulable nodes were probed") +} + +// A SIS request the spent budget stopped is cut short, not graded as SIS being +// unreachable. +func TestRunPreflight_SISStoppedByTheBudgetIsCutShort(t *testing.T) { + release := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select { + case <-r.Context().Done(): + case <-release: + } + })) + defer srv.Close() + defer close(release) + + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + res := RunPreflightForRole(ctx, PreflightConfig{}, RoleComputePlane, RoleConfig{SISURL: srv.URL}, &captureSink{}) + sis := findResult(res, "sis-reachability") + require.NotNil(t, sis) + assert.True(t, sis.CutShort) + assert.True(t, strings.HasPrefix(sis.Message, "cut short: "), sis.Message) +} + +// The checks before a validator run inside the probe share, so a probe that +// hangs cannot eat into the validator's time: the validator still runs on the +// run's budget, and the hung probe is cut short. +func TestRunPreflight_ProbeShareBoundsTheChecksBeforeTheValidator(t *testing.T) { + prevShare, prevCleanup := checkProbeShare, probePodCleanupTimeout + checkProbeShare, probePodCleanupTimeout = 400*time.Millisecond, 100*time.Millisecond + t.Cleanup(func() { checkProbeShare, probePodCleanupTimeout = prevShare, prevCleanup }) + + var validatorCtxErr error + ran := false + rc := RoleConfig{ + StaleNamespaceProber: func(ctx context.Context, _ string, _ []string) ([]StaleNamespace, error) { + <-ctx.Done() + return nil, ctx.Err() + }, + ClusterValidatorImage: "nvcr.io/nvidia/validator:1", + ClusterValidator: func(ctx context.Context, _ ClusterValidatorParams) ClusterValidatorResult { + ran, validatorCtxErr = true, ctx.Err() + return ClusterValidatorResult{Passed: true, + Logs: validatorRoleMarker + "control-plane\n" + validatorVerdictReady + "\n"} + }, + } + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + start := time.Now() + res := RunPreflightForRole(ctx, PreflightConfig{}, RoleControlPlane, rc, &captureSink{}) + assert.Less(t, time.Since(start), checkProbeShare, "the probes end inside the share") + + stale := findResult(res, "stale-namespaces") + require.NotNil(t, stale) + assert.True(t, stale.CutShort) + require.True(t, ran, "the validator still runs") + assert.NoError(t, validatorCtxErr, "on the run's budget, not the spent share") + validator := findResult(res, "cluster-validator") + require.NotNil(t, validator) + assert.True(t, validator.Passed) +} + +// The checks that run on this machine have a share of their own: registry +// probes that hang cannot spend the time set aside for the cluster checks +// after them, and a run of local checks alone is still bounded. +func TestRunPreflight_LocalChecksDoNotSpendTheClusterShare(t *testing.T) { + prevLocal, prevShare, prevCleanup := localCheckShare, checkProbeShare, probePodCleanupTimeout + localCheckShare = 300 * time.Millisecond + checkProbeShare, probePodCleanupTimeout = 400*time.Millisecond, 100*time.Millisecond + t.Cleanup(func() { localCheckShare, checkProbeShare, probePodCleanupTimeout = prevLocal, prevShare, prevCleanup }) + + hang := func(ctx context.Context, _, _ string, _, _ bool) error { + <-ctx.Done() + return ctx.Err() + } + cfg := PreflightConfig{ + Registries: []RegistryEntry{{Registry: "a.example"}, {Registry: "b.example"}}, + RegistryChecker: hang, + } + var staleCtxErr error + rc := RoleConfig{ + StaleNamespaceProber: func(ctx context.Context, _ string, _ []string) ([]StaleNamespace, error) { + staleCtxErr = ctx.Err() + return nil, nil + }, + } + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + + res := RunPreflightForRole(ctx, cfg, RoleControlPlane, rc, &captureSink{}) + stale := findResult(res, "stale-namespaces") + require.NotNil(t, stale) + assert.NoError(t, staleCtxErr, "the cluster check starts with its share intact") + assert.False(t, stale.CutShort, stale.Message) + assert.True(t, stale.Passed, stale.Message) + + start := time.Now() + local := RunPreflightForRole(ctx, cfg, RoleLocalOnly, RoleConfig{}, &captureSink{}) + assert.Less(t, time.Since(start), localCheckShare+200*time.Millisecond, "a local-only run is bounded") + require.NotEmpty(t, local) + for _, r := range local { + assert.True(t, r.CutShort, r.ID+": "+r.Message) + } +} + +// The inotify probe's own bound, and the cleanup of its pods after it, fit in +// the probe share, so a slow cluster is reported as nodes not probed rather +// than cut short. +func TestCheckProbeShare_CoversTheInotifyProbe(t *testing.T) { + assert.Less(t, inotifyProbeBudget, checkProbeShare-probePodCleanupTimeout) +} + +// A missing tool fails pre-install, where the install runs it, and only warns +// when an installed stack is checked, which runs no local tool. +func TestRunPreflight_ToolsAdvisoryWarns(t *testing.T) { + missing := BinarySpec{ + Name: "helmfile", MinVer: semver.MustParse("1.0.0"), + LookPath: func(string) (string, error) { return "", errors.New("not found") }, + } + for advisory, want := range map[bool]Severity{false: SeverityError, true: SeverityWarning} { + res := RunPreflightForRole(context.Background(), + PreflightConfig{Tools: []BinarySpec{missing}, ToolsAdvisory: advisory}, RoleLocalOnly, RoleConfig{}, &captureSink{}) + require.Len(t, res, 1) + assert.False(t, res[0].Passed) + assert.Equal(t, want, res[0].Severity, "advisory=%v", advisory) + } +} + +// A check the flags or configuration leave out is still a row: an opt-out +// passes at info, a missing input warns. Neither is cut short by a spent +// budget, since neither has work to do, and its category is reported even +// when the role runs no other check in it. +func TestRunPreflight_SkippedChecksAreRows(t *testing.T) { + rc := RoleConfig{Skipped: []SkippedCheck{ + {Category: CategoryControlPlane, ID: "cluster-validator", Message: "skipped (--skip-cluster-validation)"}, + {Category: CategoryComputePlane, ID: "cluster-validator", Message: "not set", Warn: true}, + }} + expired, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) + defer cancel() + for _, ctx := range []context.Context{context.Background(), expired} { + sink := &captureSink{} + res := RunPreflightForRole(ctx, PreflightConfig{LocalOnly: true}, RoleLocalOnly, rc, sink) + require.Len(t, res, 2) + assert.Equal(t, CategoryControlPlane, res[0].Category) + assert.True(t, res[0].Passed) + assert.Equal(t, SeverityInfo, res[0].Severity) + assert.Equal(t, CategoryComputePlane, res[1].Category) + assert.False(t, res[1].Passed) + assert.Equal(t, SeverityWarning, res[1].Severity) + for _, r := range res { + assert.False(t, r.CutShort) + } + assert.Len(t, completedRows(sink), 2) + } +} + +// category_completed counts by the one rule: an error is a failure, a warning +// is not, and a pass is a pass. +func TestRunPreflight_CategoryCountsFollowTheOneRule(t *testing.T) { + sink := &captureSink{} + rc := RoleConfig{ + StaleNamespaceProber: func(context.Context, string, []string) ([]StaleNamespace, error) { return nil, nil }, + InotifyProber: func(context.Context, string) ([]NodeInotifyLimits, error) { + return []NodeInotifyLimits{{NodeName: "n1", MaxUserInstances: 128, MaxUserWatches: 8192}}, nil + }, + Skipped: []SkippedCheck{{Category: CategoryComputePlane, ID: "cluster-validator", Message: "not set", Warn: true}}, + } + RunPreflightForRole(context.Background(), PreflightConfig{}, RoleComputePlane, rc, sink) + var cats []progress.CategoryCompleted + for _, e := range sink.events { + if cc, ok := e.(progress.CategoryCompleted); ok { + cats = append(cats, cc) + } + } + require.Len(t, cats, 1) + assert.Equal(t, 1, cats[0].PassedCount) + assert.Equal(t, 1, cats[0].FailedCount) + assert.Equal(t, 1, cats[0].WarningCount) +} + +// A registry this CLI cannot probe, or one that allows anonymous access, is a +// skip: a passing row at info on the wire, never a warning or a failure. +func TestRunPreflight_RegistrySkipsPassAtInfo(t *testing.T) { + cfg := PreflightConfig{ + Registries: []RegistryEntry{ + {Registry: "123456789012.dkr.ecr.us-west-2.amazonaws.com", Critical: true}, + {Registry: "basic.example.com", Critical: true}, + {Registry: "anon.example.com", Critical: true}, + }, + RegistryChecker: func(_ context.Context, registry, _ string, _, _ bool) error { + switch registry { + case "anon.example.com": + return registryProbeOutcome{kind: probeAnonymous, detail: "reachable anonymously"} + default: + return registryProbeOutcome{kind: probeSkipped, detail: "this probe cannot speak to it"} + } + }, + } + sink := &captureSink{} + RunPreflightForRole(context.Background(), cfg, RoleLocalOnly, RoleConfig{}, sink) + rows := completedRows(sink) + require.Len(t, rows, 3) + for _, row := range rows { + assert.True(t, row.Passed, row.ID) + assert.Equal(t, SeverityInfo, row.Severity, row.ID) + } +} + +// A cluster that cannot be contacted at all fails its role: nothing about it +// was checked. A failure part way through the probe stays a warning. +func TestStaleNamespaceCheck_UnreachableClusterIsAnError(t *testing.T) { + unreachable := staleNamespaceCheck(func(context.Context, string, []string) ([]StaleNamespace, error) { + return nil, &ClusterUnreachableError{Context: "ctx-a", Err: errors.New("connection refused")} + }, "ctx-a", []string{"nvcf"}).Run(context.Background()) + assert.True(t, unreachable.IsBlockingFailure()) + assert.Contains(t, unreachable.Message, "cannot reach ctx-a") + + midProbe := staleNamespaceCheck(func(context.Context, string, []string) ([]StaleNamespace, error) { + return nil, errors.New("list Helm secrets in nvcf: stream error") + }, "ctx-a", []string{"nvcf"}).Run(context.Background()) + assert.False(t, midProbe.Passed) + assert.Equal(t, SeverityWarning, midProbe.Severity) +} + +// A stuck namespace found before the probe share ran out still blocks the +// run: it is not graded as cut short, which would exit 5. A warning found +// before the cutoff is cut short, still blocking since a namespace the scan +// did not reach could be stuck, and keeps the namespaces it found. +func TestStaleNamespaceCheck_StuckFindingSurvivesTheBudget(t *testing.T) { + pinCurrentKubeContext(t, "") + ctx, cancel := context.WithTimeout(context.Background(), time.Nanosecond) + defer cancel() + <-ctx.Done() + cutOff := errors.Join(errors.New("get namespace sis: timeout"), context.DeadlineExceeded) + probe := func(reason string) CheckResult { + spec := staleNamespaceCheck(func(context.Context, string, []string) ([]StaleNamespace, error) { + return []StaleNamespace{{Name: "cassandra-system", Reason: reason}}, cutOff + }, "", []string{"cassandra-system", "sis"}) + return normaliseResult(ctx, spec.Run(ctx), spec.worstSeverity()) + } + + stuck := probe(StaleStuckTerminating) + assert.False(t, stuck.CutShort) + assert.True(t, stuck.IsBlockingFailure()) + assert.Contains(t, stuck.Message, "cassandra-system (stuck Terminating") + assert.Contains(t, stuck.Message, "Additionally could not probe: get namespace sis: timeout") + + partial := probe(StaleNoHelmRelease) + assert.True(t, partial.CutShort, "a warning beside a spent budget is not the whole answer") + assert.True(t, partial.IsBlockingFailure()) + assert.True(t, strings.HasPrefix(partial.Message, cutShortMessage+"; found before then: "), partial.Message) + assert.Contains(t, partial.Message, "cassandra-system (no Helm release)") + + deleting := probe(StaleTerminating) + assert.True(t, deleting.CutShort) + assert.Equal(t, SeverityError, deleting.Severity) + assert.False(t, deleting.Transient, "only a warning is transient") +} + +// A check the budget stops keeps the most severe grade it could have given, +// and the row says it was cut short on the wire. A registry whose rejection +// only warns stays a warning, cut short or not run, so a slow one cannot fail +// the run; a critical one still blocks. A tool a post-install run only +// advises on warns too. +func TestRunPreflight_CutShortRowsKeepTheirWorstSeverity(t *testing.T) { + prevLocal := localCheckShare + localCheckShare = 200 * time.Millisecond + t.Cleanup(func() { localCheckShare = prevLocal }) + hang := func(ctx context.Context, _, _ string, _, _ bool) error { + <-ctx.Done() + return ctx.Err() + } + sink := &captureSink{} + res := RunPreflightForRole(context.Background(), PreflightConfig{ + Registries: []RegistryEntry{ + {Registry: "slow.example"}, {Registry: "nvcr.io", Critical: true}, {Registry: "other.example"}, + }, + RegistryChecker: hang, + }, RoleLocalOnly, RoleConfig{}, sink) + want := map[string]struct { + severity Severity + message string + }{ + "registry-cred-slow.example": {SeverityWarning, "cut short: "}, + "registry-cred-nvcr.io": {SeverityError, "not run: "}, + "registry-cred-other.example": {SeverityWarning, "not run: "}, + } + require.Len(t, res, len(want)) + for _, r := range res { + assert.True(t, r.CutShort, r.ID) + assert.Equal(t, want[r.ID].severity, r.Severity, r.ID) + assert.True(t, strings.HasPrefix(r.Message, want[r.ID].message), r.ID+": "+r.Message) + } + rows := completedRows(sink) + require.Len(t, rows, len(want)) + for _, row := range rows { + assert.True(t, row.CutShort, row.ID) + assert.Equal(t, want[row.ID].severity, row.Severity, row.ID) + } + + for advisory, severity := range map[bool]Severity{false: SeverityError, true: SeverityWarning} { + tool := passingToolSpec("helmfile", "1.0.0") + tool.Version = func(ctx context.Context, _ string) (*semver.Version, error) { + <-ctx.Done() + return nil, ctx.Err() + } + res := RunPreflightForRole(context.Background(), PreflightConfig{Tools: []BinarySpec{tool}, + ToolsAdvisory: advisory}, RoleLocalOnly, RoleConfig{}, &captureSink{}) + require.Len(t, res, 1) + assert.True(t, res[0].CutShort, "advisory=%v", advisory) + assert.Equal(t, severity, res[0].Severity, "advisory=%v", advisory) + } +} + +// After install a critical registry can still fail, on a refused docker +// login or a refused key the cluster holds too, so a cut-short or not-run row +// for it keeps error severity, while a non-critical one warns. +func TestRunPreflight_CutShortRegistryAfterInstallKeepsItsWorst(t *testing.T) { + prevLocal := localCheckShare + localCheckShare = 200 * time.Millisecond + t.Cleanup(func() { localCheckShare = prevLocal }) + hang := func(ctx context.Context, _, _ string, _, _ bool) error { + <-ctx.Done() + return ctx.Err() + } + res := RunPreflightForRole(context.Background(), PreflightConfig{ + Registries: []RegistryEntry{ + {Registry: "nvcr.io", Critical: true}, {Registry: "harbor.example.com"}, + }, + RegistryChecker: hang, + RegistryPostInstall: true, + }, RoleLocalOnly, RoleConfig{}, &captureSink{}) + require.Len(t, res, 2) + for _, r := range res { + assert.True(t, r.CutShort, r.ID) + } + assert.Equal(t, SeverityError, res[0].Severity, res[0].Message) + assert.Equal(t, SeverityWarning, res[1].Severity, res[1].Message) +} + +// Each term of the validator's run ceiling counts as often as one run can +// spend it. Bumping a term by one second grows the ceiling by its count. +func TestClusterValidatorRunCeiling_CountsEveryTerm(t *testing.T) { + for name, tc := range map[string]struct { + term *time.Duration + count time.Duration + }{ + "its own timeout": {&clusterValidatorTimeout, 1}, + "the deadline grace": {&validatorDeadlineGrace, 1}, + "the reads that grade it": {&clusterValidatorLogFetchTimeout, 4}, + "the deferred sweeps": {&validatorCleanupTimeout, 3}, + "the margin": {&validatorRunMargin, 1}, + } { + before := ClusterValidatorRunCeiling() + prev := *tc.term + *tc.term += time.Second + after := ClusterValidatorRunCeiling() + *tc.term = prev + assert.Equal(t, tc.count*time.Second, after-before, name) + } +} diff --git a/src/clis/nvcf-cli/internal/selfhosted/progress/BUILD.bazel b/src/clis/nvcf-cli/internal/selfhosted/progress/BUILD.bazel index 76a5b654e9..ee06cc96e1 100644 --- a/src/clis/nvcf-cli/internal/selfhosted/progress/BUILD.bazel +++ b/src/clis/nvcf-cli/internal/selfhosted/progress/BUILD.bazel @@ -33,6 +33,7 @@ go_library( importpath = "nvcf-cli/internal/selfhosted/progress", visibility = ["//src/clis/nvcf-cli:__subpackages__"], deps = [ + "//src/clis/nvcf-cli/internal/selfhosted/severity", "@com_github_charmbracelet_bubbles//progress", "@com_github_charmbracelet_bubbles//spinner", "@com_github_charmbracelet_bubbletea//:bubbletea", @@ -68,6 +69,7 @@ go_test( data = glob(["testdata/**"]), embed = [":progress"], deps = [ + "//src/clis/nvcf-cli/internal/selfhosted/severity", "@com_github_charmbracelet_bubbletea//:bubbletea", "@com_github_muesli_termenv//:termenv", "@com_github_stretchr_testify//assert", diff --git a/src/clis/nvcf-cli/internal/selfhosted/progress/event.go b/src/clis/nvcf-cli/internal/selfhosted/progress/event.go index a0bb6075da..521ef66df7 100644 --- a/src/clis/nvcf-cli/internal/selfhosted/progress/event.go +++ b/src/clis/nvcf-cli/internal/selfhosted/progress/event.go @@ -21,7 +21,11 @@ limitations under the License. // (bubbletea TTY, plain streaming, JSONL). package progress -import "time" +import ( + "time" + + "nvcf-cli/internal/selfhosted/severity" +) // Event is the sealed interface every progress event implements. The // `kind()` method returns a stable string used in --json mode and by the @@ -184,10 +188,15 @@ type Final struct { // Check-mode terminal fields (M+8.11, REQ-19): set by `nvcf self-hosted check` // to surface the verdict + tally. Zero values for other modes (omit on wire). - Verdict string // "ok" | "warnings" | "failed" | "error" - TotalChecks int - PassedCount int - FailedCount int + // FailedCount counts blocking failures only; WarningCount the other misses. + Verdict string // "ok" | "warnings" | "failed" | "timeout" + TotalChecks int + PassedCount int + FailedCount int + WarningCount int + // Cleanup lists the commands that remove what the checks left in the + // cluster, on every outcome, interrupt included. + Cleanup []string } func (Final) kind() eventKind { return "final" } @@ -269,16 +278,26 @@ type CheckStarted struct { func (CheckStarted) kind() eventKind { return "check_started" } -// CheckCompleted carries the result of a single check. Severity is one of -// "info" | "warning" | "error". HintURL points to remediation docs when set. +// CheckCompleted carries the result of a single check. Severity is graded by +// severity.Of. HintURL points to remediation docs when set. type CheckCompleted struct { Category string ID string Passed bool - Severity string + Severity severity.Severity Message string Detail string // optional: short version string or extra context HintURL string // optional: remediation-docs link + // Transient marks a warning expected to clear by itself, such as a + // rollout in progress; --wait keeps polling while one remains. + Transient bool + // CutShort marks a check the run's time budget stopped before it + // finished, or before it started. Its severity is the worst the check + // could have found. + CutShort bool + // Cleanup is the command that removes what the check left in the + // cluster, when it left anything. + Cleanup string } func (CheckCompleted) kind() eventKind { return "check_completed" } @@ -288,8 +307,11 @@ func (CheckCompleted) kind() eventKind { return "check_completed" } type CategoryCompleted struct { Category string PassedCount int - FailedCount int - DurationSec float64 + // FailedCount counts error-severity failures only, the ones that fail the + // run; WarningCount counts the non-passing rest. + FailedCount int + WarningCount int + DurationSec float64 } func (CategoryCompleted) kind() eventKind { return "category_completed" } diff --git a/src/clis/nvcf-cli/internal/selfhosted/progress/k8s_watcher_test.go b/src/clis/nvcf-cli/internal/selfhosted/progress/k8s_watcher_test.go index c8e22dd93f..03e2334eea 100644 --- a/src/clis/nvcf-cli/internal/selfhosted/progress/k8s_watcher_test.go +++ b/src/clis/nvcf-cli/internal/selfhosted/progress/k8s_watcher_test.go @@ -135,7 +135,7 @@ func TestWatchResources_EmitsCountsForEachType(t *testing.T) { }, 3*time.Second, 50*time.Millisecond, "did not see all 5 resource types") last := sink.lastByResource() - assert.Equal(t, 4, last["namespaces"].Num) // phaseNum baked in + assert.Equal(t, 4, last["namespaces"].Num) // phaseNum baked in assert.Equal(t, "apply-cp", last["namespaces"].Name) // phaseName baked in assert.Equal(t, "namespaces", last["namespaces"].Resource) assert.Equal(t, 2, last["namespaces"].Done) diff --git a/src/clis/nvcf-cli/internal/selfhosted/progress/log_line_writer.go b/src/clis/nvcf-cli/internal/selfhosted/progress/log_line_writer.go index 3f6ff8f481..1a2cba7262 100644 --- a/src/clis/nvcf-cli/internal/selfhosted/progress/log_line_writer.go +++ b/src/clis/nvcf-cli/internal/selfhosted/progress/log_line_writer.go @@ -41,12 +41,12 @@ import ( // EventSink is responsible for its own concurrency (JSONLRenderer's mu; // TTYRenderer's tea.Program.Send is goroutine-safe by design). type LogLineWriter struct { - mu sync.Mutex - buf bytes.Buffer - sink EventSink - stream string // "stdout" | "stderr" - source string // short producer tag, e.g. "helmfile-cp" - closed bool + mu sync.Mutex + buf bytes.Buffer + sink EventSink + stream string // "stdout" | "stderr" + source string // short producer tag, e.g. "helmfile-cp" + closed bool } // NewLogLineWriter constructs a LogLineWriter feeding into sink. stream and diff --git a/src/clis/nvcf-cli/internal/selfhosted/progress/render_jsonl.go b/src/clis/nvcf-cli/internal/selfhosted/progress/render_jsonl.go index 3f4940c1fd..58f3e23295 100644 --- a/src/clis/nvcf-cli/internal/selfhosted/progress/render_jsonl.go +++ b/src/clis/nvcf-cli/internal/selfhosted/progress/render_jsonl.go @@ -465,11 +465,12 @@ func (r *JSONLRenderer) toWire(e Event, ts string) any { if ev.Verdict != "" { out.Verdict = ev.Verdict out.TotalChecks = ev.TotalChecks - passed := ev.PassedCount - failed := ev.FailedCount + passed, failed, warned := ev.PassedCount, ev.FailedCount, ev.WarningCount out.PassedCount = &passed out.FailedCount = &failed + out.WarningCount = &warned } + out.Cleanup = ev.Cleanup return out case Snapshot: out := wireSnapshot{ @@ -535,24 +536,28 @@ func (r *JSONLRenderer) toWire(e Event, ts string) any { } case CheckCompleted: return wireCheckCompleted{ - Event: "check_completed", - TS: ts, - Category: ev.Category, - ID: ev.ID, - Passed: ev.Passed, - Severity: ev.Severity, - Message: ev.Message, - Detail: ev.Detail, - HintURL: ev.HintURL, + Event: "check_completed", + TS: ts, + Category: ev.Category, + ID: ev.ID, + Passed: ev.Passed, + Severity: string(ev.Severity), + Message: ev.Message, + Detail: ev.Detail, + Transient: ev.Transient, + CutShort: ev.CutShort, + HintURL: ev.HintURL, + Cleanup: ev.Cleanup, } case CategoryCompleted: return wireCategoryCompleted{ - Event: "category_completed", - TS: ts, - Category: ev.Category, - PassedCount: ev.PassedCount, - FailedCount: ev.FailedCount, - DurationSec: ev.DurationSec, + Event: "category_completed", + TS: ts, + Category: ev.Category, + PassedCount: ev.PassedCount, + FailedCount: ev.FailedCount, + WarningCount: ev.WarningCount, + DurationSec: ev.DurationSec, } } return nil @@ -650,7 +655,7 @@ type wireLastProgress struct { // - PlanOnly: omitempty — omit when false; include when true (plan-only short-circuit). // - DurationSec: NO omitempty — 0 is a valid value for very fast installs. // - DroppedProgressEvents: NO omitempty — consumers need the backpressure count. -// - Verdict/TotalChecks/PassedCount/FailedCount: omitempty — check-mode only (M+8.11). +// - Verdict/TotalChecks/PassedCount/FailedCount/WarningCount: omitempty, check-mode only (M+8.11). type wireFinal struct { Event string `json:"event"` TS string `json:"ts"` @@ -670,8 +675,11 @@ type wireFinal struct { // downstream tooling that asserts the field is always present on a // check-typed Final. Nil on non-check Final keeps install/up/down // goldens unchanged. - PassedCount *int `json:"passedCount,omitempty"` - FailedCount *int `json:"failedCount,omitempty"` + PassedCount *int `json:"passedCount,omitempty"` + FailedCount *int `json:"failedCount,omitempty"` + WarningCount *int `json:"warningCount,omitempty"` + // Cleanup lists the commands that remove what a check left in the cluster. + Cleanup []string `json:"cleanup,omitempty"` } // Wire structs for status + check event types (M+8.3). @@ -710,7 +718,7 @@ type wireClusterRow struct { Event string `json:"event"` TS string `json:"ts"` Name string `json:"name"` - Context string `json:"context,omitempty"` // M+9: omitted when unknown + Context string `json:"context,omitempty"` // M+9: omitted when unknown GPU string `json:"gpu"` GPUCount int `json:"gpuCount"` ActiveDeployments int `json:"activeDeployments"` @@ -747,15 +755,22 @@ type wireCheckCompleted struct { Message string `json:"message,omitempty"` Detail string `json:"detail,omitempty"` HintURL string `json:"hintURL,omitempty"` + // Transient is set on a warning --wait keeps polling on. + Transient bool `json:"transient,omitempty"` + // CutShort is set on a check the time budget stopped before it finished. + CutShort bool `json:"cutShort,omitempty"` + // Cleanup is the command that removes what the check left in the cluster. + Cleanup string `json:"cleanup,omitempty"` } type wireCategoryCompleted struct { - Event string `json:"event"` - TS string `json:"ts"` - Category string `json:"category"` - PassedCount int `json:"passedCount"` - FailedCount int `json:"failedCount"` - DurationSec float64 `json:"durationSec"` + Event string `json:"event"` + TS string `json:"ts"` + Category string `json:"category"` + PassedCount int `json:"passedCount"` + FailedCount int `json:"failedCount"` + WarningCount int `json:"warningCount"` + DurationSec float64 `json:"durationSec"` } // Wire structs for composed status snapshot (Plan Deviation #19 / §6.5.4). @@ -766,12 +781,12 @@ type wireCategoryCompleted struct { // mode. It carries the Snapshot identity plus all ComponentHealth, ClusterRow, // and RecentEvent sub-objects inlined. type wireSnapshotComposed struct { - Event string `json:"event"` - TS string `json:"ts"` - Cluster string `json:"cluster"` - Verdict string `json:"verdict"` - ReconcileAgeSec int `json:"reconcileAgeSec"` - Identity wireSnapshotIdentity `json:"identity"` + Event string `json:"event"` + TS string `json:"ts"` + Cluster string `json:"cluster"` + Verdict string `json:"verdict"` + ReconcileAgeSec int `json:"reconcileAgeSec"` + Identity wireSnapshotIdentity `json:"identity"` Components []wireComponentInline `json:"components,omitempty"` ComputeClusters []wireClusterInline `json:"computeClusters,omitempty"` Events []wireEventInline `json:"events,omitempty"` @@ -781,7 +796,7 @@ type wireSnapshotComposed struct { type wireComponentInline struct { Name string `json:"name"` Cluster string `json:"cluster,omitempty"` - Role string `json:"role,omitempty"` // M+9: omitted in single-cluster mode + Role string `json:"role,omitempty"` // M+9: omitted in single-cluster mode Ready int `json:"ready"` Total int `json:"total"` UptimeSec int `json:"uptimeSec"` @@ -792,7 +807,7 @@ type wireComponentInline struct { // wireClusterInline is a ClusterRow without event/ts fields. type wireClusterInline struct { Name string `json:"name"` - Context string `json:"context,omitempty"` // M+9: omitted when unknown + Context string `json:"context,omitempty"` // M+9: omitted when unknown GPU string `json:"gpu"` GPUCount int `json:"gpuCount"` ActiveDeployments int `json:"activeDeployments"` diff --git a/src/clis/nvcf-cli/internal/selfhosted/progress/render_jsonl_test.go b/src/clis/nvcf-cli/internal/selfhosted/progress/render_jsonl_test.go index 3ceb92eb20..06ba02b504 100644 --- a/src/clis/nvcf-cli/internal/selfhosted/progress/render_jsonl_test.go +++ b/src/clis/nvcf-cli/internal/selfhosted/progress/render_jsonl_test.go @@ -20,10 +20,12 @@ package progress import ( "bytes" "context" + "encoding/json" "strings" "testing" "time" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -82,7 +84,7 @@ func TestRenderJSONL_PhaseFailed(t *testing.T) { ErrCategory: "helm_apply", ErrMessage: "helm install api-keys: timed out", RetryClass: "backoff", RetryAfterSec: 60, Remediation: []string{"kubectl describe pod -n cassandra-system cassandra-0", "Re-run with --debug"}, - Raw: RawFailure{Subprocess: "helmfile", ExitCode: 1, StderrTail: "Error: timeout reached", KubernetesReason: "FailedScheduling"}, + Raw: RawFailure{Subprocess: "helmfile", ExitCode: 1, StderrTail: "Error: timeout reached", KubernetesReason: "FailedScheduling"}, }, } got := runEmit(t, clock, events) @@ -286,7 +288,7 @@ func TestRenderJSONL_CheckStream(t *testing.T) { CheckStarted{Category: "pre-kubernetes-setup", ID: "gateway-api"}, CheckCompleted{Category: "pre-kubernetes-setup", ID: "gateway-api", Passed: false, Severity: "error", Message: "Gateway API CRDs not installed", HintURL: "https://docs.nvidia.com/nvcf/self-hosted/gateway-api"}, CategoryCompleted{Category: "pre-kubernetes-setup", PassedCount: 13, FailedCount: 1, DurationSec: 2.4}, - Final{Success: false, Verdict: "failed", TotalChecks: 14, PassedCount: 13, FailedCount: 1}, + Final{Success: false, Verdict: "failed", TotalChecks: 15, PassedCount: 13, FailedCount: 1, WarningCount: 1}, } got := runEmit(t, clock, events) @@ -461,3 +463,36 @@ func TestRenderJSONL_Uninstall(t *testing.T) { got := runEmitWithTotalPhases(t, clock, events, 3) assertGolden(t, "testdata/jsonl_uninstall.golden", got) } + +// A warning --wait polls on is marked transient on the wire, and a check the +// time budget stopped is marked cutShort, so a consumer of the stream can tell +// either from a finding without parsing the message. Each field is left out +// otherwise. A run that timed out ends with success false. +func TestRenderJSONL_CheckTransientAndTimeout(t *testing.T) { + clock := &fakeClock{times: []time.Time{ts("2026-04-29T03:45:01Z")}} + got := runEmit(t, clock, []Event{ + CheckCompleted{Category: "control-plane-cluster", ID: "cluster-validator", Severity: "warning", + Message: "rollout in progress", Transient: true}, + CheckCompleted{Category: "control-plane-cluster", ID: "stale-namespaces", Severity: "warning", + Message: "1 stale namespace"}, + CheckCompleted{Category: "registry-credentials", ID: "registry-cred-a.example", Severity: "warning", + Message: "cut short: the check's time budget ran out before it finished", CutShort: true}, + Final{Success: false, Verdict: "timeout", TotalChecks: 3, PassedCount: 0, FailedCount: 0}, + }) + lines := strings.Split(strings.TrimSpace(got), "\n") + var events []map[string]any + for _, l := range lines { + var m map[string]any + require.NoError(t, json.Unmarshal([]byte(l), &m), l) + if m["event"] != "schemaVersion" { + events = append(events, m) + } + } + require.Len(t, events, 4) + assert.Equal(t, true, events[0]["transient"]) + assert.NotContains(t, events[1], "transient") + assert.NotContains(t, events[1], "cutShort") + assert.Equal(t, true, events[2]["cutShort"]) + assert.Equal(t, false, events[3]["success"]) + assert.Equal(t, "timeout", events[3]["verdict"]) +} diff --git a/src/clis/nvcf-cli/internal/selfhosted/progress/render_plain.go b/src/clis/nvcf-cli/internal/selfhosted/progress/render_plain.go index 1d3046f07c..d82ffb3225 100644 --- a/src/clis/nvcf-cli/internal/selfhosted/progress/render_plain.go +++ b/src/clis/nvcf-cli/internal/selfhosted/progress/render_plain.go @@ -156,8 +156,8 @@ func (r *PlainRenderer) Emit(_ context.Context, e Event) error { ts, ev.ClusterID, ev.Duration, ev.DroppedProgressEvents) } if ev.Verdict != "" { - return r.writef("%s final verdict=%s totalChecks=%d passed=%d failed=%d\n", - ts, ev.Verdict, ev.TotalChecks, ev.PassedCount, ev.FailedCount) + return r.writef("%s final verdict=%s totalChecks=%d passed=%d failed=%d warnings=%d\n", + ts, ev.Verdict, ev.TotalChecks, ev.PassedCount, ev.FailedCount, ev.WarningCount) } return r.writef("%s final: success=%v cluster=%s backend=%s duration=%s dropped=%d\n", ts, ev.Success, ev.ClusterID, ev.NVCFBackendHealth, ev.Duration, ev.DroppedProgressEvents) @@ -216,14 +216,22 @@ func (r *PlainRenderer) Emit(_ context.Context, e Event) error { case CheckStarted: return r.writef("%s [pre] check_started %s/%s\n", ts, ev.Category, ev.ID) case CheckCompleted: - // Build category/ID column, pad to 43 chars per §6.6.2 mock. + // Build the category/ID column, padded to 43 chars as in the 6.6.2 + // mock. The space after it keeps a longer ID apart from passed=. catID := fmt.Sprintf("%s/%s", ev.Category, ev.ID) padded := fmt.Sprintf("%-43s", catID) - if err := r.writef("%s [pre] check_completed %spassed=%v", ts, padded, ev.Passed); err != nil { + if err := r.writef("%s [pre] check_completed %s passed=%v", ts, padded, ev.Passed); err != nil { return err } if !ev.Passed { - if err := r.writef(` severity=%s message="%s"`, ev.Severity, ev.Message); err != nil { + if err := r.writef(` severity=%s`, ev.Severity); err != nil { + return err + } + } + // Every row's message, so a skipped or unverified pass reads apart + // from a verified one. + if ev.Message != "" { + if err := r.writef(` message="%s"`, ev.Message); err != nil { return err } } @@ -239,8 +247,8 @@ func (r *PlainRenderer) Emit(_ context.Context, e Event) error { } return r.writef("\n") case CategoryCompleted: - return r.writef("%s [pre] category_completed %-25spassed=%d failed=%d duration=%gs\n", - ts, ev.Category, ev.PassedCount, ev.FailedCount, ev.DurationSec) + return r.writef("%s [pre] category_completed %-25spassed=%d failed=%d warnings=%d duration=%gs\n", + ts, ev.Category, ev.PassedCount, ev.FailedCount, ev.WarningCount, ev.DurationSec) case DrainProgress: return r.writef("%s [%02d/%d] drain-active: %s → %s\n", ts, ev.Num, r.effectiveTotalPhases(), ev.Deployment, ev.State) diff --git a/src/clis/nvcf-cli/internal/selfhosted/progress/render_plain_test.go b/src/clis/nvcf-cli/internal/selfhosted/progress/render_plain_test.go index 8a6eee553c..9345f0519c 100644 --- a/src/clis/nvcf-cli/internal/selfhosted/progress/render_plain_test.go +++ b/src/clis/nvcf-cli/internal/selfhosted/progress/render_plain_test.go @@ -23,6 +23,7 @@ import ( "testing" "time" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -294,7 +295,7 @@ func TestRenderPlain_CheckStream(t *testing.T) { CheckStarted{Category: "pre-kubernetes-setup", ID: "gateway-api"}, CheckCompleted{Category: "pre-kubernetes-setup", ID: "gateway-api", Passed: false, Severity: "error", Message: "Gateway API CRDs not installed", HintURL: "https://docs.nvidia.com/nvcf/self-hosted/gateway-api"}, CategoryCompleted{Category: "pre-kubernetes-setup", PassedCount: 13, FailedCount: 1, DurationSec: 2.4}, - Final{Success: false, Verdict: "failed", TotalChecks: 14, PassedCount: 13, FailedCount: 1}, + Final{Success: false, Verdict: "failed", TotalChecks: 15, PassedCount: 13, FailedCount: 1, WarningCount: 1}, } for _, ev := range events { @@ -448,3 +449,18 @@ func TestRenderPlain_Uninstall(t *testing.T) { require.NoError(t, r.Close()) assertGolden(t, "testdata/plain_uninstall.golden", buf.String()) } + +// Every row's message is printed, so a skipped or unverified pass reads apart +// from a verified one, and an ID longer than the padded column stays apart +// from passed=. +func TestRenderPlain_CheckRowsKeepTheirMessage(t *testing.T) { + var buf bytes.Buffer + r := newPlainRendererForTest(&buf, (&fakeClock{times: []time.Time{ts("2026-04-29T03:45:01Z")}}).Now) + require.NoError(t, r.Emit(context.Background(), CheckCompleted{ + Category: "registry-credentials", ID: "registry-cred-123456789012.dkr.ecr.us-west-2.amazonaws.com", + Passed: true, Severity: "info", Message: "skipped (ECR uses SigV4)", + })) + assert.Contains(t, buf.String(), + `registry-credentials/registry-cred-123456789012.dkr.ecr.us-west-2.amazonaws.com passed=true `+ + `message="skipped (ECR uses SigV4)"`) +} diff --git a/src/clis/nvcf-cli/internal/selfhosted/progress/render_tty.go b/src/clis/nvcf-cli/internal/selfhosted/progress/render_tty.go index 16b10cb677..b7fb0729cf 100644 --- a/src/clis/nvcf-cli/internal/selfhosted/progress/render_tty.go +++ b/src/clis/nvcf-cli/internal/selfhosted/progress/render_tty.go @@ -31,6 +31,8 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/muesli/termenv" + + "nvcf-cli/internal/selfhosted/severity" ) // RenderMode discriminates the layout the bubbletea Model produces. @@ -192,6 +194,14 @@ type ModelOpts struct { // Leave both empty (the default) for single-cluster mode. ControlPlaneContext string ComputePlaneContext string + + // OnQuit runs when the operator presses a quit key. Bubbletea reads the + // terminal in raw mode, so Ctrl-C arrives as a key press, not a signal: + // without this, quitting closed the dashboard while the command kept + // running behind it. It is called synchronously inside Update, so it must + // not block: cancel a context or signal a channel, and leave the cleanup + // to the command. + OnQuit func() } // checkCategoryState holds the accumulated state for one pre-flight check @@ -210,7 +220,7 @@ type checkRow struct { started bool finished bool passed bool - severity string + severity severity.Severity message string startMsg string // human label from CheckStarted.Message, shown while in-flight detail string @@ -229,6 +239,7 @@ type Model struct { controlCtx string // M+9: control-plane kubeconfig context (empty → single-cluster) computeCtx string // M+9: compute-plane kubeconfig context (empty → single-cluster) nowFunc func() time.Time + onQuit func() // dynamic state (mutated by Update) started time.Time @@ -279,12 +290,16 @@ type Model struct { recentEvents []RecentEvent // check mode (mode == ModeCheck) - checkCategories []checkCategoryState - checkCategoryIdx map[string]int - totalChecks int // hint from ModelOpts; 0 means derive from accumulated rows - checkFinalTotal int - checkFinalPassed int - checkFinalFailed int + checkCategories []checkCategoryState + checkCategoryIdx map[string]int + totalChecks int // hint from ModelOpts; 0 means derive from accumulated rows + checkFinalTotal int + checkFinalPassed int + checkFinalFailed int + checkFinalWarned int + checkFinalVerdict string + checkFinalSuccess bool + checkCancelled bool // final event came from SIGINT/SIGTERM, not a verdict // log tail (LogLine ring buffer; rendered as a "Recent" panel during // long phases like apply-cp). Capacity is fixed; new lines push older @@ -353,6 +368,7 @@ func NewModel(opts ModelOpts) Model { asciiOnly: opts.AsciiOnly, mode: opts.Mode, totalChecks: opts.TotalChecks, + onQuit: opts.OnQuit, } switch opts.Mode { @@ -436,6 +452,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: switch msg.String() { case "q", "ctrl+c", "esc": + if m.onQuit != nil { + m.onQuit() + } return m, tea.Quit } return m, nil @@ -690,16 +709,7 @@ func (m Model) applyStatusEvent(e Event) (tea.Model, tea.Cmd) { func (m Model) applyCheckEvent(e Event) (tea.Model, tea.Cmd) { switch ev := e.(type) { case CheckStarted: - i, ok := m.checkCategoryIdx[ev.Category] - if !ok { - i = len(m.checkCategories) - m.checkCategories = append(m.checkCategories, checkCategoryState{ - name: ev.Category, - keys: map[string]int{}, - }) - m.checkCategoryIdx[ev.Category] = i - } - cat := &m.checkCategories[i] + cat := m.checkCategory(ev.Category) if _, exists := cat.keys[ev.ID]; !exists { cat.keys[ev.ID] = len(cat.checks) cat.checks = append(cat.checks, checkRow{id: ev.ID, started: true, startMsg: ev.Message}) @@ -707,11 +717,9 @@ func (m Model) applyCheckEvent(e Event) (tea.Model, tea.Cmd) { return m, nil case CheckCompleted: - ci, ok := m.checkCategoryIdx[ev.Category] - if !ok { - return m, nil - } - cat := &m.checkCategories[ci] + // A row the budget stopped before it started has no CheckStarted, and + // may be the first of its category: it still needs a place. + cat := m.checkCategory(ev.Category) ri, exists := cat.keys[ev.ID] if !exists { // CheckStarted may have been suppressed; insert a row in finished state. @@ -730,11 +738,7 @@ func (m Model) applyCheckEvent(e Event) (tea.Model, tea.Cmd) { return m, nil case CategoryCompleted: - ci, ok := m.checkCategoryIdx[ev.Category] - if !ok { - return m, nil - } - cat := &m.checkCategories[ci] + cat := m.checkCategory(ev.Category) cat.duration = time.Duration(ev.DurationSec * float64(time.Second)) cat.final = true return m, nil @@ -744,11 +748,27 @@ func (m Model) applyCheckEvent(e Event) (tea.Model, tea.Cmd) { m.checkFinalTotal = ev.TotalChecks m.checkFinalPassed = ev.PassedCount m.checkFinalFailed = ev.FailedCount + m.checkFinalWarned = ev.WarningCount + m.checkFinalVerdict = ev.Verdict + m.checkFinalSuccess = ev.Success + m.checkCancelled = ev.Cancelled return m, tea.Quit } return m, nil } +// checkCategory returns the named category, adding it in arrival order when +// it is new. The pointer is valid until the next category is added. +func (m *Model) checkCategory(name string) *checkCategoryState { + i, ok := m.checkCategoryIdx[name] + if !ok { + i = len(m.checkCategories) + m.checkCategories = append(m.checkCategories, checkCategoryState{name: name, keys: map[string]int{}}) + m.checkCategoryIdx[name] = i + } + return &m.checkCategories[i] +} + // percentOf returns done/total clamped to [0, 1]. A zero Total renders an // empty bar rather than panicking on divide-by-zero. func percentOf(done, total int) float64 { @@ -1763,18 +1783,23 @@ func humanCategoryName(raw string) string { // checkGlyph returns the bracketed glyph for a check row. Matches install-mode // convention: bracket + unicode, unchanged in ASCII-only mode (AsciiOnly only -// strips ANSI color codes, not unicode characters). +// strips ANSI color codes, not unicode characters). A warning, tallied and +// exited on as one, gets its own mark. func checkGlyph(row checkRow) string { switch { - case row.finished && row.passed: - return "[✓]" - case row.finished && !row.passed: - return "[✘]" - case row.started && !row.finished: - return "[▶]" - default: + case !row.finished && row.started: + return "[\u25b6]" + case !row.finished: return "[ ]" } + switch severity.Of(row.passed, row.severity) { + case severity.Pass: + return "[\u2713]" + case severity.Warn: + return "[!]" + default: + return "[\u2718]" + } } // viewCheck renders the ModeCheck dashboard per §6.6.1. @@ -1819,10 +1844,11 @@ func (m Model) viewCheck(now time.Time) string { } // ── status line ─────────────────────────────────────────────────────────── - passed, failed, total := m.checkTally() + passed, failed, warned, total := m.checkTally() if m.finished && m.checkFinalTotal > 0 { passed = m.checkFinalPassed failed = m.checkFinalFailed + warned = m.checkFinalWarned total = m.checkFinalTotal } if total < m.totalChecks { @@ -1842,17 +1868,27 @@ func (m Model) viewCheck(now time.Time) string { } } - allFinished := !anyInFlight && m.finished + tally := fmt.Sprintf("%d/%d passed, %d failed", passed, total, failed) + if warned > 0 { + tally += fmt.Sprintf(", %d warning(s)", warned) + } + // Once finished, the final event's verdict decides, as it does the exit + // code and the JSON and plain output. var statusLine string switch { - case anyInFlight || (!m.finished && len(m.checkCategories) > 0 && !allFinished): - statusLine = fmt.Sprintf("Status: in progress (%d/%d passed, 0 failed)", passed, total) - case m.finished && failed > 0: - statusLine = fmt.Sprintf("Status: ✘ failed (%d/%d passed, %d failed)", passed, total, failed) - case m.finished && failed == 0: - statusLine = fmt.Sprintf("Status: ✓ ok (%d/%d passed, 0 failed)", passed, total) + case m.finished && m.checkCancelled: + // Checks cut short by the interrupt are neither passed nor failed. + statusLine = fmt.Sprintf("Status: cancelled (%s)", tally) + case anyInFlight || !m.finished: + statusLine = fmt.Sprintf("Status: in progress (%d/%d passed, %d failed)", passed, total, failed) + case m.checkFinalVerdict == "timeout": + statusLine = fmt.Sprintf("Status: \u2718 timed out (%s)", tally) + case !m.checkFinalSuccess || failed > 0: + statusLine = fmt.Sprintf("Status: \u2718 failed (%s)", tally) + case warned > 0: + statusLine = fmt.Sprintf("Status: \u2713 ok with warnings (%s)", tally) default: - statusLine = fmt.Sprintf("Status: in progress (%d/%d passed, 0 failed)", passed, total) + statusLine = fmt.Sprintf("Status: \u2713 ok (%s)", tally) } b.WriteString("\n") @@ -1862,21 +1898,26 @@ func (m Model) viewCheck(now time.Time) string { return b.String() } -// checkTally returns (passed, failed, total) counts from all accumulated check rows. -func (m Model) checkTally() (passed, failed, total int) { +// checkTally counts finished rows by the rule the final event uses, +// severity.Of, and every row toward the total. +func (m Model) checkTally() (passed, failed, warned, total int) { for _, cat := range m.checkCategories { for _, row := range cat.checks { total++ - if row.finished { - if row.passed { - passed++ - } else { - failed++ - } + if !row.finished { + continue + } + switch severity.Of(row.passed, row.severity) { + case severity.Pass: + passed++ + case severity.Fail: + failed++ + default: + warned++ } } } - return passed, failed, total + return passed, failed, warned, total } // ─── style helpers ──────────────────────────────────────────────────────────── @@ -1998,11 +2039,13 @@ func resourceLabelFor(resource string) string { // invoked early so callers can't deadlock on tea.Program.Send (which blocks // indefinitely against an unstarted program). Start is idempotent. Close is // safe to call before Start (no-op) so deferred cleanup on construction-error -// paths doesn't deadlock either. +// paths doesn't deadlock either, and safe to call again. type TTYRenderer struct { program *tea.Program started atomic.Bool - runErr error + // done is closed once Run has returned and runErr is set. + done chan struct{} + runErr error } // NewTTYRenderer constructs a TTYRenderer drawing to stderr. The same stderr @@ -2021,9 +2064,11 @@ func NewTTYRenderer(stderr io.Writer, opts ModelOpts) *TTYRenderer { // dashboard vertically (iter #10 from dev-VM E2E). tea.WithAltScreen(), ) - return &TTYRenderer{ - program: prog, - } + return newTTYRendererFor(prog) +} + +func newTTYRendererFor(prog *tea.Program) *TTYRenderer { + return &TTYRenderer{program: prog, done: make(chan struct{})} } // Start launches the bubbletea Run loop in a goroutine. Idempotent — calling @@ -2036,6 +2081,7 @@ func (r *TTYRenderer) Start() { go func() { _, err := r.program.Run() r.runErr = err + close(r.done) }() } @@ -2053,13 +2099,18 @@ func (r *TTYRenderer) Emit(_ context.Context, e Event) error { // Close requests Program shutdown and waits for the Run loop to return. // Safe to call before Start (no-op) so deferred Close() on error paths -// doesn't deadlock. After Wait returns, runErr is safe to read — bubbletea -// guarantees happens-before from Run completion to Wait return. +// doesn't deadlock, and safe to call again or concurrently. It waits on done +// rather than tea.Program.Wait, which returns only once, signals before Run +// has returned, and never returns when Run fails to start. func (r *TTYRenderer) Close() error { if !r.started.Load() { return nil } r.program.Quit() - r.program.Wait() + <-r.done return r.runErr } + +// OwnsTerminal reports that the dashboard draws on the alternate screen: +// anything else written to the terminal before Close returns is lost. +func (r *TTYRenderer) OwnsTerminal() bool { return true } diff --git a/src/clis/nvcf-cli/internal/selfhosted/progress/render_tty_test.go b/src/clis/nvcf-cli/internal/selfhosted/progress/render_tty_test.go index df09f04d18..07fa34716b 100644 --- a/src/clis/nvcf-cli/internal/selfhosted/progress/render_tty_test.go +++ b/src/clis/nvcf-cli/internal/selfhosted/progress/render_tty_test.go @@ -31,6 +31,8 @@ import ( "github.com/muesli/termenv" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "nvcf-cli/internal/selfhosted/severity" ) // ansiRE matches CSI/OSC ANSI escape sequences so tests can verify the @@ -568,6 +570,106 @@ func TestCheckOneShotRenderer_FlushesFailedSummaryOnFinal(t *testing.T) { assert.Contains(t, out, "Status: ✘ failed (1/2 passed, 1 failed)") } +// An interrupted check ends on a cancelled final event, which must not read +// as a pass. +func TestCheckOneShotRenderer_CancelledIsNotOK(t *testing.T) { + t.Setenv("NO_COLOR", "1") + var buf bytes.Buffer + r := NewCheckOneShotRenderer(&buf, ModelOpts{Mode: ModeCheck, Output: &buf}) + ctx := context.Background() + require.NoError(t, r.Emit(ctx, CheckStarted{Category: "local-host-tools", ID: "kubectl-on-path"})) + require.NoError(t, r.Emit(ctx, CheckCompleted{ + Category: "local-host-tools", ID: "kubectl-on-path", Passed: true, Severity: "info", Message: "kubectl on PATH", + })) + require.NoError(t, r.Emit(ctx, Final{Cancelled: true})) + require.NoError(t, r.Close()) + + out := buf.String() + assert.Contains(t, out, "Status: cancelled") + assert.NotContains(t, out, "Status: \u2713 ok") +} + +// The dashboard's glyph and tally grade a row as the final event does. A miss +// at a severity other than info or warning, a misspelled or empty one +// included, is a failure. +func TestCheckGlyphAndTally_GradeLikeTheFinalEvent(t *testing.T) { + for _, tc := range []struct { + passed bool + sev severity.Severity + glyph string + tally [3]int // passed, failed, warned + }{ + {true, "info", "[\u2713]", [3]int{1, 0, 0}}, + {true, "error", "[\u2713]", [3]int{1, 0, 0}}, + {false, "info", "[!]", [3]int{0, 0, 1}}, + {false, "warning", "[!]", [3]int{0, 0, 1}}, + {false, "error", "[\u2718]", [3]int{0, 1, 0}}, + {false, "Error", "[\u2718]", [3]int{0, 1, 0}}, + {false, "", "[\u2718]", [3]int{0, 1, 0}}, + } { + row := checkRow{id: "x", started: true, finished: true, passed: tc.passed, severity: tc.sev} + assert.Equal(t, tc.glyph, checkGlyph(row), "%v %q", tc.passed, tc.sev) + m := Model{checkCategories: []checkCategoryState{{checks: []checkRow{row}}}} + p, f, w, total := m.checkTally() + assert.Equal(t, tc.tally, [3]int{p, f, w}, "%v %q", tc.passed, tc.sev) + assert.Equal(t, 1, total) + } +} + +// A row the budget stopped before it started arrives with no CheckStarted, and +// can be the first of its category. It is still shown, so the rows that +// explain a timeout are on the dashboard. +func TestTTY_CheckRowOfAnUnseenCategoryIsShown(t *testing.T) { + t.Setenv("NO_COLOR", "1") + var buf bytes.Buffer + r := NewCheckOneShotRenderer(&buf, ModelOpts{Mode: ModeCheck, Output: &buf, AsciiOnly: true}) + ctx := context.Background() + require.NoError(t, r.Emit(ctx, CheckCompleted{ + Category: "compute-plane-cluster", ID: "cluster-validator", Severity: "error", + Message: "not run: the check's time budget ran out before it started", + })) + require.NoError(t, r.Emit(ctx, CategoryCompleted{Category: "compute-plane-cluster", FailedCount: 1})) + require.NoError(t, r.Emit(ctx, Final{Verdict: "timeout", TotalChecks: 1, FailedCount: 1})) + out := buf.String() + assert.Contains(t, out, "[\u2718] not run: the check's time budget ran out before it started") + assert.Contains(t, out, "Status: \u2718 timed out (0/1 passed, 1 failed)") +} + +// Once finished, the status line follows the final event's verdict, as the +// exit code does: a timeout or an unsuccessful run never reads as ok. +func TestTTY_CheckStatusFollowsTheFinalVerdict(t *testing.T) { + t.Setenv("NO_COLOR", "1") + for name, tc := range map[string]struct { + final Final + want string + }{ + "timeout with only warnings": { + final: Final{Verdict: "timeout", TotalChecks: 2, PassedCount: 1, WarningCount: 1}, + want: "Status: \u2718 timed out (1/2 passed, 0 failed, 1 warning(s))", + }, + "unsuccessful with nothing failed": { + final: Final{Verdict: "failed", TotalChecks: 2, PassedCount: 1, WarningCount: 1}, + want: "Status: \u2718 failed (1/2 passed, 0 failed, 1 warning(s))", + }, + "warnings": { + final: Final{Success: true, Verdict: "warnings", TotalChecks: 2, PassedCount: 1, WarningCount: 1}, + want: "Status: \u2713 ok with warnings (1/2 passed, 0 failed, 1 warning(s))", + }, + "ok": { + final: Final{Success: true, Verdict: "ok", TotalChecks: 2, PassedCount: 2}, + want: "Status: \u2713 ok (2/2 passed, 0 failed)", + }, + } { + var buf bytes.Buffer + r := NewCheckOneShotRenderer(&buf, ModelOpts{Mode: ModeCheck, Output: &buf, AsciiOnly: true}) + ctx := context.Background() + require.NoError(t, r.Emit(ctx, CheckCompleted{Category: "c", ID: "a", Passed: true, Severity: "info"})) + require.NoError(t, r.Emit(ctx, CheckCompleted{Category: "c", ID: "b", Severity: "warning", Transient: true})) + require.NoError(t, r.Emit(ctx, tc.final)) + assert.Contains(t, buf.String(), tc.want, name) + } +} + func TestCheckOneShotRenderer_UsesFinalTallyForStatus(t *testing.T) { t.Setenv("NO_COLOR", "1") @@ -881,3 +983,65 @@ func TestTTY_StatusWatchTickResetsBetweenSnapshots(t *testing.T) { assert.Equal(t, 1, sisCount, "rendered View should show SIS exactly once after 5 watch ticks, not %d times", sisCount) } + +// A finished run with a warning says so and accounts for it, rather than +// printing "ok (2/3 passed, 0 failed)" with one check unexplained. +func TestTTY_CheckCompleteWithWarning(t *testing.T) { + t.Setenv("NO_COLOR", "1") + m := NewModel(ModelOpts{Mode: ModeCheck, AsciiOnly: true, TotalChecks: 3, + NowFunc: func() time.Time { return ts("2026-04-29T03:45:12Z") }}) + m.SetSize(120, 40) + events := []Event{ + CheckStarted{Category: "local-host-tools", ID: "a"}, + CheckCompleted{Category: "local-host-tools", ID: "a", Passed: true, Severity: "info", Message: "a"}, + CheckStarted{Category: "local-host-tools", ID: "b"}, + CheckCompleted{Category: "local-host-tools", ID: "b", Passed: true, Severity: "info", Message: "b"}, + CheckStarted{Category: "registry-credentials", ID: "c"}, + CheckCompleted{Category: "registry-credentials", ID: "c", Passed: false, Severity: "warning", Message: "c"}, + Final{Success: true, Verdict: "warnings", TotalChecks: 3, PassedCount: 2, FailedCount: 0, WarningCount: 1}, + } + var model tea.Model = m + for _, e := range events { + model, _ = model.(Model).applyCheckEvent(e) + } + assert.Contains(t, model.(Model).View(), "ok with warnings (2/3 passed, 0 failed, 1 warning(s))") +} + +// Bubbletea reads the terminal in raw mode, so Ctrl-C is a key press, not a +// signal. Each quit key must reach the run, or quitting closes the dashboard +// while the command keeps running behind it. +func TestModel_QuitKeysCallOnQuit(t *testing.T) { + for _, key := range []tea.KeyMsg{{Type: tea.KeyCtrlC}, {Type: tea.KeyEsc}, {Type: tea.KeyRunes, Runes: []rune("q")}} { + called := 0 + m := NewModel(ModelOpts{Mode: ModeCheck, OnQuit: func() { called++ }}) + _, cmd := m.Update(key) + require.NotNil(t, cmd, key.String()) + assert.Equal(t, 1, called, key.String()) + } + _, cmd := NewModel(ModelOpts{Mode: ModeCheck}).Update(tea.KeyMsg{Type: tea.KeyCtrlC}) + assert.NotNil(t, cmd, "without a hook the dashboard still quits") +} + +// Close can run twice: the check command closes a dashboard early to print +// the interrupt note on the restored screen, then again as it exits. +// tea.Program.Wait returns only once, so a second Close through it hung. +func TestTTYRenderer_CloseTwice(t *testing.T) { + var buf bytes.Buffer + r := newTTYRendererFor(tea.NewProgram(NewModel(ModelOpts{Mode: ModeCheck, Output: &buf}), + tea.WithInput(nil), tea.WithOutput(&buf), tea.WithoutSignalHandler())) + assert.True(t, r.OwnsTerminal()) + r.Start() + done := make(chan error, 2) + go func() { + done <- r.Close() + done <- r.Close() + }() + for i := 0; i < 2; i++ { + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(5 * time.Second): + t.Fatalf("Close call %d did not return", i+1) + } + } +} diff --git a/src/clis/nvcf-cli/internal/selfhosted/progress/select.go b/src/clis/nvcf-cli/internal/selfhosted/progress/select.go index f8e3ff017d..deec5bfa27 100644 --- a/src/clis/nvcf-cli/internal/selfhosted/progress/select.go +++ b/src/clis/nvcf-cli/internal/selfhosted/progress/select.go @@ -105,6 +105,9 @@ type RenderOpts struct { // (7-phase teardown) and ModeUninstall (3-5 phase per-plane teardown). // Zero leaves the renderer's default in place. TotalPhases int + + // OnQuit is passed to the TTY renderer; see ModelOpts.OnQuit. + OnQuit func() } // SelectRenderer picks an EventSink based on opts and ambient state. The @@ -170,21 +173,20 @@ func SelectRenderer(stderr io.Writer, opts RenderOpts) (EventSink, RendererKind, size = detectTerminalSize(stderr) } if size != nil && (size.Cols < compactThresholdCols || size.Rows < compactThresholdRows) { - return NewTTYRenderer(stderr, ModelOpts{ - Output: stderr, - Mode: opts.Mode, - TotalPhases: opts.TotalPhases, - Cluster: opts.Cluster, - Target: opts.Target, - Stack: opts.Stack, - ControlPlaneContext: opts.ControlPlaneContext, - ComputePlaneContext: opts.ComputePlaneContext, - NowFunc: func() time.Time { return time.Now().UTC() }, - }), RendererTTYCompact, nil + return newTTYRenderer(stderr, ttyModelOpts(stderr, opts)), RendererTTYCompact, nil } // Row 9: default — full bubbletea. - return NewTTYRenderer(stderr, ModelOpts{ + return newTTYRenderer(stderr, ttyModelOpts(stderr, opts)), RendererTTYFull, nil +} + +// newTTYRenderer is a test seam over NewTTYRenderer. +var newTTYRenderer = NewTTYRenderer + +// ttyModelOpts is the bubbletea model configuration both TTY layouts use. It +// carries OnQuit: in raw mode a quit key is the only Ctrl-C the run sees. +func ttyModelOpts(stderr io.Writer, opts RenderOpts) ModelOpts { + return ModelOpts{ Output: stderr, Mode: opts.Mode, TotalPhases: opts.TotalPhases, @@ -194,7 +196,8 @@ func SelectRenderer(stderr io.Writer, opts RenderOpts) (EventSink, RendererKind, ControlPlaneContext: opts.ControlPlaneContext, ComputePlaneContext: opts.ComputePlaneContext, NowFunc: func() time.Time { return time.Now().UTC() }, - }), RendererTTYFull, nil + OnQuit: opts.OnQuit, + } } // isWriterTTY returns true iff w is an *os.File whose file descriptor is @@ -226,10 +229,19 @@ func detectTerminalSize(w io.Writer) *Size { // Aligns with how GitHub Actions, GitLab CI, CircleCI, etc. set CI=true. // Falsy values (false, 0, no, empty) leave the matrix free to fall through. func isTruthy(v string) bool { + on, _ := ParseToggle(v) + return on +} + +// ParseToggle reads a boolean environment value, in any case and with +// surrounding space: true, 1, yes, y and on are on; false, 0, no, n and off +// are off. ok is false for any other value, the empty one included. +func ParseToggle(v string) (on, ok bool) { switch strings.ToLower(strings.TrimSpace(v)) { case "true", "1", "yes", "y", "on": - return true - default: - return false + return true, true + case "false", "0", "no", "n", "off": + return false, true } + return false, false } diff --git a/src/clis/nvcf-cli/internal/selfhosted/progress/select_test.go b/src/clis/nvcf-cli/internal/selfhosted/progress/select_test.go index 0985bae7c5..b4cdd7f6e6 100644 --- a/src/clis/nvcf-cli/internal/selfhosted/progress/select_test.go +++ b/src/clis/nvcf-cli/internal/selfhosted/progress/select_test.go @@ -19,6 +19,7 @@ package progress import ( "bytes" + "io" "os" "testing" @@ -206,3 +207,33 @@ func TestSelectRenderer_BoundaryConditions(t *testing.T) { require.NoError(t, err) assert.Equal(t, RendererTTYFull, kind) } + +// Both TTY layouts read the terminal in raw mode, where Ctrl-C is a key press +// and OnQuit is the only way it reaches the command. +func TestSelectRenderer_TTYLayoutsCarryOnQuit(t *testing.T) { + prev := newTTYRenderer + t.Cleanup(func() { newTTYRenderer = prev }) + var got ModelOpts + newTTYRenderer = func(stderr io.Writer, opts ModelOpts) *TTYRenderer { + got = opts + return prev(stderr, opts) + } + for size, want := range map[Size]RendererKind{ + {Cols: 80, Rows: 24}: RendererTTYCompact, + {Cols: 200, Rows: 60}: RendererTTYFull, + } { + got = ModelOpts{} + quits := 0 + _, kind, err := SelectRenderer(os.Stderr, RenderOpts{ + TerminalSize: &size, + Env: map[string]string{"TERM": "xterm-256color"}, + Mode: ModeCheck, + OnQuit: func() { quits++ }, + }) + require.NoError(t, err) + require.Equal(t, want, kind) + require.NotNil(t, got.OnQuit, "%s drops OnQuit", kind) + got.OnQuit() + assert.Equal(t, 1, quits, "%s must call the caller's OnQuit", kind) + } +} diff --git a/src/clis/nvcf-cli/internal/selfhosted/progress/testdata/jsonl_check.golden b/src/clis/nvcf-cli/internal/selfhosted/progress/testdata/jsonl_check.golden index 597f7b68f9..060bcd4e12 100644 --- a/src/clis/nvcf-cli/internal/selfhosted/progress/testdata/jsonl_check.golden +++ b/src/clis/nvcf-cli/internal/selfhosted/progress/testdata/jsonl_check.golden @@ -5,5 +5,5 @@ {"event":"check_completed","ts":"2026-04-29T03:45:01Z","category":"local-host-tools","id":"helmfile-on-path","passed":true,"severity":"info","message":"helmfile 0.162.0 on PATH","detail":"0.162.0"} {"event":"check_started","ts":"2026-04-29T03:45:08Z","category":"pre-kubernetes-setup","id":"gateway-api"} {"event":"check_completed","ts":"2026-04-29T03:45:08Z","category":"pre-kubernetes-setup","id":"gateway-api","passed":false,"severity":"error","message":"Gateway API CRDs not installed","hintURL":"https://docs.nvidia.com/nvcf/self-hosted/gateway-api"} -{"event":"category_completed","ts":"2026-04-29T03:45:08Z","category":"pre-kubernetes-setup","passedCount":13,"failedCount":1,"durationSec":2.4} -{"event":"final","ts":"2026-04-29T03:45:09Z","success":false,"durationSec":0,"droppedProgressEvents":0,"verdict":"failed","totalChecks":14,"passedCount":13,"failedCount":1} +{"event":"category_completed","ts":"2026-04-29T03:45:08Z","category":"pre-kubernetes-setup","passedCount":13,"failedCount":1,"warningCount":0,"durationSec":2.4} +{"event":"final","ts":"2026-04-29T03:45:09Z","success":false,"durationSec":0,"droppedProgressEvents":0,"verdict":"failed","totalChecks":15,"passedCount":13,"failedCount":1,"warningCount":1} diff --git a/src/clis/nvcf-cli/internal/selfhosted/progress/testdata/plain_check.golden b/src/clis/nvcf-cli/internal/selfhosted/progress/testdata/plain_check.golden index 6312b923fe..43e8e654f8 100644 --- a/src/clis/nvcf-cli/internal/selfhosted/progress/testdata/plain_check.golden +++ b/src/clis/nvcf-cli/internal/selfhosted/progress/testdata/plain_check.golden @@ -1,8 +1,8 @@ 2026-04-29T03:45:01Z [pre] check_started local-host-tools/kubectl-on-path -2026-04-29T03:45:01Z [pre] check_completed local-host-tools/kubectl-on-path passed=true detail="1.30.2" +2026-04-29T03:45:01Z [pre] check_completed local-host-tools/kubectl-on-path passed=true message="kubectl 1.30.2 on PATH" detail="1.30.2" 2026-04-29T03:45:01Z [pre] check_started local-host-tools/helmfile-on-path -2026-04-29T03:45:01Z [pre] check_completed local-host-tools/helmfile-on-path passed=true detail="0.162.0" +2026-04-29T03:45:01Z [pre] check_completed local-host-tools/helmfile-on-path passed=true message="helmfile 0.162.0 on PATH" detail="0.162.0" 2026-04-29T03:45:08Z [pre] check_started pre-kubernetes-setup/gateway-api -2026-04-29T03:45:08Z [pre] check_completed pre-kubernetes-setup/gateway-api passed=false severity=error message="Gateway API CRDs not installed" hintURL="https://docs.nvidia.com/nvcf/self-hosted/gateway-api" -2026-04-29T03:45:08Z [pre] category_completed pre-kubernetes-setup passed=13 failed=1 duration=2.4s -2026-04-29T03:45:09Z final verdict=failed totalChecks=14 passed=13 failed=1 +2026-04-29T03:45:08Z [pre] check_completed pre-kubernetes-setup/gateway-api passed=false severity=error message="Gateway API CRDs not installed" hintURL="https://docs.nvidia.com/nvcf/self-hosted/gateway-api" +2026-04-29T03:45:08Z [pre] category_completed pre-kubernetes-setup passed=13 failed=1 warnings=0 duration=2.4s +2026-04-29T03:45:09Z final verdict=failed totalChecks=15 passed=13 failed=1 warnings=1 diff --git a/src/clis/nvcf-cli/internal/selfhosted/pullsecret.go b/src/clis/nvcf-cli/internal/selfhosted/pullsecret.go index d01311e802..ec1a9735a5 100644 --- a/src/clis/nvcf-cli/internal/selfhosted/pullsecret.go +++ b/src/clis/nvcf-cli/internal/selfhosted/pullsecret.go @@ -21,9 +21,10 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "fmt" - "os" "strings" + "sync" "time" corev1 "k8s.io/api/core/v1" @@ -32,16 +33,62 @@ import ( "k8s.io/client-go/kubernetes" ) +// deleteExactly pins a delete to the object that was inspected. The ownership +// guards read an object and then delete it by name, and in that window another +// actor can relabel it, or delete and recreate it under the same name. Without +// preconditions the delete lands on whatever holds the name by then; with them +// the apiserver rejects it as a conflict instead. +func deleteExactly(o metav1.Object) metav1.DeleteOptions { + // UID only. UID already pins identity, which is the whole goal: a + // delete-and-recreate under the same name changes it. ResourceVersion + // additionally pins the object's version, so any concurrent write turns + // the delete into a 409 that every sweep then swallows. That bites hardest + // on a Job, whose .status the Job controller mutates continuously. + uid := o.GetUID() + return metav1.DeleteOptions{ + Preconditions: &metav1.Preconditions{UID: &uid}, + } +} + const ( // Project-specific name so the auto-created / mirrored pull secret // can never collide with the conventional `nvcr-pull-secret` that // operators or the install flow may already manage in 'default'. - // Pairs with isManagedByValidatorCLI label guard for defense-in-depth. - validatorPullSecretName = "nvcf-preflight-pull-secret" - validatorPullSecretScanTimeout = 10 * time.Second + // + // The name is suffixed per role by validatorPullSecretRoleName: ModeSplit + // runs both validators concurrently, and the two kubecontexts can resolve + // to the same cluster. One shared Secret means the role that finishes first + // deletes it while the other Job is still pulling, which the kubelet + // reports as FailedToRetrieveImagePullSecret. + validatorPullSecretName = "nvcf-preflight-pull-secret" ) -// Mirrors the chain used by ensureLocalImagePullSecrets in cmd/self_hosted_up.go. +// validatorPullSecretRoleName returns the managed pull-secret name for a role. +// A Secret cannot be co-owned, so each role needs its own rather than a shared +// object with a role label. +func validatorPullSecretRoleName(role string) string { + if role == "" { + return validatorPullSecretName + } + return validatorPullSecretName + "-" + role +} + +// validatorPullSecretRunName is the name this run mints under. It carries the +// run's unguessable suffix because the managed labels are three public +// constants: with a predictable name, anyone able to create a Secret in this +// namespace could pre-create it wearing those labels, pass the ownership check, +// and have the NGC credential written into an object they control. Same +// reasoning as the per-run RBAC names. +func validatorPullSecretRunName(role, runID string) string { + base := validatorPullSecretRoleName(role) + if runID == "" { + return base + } + return base + "-" + runID +} + +// The NGC API key's environment variables, in the order +// ensureLocalImagePullSecrets in cmd/self_hosted_up.go reads them. var ngcAPIKeyEnvNames = []string{ "NGC_IMAGE_PULL_API_KEY", "NVCF_NGCR_API_KEY", @@ -67,141 +114,318 @@ var validatorPullSecretSearchNamespaces = []string{ "nvcf-backend", } +// validatorPullSecretListTimeout bounds one namespace's List in the scan, so +// a slow namespace cannot starve the rest; validatorPullSecretWriteTimeout +// bounds the Create of the Secret this run attaches, which gets its own time +// rather than whatever the scan left. +var ( + validatorPullSecretListTimeout = 5 * time.Second + validatorPullSecretWriteTimeout = 10 * time.Second +) + // resolveValidatorPullSecret picks the imagePullSecret name to attach to the // validator Job. Precedence: // // 1. provided != "" -> use as-is (--cluster-validator-pull-secret flag). -// 2. Cluster scan -> mirror a matching secret into the validator namespace. -// 3. NGC env var -> mint a secret in the validator namespace. -// 4. "" -> kubelet will surface ImagePullBackOff if the image is private. +// 2. Cluster scan -> adopt an operator's Secret in the validator namespace, +// or copy the matching registry's entry of one found elsewhere. +// 3. This machine's credential for the image's registry, the one the local +// credential check probed -> mint a Secret for this run. The NGC key +// goes only to nvcr.io. +// 4. "" -> the image is pulled without a secret. +// +// note says what the run copied or created, for the operator to see. err is +// a copy or create that failed; read-side failures fall through to the next +// layer. // -// Only hard mirror/create failures propagate; read-side failures fall through -// to the next layer. +// A Secret this run mints or mirrors is recorded in created, the only record +// its cleanup trusts. func resolveValidatorPullSecret( ctx context.Context, client kubernetes.Interface, - provided, image string, -) (string, error) { + provided, image, role, runID string, + preserve bool, + created runObjects, +) (name, note string, err error) { if provided != "" { - return provided, nil + return provided, "", nil } - registry := parseRegistryFromImage(image) - if registry == "" { - return "", nil + registry, repo, _, ok := parseImageRef(image) + if !ok { + return "", "", nil } - scanCtx, cancel := context.WithTimeout(ctx, validatorPullSecretScanTimeout) - defer cancel() - - if name, err := scanAndMirrorPullSecret(scanCtx, client, registry); err != nil { - return "", err - } else if name != "" { - return name, nil + runName := validatorPullSecretRunName(role, runID) + write := func(cfg []byte) error { + wctx, cancel := context.WithTimeout(ctx, validatorPullSecretWriteTimeout) + defer cancel() + return writeDockerConfigSecret(wctx, client, clusterValidatorNamespace, runName, role, runID, cfg, preserve, + created) } - if name, err := autoCreatePullSecretFromEnv(scanCtx, client, registry); err != nil { - return "", err - } else if name != "" { - return name, nil + if src, cfg := findClusterPullSecret(ctx, client, registry, repo); src != nil { + if src.Namespace == clusterValidatorNamespace { + return src.Name, "", nil + } + // Mirror under the validator's run-scoped name rather than the source + // secret's name, which could collide with an unrelated Secret in the + // destination namespace. + if err := write(cfg); err != nil { + return "", "", fmt.Errorf("copy pull secret %s/%s to %s/%s: %w", + src.Namespace, src.Name, clusterValidatorNamespace, runName, err) + } + return runName, fmt.Sprintf("copied the pull credential for %s from %s/%s into %s/%s for this run", + registry, src.Namespace, src.Name, clusterValidatorNamespace, runName), nil } - return "", nil -} - -// Returns "" for Docker Hub shorthand (no host segment), since the scan -// needs an auths. key to match against. -func parseRegistryFromImage(image string) string { - image = strings.TrimSpace(image) - if image == "" { - return "" + settled := registryCredentialsFrom(ctx).lookup(ctx, registry, repo) + cred := settled.cred + if !settled.ok { + if settled.err != nil { + return "", "", fmt.Errorf("no pull secret for %s in the cluster, and reading this machine's "+ + "credential failed: %w", registry, settled.err) + } + return "", "", nil } - slash := strings.Index(image, "/") - if slash <= 0 { - return "" + cfg, err := buildDockerConfigJSON(dockerConfigKey(registry), cred.user, cred.pass) + if err != nil { + return "", "", fmt.Errorf("encode dockerconfigjson for %s: %w", registry, err) } - head := image[:slash] - if !strings.ContainsAny(head, ".:") && head != "localhost" { - return "" + if err := write(cfg); err != nil { + return "", "", fmt.Errorf("create pull secret %s/%s: %w", clusterValidatorNamespace, runName, err) } - return head + return runName, fmt.Sprintf("created pull secret %s/%s for %s from %s for this run", + clusterValidatorNamespace, runName, registry, cred.source), nil } -// Walks validatorPullSecretSearchNamespaces and returns the first -// docker-registry secret whose dockerconfigjson has an auths entry for -// registry. Mirrors the body into clusterValidatorNamespace when the match -// lives elsewhere, so the Job can reference the secret without cross-namespace -// lookups. -func scanAndMirrorPullSecret(ctx context.Context, client kubernetes.Interface, registry string) (string, error) { +// findClusterPullSecret walks validatorPullSecretSearchNamespaces and returns +// the first docker-registry Secret with credentials for registry, with a +// dockerconfigjson holding only that registry's entry. An operator's Secret +// in the validator namespace is returned to be used as is; one this CLI +// minted for another run is skipped (see isAnyValidatorSecret). +func findClusterPullSecret( + ctx context.Context, client kubernetes.Interface, registry, repo string, +) (*corev1.Secret, []byte) { + var found *corev1.Secret + var foundCfg []byte + _ = scanClusterPullSecrets(ctx, client, registry, repo, func(s *corev1.Secret, cfg []byte) bool { + found, foundCfg = s, cfg + return true + }) + return found, foundCfg +} + +// scanClusterPullSecrets calls visit, in validatorPullSecretSearchNamespaces +// order, with each docker-registry Secret holding credentials for registry +// and a dockerconfigjson of that entry alone, until visit returns true. The +// error names the namespaces that could not be listed. +func scanClusterPullSecrets( + ctx context.Context, client kubernetes.Interface, registry, repo string, + visit func(s *corev1.Secret, cfg []byte) bool, +) error { + var errs []error for _, ns := range validatorPullSecretSearchNamespaces { - // Filter client-side rather than via FieldSelector: server-side - // type= selector on Secrets is only honored from k8s 1.27 onward. - secrets, err := client.CoreV1().Secrets(ns).List(ctx, metav1.ListOptions{}) + secrets, err := listDockerConfigSecrets(ctx, client, ns) if err != nil { + errs = append(errs, fmt.Errorf("list Secrets in %s: %w", ns, err)) continue } - for _, s := range secrets.Items { + for i := range secrets { + s := &secrets[i] if s.Type != corev1.SecretTypeDockerConfigJson { continue } - cfg, ok := s.Data[corev1.DockerConfigJsonKey] - if !ok || !dockerConfigHasRegistry(cfg, registry) { + // Adopt only an operator-supplied Secret (no managed labels, so + // no sweep touches it). Never one another run minted: adopting + // one reuses a possibly stale credential, can outlive this run, + // and can be deleted by its own run's sweep mid-pull, which the + // kubelet reports as FailedToRetrieveImagePullSecret. + if s.Namespace == clusterValidatorNamespace && isAnyValidatorSecret(s) { continue } - if s.Namespace == clusterValidatorNamespace { - return s.Name, nil - } - // Mirror under the validator's well-known name rather than the - // source secret's name. Using the source name risks colliding - // with an unrelated operator/chart secret of the same name in - // the destination namespace, and writeDockerConfigSecret's - // delete-and-recreate path would otherwise destroy that - // secret on type mismatch. - if err := writeDockerConfigSecret(ctx, client, clusterValidatorNamespace, validatorPullSecretName, cfg); err != nil { - return "", fmt.Errorf("mirror pull secret %s/%s to %s/%s: %w", - s.Namespace, s.Name, clusterValidatorNamespace, validatorPullSecretName, err) + if cfg, ok := filterDockerConfig(s.Data[corev1.DockerConfigJsonKey], registry, repo); ok && visit(s, cfg) { + return nil } - return validatorPullSecretName, nil } } - return "", nil + return errors.Join(errs...) } -func dockerConfigHasRegistry(cfg []byte, registry string) bool { - var doc struct { - Auths map[string]json.RawMessage `json:"auths"` +// ClusterPullSecretChecker returns the pull secret, in a cluster of a plane +// the run checks as installed, that holds user and secret for repo on +// registry, or "" when none does. err reports a cluster whose pull secrets +// could not all be read, so the answer is not known. +type ClusterPullSecretChecker func(ctx context.Context, registry, repo, user, secret string) (string, error) + +// clusterPullSecretCheckTimeout bounds one ClusterPullSecretChecker call +// across its clusters. A var so tests can shorten it. +var clusterPullSecretCheckTimeout = 30 * time.Second + +// NewClusterPullSecretChecker returns a ClusterPullSecretChecker that reads +// the pull secrets findClusterPullSecret reads, in the cluster behind each of +// kubeContexts, "" naming the current context. It is meant for one poll: it +// connects to each cluster once, keeping a cluster it cannot reach as such, +// and answers a question it was asked before from what it found then, so the +// registry rows, which run one after another in one share of the run's time, +// do not each wait on the same clusters. +func NewClusterPullSecretChecker(kubeContexts []string) ClusterPullSecretChecker { + type connection struct { + client kubernetes.Interface + err error } - if err := json.Unmarshal(cfg, &doc); err != nil { + type answer struct { + holder string + err error + } + // connMu is held across a connection, so a cluster is connected to once. + var connMu, answersMu sync.Mutex + connections := map[string]connection{} + answers := map[[4]string]answer{} + connect := func(ctx context.Context, kubeContext string) (kubernetes.Interface, error) { + connMu.Lock() + defer connMu.Unlock() + if c, ok := connections[kubeContext]; ok { + return c.client, c.err + } + client, err := connectCluster(ctx, kubeContext) + if ctx.Err() == nil { + connections[kubeContext] = connection{client, err} + } + return client, err + } + return func(ctx context.Context, registry, repo, user, secret string) (string, error) { + key := [4]string{registry, repo, user, secret} + answersMu.Lock() + a, ok := answers[key] + answersMu.Unlock() + if ok { + return a.holder, a.err + } + ctx, cancel := context.WithTimeout(ctx, clusterPullSecretCheckTimeout) + defer cancel() + holder, err := func() (string, error) { + var errs []error + for _, kubeContext := range kubeContexts { + client, err := connect(ctx, kubeContext) + if err != nil { + errs = append(errs, err) + continue + } + holder, err := clusterPullSecretHolding(ctx, client, registry, repo, user, secret) + if holder != "" { + if kubeContext != "" { + holder += " in context " + kubeContext + } + return holder, nil + } + if err != nil { + errs = append(errs, err) + } + } + return "", errors.Join(errs...) + }() + // An answer the call's bound or the run's end cut off is no answer. + if ctx.Err() == nil { + answersMu.Lock() + answers[key] = answer{holder, err} + answersMu.Unlock() + } + return holder, err + } +} + +// clusterPullSecretHolding returns the namespace/name of a pull secret in +// client's cluster whose entry for registry holds user and secret. +func clusterPullSecretHolding( + ctx context.Context, client kubernetes.Interface, registry, repo, user, secret string, +) (string, error) { + holder := "" + err := scanClusterPullSecrets(ctx, client, registry, repo, func(s *corev1.Secret, cfg []byte) bool { + var doc struct { + Auths map[string]dockerConfigAuth `json:"auths"` + } + if json.Unmarshal(cfg, &doc) != nil { + return false + } + for _, entry := range doc.Auths { + if u, p, ok := decodeDockerConfigAuth(entry); ok && u == user && p == secret { + holder = s.Namespace + "/" + s.Name + return true + } + } return false + }) + if holder != "" { + return holder, nil } - _, ok := doc.Auths[registry] - return ok + return "", err } -func autoCreatePullSecretFromEnv(ctx context.Context, client kubernetes.Interface, registry string) (string, error) { - apiKey := firstNonEmptyEnv(ngcAPIKeyEnvNames...) - if apiKey == "" { - return "", nil +// listDockerConfigSecrets lists namespace's docker-registry Secrets, so the +// scan does not fetch every other Secret's key material. A server that +// refuses the type field selector is asked for everything instead, and the +// caller filters. +func listDockerConfigSecrets( + ctx context.Context, client kubernetes.Interface, namespace string, +) ([]corev1.Secret, error) { + lctx, cancel := context.WithTimeout(ctx, validatorPullSecretListTimeout) + defer cancel() + list, err := client.CoreV1().Secrets(namespace).List(lctx, metav1.ListOptions{ + FieldSelector: "type=" + string(corev1.SecretTypeDockerConfigJson), + }) + if apierrors.IsBadRequest(err) { + list, err = client.CoreV1().Secrets(namespace).List(lctx, metav1.ListOptions{}) } - cfg, err := buildDockerConfigJSON(registry, "$oauthtoken", apiKey) if err != nil { - return "", fmt.Errorf("encode dockerconfigjson for %s: %w", registry, err) - } - if err := writeDockerConfigSecret(ctx, client, clusterValidatorNamespace, validatorPullSecretName, cfg); err != nil { - return "", fmt.Errorf("auto-create pull secret %s/%s: %w", - clusterValidatorNamespace, validatorPullSecretName, err) + return nil, err } - return validatorPullSecretName, nil + return list.Items, nil } -// Mirrors cmd/self_hosted_up.go's firstNonEmptyEnv; kept local to avoid an -// internal->cmd import. -func firstNonEmptyEnv(names ...string) string { - for _, name := range names { - if v := os.Getenv(name); v != "" { - return v +// filterDockerConfig returns a dockerconfigjson holding only cfg's entry for +// registry, and whether it has one. A copy carries no other registry's +// credential. Keys match the way the kubelet matches them: scheme and a +// /v1/ or /v2/ prefix ignored, and a key with a path only for repositories +// under it. +func filterDockerConfig(cfg []byte, registry, repo string) ([]byte, bool) { + var doc struct { + Auths map[string]json.RawMessage `json:"auths"` + } + if err := json.Unmarshal(cfg, &doc); err != nil { + return nil, false + } + key, ok := "", false + if _, exact := doc.Auths[dockerConfigKey(registry)]; exact { + key, ok = dockerConfigKey(registry), true + } else { + for k := range doc.Auths { + if dockerConfigKeyMatches(k, registry, repo) && (!ok || k < key) { + key, ok = k, true + } } } - return "" + if !ok { + return nil, false + } + out, err := json.Marshal(map[string]any{"auths": map[string]json.RawMessage{key: doc.Auths[key]}}) + return out, err == nil +} + +// dockerConfigKeyMatches reports whether a dockerconfigjson auths key covers +// an image in repo on registry, as the kubelet reads the key. +func dockerConfigKeyMatches(key, registry, repo string) bool { + rest := strings.TrimPrefix(strings.TrimPrefix(key, "https://"), "http://") + host, path, _ := strings.Cut(rest, "/") + if !sameDockerHost(host, registry) { + return false + } + path = strings.TrimSuffix(path, "/") + if p, ok := strings.CutPrefix(path, "v1"); ok && (p == "" || strings.HasPrefix(p, "/")) { + path = strings.TrimPrefix(p, "/") + } else if p, ok := strings.CutPrefix(path, "v2"); ok && (p == "" || strings.HasPrefix(p, "/")) { + path = strings.TrimPrefix(p, "/") + } + return path == "" || repo == path || strings.HasPrefix(repo, path+"/") } // Mirrors cmd/self_hosted_up.go's dockerConfigJSON. @@ -218,96 +442,85 @@ func buildDockerConfigJSON(registry, username, password string) ([]byte, error) }) } -// Mirrors cmd/self_hosted_up.go's ensureDockerConfigSecret. Attaches the -// CLI's managed-by labels so a future cleanup path can identify resources -// to remove. -func writeDockerConfigSecret(ctx context.Context, client kubernetes.Interface, namespace, name string, dockerConfig []byte) error { - labels := clusterValidatorLabels() - secrets := client.CoreV1().Secrets(namespace) +// writeDockerConfigSecret creates the pull Secret this run will reference from +// a .dockerconfigjson body, and records it in created. +// +// Create-only. The name carries this run's unguessable suffix, so nothing this +// CLI created can already hold it and any collision is another object. +// Adopting one would write the NGC credential into something we do not own, +// which label-based ownership cannot prevent: the managed labels are three +// public constants anyone can copy onto a Secret they pre-create under a +// predictable name. +// +// The Secret is the legacy kubernetes.io/dockercfg type, which the kubelet +// pulls with just as well. Released CLIs adopt any kubernetes.io/dockerconfigjson +// Secret in the namespace whose registry matches, whatever its labels, so with +// that type they would reuse this run's NGC key and lose it mid-pull when this +// run's cleanup deletes it. +func writeDockerConfigSecret( + ctx context.Context, client kubernetes.Interface, namespace, name, role, runID string, dockerConfig []byte, + preserve bool, created runObjects, +) error { + dockercfg, err := dockerCfgFromConfigJSON(dockerConfig) + if err != nil { + return fmt.Errorf("convert docker config: %w", err) + } secret := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: namespace, - Labels: labels, + Labels: clusterValidatorRunLabels(role, runID, preserve), }, - Type: corev1.SecretTypeDockerConfigJson, - Data: map[string][]byte{corev1.DockerConfigJsonKey: dockerConfig}, + Type: corev1.SecretTypeDockercfg, + Data: map[string][]byte{corev1.DockerConfigKey: dockercfg}, } - current, err := secrets.Get(ctx, name, metav1.GetOptions{}) - switch { - case err == nil && current.Type == corev1.SecretTypeDockerConfigJson: - if current.Data == nil { - current.Data = map[string][]byte{} - } - current.Data[corev1.DockerConfigJsonKey] = dockerConfig - if current.Labels == nil { - current.Labels = map[string]string{} - } - for k, v := range labels { - current.Labels[k] = v - } - if _, err := secrets.Update(ctx, current, metav1.UpdateOptions{}); err != nil { - return fmt.Errorf("update: %w", err) - } - return nil - case err == nil: - // Secret.Type is immutable; can't Update across a type change. - // Guard the delete: only replace secrets we previously managed. - // Without this, an operator-owned secret with a colliding name - // (e.g. an Opaque secret in 'default' sharing the validator's - // pull-secret name) would be silently destroyed. - if !isManagedByValidatorCLI(current) { + got, err := client.CoreV1().Secrets(namespace).Create(ctx, secret, metav1.CreateOptions{}) + if err != nil { + if apierrors.IsAlreadyExists(err) { return fmt.Errorf( - "refusing to replace %s/%s (type=%s) which is not managed by nvcf-cli; "+ - "pass --cluster-validator-pull-secret to choose an explicit secret name", - namespace, name, current.Type) - } - if err := secrets.Delete(ctx, name, metav1.DeleteOptions{}); err != nil && !apierrors.IsNotFound(err) { - return fmt.Errorf("delete incompatible secret type %s: %w", current.Type, err) - } - case !apierrors.IsNotFound(err): - return fmt.Errorf("get: %w", err) - } - if _, err := secrets.Create(ctx, secret, metav1.CreateOptions{}); err != nil { - if !apierrors.IsAlreadyExists(err) { - return fmt.Errorf("create: %w", err) - } - // Lost a delete->create race: another actor recreated the secret - // in the narrow window between our Delete and Create. Refetch and - // overwrite with our content so the caller still gets the - // credentials it asked for. - current, getErr := secrets.Get(ctx, name, metav1.GetOptions{}) - if getErr != nil { - return fmt.Errorf("get after create race: %w", getErr) - } - if current.Type != corev1.SecretTypeDockerConfigJson { - return fmt.Errorf("create race left secret %s/%s with type %s, want %s", - namespace, name, current.Type, corev1.SecretTypeDockerConfigJson) - } - if current.Data == nil { - current.Data = map[string][]byte{} - } - current.Data[corev1.DockerConfigJsonKey] = dockerConfig - if current.Labels == nil { - current.Labels = map[string]string{} - } - for k, v := range labels { - current.Labels[k] = v - } - if _, err := secrets.Update(ctx, current, metav1.UpdateOptions{}); err != nil { - return fmt.Errorf("update after create race: %w", err) + "refusing to overwrite existing secret %s/%s: this run generated that name, so "+ + "another object already holds it; pass --cluster-validator-pull-secret to "+ + "choose an explicit secret name", + namespace, name) } + return fmt.Errorf("create: %w", err) } + created.add(kindSecret, got) return nil } -// isManagedByValidatorCLI reports whether the secret carries the labels -// writeDockerConfigSecret stamps on every secret it creates. Used to -// gate the delete-then-recreate branch so we never destroy an operator- -// or chart-owned secret that happens to share a name with one of ours. -func isManagedByValidatorCLI(s *corev1.Secret) bool { +// dockerCfgFromConfigJSON turns a .dockerconfigjson body into a .dockercfg +// one: the same per-registry entries without the "auths" wrapper. +func dockerCfgFromConfigJSON(cfg []byte) ([]byte, error) { + var doc struct { + Auths map[string]json.RawMessage `json:"auths"` + } + if err := json.Unmarshal(cfg, &doc); err != nil { + return nil, err + } + if len(doc.Auths) == 0 { + return nil, fmt.Errorf("no auths entries") + } + return json.Marshal(doc.Auths) +} + +// isAnyValidatorSecret reports whether a Secret was minted by any version of +// this CLI's validator, including released ones that label with +// managed-by=nvcf-cli and use the bare nvcf-preflight-pull-secret name. Such a +// Secret belongs to another run: its CLI deletes it on exit, possibly while +// this run's pod is still pulling, and a stale one would be reused forever. +func isAnyValidatorSecret(s *corev1.Secret) bool { + return hasValidatorManagedLabels(s.Labels) || + s.Labels["app.kubernetes.io/name"] == clusterValidatorAppLabel || + strings.HasPrefix(s.Name, validatorPullSecretName) +} + +// hasValidatorManagedLabels reports whether an object carries the labels this +// CLI stamps on everything it creates. It is the ownership test for every +// resource the validator writes to or deletes by name, not just Secrets. +func hasValidatorManagedLabels(labels map[string]string) bool { for k, v := range clusterValidatorLabels() { - if s.Labels[k] != v { + if labels[k] != v { return false } } diff --git a/src/clis/nvcf-cli/internal/selfhosted/pullsecret_test.go b/src/clis/nvcf-cli/internal/selfhosted/pullsecret_test.go index 7a77df310c..59bebea33a 100644 --- a/src/clis/nvcf-cli/internal/selfhosted/pullsecret_test.go +++ b/src/clis/nvcf-cli/internal/selfhosted/pullsecret_test.go @@ -21,14 +21,24 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/kubernetes/fake" + ktesting "k8s.io/client-go/testing" ) func dockerConfigBlob(t *testing.T, registry, user, pass string) []byte { @@ -54,46 +64,59 @@ func mustDockerConfigJSON(t *testing.T, registry, user, pass string) []byte { return b } -func TestParseRegistryFromImage(t *testing.T) { - cases := []struct { - name string - in string - want string - }{ - {"FQDN with tag", "private.registry.test/nvidia/nvcf-byoc/cluster-validator:3.0.0-rc.26", "private.registry.test"}, - {"FQDN with digest", "nvcr.io/foo/bar@sha256:abc", "nvcr.io"}, - {"FQDN latest", "nvcr.io/foo/bar:latest", "nvcr.io"}, - {"localhost with port", "localhost:5000/foo/bar:latest", "localhost:5000"}, - {"localhost no port", "localhost/foo/bar", "localhost"}, - {"docker hub shorthand", "foo/bar:latest", ""}, - {"single segment", "bar", ""}, - {"empty", "", ""}, - {"only slashes", "///", ""}, - {"leading whitespace", " nvcr.io/foo/bar:latest ", "nvcr.io"}, +// resolvePullSecret runs the resolver for image with no explicit secret. +func resolvePullSecret(t *testing.T, client *fake.Clientset, image string) (string, string, error) { + t.Helper() + return resolveValidatorPullSecret(context.Background(), client, "", image, + clusterValidatorControlPlaneRole, "runid", false, nil) +} + +func dockerConfigSecret(name, namespace string, cfg []byte) *corev1.Secret { + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, + Type: corev1.SecretTypeDockerConfigJson, + Data: map[string][]byte{corev1.DockerConfigJsonKey: cfg}, } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - assert.Equal(t, c.want, parseRegistryFromImage(c.in)) - }) +} + +// mintedKeys returns the registries a Secret this CLI minted or copied, a +// kubernetes.io/dockercfg one, carries credentials for. +func mintedKeys(t *testing.T, s *corev1.Secret) []string { + t.Helper() + require.Equal(t, corev1.SecretTypeDockercfg, s.Type) + var entries map[string]json.RawMessage + require.NoError(t, json.Unmarshal(s.Data[corev1.DockerConfigKey], &entries)) + var keys []string + for k := range entries { + keys = append(keys, k) } + return keys } -func TestDockerConfigHasRegistry(t *testing.T) { - t.Run("registry present", func(t *testing.T) { - cfg := dockerConfigBlob(t, "private.registry.test", "$oauthtoken", "key") - assert.True(t, dockerConfigHasRegistry(cfg, "private.registry.test")) - }) - t.Run("registry absent", func(t *testing.T) { - cfg := dockerConfigBlob(t, "nvcr.io", "$oauthtoken", "key") - assert.False(t, dockerConfigHasRegistry(cfg, "private.registry.test")) - }) - t.Run("malformed JSON", func(t *testing.T) { - assert.False(t, dockerConfigHasRegistry([]byte("not json"), "private.registry.test")) - }) - t.Run("missing auths key", func(t *testing.T) { - cfg, _ := json.Marshal(map[string]any{"unrelated": "value"}) - assert.False(t, dockerConfigHasRegistry(cfg, "private.registry.test")) - }) +// Keys match the way the kubelet reads them: a scheme or a /v1/ path is +// ignored, and a key with another path covers only repositories under it. +func TestFilterDockerConfig(t *testing.T) { + for _, tc := range []struct { + key, registry, repo string + want bool + }{ + {"private.registry.test", "private.registry.test", "a/b", true}, + {"https://private.registry.test", "private.registry.test", "a/b", true}, + {"https://private.registry.test/v1/", "private.registry.test", "a/b", true}, + {"private.registry.test/v2/", "private.registry.test", "a/b", true}, + {"private.registry.test/a", "private.registry.test", "a/b", true}, + {"private.registry.test/ab", "private.registry.test", "a/b", false}, + {"nvcr.io", "private.registry.test", "a/b", false}, + {"https://index.docker.io/v1/", dockerHubRegistry, "library/busybox", true}, + } { + _, ok := filterDockerConfig(dockerConfigBlob(t, tc.key, "u", "p"), tc.registry, tc.repo) + assert.Equal(t, tc.want, ok, "%s for %s/%s", tc.key, tc.registry, tc.repo) + } + _, ok := filterDockerConfig([]byte("not json"), "private.registry.test", "a") + assert.False(t, ok) + noAuths, _ := json.Marshal(map[string]any{"unrelated": "value"}) + _, ok = filterDockerConfig(noAuths, "private.registry.test", "a") + assert.False(t, ok) } func TestBuildDockerConfigJSON(t *testing.T) { @@ -115,295 +138,643 @@ func TestBuildDockerConfigJSON(t *testing.T) { assert.Equal(t, wantAuth, entry.Auth, "auth field must be base64(username:password)") } -func TestFirstNonEmptyEnv(t *testing.T) { - t.Setenv("VALIDATOR_TEST_FIRST", "first-val") - t.Setenv("VALIDATOR_TEST_SECOND", "") - t.Setenv("VALIDATOR_TEST_THIRD", "third-val") - - t.Run("returns first non-empty in order", func(t *testing.T) { - got := firstNonEmptyEnv("VALIDATOR_TEST_SECOND", "VALIDATOR_TEST_FIRST", "VALIDATOR_TEST_THIRD") - assert.Equal(t, "first-val", got, "second is empty so first hits") - }) - t.Run("returns empty when all unset", func(t *testing.T) { - got := firstNonEmptyEnv("VALIDATOR_TEST_NONEXISTENT_A", "VALIDATOR_TEST_NONEXISTENT_B") - assert.Equal(t, "", got) - }) -} - -func TestWriteDockerConfigSecret_Create(t *testing.T) { - client := fake.NewSimpleClientset() +// An operator's Secret in default is used as is, without a copy. +func TestResolveValidatorPullSecret_AdoptsOperatorSecretInDefault(t *testing.T) { cfg := dockerConfigBlob(t, "private.registry.test", "$oauthtoken", "key") + client := fake.NewSimpleClientset(dockerConfigSecret("operator-secret", "default", cfg)) - require.NoError(t, writeDockerConfigSecret(context.Background(), client, "default", "nvcr-pull-secret", cfg)) - - got, err := client.CoreV1().Secrets("default").Get(context.Background(), "nvcr-pull-secret", metav1.GetOptions{}) + name, note, err := resolvePullSecret(t, client, "private.registry.test/nvidia/cv:1") require.NoError(t, err) - assert.Equal(t, corev1.SecretTypeDockerConfigJson, got.Type) - assert.Equal(t, cfg, got.Data[corev1.DockerConfigJsonKey]) - assert.Equal(t, clusterValidatorAppLabel, got.Labels["app.kubernetes.io/name"], - "label must be attached so the secret is identifiable as CLI-managed") + assert.Equal(t, "operator-secret", name) + assert.Empty(t, note) + list, _ := client.CoreV1().Secrets("default").List(context.Background(), metav1.ListOptions{}) + assert.Len(t, list.Items, 1, "no copy when the source is already in the validator namespace") } -func TestWriteDockerConfigSecret_Update(t *testing.T) { - old := dockerConfigBlob(t, "private.registry.test", "$oauthtoken", "stale-key") - fresh := dockerConfigBlob(t, "private.registry.test", "$oauthtoken", "rotated-key") - - client := fake.NewSimpleClientset(&corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "nvcr-pull-secret", - Namespace: "default", - Labels: map[string]string{"existing-label": "preserved"}, +// A Secret found elsewhere is copied into default under the run's own name, +// holding only the image registry's entry, and the run says what it copied +// from where. +func TestResolveValidatorPullSecret_CopiesOnlyTheRegistrysEntry(t *testing.T) { + cfg, err := json.Marshal(map[string]any{"auths": map[string]any{ + "nvcr.io": map[string]string{"auth": base64.StdEncoding.EncodeToString([]byte("a:b"))}, + "private.corp.example": map[string]string{"auth": base64.StdEncoding.EncodeToString([]byte("c:d"))}, + "https://index.docker.io/v1/": map[string]string{ + "auth": base64.StdEncoding.EncodeToString([]byte("e:f")), }, - Type: corev1.SecretTypeDockerConfigJson, - Data: map[string][]byte{corev1.DockerConfigJsonKey: old}, - }) + }}) + require.NoError(t, err) + client := fake.NewSimpleClientset(dockerConfigSecret("regcred", "vault-system", cfg)) - require.NoError(t, writeDockerConfigSecret(context.Background(), client, "default", "nvcr-pull-secret", fresh)) + name, note, err := resolvePullSecret(t, client, "nvcr.io/nvidia/cv:1") + require.NoError(t, err) + runName := validatorPullSecretRunName(clusterValidatorControlPlaneRole, "runid") + assert.Equal(t, runName, name) + assert.Equal(t, "copied the pull credential for nvcr.io from vault-system/regcred into default/"+runName+ + " for this run", note) - got, _ := client.CoreV1().Secrets("default").Get(context.Background(), "nvcr-pull-secret", metav1.GetOptions{}) - assert.Equal(t, fresh, got.Data[corev1.DockerConfigJsonKey], "data must be updated to the fresh body") - assert.Equal(t, "preserved", got.Labels["existing-label"], "pre-existing labels must be preserved on update") - assert.Equal(t, clusterValidatorAppLabel, got.Labels["app.kubernetes.io/name"], "CLI labels must be added on update") + copied, err := client.CoreV1().Secrets("default").Get(context.Background(), runName, metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, []string{"nvcr.io"}, mintedKeys(t, copied), + "no other registry's credential is copied") + src, err := client.CoreV1().Secrets("vault-system").Get(context.Background(), "regcred", metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, cfg, src.Data[corev1.DockerConfigJsonKey], "the source is untouched") } -// Regression guard for the immutable-Type silent-failure path: a pre-existing -// secret with the wrong Type (but our labels) must be replaced (delete + -// create), not Updated. -func TestWriteDockerConfigSecret_TypeMismatchReplaces(t *testing.T) { - fresh := dockerConfigBlob(t, "private.registry.test", "$oauthtoken", "key") - client := fake.NewSimpleClientset(&corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "nvcr-pull-secret", - Namespace: "default", - Labels: clusterValidatorLabels(), - }, - Type: corev1.SecretTypeOpaque, // wrong type — Update would 422 - Data: map[string][]byte{"some-key": []byte("some-value")}, +// The scan asks for docker-registry Secrets only, and falls back to listing +// everything when the server refuses the field selector. +func TestResolveValidatorPullSecret_ListsOnlyDockerConfigSecrets(t *testing.T) { + cfg := dockerConfigBlob(t, "private.registry.test", "u", "p") + client := fake.NewSimpleClientset(dockerConfigSecret("creds", "nvcf", cfg)) + var selectors []string + refused := false + client.PrependReactor("list", "secrets", func(action ktesting.Action) (bool, runtime.Object, error) { + sel := action.(ktesting.ListActionImpl).GetListRestrictions().Fields.String() + selectors = append(selectors, sel) + if sel != "" && !refused { + refused = true + return true, nil, apierrors.NewBadRequest("field selector not supported") + } + return false, nil, nil }) - - require.NoError(t, writeDockerConfigSecret(context.Background(), client, "default", "nvcr-pull-secret", fresh)) - - got, err := client.CoreV1().Secrets("default").Get(context.Background(), "nvcr-pull-secret", metav1.GetOptions{}) + name, _, err := resolvePullSecret(t, client, "private.registry.test/nvidia/cv:1") require.NoError(t, err) - assert.Equal(t, corev1.SecretTypeDockerConfigJson, got.Type, - "secret must be recreated with the correct dockerconfigjson type") - assert.Equal(t, fresh, got.Data[corev1.DockerConfigJsonKey]) - assert.NotContains(t, got.Data, "some-key", "old Opaque payload must not survive the recreate") + assert.Equal(t, validatorPullSecretRunName(clusterValidatorControlPlaneRole, "runid"), name) + require.NotEmpty(t, selectors) + assert.Equal(t, "type=kubernetes.io/dockerconfigjson", selectors[0]) + assert.Contains(t, selectors, "", "a refused selector falls back to an unfiltered list") } -// Operator-owned secret with the same name but no CLI labels must NOT be -// destroyed. The replace path is the only place operator data is at risk -// (immutable Type forces delete+create), so the label guard lives there. -func TestWriteDockerConfigSecret_TypeMismatchRefusesUnlabeledSecret(t *testing.T) { - fresh := dockerConfigBlob(t, "private.registry.test", "$oauthtoken", "key") - operatorPayload := []byte("operator-data-do-not-destroy") - client := fake.NewSimpleClientset(&corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "nvcr-pull-secret", - Namespace: "default", - // No CLI labels: an operator- or chart-owned secret. - }, - Type: corev1.SecretTypeOpaque, - Data: map[string][]byte{"operator-payload": operatorPayload}, - }) - - err := writeDockerConfigSecret(context.Background(), client, "default", "nvcr-pull-secret", fresh) - require.Error(t, err, "must refuse to destroy a non-CLI-managed secret") - assert.Contains(t, err.Error(), "not managed by nvcf-cli") - - // Original payload must still be intact. - got, getErr := client.CoreV1().Secrets("default").Get(context.Background(), "nvcr-pull-secret", metav1.GetOptions{}) - require.NoError(t, getErr) - assert.Equal(t, corev1.SecretTypeOpaque, got.Type, "type must be unchanged") - assert.Equal(t, operatorPayload, got.Data["operator-payload"], "operator data must not be touched") +func TestResolveValidatorPullSecret_RegistryMismatch(t *testing.T) { + cfg := dockerConfigBlob(t, "nvcr.io", "$oauthtoken", "key") + client := fake.NewSimpleClientset(dockerConfigSecret("wrong-registry", "nvcf", cfg)) + name, _, err := resolvePullSecret(t, client, "private.registry.test/nvidia/cv:1") + require.NoError(t, err) + assert.Equal(t, "", name, "secret for a different registry must not match") } -func TestScanAndMirrorPullSecret_FoundInDefault(t *testing.T) { +func TestResolveValidatorPullSecret_PrefersDefaultNamespace(t *testing.T) { cfg := dockerConfigBlob(t, "private.registry.test", "$oauthtoken", "key") - client := fake.NewSimpleClientset(&corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{Name: "operator-secret", Namespace: "default"}, - Type: corev1.SecretTypeDockerConfigJson, - Data: map[string][]byte{corev1.DockerConfigJsonKey: cfg}, - }) + client := fake.NewSimpleClientset( + dockerConfigSecret("in-default", "default", cfg), + dockerConfigSecret("in-nvcf", "nvcf", cfg), + ) + name, _, err := resolvePullSecret(t, client, "private.registry.test/nvidia/cv:1") + require.NoError(t, err) + assert.Equal(t, "in-default", name) +} - name, err := scanAndMirrorPullSecret(context.Background(), client, "private.registry.test") +// A Secret created with --docker-server=https://host is the kubelet's +// credential for host, so the scan finds it too. +func TestResolveValidatorPullSecret_MatchesSchemePrefixedKeys(t *testing.T) { + cfg := dockerConfigBlob(t, "https://nvcr.io", "$oauthtoken", "key") + client := fake.NewSimpleClientset(dockerConfigSecret("regcred", "nvcf", cfg)) + name, _, err := resolvePullSecret(t, client, "nvcr.io/nvidia/cv:1") require.NoError(t, err) - assert.Equal(t, "operator-secret", name) + assert.Equal(t, validatorPullSecretRunName(clusterValidatorControlPlaneRole, "runid"), name) +} + +func TestResolveValidatorPullSecret_FlagOverride(t *testing.T) { + client := fake.NewSimpleClientset() + got, note, err := resolveValidatorPullSecret(context.Background(), client, "custom-secret", + "private.registry.test/nvidia/nvcf-byoc/cluster-validator:rc26", clusterValidatorControlPlaneRole, "runid", false, + nil) + require.NoError(t, err) + assert.Equal(t, "custom-secret", got, "explicit override always wins") + assert.Empty(t, note) - // Secret in default should not have been duplicated. + // No cluster mutation expected on the override path. list, _ := client.CoreV1().Secrets("default").List(context.Background(), metav1.ListOptions{}) - assert.Len(t, list.Items, 1, "no mirror should happen when the source is already in clusterValidatorNamespace") + assert.Empty(t, list.Items) } -func TestScanAndMirrorPullSecret_FoundInNvcfMirroredToDefault(t *testing.T) { - cfg := dockerConfigBlob(t, "private.registry.test", "$oauthtoken", "key") - // Source name deliberately different from validatorPullSecretName - // to verify the mirror writes under our well-known destination - // name, not the source name (which could collide with an - // operator-owned secret in default). - client := fake.NewSimpleClientset(&corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{Name: "chart-owned-creds", Namespace: "nvcf"}, - Type: corev1.SecretTypeDockerConfigJson, - Data: map[string][]byte{corev1.DockerConfigJsonKey: cfg}, - }) +// With no matching cluster Secret, the run mints one from this machine's +// credential for the image's registry, the one the local row checked: the +// NGC key for nvcr.io, a docker login for any other registry. +func TestResolveValidatorPullSecret_MintsTheCredentialTheRowChecked(t *testing.T) { + runName := validatorPullSecretRunName(clusterValidatorControlPlaneRole, "runid") + minted := func(t *testing.T, client *fake.Clientset) *corev1.Secret { + t.Helper() + s, err := client.CoreV1().Secrets("default").Get(context.Background(), runName, metav1.GetOptions{}) + require.NoError(t, err) + return s + } - name, err := scanAndMirrorPullSecret(context.Background(), client, "private.registry.test") + t.Setenv("NVCF_NGC_API_KEY", "nvapi-fallback") + client := fake.NewSimpleClientset() + name, note, err := resolvePullSecret(t, client, "nvcr.io/nvidia/nvcf-byoc/cluster-validator:rc26") require.NoError(t, err) - assert.Equal(t, validatorPullSecretName, name) - - mirrored, err := client.CoreV1().Secrets("default").Get(context.Background(), validatorPullSecretName, metav1.GetOptions{}) - require.NoError(t, err, "mirror must be created under validatorPullSecretName") - assert.Equal(t, cfg, mirrored.Data[corev1.DockerConfigJsonKey]) + assert.Equal(t, runName, name) + assert.Contains(t, note, "from NVCF_NGC_API_KEY") + assert.Equal(t, []string{"nvcr.io"}, mintedKeys(t, minted(t, client))) - // The source secret must remain untouched in the source namespace. - src, err := client.CoreV1().Secrets("nvcf").Get(context.Background(), "chart-owned-creds", metav1.GetOptions{}) + dockerHome(t, inlineDockerConfig(t, "example.nvcr.io", "robot", "pw", "")) + client = fake.NewSimpleClientset() + name, note, err = resolvePullSecret(t, client, "example.nvcr.io/nvidia/cv:1") require.NoError(t, err) - assert.Equal(t, cfg, src.Data[corev1.DockerConfigJsonKey]) + assert.Equal(t, runName, name, "a docker login reaches the Job the row approved it for") + assert.Contains(t, note, "docker config") + secret := minted(t, client) + assert.NotContains(t, string(secret.Data[corev1.DockerConfigKey]), "nvapi-fallback", + "the NGC key never goes to another registry") + assert.Equal(t, []string{"example.nvcr.io"}, mintedKeys(t, secret)) } -func TestScanAndMirrorPullSecret_RegistryMismatch(t *testing.T) { - cfg := dockerConfigBlob(t, "nvcr.io", "$oauthtoken", "key") - client := fake.NewSimpleClientset(&corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{Name: "wrong-registry", Namespace: "nvcf"}, - Type: corev1.SecretTypeDockerConfigJson, - Data: map[string][]byte{corev1.DockerConfigJsonKey: cfg}, - }) +// The NGC key is minted for nvcr.io only. Another registry, staging NGC +// included, never gets it. +func TestResolveValidatorPullSecret_NGCKeyOnlyForNvcrIO(t *testing.T) { + t.Setenv("NGC_API_KEY", "nvapi-test-123") + for _, image := range []string{ + "example.nvcr.io/a/b:1", "registry.example.nvidia.com:5000/a/b:1", "ghcr.io/a/b:1", "bareimage", + } { + client := fake.NewSimpleClientset() + got, _, err := resolvePullSecret(t, client, image) + require.NoError(t, err) + assert.Empty(t, got, image) + list, _ := client.CoreV1().Secrets("default").List(context.Background(), metav1.ListOptions{}) + assert.Empty(t, list.Items, "%s must not get the NGC key", image) + } +} - name, err := scanAndMirrorPullSecret(context.Background(), client, "private.registry.test") +func TestResolveValidatorPullSecret_AllEmpty(t *testing.T) { + client := fake.NewSimpleClientset() + got, _, err := resolvePullSecret(t, client, "private.registry.test/nvidia/nvcf-byoc/cluster-validator:rc26") require.NoError(t, err) - assert.Equal(t, "", name, "secret for a different registry must not match") + assert.Equal(t, "", got, "no override, no scan match, no local credential -> pulled without a secret") } -func TestScanAndMirrorPullSecret_NoSecrets(t *testing.T) { +// A refused create is returned with the step that failed, not dropped. +func TestResolveValidatorPullSecret_ReportsARefusedCreate(t *testing.T) { + t.Setenv("NGC_API_KEY", "key") client := fake.NewSimpleClientset() - name, err := scanAndMirrorPullSecret(context.Background(), client, "private.registry.test") - require.NoError(t, err) - assert.Equal(t, "", name) + client.PrependReactor("create", "secrets", func(ktesting.Action) (bool, runtime.Object, error) { + return true, nil, apierrors.NewForbidden(corev1.Resource("secrets"), "x", errors.New("denied by policy")) + }) + _, _, err := resolvePullSecret(t, client, "nvcr.io/nvidia/cv:1") + require.Error(t, err) + assert.Contains(t, err.Error(), "create pull secret default/") + assert.Contains(t, err.Error(), "denied by policy") } -func TestScanAndMirrorPullSecret_PrefersDefaultNamespace(t *testing.T) { - cfg := dockerConfigBlob(t, "private.registry.test", "$oauthtoken", "key") - client := fake.NewSimpleClientset( - &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{Name: "in-default", Namespace: "default"}, - Type: corev1.SecretTypeDockerConfigJson, - Data: map[string][]byte{corev1.DockerConfigJsonKey: cfg}, - }, - &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{Name: "in-nvcf", Namespace: "nvcf"}, - Type: corev1.SecretTypeDockerConfigJson, - Data: map[string][]byte{corev1.DockerConfigJsonKey: cfg}, - }, - ) +// ModeSplit runs both validators concurrently against contexts that can resolve +// to the same cluster, and runs of one role can overlap. A Secret cannot be +// co-owned, so each role and each run needs its own: with a shared name the +// run that finishes first deletes it while another Job is still pulling, which +// the kubelet reports as FailedToRetrieveImagePullSecret. One run's sweep +// removes its own Secret and leaves the other three. +func TestManagedPullSecret_IsScopedPerRoleAndRun(t *testing.T) { + ctx := context.Background() + cfg := dockerConfigBlob(t, "private.registry.test", "user", "pass") + client := fake.NewSimpleClientset() + names := map[string]bool{} + ledgers := map[string]runObjects{} + for _, role := range []string{clusterValidatorControlPlaneRole, clusterValidatorComputePlaneRole} { + for _, run := range []string{"run1", "run2"} { + name := validatorPullSecretRunName(role, run) + require.False(t, names[name], "%s/%s reuses %s", role, run, name) + names[name] = true + ledgers[role+"/"+run] = runObjects{} + require.NoError(t, writeDockerConfigSecret(ctx, client, clusterValidatorNamespace, + name, role, run, cfg, false, ledgers[role+"/"+run])) + } + } - name, err := scanAndMirrorPullSecret(context.Background(), client, "private.registry.test") - require.NoError(t, err) - assert.Equal(t, "in-default", name) -} + deleteCreated(ctx, client, ledgers[clusterValidatorControlPlaneRole+"/run1"], kindSecret) -func TestAutoCreatePullSecretFromEnv_NoEnv(t *testing.T) { - // Clear all env vars in the chain. - for _, name := range ngcAPIKeyEnvNames { - t.Setenv(name, "") + secrets := client.CoreV1().Secrets(clusterValidatorNamespace) + for name := range names { + _, err := secrets.Get(ctx, name, metav1.GetOptions{}) + if name == validatorPullSecretRunName(clusterValidatorControlPlaneRole, "run1") { + assert.True(t, apierrors.IsNotFound(err), "the run's own Secret is swept") + continue + } + assert.NoError(t, err, "%s belongs to another role or run and must survive", name) } +} + +// The minted Secret is the legacy kubernetes.io/dockercfg type. A released +// CLI adopts any kubernetes.io/dockerconfigjson Secret in the namespace whose +// registry matches, labels or not, so it would reuse this run's NGC key and +// then lose it mid-pull when this run's cleanup deletes it. +func TestWriteDockerConfigSecret_IsHiddenFromReleasedScans(t *testing.T) { + ctx := context.Background() client := fake.NewSimpleClientset() + cfg := dockerConfigBlob(t, "nvcr.io", "$oauthtoken", "key") + name := validatorPullSecretRunName(clusterValidatorControlPlaneRole, "run1") + require.NoError(t, writeDockerConfigSecret(ctx, client, clusterValidatorNamespace, + name, clusterValidatorControlPlaneRole, "run1", cfg, false, nil)) - got, err := autoCreatePullSecretFromEnv(context.Background(), client, "private.registry.test") + // What a released CLI's scan matches on. + list, err := client.CoreV1().Secrets(clusterValidatorNamespace).List(ctx, metav1.ListOptions{}) require.NoError(t, err) - assert.Equal(t, "", got, "no env var set means no secret minted") + for _, s := range list.Items { + _, hasRegistry := filterDockerConfig(s.Data[corev1.DockerConfigJsonKey], "nvcr.io", "") + adoptable := s.Type == corev1.SecretTypeDockerConfigJson && hasRegistry + assert.False(t, adoptable, "%s must not look like a dockerconfigjson pull secret", s.Name) + } - list, _ := client.CoreV1().Secrets("default").List(context.Background(), metav1.ListOptions{}) - assert.Empty(t, list.Items, "no secret must be created when env is empty") + got, err := client.CoreV1().Secrets(clusterValidatorNamespace).Get(ctx, name, metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, corev1.SecretTypeDockercfg, got.Type) + var entries map[string]struct { + Username string `json:"username"` + Password string `json:"password"` + } + require.NoError(t, json.Unmarshal(got.Data[corev1.DockerConfigKey], &entries)) + assert.Equal(t, "$oauthtoken", entries["nvcr.io"].Username, "the kubelet reads the registry's entry") + assert.Equal(t, "key", entries["nvcr.io"].Password) } -func TestAutoCreatePullSecretFromEnv_KeyPresent(t *testing.T) { - for _, name := range ngcAPIKeyEnvNames { - t.Setenv(name, "") - } - t.Setenv("NGC_API_KEY", "nvapi-test-123") +// clusterValidatorRoleLabels are the managed labels plus the role, with no run +// ID. +func clusterValidatorRoleLabels(role string) map[string]string { + return clusterValidatorRunLabels(role, "", false) +} + +// validatorRoleSelector matches the objects this CLI created for one role. +func validatorRoleSelector(role string) string { + return labels.SelectorFromSet(clusterValidatorRoleLabels(role)).String() +} +// The managed labels a created secret carries must satisfy the sweep selector +// for that role, and must not satisfy another role's. +func TestManagedPullSecret_LabelsMatchOnlyItsOwnRole(t *testing.T) { + ctx := context.Background() client := fake.NewSimpleClientset() - got, err := autoCreatePullSecretFromEnv(context.Background(), client, "private.registry.test") + cfg := dockerConfigBlob(t, "nvcr.io", "$oauthtoken", "key") + name := validatorPullSecretRunName(clusterValidatorControlPlaneRole, "runid") + require.NoError(t, writeDockerConfigSecret(ctx, client, clusterValidatorNamespace, + name, clusterValidatorControlPlaneRole, "run1", cfg, false, nil)) + + own, err := client.CoreV1().Secrets(clusterValidatorNamespace).List(ctx, + metav1.ListOptions{LabelSelector: validatorRoleSelector(clusterValidatorControlPlaneRole)}) require.NoError(t, err) - assert.Equal(t, validatorPullSecretName, got) + assert.Len(t, own.Items, 1, "the secret must match its own role's selector") - s, err := client.CoreV1().Secrets("default").Get(context.Background(), validatorPullSecretName, metav1.GetOptions{}) + other, err := client.CoreV1().Secrets(clusterValidatorNamespace).List(ctx, + metav1.ListOptions{LabelSelector: validatorRoleSelector(clusterValidatorComputePlaneRole)}) require.NoError(t, err) - assert.True(t, dockerConfigHasRegistry(s.Data[corev1.DockerConfigJsonKey], "private.registry.test"), - "minted secret must contain auth for the validator image's registry") + assert.Empty(t, other.Items, "it must not match the other role's selector") } -func TestResolveValidatorPullSecret_FlagOverride(t *testing.T) { +func TestResolveValidatorPullSecret_RefusesNonNGCRegistry(t *testing.T) { + t.Setenv("NGC_API_KEY", "nvapi-secret-value") client := fake.NewSimpleClientset() - got, err := resolveValidatorPullSecret(context.Background(), client, "custom-secret", "private.registry.test/nvidia/nvcf-byoc/cluster-validator:rc26") + + name, _, err := resolveValidatorPullSecret(context.Background(), client, "", + "ghcr.io/a/b:1", clusterValidatorControlPlaneRole, "runid", false, nil) require.NoError(t, err) - assert.Equal(t, "custom-secret", got, "explicit override always wins") + assert.Empty(t, name, "no secret may be minted for a non-NGC registry") - // No cluster mutation expected on the override path. - list, _ := client.CoreV1().Secrets("default").List(context.Background(), metav1.ListOptions{}) - assert.Empty(t, list.Items) + secrets, err := client.CoreV1().Secrets(clusterValidatorNamespace).List( + context.Background(), metav1.ListOptions{}) + require.NoError(t, err) + assert.Empty(t, secrets.Items, "the NGC key must not reach a third-party registry") } -func TestResolveValidatorPullSecret_ScanWins(t *testing.T) { - for _, name := range ngcAPIKeyEnvNames { - t.Setenv(name, "") +// In ModeSplit both kubecontexts can resolve to one cluster. Adopting the +// other role's managed Secret means that role's sweep deletes it while this +// role's pod is still pulling. +func TestResolveValidatorPullSecret_DoesNotAdoptTheOtherRolesSecret(t *testing.T) { + cfg := dockerConfigBlob(t, "nvcr.io", "$oauthtoken", "key") + otherRole := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: validatorPullSecretRunName(clusterValidatorControlPlaneRole, "runid"), + Namespace: clusterValidatorNamespace, + Labels: clusterValidatorRoleLabels(clusterValidatorControlPlaneRole), + }, + Type: corev1.SecretTypeDockerConfigJson, + Data: map[string][]byte{corev1.DockerConfigJsonKey: cfg}, } - cfg := dockerConfigBlob(t, "private.registry.test", "$oauthtoken", "scan-key") - client := fake.NewSimpleClientset(&corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{Name: "operator-secret", Namespace: "nvcf"}, + client := fake.NewSimpleClientset(otherRole) + + got, _, err := resolveValidatorPullSecret(context.Background(), client, "", "nvcr.io/nvidia/cv:1", + clusterValidatorComputePlaneRole, "runid", false, nil) + require.NoError(t, err) + assert.NotEqual(t, otherRole.Name, got, + "the compute role must not adopt the control-plane role's managed Secret") +} + +// An operator-supplied Secret carries no managed labels, so no sweep touches +// it and adopting it is safe. +func TestResolveValidatorPullSecret_AdoptsOperatorSuppliedSecret(t *testing.T) { + cfg := dockerConfigBlob(t, "nvcr.io", "$oauthtoken", "key") + operator := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "operator-pull", Namespace: clusterValidatorNamespace}, Type: corev1.SecretTypeDockerConfigJson, Data: map[string][]byte{corev1.DockerConfigJsonKey: cfg}, - }) + } + client := fake.NewSimpleClientset(operator) - got, err := resolveValidatorPullSecret(context.Background(), client, "", "private.registry.test/nvidia/nvcf-byoc/cluster-validator:rc26") + got, _, err := resolveValidatorPullSecret(context.Background(), client, "", "nvcr.io/nvidia/cv:1", + clusterValidatorComputePlaneRole, "runid", false, nil) require.NoError(t, err) - // Mirror destination is the validator's well-known name, not the - // source secret's name (avoids same-name collisions with operator - // or chart-owned secrets in the destination namespace). - assert.Equal(t, validatorPullSecretName, got) + assert.Equal(t, "operator-pull", got) +} + +// No Secret another run minted is adopted, even one of the same role: it may +// hold a stale key, may be deleted by its own run's sweep mid-pull, and a +// --no-cleanup one would otherwise be adopted by every later run. +func TestResolveValidatorPullSecret_NeverAdoptsAnotherRunsSecret(t *testing.T) { + cfg := dockerConfigBlob(t, "nvcr.io", "$oauthtoken", "key") + secret := func(runID string, preserve bool) *corev1.Secret { + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: validatorPullSecretRunName(clusterValidatorComputePlaneRole, runID), + Namespace: clusterValidatorNamespace, + Labels: clusterValidatorRunLabels(clusterValidatorComputePlaneRole, runID, preserve), + }, + Type: corev1.SecretTypeDockerConfigJson, + Data: map[string][]byte{corev1.DockerConfigJsonKey: cfg}, + } + } + client := fake.NewSimpleClientset(secret("earlier", false), secret("kept", true)) - mirrored, err := client.CoreV1().Secrets("default").Get(context.Background(), validatorPullSecretName, metav1.GetOptions{}) + got, _, err := resolveValidatorPullSecret(context.Background(), client, "", "nvcr.io/nvidia/cv:1", + clusterValidatorComputePlaneRole, "runid", false, nil) require.NoError(t, err) - assert.True(t, dockerConfigHasRegistry(mirrored.Data[corev1.DockerConfigJsonKey], "private.registry.test")) + assert.Empty(t, got, "a managed Secret from another run must not be adopted") } -func TestResolveValidatorPullSecret_EnvFallback(t *testing.T) { - for _, name := range ngcAPIKeyEnvNames { - t.Setenv(name, "") +// writeDockerConfigSecret is create-only. The name carries this run's +// unguessable suffix, so nothing this CLI created can already hold it and any +// collision is another object. Adopting one would write the NGC credential +// into something we do not own, which label-based ownership cannot prevent: +// the managed labels are three public constants anyone can copy. +func TestWriteDockerConfigSecret_CreatesUnderTheRunScopedName(t *testing.T) { + cfg := dockerConfigBlob(t, "nvcr.io", "$oauthtoken", "key") + client := fake.NewSimpleClientset() + name := validatorPullSecretRunName(clusterValidatorControlPlaneRole, "runid") + + require.NoError(t, writeDockerConfigSecret(context.Background(), client, + clusterValidatorNamespace, name, clusterValidatorControlPlaneRole, "run1", cfg, false, nil)) + + s, err := client.CoreV1().Secrets(clusterValidatorNamespace).Get( + context.Background(), name, metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, corev1.SecretTypeDockercfg, s.Type) + assert.True(t, hasValidatorManagedLabels(s.Labels)) +} + +func TestWriteDockerConfigSecret_RefusesAnyCollision(t *testing.T) { + cfg := dockerConfigBlob(t, "nvcr.io", "$oauthtoken", "key") + name := validatorPullSecretRunName(clusterValidatorControlPlaneRole, "runid") + + // Even wearing our managed labels: labels are forgeable, the name is not. + squatter := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, Namespace: clusterValidatorNamespace, + Labels: clusterValidatorLabels(), + }, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{"planted": []byte("x")}, } - t.Setenv("NVCF_NGC_API_KEY", "nvapi-fallback") + client := fake.NewSimpleClientset(squatter) + + err := writeDockerConfigSecret(context.Background(), client, + clusterValidatorNamespace, name, clusterValidatorControlPlaneRole, "run1", cfg, false, nil) + require.Error(t, err, "a collision on an unguessable name must never be adopted") + assert.Contains(t, err.Error(), "refusing to overwrite") + + got, gerr := client.CoreV1().Secrets(clusterValidatorNamespace).Get( + context.Background(), name, metav1.GetOptions{}) + require.NoError(t, gerr) + assert.Equal(t, corev1.SecretTypeOpaque, got.Type, "the other object must be untouched") + assert.Contains(t, got.Data, "planted") + assert.NotContains(t, got.Data, corev1.DockerConfigKey, + "the NGC credential must not be written into an object we do not own") +} + +// The unguessable suffix is the actual control. Labels cannot carry it: they +// are three public constants, so a predictable name lets anyone able to create +// a Secret here pre-create it wearing those labels and receive the NGC +// credential. Create-only is only safe because the name cannot be guessed. +func TestValidatorPullSecretRunName_CarriesTheRunID(t *testing.T) { + name := validatorPullSecretRunName(clusterValidatorControlPlaneRole, "a1b2c3d4e5") + assert.Contains(t, name, "a1b2c3d4e5", + "a predictable secret name can be pre-created by someone else") + assert.NotEqual(t, validatorPullSecretRoleName(clusterValidatorControlPlaneRole), name) + + // Two runs never collide, so one run's sweep cannot take out another's. + other := validatorPullSecretRunName(clusterValidatorControlPlaneRole, "f6g7h8i9j0") + assert.NotEqual(t, name, other) +} + +// The mint path must use the run-scoped name, not the role name. +func TestResolveValidatorPullSecret_MintsUnderTheRunScopedName(t *testing.T) { + t.Setenv("NGC_API_KEY", "nvapi-secret-value") client := fake.NewSimpleClientset() - got, err := resolveValidatorPullSecret(context.Background(), client, "", "private.registry.test/nvidia/nvcf-byoc/cluster-validator:rc26") + got, _, err := resolveValidatorPullSecret(context.Background(), client, "", + "nvcr.io/nvidia/cv:1", clusterValidatorControlPlaneRole, "a1b2c3d4e5", false, nil) require.NoError(t, err) - assert.Equal(t, validatorPullSecretName, got) + assert.Contains(t, got, "a1b2c3d4e5", "the minted secret must be run-scoped") +} - s, err := client.CoreV1().Secrets("default").Get(context.Background(), validatorPullSecretName, metav1.GetOptions{}) +// A released CLI's pull Secret carries managed-by=nvcf-cli and the bare name. +// It is that CLI's to delete, so adopting it breaks a pull mid-flight or +// reuses a stale key forever. +func TestResolveValidatorPullSecret_NeverAdoptsAReleasedCLIsSecret(t *testing.T) { + cfg := dockerConfigBlob(t, "nvcr.io", "$oauthtoken", "key") + legacy := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: validatorPullSecretName, + Namespace: clusterValidatorNamespace, + Labels: map[string]string{ + "app.kubernetes.io/name": clusterValidatorAppLabel, + "app.kubernetes.io/managed-by": "nvcf-cli", + "app.kubernetes.io/component": "preflight", + }, + }, + Type: corev1.SecretTypeDockerConfigJson, + Data: map[string][]byte{corev1.DockerConfigJsonKey: cfg}, + } + client := fake.NewSimpleClientset(legacy) + got, _, err := resolveValidatorPullSecret(context.Background(), client, "", "nvcr.io/nvidia/cv:1", + clusterValidatorComputePlaneRole, "runid", false, nil) require.NoError(t, err) - assert.True(t, dockerConfigHasRegistry(s.Data[corev1.DockerConfigJsonKey], "private.registry.test")) + assert.Empty(t, got) } -func TestResolveValidatorPullSecret_AllEmpty(t *testing.T) { +// A --no-cleanup run's Secret carries the preserve marker on both the mirror +// and the mint path, so the orphan sweep keeps it for a day rather than +// 30 minutes, and a re-run of the kept Job can still pull. +func TestPullSecret_PreservedRunIsMarkedOnBothPaths(t *testing.T) { for _, name := range ngcAPIKeyEnvNames { t.Setenv(name, "") } - client := fake.NewSimpleClientset() + t.Setenv("NGC_API_KEY", "nvapi-test-123") + runName := validatorPullSecretRunName(clusterValidatorControlPlaneRole, "runid") + for _, preserve := range []bool{false, true} { + mirror := fake.NewSimpleClientset(&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "chart-owned-creds", Namespace: "nvcf"}, + Type: corev1.SecretTypeDockerConfigJson, + Data: map[string][]byte{corev1.DockerConfigJsonKey: dockerConfigBlob(t, "private.registry.test", + "$oauthtoken", "key")}, + }) + _, _, err := resolveValidatorPullSecret(context.Background(), mirror, "", "private.registry.test/cv:1", + clusterValidatorControlPlaneRole, "runid", preserve, nil) + require.NoError(t, err) + mint := fake.NewSimpleClientset() + _, _, err = resolveValidatorPullSecret(context.Background(), mint, "", "nvcr.io/nvidia/cv:1", + clusterValidatorControlPlaneRole, "runid", preserve, nil) + require.NoError(t, err) + for path, client := range map[string]*fake.Clientset{"mirror": mirror, "mint": mint} { + s, err := client.CoreV1().Secrets("default").Get(context.Background(), runName, metav1.GetOptions{}) + require.NoError(t, err, path) + assert.Equal(t, preserve, s.Labels[clusterValidatorPreserveLabel] == "true", "%s, preserve=%v", path, preserve) + } + } +} + +// A refused credential is looked for in the pull secrets the validator would +// copy from. A match names the Secret, a validator run's own Secret is not +// the cluster's, and a namespace that cannot be listed leaves the answer +// unknown unless another Secret already holds the credential. +func TestClusterPullSecretHolding(t *testing.T) { + refused := dockerConfigBlob(t, "nvcr.io", "$oauthtoken", "revoked") + another := dockerConfigBlob(t, "nvcr.io", "$oauthtoken", "another") + minted := dockerConfigSecret(validatorPullSecretRunName(clusterValidatorComputePlaneRole, "abc"), + clusterValidatorNamespace, refused) + minted.Labels = clusterValidatorRunLabels(clusterValidatorComputePlaneRole, "abc", false) + forbidList := func(namespace string) ktesting.ReactionFunc { + return func(action ktesting.Action) (bool, runtime.Object, error) { + if action.GetNamespace() != namespace { + return false, nil, nil + } + return true, nil, apierrors.NewForbidden(corev1.Resource("secrets"), "", errors.New("no list")) + } + } + holding := func(client *fake.Clientset) (string, error) { + return clusterPullSecretHolding(context.Background(), client, "nvcr.io", "nvidia/cv", "$oauthtoken", "revoked") + } - got, err := resolveValidatorPullSecret(context.Background(), client, "", "private.registry.test/nvidia/nvcf-byoc/cluster-validator:rc26") + client := fake.NewSimpleClientset(dockerConfigSecret("nvcr-pull-secret", "nvcf", refused)) + holder, err := holding(client) require.NoError(t, err) - assert.Equal(t, "", got, "no override, no scan match, no env var -> empty so kubelet surfaces ImagePullBackOff") + assert.Equal(t, "nvcf/nvcr-pull-secret", holder) + + client = fake.NewSimpleClientset(minted, dockerConfigSecret("nvcr-pull-secret", "nvcf", another)) + holder, err = holding(client) + require.NoError(t, err) + assert.Empty(t, holder, "the validator's own Secret and another key are not a match") + + client = fake.NewSimpleClientset(dockerConfigSecret("nvcr-pull-secret", "nvcf", another)) + client.PrependReactor("list", "secrets", forbidList("ess")) + holder, err = holding(client) + assert.Empty(t, holder) + assert.ErrorContains(t, err, "list Secrets in ess") + + client = fake.NewSimpleClientset(dockerConfigSecret("nvcr-pull-secret", "nvcf", refused)) + client.PrependReactor("list", "secrets", forbidList(clusterValidatorNamespace)) + holder, err = holding(client) + require.NoError(t, err, "a match answers the question whatever else could not be read") + assert.Equal(t, "nvcf/nvcr-pull-secret", holder) } -func TestResolveValidatorPullSecret_UnparsableImage(t *testing.T) { - for _, name := range ngcAPIKeyEnvNames { - t.Setenv(name, "this-key-should-not-create-a-secret") - } - client := fake.NewSimpleClientset() +// pullSecretServer serves a cluster whose nvcf namespace holds stored, and +// counts the /version calls a connection makes and the Secret lists a scan +// makes. +func pullSecretServer(t *testing.T, stored *corev1.Secret) (srv *httptest.Server, versions, lists *atomic.Int32) { + t.Helper() + versions, lists = &atomic.Int32{}, &atomic.Int32{} + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.URL.Path == "/version": + versions.Add(1) + _, _ = w.Write([]byte(`{"major":"1","minor":"30","gitVersion":"v1.30.0"}`)) + case r.URL.Path == "/api/v1/namespaces/nvcf/secrets": + lists.Add(1) + _ = json.NewEncoder(w).Encode(corev1.SecretList{ + TypeMeta: metav1.TypeMeta{Kind: "SecretList", APIVersion: "v1"}, Items: []corev1.Secret{*stored}}) + case strings.HasSuffix(r.URL.Path, "/secrets"): + lists.Add(1) + _, _ = w.Write([]byte(`{"kind":"SecretList","apiVersion":"v1","items":[]}`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + return srv, versions, lists +} + +// The checker reads each context's cluster, and a cluster it cannot reach +// leaves the answer unknown. +func TestNewClusterPullSecretChecker_ReadsTheContextsCluster(t *testing.T) { + stored := dockerConfigSecret("nvcr-pull-secret", "nvcf", dockerConfigBlob(t, "nvcr.io", "$oauthtoken", "revoked")) + srv, _, _ := pullSecretServer(t, stored) + writeKubeconfig(t, srv.URL) + check := NewClusterPullSecretChecker([]string{"test"}) + ctx := context.Background() - got, err := resolveValidatorPullSecret(context.Background(), client, "", "bareimage") + holder, err := check(ctx, "nvcr.io", "nvidia/cv", "$oauthtoken", "revoked") + require.NoError(t, err) + assert.Equal(t, "nvcf/nvcr-pull-secret in context test", holder) + holder, err = check(ctx, "nvcr.io", "nvidia/cv", "$oauthtoken", "another") require.NoError(t, err) - assert.Equal(t, "", got, "no registry hostname means no scan target and no auto-create") + assert.Empty(t, holder) - list, _ := client.CoreV1().Secrets("default").List(context.Background(), metav1.ListOptions{}) - assert.Empty(t, list.Items, "no secret must be created when registry cannot be derived") + srv.Close() + holder, err = NewClusterPullSecretChecker([]string{"test"})(ctx, "nvcr.io", "nvidia/cv", "$oauthtoken", "revoked") + assert.Empty(t, holder) + var unreachable *ClusterUnreachableError + assert.ErrorAs(t, err, &unreachable) +} + +// The registry rows run one after another in one share of the run's time, +// and each refused one asks the checker. Within one checker, a poll's, each +// cluster is connected to once, and a question asked before is answered from +// what was read then; a cluster it could not reach is not tried again. A new +// checker, the next poll's, reads the clusters again. +func TestNewClusterPullSecretChecker_ReadsEachClusterOncePerPoll(t *testing.T) { + stored := dockerConfigSecret("nvcr-pull-secret", "nvcf", dockerConfigBlob(t, "nvcr.io", "$oauthtoken", "revoked")) + srv, versions, lists := pullSecretServer(t, stored) + writeKubeconfig(t, srv.URL) + ctx := context.Background() + + check := NewClusterPullSecretChecker([]string{"test"}) + var scan int32 + for i := range 3 { + holder, err := check(ctx, "nvcr.io", "nvidia/cv", "$oauthtoken", "revoked") + require.NoError(t, err) + assert.Equal(t, "nvcf/nvcr-pull-secret in context test", holder) + if i == 0 { + scan = lists.Load() + require.Positive(t, scan) + } + } + assert.EqualValues(t, 1, versions.Load(), "one connection") + assert.Equal(t, scan, lists.Load(), "a question asked before is not scanned for again") + _, err := check(ctx, "nvcr.io", "nvidia/cv", "$oauthtoken", "another") + require.NoError(t, err) + assert.EqualValues(t, 1, versions.Load(), "the connection is reused for another question") + assert.Greater(t, lists.Load(), scan, "another question is a new scan") + + _, err = NewClusterPullSecretChecker([]string{"test"})(ctx, "nvcr.io", "nvidia/cv", "$oauthtoken", "revoked") + require.NoError(t, err) + assert.EqualValues(t, 2, versions.Load(), "the next poll connects again") + + prev := clusterFirstCallTimeout + clusterFirstCallTimeout = 200 * time.Millisecond + t.Cleanup(func() { clusterFirstCallTimeout = prev }) + var silentCalls atomic.Int32 + silent := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + silentCalls.Add(1) + <-r.Context().Done() + })) + t.Cleanup(silent.Close) + writeKubeconfig(t, silent.URL) + check = NewClusterPullSecretChecker([]string{"test"}) + var waited int32 + for i, pass := range []string{"revoked", "another"} { + _, err = check(ctx, "nvcr.io", "nvidia/cv", "$oauthtoken", pass) + var unreachable *ClusterUnreachableError + assert.ErrorAs(t, err, &unreachable, pass) + if i == 0 { + waited = silentCalls.Load() + require.Positive(t, waited) + } + } + assert.Equal(t, waited, silentCalls.Load(), "a cluster found unreachable is not waited on again within the poll") } diff --git a/src/clis/nvcf-cli/internal/selfhosted/register.go b/src/clis/nvcf-cli/internal/selfhosted/register.go index 14d18dfdb6..ee8f1d3310 100644 --- a/src/clis/nvcf-cli/internal/selfhosted/register.go +++ b/src/clis/nvcf-cli/internal/selfhosted/register.go @@ -315,6 +315,33 @@ func resolveClusterID(resp *client.RegisterClusterResponse) string { // avoid touching a real kubeconfig file. var loadKubeConfigFn = loadKubeConfig +// currentKubeContextNameFn is a seam: tests must not depend on the developer's +// kubeconfig, and hint output would otherwise vary by machine. +var currentKubeContextNameFn = currentKubeContextName + +// currentKubeContextName returns the kubeconfig's current-context, or "" when +// it cannot be read. Used to name the context in remediation hints when the +// caller did not pass one explicitly. Best-effort by design: a hint without +// the flag is still correct at the moment it is printed. +func currentKubeContextName() string { + raw, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( + clientcmd.NewDefaultClientConfigLoadingRules(), &clientcmd.ConfigOverrides{}, + ).RawConfig() + if err != nil { + return "" + } + return raw.CurrentContext +} + +// effectiveKubeContext resolves the context a probe actually used, so a hint +// naming it stays correct after the current-context changes. +func effectiveKubeContext(kctx string) string { + if kctx != "" { + return kctx + } + return currentKubeContextNameFn() +} + // loadKubeConfig builds a *rest.Config for the given kubeconfig context. // When kctx is empty the kubeconfig's current-context is used, preserving // single-cluster behavior. The loading-rules chain follows the same priority diff --git a/src/clis/nvcf-cli/internal/selfhosted/registry_cred.go b/src/clis/nvcf-cli/internal/selfhosted/registry_cred.go new file mode 100644 index 0000000000..ebe909400b --- /dev/null +++ b/src/clis/nvcf-cli/internal/selfhosted/registry_cred.go @@ -0,0 +1,813 @@ +/* +SPDX-FileCopyrightText: Copyright (c) NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package selfhosted + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "os" + "slices" + "sort" + "strconv" + "strings" + "time" + + "sigs.k8s.io/yaml" +) + +const ( + registryProbeTimeout = 10 * time.Second + + // certManagerRegistry is cert-manager's upstream registry. The stack moves + // the cert-manager controller images to global.image.registry, but the + // ACME solver stays here unless certManager.acmesolver.image is set. + certManagerRegistry = "quay.io" + + // ngcRegistry is the default global.image.registry in the stack's + // environments/base.yaml, and the fallback when no source named a + // registry. See the tail of EnumerateRegistries. + ngcRegistry = "nvcr.io" +) + +// RegistryCredentialChecker probes whether the local credential for a +// registry is accepted. repoHint is the repository path used as the OAuth +// scope; pass "" to probe without a specific scope. critical signals that +// anonymous access is not enough: the install pulls private repositories. +// chartScope marks, before install, the scope helm on this machine pulls the +// stack's charts from with the docker login, which the probe then judges for +// it even where the run sends the NGC key first. +// Returns nil when a credential was sent and accepted, and otherwise a +// registryProbeOutcome or a plain error for a probe the run's context ended. +type RegistryCredentialChecker func(ctx context.Context, registry, repoHint string, critical, chartScope bool) error + +// NewRegistryCredentialChecker returns a production RegistryCredentialChecker +// backed by real HTTP calls. +func NewRegistryCredentialChecker() RegistryCredentialChecker { + return probeRegistryCredential +} + +// registryProbeKind classifies a probe that did not verify a credential. +type registryProbeKind int + +const ( + // probeSkipped: the registry speaks a flow this probe does not (ECR's + // SigV4, Basic auth, no Bearer realm), did not identify as a registry, + // or named a token realm the CLI will not send credentials to. + probeSkipped registryProbeKind = iota + 1 + // probeAnonymous: reachable without a credential, and none was found + // locally, so nothing was checked. + probeAnonymous + // probeNotVerified: a credential exists, but /v2/ lets anyone in, so it + // was never sent. + probeNotVerified + // probeNoCredential: the registry needs a credential and none was read + // locally. Not proof there is none: a helper may not be readable here. + probeNoCredential + // probeUnverifiable: the registry or its token service could not be + // reached, or failed, after retries. A limit of this machine or a + // transient fault, not evidence about the credential. + probeUnverifiable + // probeRejected: the token endpoint answered 401 or 403 to a request + // that carried the credential. The one outcome that is evidence of a bad + // credential. + probeRejected + // probeLoginRejected: the registry rejected the docker login and + // accepted the NGC key sent in its place. The run uses the key, but + // docker and helm on this machine still send the login, so it fails like + // a rejected credential. + probeLoginRejected + // probeOtherCredential: the docker login has no access to the scope, and + // the registry accepted the NGC key for it, which the run uses there. + // Before install it fails for the scope helm pulls the stack's charts + // from with the docker login. + probeOtherCredential +) + +// registryProbeOutcome is the result of a probe that did not verify a +// credential, or verified one only after the registry rejected another or +// gave it no access. Only probeRejected, probeLoginRejected, and before +// install probeOtherCredential for the stack's chart scope, can fail a run. +type registryProbeOutcome struct { + kind registryProbeKind + detail string + // refused is the credential a probeRejected or probeLoginRejected is + // about: the one the registry refused, or the docker login it rejected + // before it accepted the NGC key. A run that checks an installed stack + // looks for it in the cluster's pull secrets. + refused registryCredential + // keyOnly marks a probeRejected of the NGC key with no docker login + // rejected for the scope: the one refusal that may be this machine's + // alone once the stack is installed. + keyOnly bool +} + +func (o registryProbeOutcome) Error() string { return o.detail } + +// probeRegistryCredential authenticates to registry using the OCI Bearer token +// flow, with the run's local credential for it. repoHint is the OAuth scope +// repository path. +// +// Exactly one result can count against the credential: a 401 or 403 from the +// token endpoint to a request that carried it. Unreachable registries, token +// services that fail or throttle, and realms the CLI refuses are retried where +// that can help, then reported as advisory outcomes. +func probeRegistryCredential(ctx context.Context, registry, repoHint string, critical, chartScope bool) error { + // ECR uses AWS SigV4, not the OCI Bearer flow, so this probe cannot speak + // to it. + if isECRRegistry(registry) { + return registryProbeOutcome{kind: probeSkipped, + detail: "ECR uses AWS SigV4; verify with: " + ecrLoginHint(registry)} + } + + pctx, cancel := context.WithTimeout(ctx, registryProbeTimeout) + defer cancel() + client := newRegistryHTTPClient(registryProbeTimeout) + creds := registryCredentialsFrom(ctx) + + // interrupted keeps an ended run from being graded as a registry fault. + interrupted := func(err error) error { + if ctx.Err() != nil { + return fmt.Errorf("checking %s: %w", registry, ctx.Err()) + } + return err + } + + // Step 1: probe /v2/ unauthenticated. + resp, err := doRegistryRequest(pctx, client, func() (*http.Request, error) { + return http.NewRequestWithContext(pctx, http.MethodGet, "https://"+registry+"/v2/", nil) + }) + if err != nil { + var refused errRefusedRedirect + if errors.As(err, &refused) { + return registryProbeOutcome{kind: probeSkipped, detail: refused.msg} + } + return interrupted(registryProbeOutcome{kind: probeUnverifiable, + detail: fmt.Sprintf("could not verify credentials from this machine: cannot reach %s: %v", registry, err)}) + } + + // A response only says something about credentials when it comes from a + // registry. A captive portal or TLS-intercepting proxy answers too, so + // require the registry to identify itself. The header is a Docker + // convention rather than an OCI requirement, so a conformant registry + // may omit it: report that the result is unverifiable instead of + // guessing either way. + isOCI := resp.Header.Get("Docker-Distribution-Api-Version") != "" || + strings.Contains(strings.Join(resp.Header.Values("Www-Authenticate"), ","), "realm") + wwwAuth := selectAuthChallenge(resp.Header.Values("Www-Authenticate")) + resp.Body.Close() + switch { + case isTransientStatus(resp.StatusCode) || (isOCI && resp.StatusCode != http.StatusOK && + resp.StatusCode != http.StatusUnauthorized): + return interrupted(registryProbeOutcome{kind: probeUnverifiable, + detail: fmt.Sprintf("could not verify credentials from this machine: %s answered %s", registry, resp.Status)}) + case !isOCI && resp.StatusCode != http.StatusUnauthorized: + return registryProbeOutcome{kind: probeSkipped, + detail: fmt.Sprintf("%s answered %s but did not identify as an OCI registry; "+ + "if this is unexpected, check for a proxy or captive portal", registry, resp.Status)} + } + + if resp.StatusCode == http.StatusOK { + // Anonymous read works, which says nothing about the repositories + // the install pulls, and a credential cannot be checked where /v2/ + // accepts anyone. + read := creds.read(pctx, registry) + if read.ok { + return registryProbeOutcome{kind: probeNotVerified, + detail: "credentials configured (" + read.cred.source + "); not verified, " + + "because the registry allows anonymous access to /v2/"} + } + return anonymousOutcome(registry, critical, read.err) + } + + // Only the Bearer flow is implemented. Self-hosted Harbor and htpasswd + // mirrors commonly answer with Basic, where the Bearer path would report a + // spurious credential failure, so skip instead. + if scheme := authChallengeScheme(wwwAuth); scheme != "" && !strings.EqualFold(scheme, "Bearer") { + return registryProbeOutcome{kind: probeSkipped, + detail: fmt.Sprintf("%s uses %s auth; only the OCI Bearer flow is probed", registry, scheme)} + } + + // Step 2: exchange the credential the run settled for this scope for a + // Bearer token scoped to the actual repository (repoHint). A made-up + // repository name draws org-level 403s from NGC and GHCR. + settled := creds.lookupChallenged(pctx, registry, repoHint, wwwAuth) + if chartScope { + settled = creds.judgeLogin(pctx, registry, repoHint, wwwAuth, settled) + } + cred, hasCred, lookupErr := settled.cred, settled.ok, settled.err + var sent *registryCredential + if hasCred { + sent = &cred + } + _, err = exchangeBearerToken(pctx, client, registry, repoHint, wwwAuth, sent) + note := "" + if login := settled.rejectedLogin; login != nil && hasCred { + note = "; " + rejectedLoginNote(registry, login.source) + } + if err == nil { + switch { + case !hasCred: + return anonymousOutcome(registry, critical, lookupErr) + case note != "": + return registryProbeOutcome{kind: probeLoginRejected, refused: *settled.rejectedLogin, + detail: note[2:] + "; credentials from " + cred.source + " valid, and the run uses them in its place"} + case settled.noAccessLogin != nil: + return registryProbeOutcome{kind: probeOtherCredential, detail: fmt.Sprintf( + "credentials from %s valid; the docker login from %s has no access to %s, so the run sends %s for it", + cred.source, settled.noAccessLogin.source, scopeLabel(registry, repoHint), cred.source)} + case settled.loginUnjudged != "": + return registryProbeOutcome{kind: probeUnverifiable, detail: fmt.Sprintf( + "credentials from %s valid; could not verify the docker login helm on this machine pulls the "+ + "charts from %s with: %s", cred.source, scopeLabel(registry, repoHint), settled.loginUnjudged)} + } + return nil + } + var te *tokenExchangeError + switch { + case !errors.As(err, &te): + return interrupted(registryProbeOutcome{kind: probeUnverifiable, + detail: "could not verify credentials from this machine: " + err.Error() + note}) + case te.refused: + return registryProbeOutcome{kind: probeSkipped, detail: te.msg} + case te.rejected() && te.status == http.StatusForbidden: + // A 403 is no access to this repository, which says nothing about + // whether the credential itself is still valid. + detail := fmt.Sprintf("credentials from %s have no access to %s: %s; use a credential with pull access to it", + cred.source, scopeLabel(registry, repoHint), te.msg) + if settled.fallbackFailure != "" { + detail += "; " + settled.fallbackFailure + } + return registryProbeOutcome{kind: probeRejected, detail: detail + note, refused: cred, + keyOnly: cred.ngcKey && settled.rejectedLogin == nil} + case te.rejected(): + return registryProbeOutcome{kind: probeRejected, detail: fmt.Sprintf("credentials from %s rejected: %s; %s%s", + cred.source, te.msg, rejectedHint(registry, cred), note), refused: cred, + keyOnly: cred.ngcKey && settled.rejectedLogin == nil} + case !hasCred && (te.status == http.StatusUnauthorized || te.status == http.StatusForbidden): + // Anonymous access refused: the registry needs a credential this + // machine does not have. + return noCredentialOutcome(registry, lookupErr) + } + return interrupted(registryProbeOutcome{kind: probeUnverifiable, + detail: "could not verify credentials from this machine: " + te.msg + note}) +} + +// scopeLabel names the repository a probe asked for, or the registry when it +// asked for none. +func scopeLabel(registry, repo string) string { + if repo == "" { + return registry + } + return registry + "/" + repo +} + +// rejectedLoginNote says that registry rejected the docker login read from +// source, which docker and helm on this machine still send, and how to renew +// it. +func rejectedLoginNote(registry, source string) string { + return fmt.Sprintf("the docker login from %s was rejected, and docker and helm on this machine still send it; "+ + "renew it with: docker login %s --username '$oauthtoken'", source, registry) +} + +// anonymousOutcome grades a registry that let this machine in without a +// credential. A critical registry still needs one for the private +// repositories the install pulls. +func anonymousOutcome(registry string, critical bool, lookupErr error) error { + if critical || lookupErr != nil { + return noCredentialOutcome(registry, lookupErr) + } + return registryProbeOutcome{kind: probeAnonymous, detail: "reachable anonymously; no local credential checked"} +} + +// noCredentialOutcome reports that no local credential was read for +// registry, with the reason a credential store failed when one did. +func noCredentialOutcome(registry string, lookupErr error) error { + why := "(a credential helper such as credsStore may not be readable here)" + if lookupErr != nil { + why = "(reading them failed: " + lookupErr.Error() + ")" + } + return registryProbeOutcome{kind: probeNoCredential, + detail: fmt.Sprintf("no local credentials found for %s %s; if 'docker pull' works this can be ignored, "+ + "otherwise %s", registry, why, loginHint(registry))} +} + +// loginHint says how to give this machine a credential for registry. docker +// reads $DOCKER_CONFIG/config.json, ~/.docker/config.json by default. +func loginHint(registry string) string { + if isECRRegistry(registry) { + return "run: " + ecrLoginHint(registry) + } + login := "run: docker login " + registry + if isNGCKeyRegistry(registry) { + return "export NGC_API_KEY, or " + login + " --username '$oauthtoken'" + } + return login + " (docker reads $DOCKER_CONFIG/config.json, ~/.docker/config.json by default)" +} + +// rejectedHint says how to replace a credential registry refused. +func rejectedHint(registry string, cred registryCredential) string { + if cred.ngcKey { + return "generate a new NGC API key and export it as " + cred.source + } + return loginHint(registry) +} + +// ecrLoginHint is a command that logs in to an ECR registry in its own +// region, which also proves the registry accepts the operator's AWS identity. +func ecrLoginHint(registry string) string { + region := "" + if host, _, err := net.SplitHostPort(registry); err == nil { + registry = host + } + if _, rest, ok := strings.Cut(registry, ".dkr.ecr."); ok { + region, _, _ = strings.Cut(rest, ".") + } + cmd := "aws ecr get-login-password" + if region != "" { + cmd += " --region " + region + } + return cmd + " | docker login --username AWS --password-stdin " + registry +} + +// selectAuthChallenge picks the challenge to answer when a registry offers +// several, as it may in separate WWW-Authenticate headers: the Bearer one if +// present, otherwise the first. Reading only the first header reported a +// registry answering "Negotiate" then "Bearer" as a clean skip. +func selectAuthChallenge(challenges []string) string { + for _, c := range challenges { + if strings.EqualFold(authChallengeScheme(c), "Bearer") { + return c + } + // Several challenges can also share one header, comma-separated: + // `Negotiate, Bearer realm="..."`. Take the Bearer one from there. + if i := bearerChallengeStart(c); i > 0 { + return c[i:] + } + } + if len(challenges) > 0 { + return challenges[0] + } + return "" +} + +// bearerChallengeStart returns where a Bearer challenge begins after a comma +// in a joined WWW-Authenticate value, or -1. +func bearerChallengeStart(header string) int { + lower := strings.ToLower(header) + for from := 0; ; { + j := strings.Index(lower[from:], "bearer ") + if j < 0 { + return -1 + } + j += from + if j > 0 && strings.HasSuffix(strings.TrimRight(lower[:j], " "), ",") { + return j + } + from = j + 1 + } +} + +// authChallengeScheme returns the auth scheme named by a WWW-Authenticate +// header, or "" when the header is absent or malformed. +func authChallengeScheme(header string) string { + header = strings.TrimSpace(header) + if header == "" { + return "" + } + if i := strings.IndexByte(header, ' '); i > 0 { + return header[:i] + } + return header +} + +// isECRRegistry returns true for AWS Elastic Container Registry hostnames, +// which use AWS SigV4 auth instead of the OCI Bearer token flow. +func isECRRegistry(registry string) bool { + if host, _, err := net.SplitHostPort(registry); err == nil { + registry = host + } + return strings.Contains(registry, ".dkr.ecr.") && + strings.HasSuffix(registry, ".amazonaws.com") +} + +// RegistryEntry is one registry endpoint to credential-check. +type RegistryEntry struct { + // Registry is the hostname (and optional port) of the container registry. + Registry string + // RepoHint is the repository path used as the OAuth scope when probing + // credentials (e.g. "nvidia/nvcf-byoc/cluster-validator" for NGC). + // Empty means probe without a specific scope, which works for public + // registries (quay.io, Docker Hub public images) and GHCR anonymous access. + RepoHint string + // Critical marks a registry a source named that the install must pull + // private images from: an NVIDIA registry named by the validator image or + // the stack. A credential it rejects fails the run; everything else about + // it, and every other registry, is a warning at most. + Critical bool + // Charts marks the scope helm on this machine pulls the stack's charts + // from, with the docker login: the stack's OCI chart source. + Charts bool + // ChartsOnly marks a Charts entry no other source named. The clusters + // pull nothing from it, so the control-plane validator does not dial it. + ChartsOnly bool +} + +// ParseRegistryExtra parses one --cluster-validator-registries entry, +// host[:port][/path]. A path, as the stack's global.image.registry may carry, +// becomes the probe's scope hint. Anything else is an error naming the entry. +func ParseRegistryExtra(s string) (RegistryEntry, error) { + s = strings.TrimSpace(s) + hostPort, path, _ := strings.Cut(s, "/") + host, port := parseRegistryHostPort(hostPort) + path = strings.TrimSuffix(path, "/") + if host == "" || (path != "" && !isRepositoryPath(path)) { + return RegistryEntry{}, fmt.Errorf("%q: expected host[:port] or host[:port]/path, "+ + "for example harbor.example.com:5000/nvcf", s) + } + reg := host + if port != 443 { + reg = net.JoinHostPort(host, strconv.Itoa(port)) + } else if strings.Contains(host, ":") { + // IPv6 literal on the default port. parseRegistryHostPort returns + // it unbracketed, so bracket it once for the URL host. + reg = "[" + host + "]" + } + return RegistryEntry{Registry: reg, RepoHint: path}, nil +} + +// StackValues are the stack settings preflight needs, read from the stack's +// environment values with the same layering helmfile uses. +type StackValues struct { + // Found is true when at least one values file was read. + Found bool + // ImageRegistry is global.image.registry. The stack renders + // registry + "/" + repository, so it may carry a path. + ImageRegistry string + // ImageRepository is global.image.repository, the NGC org and team or + // mirror path every stack image is pulled from. + ImageRepository string + // ChartRegistry and ChartRepository are global.helm.sources.registry and + // .repository, the OCI repository helm pulls the stack's charts from with + // the docker login. Both are empty when global.helm.sources.url names an + // HTTPS chart repository instead, which has credentials of its own. + ChartRegistry, ChartRepository string + // CertManagerEnabled is certManager.enabled, the cert-manager release's + // condition. + CertManagerEnabled bool + // ACMESolverRepository is certManager.acmesolver.image.repository. When + // unset the ACME solver keeps the chart's quay.io/jetstack default. + ACMESolverRepository string + // EnvoyGatewayNamespace is ingress.gatewayApi.controllerNamespace. + EnvoyGatewayNamespace string + // Gateways are the NVCF Gateways the stack names under + // ingress.gatewayApi.gateways, as sorted, distinct namespace/name entries. + Gateways []string + // ExternalComponents are the stack dependencies the stack does not + // install, because their release condition is off: nats, openbao or + // cassandra. The validator reads them as NVCF_EXTERNAL_COMPONENTS. + ExternalComponents []string + // StorageClass is global.storageClass, the class every stack PVC names. + // The validator reads it as NVCF_STORAGE_CLASS and then checks that class + // instead of requiring a default one. + StorageClass string +} + +// LoadStackValues reads files in order and layers each over the previous, +// the way helmfile layers an environment file over base.yaml. Unreadable or +// malformed files are skipped. +func LoadStackValues(files []string) StackValues { + merged, found := loadValuesFiles(files) + var chartRegistry, chartRepository string + if digString(merged, "global", "helm", "sources", "url") == "" { + chartRegistry = digString(merged, "global", "helm", "sources", "registry") + chartRepository = strings.Trim(digString(merged, "global", "helm", "sources", "repository"), "/") + } + return StackValues{ + Found: found, + ImageRegistry: digString(merged, "global", "image", "registry"), + ImageRepository: strings.Trim(digString(merged, "global", "image", "repository"), "/"), + ChartRegistry: chartRegistry, + ChartRepository: chartRepository, + CertManagerEnabled: releaseCondition(merged, "certManager", "enabled"), + ACMESolverRepository: digString(merged, "certManager", "acmesolver", "image", "repository"), + EnvoyGatewayNamespace: digString(merged, "ingress", "gatewayApi", "controllerNamespace"), + Gateways: stackGateways(merged), + ExternalComponents: stackExternalComponents(merged), + StorageClass: digString(merged, "global", "storageClass"), + } +} + +// stackExternalComponents returns the validator's external components the +// stack leaves out: each release whose condition (nats.enabled, +// openbao.enabled, cassandra.enabled) is set and not true. A component the +// values never mention is not listed; helmfile refuses to evaluate a missing +// condition, so such values do not describe a control-plane install. Sorted. +func stackExternalComponents(merged map[string]any) []string { + var out []string + for _, component := range validatorExternalComponents { + if _, set := merged[component]; set && !releaseCondition(merged, component, "enabled") { + out = append(out, component) + } + } + return out +} + +// validatorExternalComponents are the stack dependencies the validator can be +// told run outside the stack, through NVCF_EXTERNAL_COMPONENTS. Sorted. +var validatorExternalComponents = []string{"cassandra", "nats", "openbao"} + +// ParseExternalComponents normalises comma-separated NVCF_EXTERNAL_COMPONENTS +// entries into a sorted, distinct list, and rejects a name the validator does +// not know, which it would otherwise ignore silently. +func ParseExternalComponents(entries []string) ([]string, error) { + seen := map[string]bool{} + var out []string + for _, raw := range entries { + for _, c := range strings.Split(raw, ",") { + c = strings.ToLower(strings.TrimSpace(c)) + if c == "" || seen[c] { + continue + } + if !slices.Contains(validatorExternalComponents, c) { + return nil, fmt.Errorf("unknown component %q: use a subset of %s", + c, strings.Join(validatorExternalComponents, ", ")) + } + seen[c] = true + out = append(out, c) + } + } + sort.Strings(out) + return out, nil +} + +// stackGateways returns the Gateways the stack wires NVCF routes to, as +// sorted, distinct namespace/name entries, applying the gates the stack +// applies. The ingress release that renders nvcfGatewayRoutes has the +// condition ingress.gatewayApi.enabled. Inside it global.yaml.gotmpl wires +// shared and grpc always, nats only when routes.nats.enabled, and llmGrpc +// and llmQuic only when routes.llmWorker.enabled, each read by a template if. +// An environment file can name a Gateway whose route stays off, and the stack +// never creates or wires that Gateway, so the validator must not look for it. +func stackGateways(merged map[string]any) []string { + if !releaseCondition(merged, "ingress", "gatewayApi", "enabled") { + return nil + } + wired := []string{"shared", "grpc"} + if templateTruth(digAny(merged, "ingress", "gatewayApi", "routes", "nats", "enabled")) { + wired = append(wired, "nats") + } + if templateTruth(digAny(merged, "ingress", "gatewayApi", "routes", "llmWorker", "enabled")) { + wired = append(wired, "llmGrpc", "llmQuic") + } + gateways, _ := digAny(merged, "ingress", "gatewayApi", "gateways").(map[string]any) + seen := map[string]bool{} + var out []string + for _, key := range wired { + name := digString(gateways, key, "name") + ns := digString(gateways, key, "namespace") + if name == "" || ns == "" || strings.Contains(name, "/") || strings.Contains(ns, "/") { + continue + } + if entry := ns + "/" + name; !seen[entry] { + seen[entry] = true + out = append(out, entry) + } + } + sort.Strings(out) + return out +} + +// loadValuesFiles layers files in order and reports whether any was read. +// Unreadable or malformed files are skipped. +func loadValuesFiles(files []string) (map[string]any, bool) { + merged := map[string]any{} + found := false + for _, path := range files { + data, err := os.ReadFile(path) + if err != nil { + continue + } + var doc map[string]any + if err := yaml.Unmarshal(data, &doc); err != nil { + continue + } + found = true + mergeValues(merged, doc) + } + return merged, found +} + +// mergeValues deep-merges src into dst: maps merge key by key, anything else +// in src replaces what dst held. +func mergeValues(dst, src map[string]any) { + for k, v := range src { + if sv, ok := v.(map[string]any); ok { + if dv, ok := dst[k].(map[string]any); ok { + mergeValues(dv, sv) + continue + } + } + dst[k] = v + } +} + +// releaseCondition reads a helmfile release condition, which is on only for +// a YAML boolean true. A quoted "true" leaves the release out. +func releaseCondition(m map[string]any, keys ...string) bool { + b, _ := digAny(m, keys...).(bool) + return b +} + +// templateTruth reads a value the way a Go template's if does: false, zero, +// empty and nil are false, and anything else is true, a quoted "false" +// included. The stack's route gates are such ifs over a dig. +func templateTruth(v any) bool { + switch x := v.(type) { + case nil: + return false + case bool: + return x + case string: + return x != "" + case float64: + return x != 0 + case int64: + return x != 0 + case int: + return x != 0 + case map[string]any: + return len(x) > 0 + case []any: + return len(x) > 0 + } + return true +} + +func digString(m map[string]any, keys ...string) string { + s, _ := digAny(m, keys...).(string) + return strings.TrimSpace(s) +} + +func digAny(m map[string]any, keys ...string) any { + var cur any = m + for _, k := range keys { + mm, ok := cur.(map[string]any) + if !ok { + return nil + } + cur = mm[k] + } + return cur +} + +// EnumerateRegistries builds the list of registries to credential-check from +// the validator image ref, the stack values (global.image.registry and +// repository, the OCI chart source, and quay.io for the cert-manager ACME +// solver), and the operator-supplied extras. stack is empty when the values +// that describe the install could not be read; then only the image and the +// extras name registries, and nvcr.io is a non-critical guess. +func EnumerateRegistries(imageRef string, stack StackValues, extras []RegistryEntry) []RegistryEntry { + var out []RegistryEntry + + // add returns the entry that lists the registry, or nil, so a source only + // counts as having named a registry once its value passed validation. A + // registry already listed is listed again only for a scope not yet probed: + // the validator image's org and the stack's org can differ on one registry. + // An entry for any scope probes the registry, unless exact asks for that + // scope alone. An entry another source also names is not chart-only. + add := func(registry, repoHint string, critical, exact bool) *RegistryEntry { + registry = strings.TrimSpace(registry) + if registry == "" { + return nil + } + // Every source goes through the same host validation. The probe builds + // "https://" + registry + "/v2/", so a value carrying "/" or "@" moves + // the host: global.image.registry set to "nvcr.io@attacker.example.com" + // would otherwise aim an outbound request wherever that names. + if host, _ := parseRegistryHostPort(registry); host == "" { + return nil + } + for i, e := range out { + if e.Registry == registry && (e.RepoHint == repoHint || (repoHint == "" && !exact)) { + out[i].ChartsOnly = false + return &out[i] + } + } + out = append(out, RegistryEntry{Registry: registry, RepoHint: repoHint, Critical: critical}) + return &out[len(out)-1] + } + + // Source 1: base registry from the configured validator image. + // Carry the repo path as a scope hint so the token exchange uses the + // operator's actual org rather than a fake one: NGC returns 403 for + // orgs the API key cannot access, even if the key itself is valid. + // + // Critical follows the same rule as every other source rather than being + // forced true: an air-gapped install that side-loaded the image never + // contacts this registry at pull time (ImagePullPolicy is IfNotPresent). + imageNamedRegistry := false + if reg, repo, _, ok := parseImageRef(imageRef); ok { + imageNamedRegistry = add(reg, repo, isNGCRegistry(reg), false) != nil + } + + // Source 2: global.image.registry and global.image.repository from the + // stack values, which every stack image is pulled from. The probe is + // scoped to that repository, so a key without access to the stack's org + // is caught even when the validator image comes from another org. The + // stack renders registry + "/" + repository, so "harbor.corp.example/nvcf" + // is a valid registry: probe its host and fold the path into the scope. + stackNamedRegistry := false + if host, scope := stackScope(stack.ImageRegistry, stack.ImageRepository); host != "" { + // If it's an NGC registry, mark critical; customer mirrors are non-critical. + stackNamedRegistry = add(host, scope, isNGCRegistry(host), false) != nil + } + + // Source 3: the stack's OCI chart source, global.helm.sources.registry and + // repository, folded into a host and scope the same way. helm on this + // machine pulls the stack's charts from it with the docker login, so a + // login it rejects, or one with no access to that scope, fails the + // install. It marks the entry already listed for that scope, and is + // otherwise listed on its own, critical for an NGC registry like the + // stack's images. + if host, scope := stackScope(stack.ChartRegistry, stack.ChartRepository); host != "" { + listed := len(out) + if e := add(host, scope, isNGCRegistry(host), true); e != nil { + e.Charts = true + e.ChartsOnly = len(out) > listed + } + } + + // Source 4: cert-manager's ACME solver. global.yaml.gotmpl moves the + // controller images to the mirror, but the solver keeps the chart's + // quay.io/jetstack default unless certManager.acmesolver.image is set, so + // a mirrored site still pulls it from quay.io at its first HTTP-01 order. + // Probe quay.io unless the stack leaves cert-manager out or points the + // solver somewhere else. Without stack values, probe it as a guess. + if acme := stack.ACMESolverRepository; !stack.Found || + (stack.CertManagerEnabled && (acme == "" || strings.HasPrefix(acme, certManagerRegistry+"/"))) { + add(certManagerRegistry, "", false, false) + } + + // Source 5: operator-supplied extras (--cluster-validator-registries), + // parsed by ParseRegistryExtra. + for _, e := range extras { + add(e.Registry, e.RepoHint, false, false) + } + + // Fallback for "no source named a registry at all": no validator image is + // configured and no stack values describe the install. Without this the + // run reports on quay.io alone while the operator's NGC credentials go + // unchecked. environments/base.yaml sets global.image.registry to nvcr.io, + // so it is the right guess when there is nothing else to go on. + // + // Deliberately not triggered when the image or the stack named a non-NGC + // registry: that is a mirrored install, which has no reason to reach + // nvcr.io, and probing it there would add a failing round trip for the + // sites least able to make it. + // + // Non-critical, unlike an NGC registry a source actually named. A guess + // must not hard-fail the run, and this category has no opt-out flag. + if !imageNamedRegistry && !stackNamedRegistry { + add(ngcRegistry, "", false, false) + } + + return out +} + +// stackScope splits a stack's registry and repository, which the stack +// renders as registry + "/" + repository, into the host to probe and the +// scope to probe it for: "harbor.corp.example/nvcf" is a valid registry, so +// its path is folded into the scope. +func stackScope(registry, repository string) (host, scope string) { + host, path, _ := strings.Cut(registry, "/") + if host == "" { + return "", "" + } + scope = strings.Trim(strings.Join([]string{strings.Trim(path, "/"), repository}, "/"), "/") + // One segment is an org with no team, which is no repository, and nvcr.io + // answers that scope with 400 "malformed token scope": a probe that could + // then only report it unverifiable, a revoked key included. With no scope + // the token endpoint still judges the key. + if !strings.Contains(scope, "/") { + scope = "" + } + return host, scope +} diff --git a/src/clis/nvcf-cli/internal/selfhosted/registry_cred_test.go b/src/clis/nvcf-cli/internal/selfhosted/registry_cred_test.go new file mode 100644 index 0000000000..b6e664164e --- /dev/null +++ b/src/clis/nvcf-cli/internal/selfhosted/registry_cred_test.go @@ -0,0 +1,1462 @@ +/* +SPDX-FileCopyrightText: Copyright (c) NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package selfhosted + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "slices" + "sort" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/client-go/kubernetes/fake" + "sigs.k8s.io/yaml" +) + +// -- probeRegistryCredential -- + +func TestProbeRegistryCredential_PublicRegistry(t *testing.T) { + // Registry returns 200 on /v2/ with the registry API header -> public, + // no credentials needed. + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Docker-Distribution-Api-Version", "registry/2.0") + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + // Replace the default transport with the test server's transport so TLS + // validation passes against the self-signed cert. + origTransport := http.DefaultTransport + http.DefaultTransport = srv.Client().Transport + t.Cleanup(func() { http.DefaultTransport = origTransport }) + + // Use the test server's host as the registry. + host := strings.TrimPrefix(srv.URL, "https://") + err := probeRegistryCredential(context.Background(), host, "", false, false) + var outcome registryProbeOutcome + require.ErrorAs(t, err, &outcome) + assert.Equal(t, probeAnonymous, outcome.kind, "no credential was sent, so none is reported valid") + + r := registryCredentialCheck(probeRegistryCredential, RegistryEntry{Registry: host}, host, false, nil). + Run(context.Background()) + assert.True(t, r.Passed) + assert.Equal(t, SeverityInfo, r.Severity) + assert.Contains(t, r.Message, "reachable anonymously; no local credential checked") + assert.NotContains(t, r.Message, "credentials valid") +} + +func TestProbeRegistryCredential_AuthSucceeds(t *testing.T) { + // Registry: /v2/ returns 401 with WWW-Authenticate; token endpoint returns a token. + tokenSrv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Token endpoint always succeeds. + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"token":"test-token-abc"}`)) + })) + defer tokenSrv.Close() + + registrySrv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") == "Bearer test-token-abc" { + w.WriteHeader(http.StatusOK) + return + } + // Point the client at our token server. + realm := tokenSrv.URL + "/token" + w.Header().Set("Www-Authenticate", + `Bearer realm="`+realm+`",service="test-registry",scope="repository:test:pull"`) + w.WriteHeader(http.StatusUnauthorized) + })) + defer registrySrv.Close() + + transport := registrySrv.Client().Transport + origTransport := http.DefaultTransport + http.DefaultTransport = transport + t.Cleanup(func() { http.DefaultTransport = origTransport }) + + host := strings.TrimPrefix(registrySrv.URL, "https://") + err := probeRegistryCredential(context.Background(), host, "", false, false) + var outcome registryProbeOutcome + require.ErrorAs(t, err, &outcome, "an anonymous token is not a valid credential") + assert.Equal(t, probeAnonymous, outcome.kind) + + dockerHome(t, inlineDockerConfig(t, host, "u", "p", "")) + err = probeRegistryCredential(context.Background(), host, "", false, false) + assert.NoError(t, err, "a token issued for a credential that was sent is a valid credential") + r := registryCredentialCheck(probeRegistryCredential, RegistryEntry{Registry: host}, host, false, nil). + Run(context.Background()) + assert.Contains(t, r.Message, "credentials valid") +} + +func TestProbeRegistryCredential_AuthFails(t *testing.T) { + // Registry returns 401 but the token endpoint returns 401 too -> bad credentials. + tokenSrv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer tokenSrv.Close() + + registrySrv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + realm := tokenSrv.URL + "/token" + w.Header().Set("Www-Authenticate", + `Bearer realm="`+realm+`",service="test-registry"`) + w.WriteHeader(http.StatusUnauthorized) + })) + defer registrySrv.Close() + + transport := registrySrv.Client().Transport + origTransport := http.DefaultTransport + http.DefaultTransport = transport + t.Cleanup(func() { http.DefaultTransport = origTransport }) + + host := strings.TrimPrefix(registrySrv.URL, "https://") + err := probeRegistryCredential(context.Background(), host, "", false, false) + assert.Error(t, err, "failed token exchange must return an error") +} + +func TestProbeRegistryCredential_ECRSkipped(t *testing.T) { + // ECR registries must get a clear "use AWS CLI" message rather than a + // confusing Bearer token failure. + err := probeRegistryCredential(context.Background(), + "123456789.dkr.ecr.us-east-1.amazonaws.com", "", false, false) + require.Error(t, err) + assert.Contains(t, err.Error(), "ECR", "ECR registries must produce a clear diagnostic") + assert.Contains(t, err.Error(), "AWS", "message must mention AWS") +} + +// -- EnumerateRegistries -- + +func TestEnumerateRegistries_FromImageRef(t *testing.T) { + entries := EnumerateRegistries("nvcr.io/nvidia/nvcf-byoc/cluster-validator:3.1.0", StackValues{}, nil) + require.NotEmpty(t, entries) + + found := false + for _, e := range entries { + if e.Registry == "nvcr.io" { + assert.True(t, e.Critical, "nvcr.io must be marked critical") + found = true + } + } + assert.True(t, found, "nvcr.io must appear in the enumerated registries") +} + +func TestEnumerateRegistries_RepoHintFromImageRef(t *testing.T) { + // The RepoHint must be the repo path from the image ref so the token + // exchange uses the operator's actual org, not a fake one. + entries := EnumerateRegistries("nvcr.io/nvidia/nvcf-byoc/cluster-validator:3.1.0", StackValues{}, nil) + for _, e := range entries { + if e.Registry == "nvcr.io" { + assert.Equal(t, "nvidia/nvcf-byoc/cluster-validator", e.RepoHint, + "RepoHint must carry the actual repo path for correct NGC token scope") + return + } + } + t.Fatal("nvcr.io not found in entries") +} + +func TestEnumerateRegistries_IncludesCertManagerWhenStackIsNotMirroring(t *testing.T) { + // With no stack values file there is no global.image.registry to mirror + // cert-manager to, so its upstream registry is genuinely contacted. + entries := EnumerateRegistries("nvcr.io/some/image:1.0", StackValues{}, nil) + found := false + for _, e := range entries { + if e.Registry == "quay.io" { + assert.False(t, e.Critical, "quay.io must be non-critical") + found = true + } + } + assert.True(t, found, "quay.io must always be included for cert-manager") +} + +func TestEnumerateRegistries_ExtrasAppended(t *testing.T) { + entries := EnumerateRegistries("nvcr.io/some/image:1.0", StackValues{}, + parseExtras(t, "harbor.company.internal:443", "ghcr.io:443")) + + registries := make(map[string]bool, len(entries)) + for _, e := range entries { + registries[e.Registry] = true + } + assert.True(t, registries["harbor.company.internal"], "extra registry must be added") + assert.True(t, registries["ghcr.io"], "extra registry must be added") +} + +func TestEnumerateRegistries_NoDuplicates(t *testing.T) { + // Pass nvcr.io both as the image registry and as an extra - must not dedup. + entries := EnumerateRegistries("nvcr.io/some/image:1.0", StackValues{}, parseExtras(t, "nvcr.io")) + + count := 0 + for _, e := range entries { + if e.Registry == "nvcr.io" { + count++ + } + } + assert.Equal(t, 1, count, "nvcr.io must appear exactly once even when listed twice") +} + +func TestEnumerateRegistries_StackValuesFile(t *testing.T) { + // Write a minimal environments/local.yaml to a temp dir. + dir := t.TempDir() + valuesPath := dir + "/local.yaml" + require.NoError(t, writeFile(valuesPath, []byte(` +global: + image: + registry: example.nvcr.io +`))) + + entries := EnumerateRegistries("nvcr.io/some/image:1.0", LoadStackValues([]string{valuesPath}), nil) + found := false + for _, e := range entries { + if e.Registry == "example.nvcr.io" { + assert.True(t, e.Critical, "NGC staging registry must be critical") + found = true + } + } + assert.True(t, found, "registry from values file must be included") +} + +func TestReadGlobalImageRegistry_ValidFile(t *testing.T) { + dir := t.TempDir() + path := dir + "/values.yaml" + require.NoError(t, writeFile(path, []byte(` +global: + image: + registry: nvcr.io + repository: nvidia/nvcf-byoc +`))) + got := LoadStackValues([]string{path}).ImageRegistry + assert.Equal(t, "nvcr.io", got) +} + +func TestReadGlobalImageRegistry_MissingFile(t *testing.T) { + got := LoadStackValues([]string{"/nonexistent/path/values.yaml"}).ImageRegistry + assert.Empty(t, got, "missing file must return empty string, not error") +} + +func TestReadGlobalImageRegistry_MissingKey(t *testing.T) { + dir := t.TempDir() + path := dir + "/values.yaml" + require.NoError(t, writeFile(path, []byte(`other: value`))) + got := LoadStackValues([]string{path}).ImageRegistry + assert.Empty(t, got, "missing global.image.registry must return empty string") +} + +// -- isECRRegistry -- + +func TestIsECRRegistry(t *testing.T) { + assert.True(t, isECRRegistry("123456789012.dkr.ecr.us-east-1.amazonaws.com")) + assert.True(t, isECRRegistry("999999999999.dkr.ecr.eu-west-1.amazonaws.com")) + assert.False(t, isECRRegistry("nvcr.io")) + assert.False(t, isECRRegistry("ghcr.io")) + assert.False(t, isECRRegistry("harbor.company.internal")) + assert.False(t, isECRRegistry("s3.amazonaws.com")) // S3, not ECR +} + +// -- registryCredentialCheck binaryCheckSpec -- + +func TestRegistryCredentialCheck_PassWhenNoError(t *testing.T) { + checker := func(_ context.Context, reg, _ string, _, _ bool) error { return nil } + spec := registryCredentialCheck(checker, RegistryEntry{Registry: "nvcr.io", Critical: true}, "nvcr.io", false, nil) + r := spec.Run(context.Background()) + assert.True(t, r.Passed) + assert.Equal(t, SeverityInfo, r.Severity) + assert.Contains(t, r.Message, "nvcr.io") +} + +func TestRegistryCredentialCheck_CriticalSeverityOnFailure(t *testing.T) { + checker := func(_ context.Context, reg, _ string, _, _ bool) error { + return errorf("credentials rejected") + } + spec := registryCredentialCheck(checker, RegistryEntry{Registry: "nvcr.io", Critical: true}, "nvcr.io", false, nil) + r := spec.Run(context.Background()) + assert.False(t, r.Passed) + assert.Equal(t, SeverityError, r.Severity, "critical registry failure must be error severity") +} + +func TestRegistryCredentialCheck_WarningSeverityOnNonCriticalFailure(t *testing.T) { + checker := func(_ context.Context, reg, _ string, _, _ bool) error { + return errorf("credentials rejected") + } + spec := registryCredentialCheck(checker, RegistryEntry{Registry: "quay.io", Critical: false}, "quay.io", false, nil) + r := spec.Run(context.Background()) + assert.False(t, r.Passed) + assert.Equal(t, SeverityWarning, r.Severity, "non-critical registry failure must be warning severity") +} + +// helpers + +// parseExtras parses --cluster-validator-registries entries. +func parseExtras(t *testing.T, entries ...string) []RegistryEntry { + t.Helper() + var out []RegistryEntry + for _, e := range entries { + entry, err := ParseRegistryExtra(e) + require.NoError(t, err, e) + out = append(out, entry) + } + return out +} + +func errorf(msg string) error { return fmt.Errorf("%s", msg) } + +func writeFile(path string, data []byte) error { + return os.WriteFile(path, data, 0o644) +} + +// When the stack also mirrors the ACME solver, nothing it installs is pulled +// from quay.io, so probing it is a pointless 10-second round trip. +func TestEnumerateRegistries_SkipsCertManagerWhenSolverIsMirrored(t *testing.T) { + dir := t.TempDir() + valuesPath := dir + "/base.yaml" + require.NoError(t, writeFile(valuesPath, []byte(` +global: + image: + registry: mirror.company.internal +certManager: + acmesolver: + image: + repository: mirror.company.internal/jetstack/cert-manager-acmesolver +`))) + + entries := EnumerateRegistries("nvcr.io/some/image:1.0", LoadStackValues([]string{valuesPath}), nil) + for _, e := range entries { + assert.NotEqual(t, certManagerRegistry, e.Registry, + "a stack mirroring images must not trigger a quay.io probe") + } +} + +// A bare IPv6 literal must stay bracketed or the probe URL is malformed. +func TestEnumerateRegistries_BracketsIPv6Extras(t *testing.T) { + entries := EnumerateRegistries("nvcr.io/some/image:1.0", StackValues{}, parseExtras(t, "[::1]:5000")) + + found := false + for _, e := range entries { + if strings.Contains(e.Registry, "::1") { + found = true + assert.Equal(t, "[::1]:5000", e.Registry, + "an IPv6 literal must keep its brackets so the probe URL parses") + } + } + assert.True(t, found, "the IPv6 extra must be enumerated") +} + +// The validator image's registry follows the same Critical policy as every +// other source. Forcing it true makes an air-gapped install fail preflight for +// a registry that is never contacted (ImagePullPolicy is IfNotPresent). +func TestEnumerateRegistries_NonNGCImageRegistryIsNotCritical(t *testing.T) { + entries := EnumerateRegistries("mirror.company.internal/nvcf/cluster-validator:1.0", StackValues{}, nil) + for _, e := range entries { + if e.Registry == "mirror.company.internal" { + assert.False(t, e.Critical, "a non-NGC mirror must not be forced critical") + return + } + } + t.Fatal("mirror.company.internal not enumerated") +} + +// With no validator image and no stack values file, nothing names a registry +// and the run would report on quay.io alone, leaving the operator's NGC +// credentials unchecked in what is the default configuration. +func TestEnumerateRegistries_FallsBackToNGCWhenNothingNamesARegistry(t *testing.T) { + got := EnumerateRegistries("", StackValues{}, nil) + + var ngc *RegistryEntry + for i := range got { + if got[i].Registry == "nvcr.io" { + ngc = &got[i] + } + } + require.NotNil(t, ngc, "nvcr.io must be probed when no source names a registry") + assert.False(t, ngc.Critical, + "a guessed registry must not hard-fail the run; this category has no opt-out flag") +} + +// A mirrored install has no reason to reach nvcr.io, and probing it there adds +// a failing round trip for the sites least able to make it. +func TestEnumerateRegistries_NoNGCFallbackForAMirroredStack(t *testing.T) { + dir := t.TempDir() + values := filepath.Join(dir, "base.yaml") + require.NoError(t, os.WriteFile(values, + []byte("global:\n image:\n registry: harbor.example.com\n"), 0o600)) + + got := EnumerateRegistries("", LoadStackValues([]string{values}), nil) + for _, e := range got { + assert.NotEqual(t, "nvcr.io", e.Registry, + "a stack that named its own registry must not be probed against NGC") + } +} + +// The fallback must not shadow or duplicate an NGC registry a source named, +// which carries a repo hint and is critical. +func TestEnumerateRegistries_FallbackDoesNotDuplicateNamedNGC(t *testing.T) { + got := EnumerateRegistries("nvcr.io/nvidia/nvcf-byoc/cluster-validator:1.0.0", StackValues{}, nil) + + n := 0 + for _, e := range got { + if e.Registry == "nvcr.io" { + n++ + assert.True(t, e.Critical, "an NGC registry named by the image stays critical") + assert.Equal(t, "nvidia/nvcf-byoc/cluster-validator", e.RepoHint) + } + } + assert.Equal(t, 1, n, "nvcr.io must appear exactly once") +} + +// An IPv6 literal, bracketed or not, is listed once with one pair of +// brackets. A value that arrived still bracketed and was bracketed again +// became "[[fd00::1]]", which http.NewRequest rejects. +func TestEnumerateRegistries_DoesNotDoubleBracketIPv6(t *testing.T) { + for _, in := range []string{"[fd00::1]", "fd00::1"} { + got := EnumerateRegistries("", StackValues{}, parseExtras(t, in)) + var found string + for _, e := range got { + if strings.Contains(e.Registry, "fd00") { + found = e.Registry + } + } + assert.Equal(t, "[fd00::1]", found, "input %q must yield a singly-bracketed host", in) + } +} + +func TestEnumerateRegistries_KeepsBracketedIPv6WithPort(t *testing.T) { + got := EnumerateRegistries("", StackValues{}, parseExtras(t, "[fd00::1]:5000")) + var found string + for _, e := range got { + if strings.Contains(e.Registry, "fd00") { + found = e.Registry + } + } + assert.Equal(t, "[fd00::1]:5000", found) +} + +// The probe builds "https://" + registry + "/v2/", so a registry string that +// carries "/" or "@" moves the host. A values file the CLI finds by walking up +// from CWD must not be able to aim an outbound request. +func TestEnumerateRegistries_RejectsHostMovingRegistryStrings(t *testing.T) { + dir := t.TempDir() + values := filepath.Join(dir, "base.yaml") + require.NoError(t, os.WriteFile(values, + []byte("global:\n image:\n registry: nvcr.io@attacker.example.com\n"), 0o600)) + + for _, e := range EnumerateRegistries("nvcr.io@evil.test/x/y:1", LoadStackValues([]string{values}), nil) { + assert.NotContains(t, e.Registry, "@", "a host-moving string must not be probed") + assert.NotContains(t, e.Registry, "attacker.example.com") + assert.NotContains(t, e.Registry, "evil.test") + } +} + +// helmfile layers the environment file over base.yaml, so an env file that +// overrides only the repository keeps base.yaml's registry, and an env file +// that sets a mirror replaces it. +func TestLoadStackValues_LayersEnvOverBase(t *testing.T) { + dir := t.TempDir() + base, env := dir+"/base.yaml", dir+"/airgap.yaml" + require.NoError(t, writeFile(base, []byte(` +global: + image: + registry: nvcr.io + repository: nvidia/nvcf-byoc +ingress: + gatewayApi: + controllerNamespace: envoy-gateway-system +`))) + require.NoError(t, writeFile(env, []byte(` +global: + image: + repository: other/repo +`))) + got := LoadStackValues([]string{base, env}) + assert.True(t, got.Found) + assert.Equal(t, "nvcr.io", got.ImageRegistry, "an env file that only overrides the repository keeps the base registry") + assert.Equal(t, "envoy-gateway-system", got.EnvoyGatewayNamespace) + + require.NoError(t, writeFile(env, []byte(` +global: + image: + registry: harbor.corp.example +`))) + assert.Equal(t, "harbor.corp.example", LoadStackValues([]string{base, env}).ImageRegistry) +} + +// global.storageClass is the class every stack PVC names; an environment file +// sets it over base.yaml's empty default. +func TestLoadStackValues_StorageClass(t *testing.T) { + dir := t.TempDir() + base, env := filepath.Join(dir, "base.yaml"), filepath.Join(dir, "prod.yaml") + require.NoError(t, writeFile(base, []byte("global:\n storageClass: \"\"\n"))) + require.NoError(t, writeFile(env, []byte("global:\n storageClass: fast-ssd\n"))) + assert.Empty(t, LoadStackValues([]string{base}).StorageClass) + assert.Equal(t, "fast-ssd", LoadStackValues([]string{base, env}).StorageClass) +} + +// A registry value carrying a path is valid for the stack, which renders +// registry + "/" + repository. It must still be probed, and it must still +// suppress the nvcr.io guess, rather than dropping the whole category. +func TestEnumerateRegistries_StackRegistryWithPath(t *testing.T) { + dir := t.TempDir() + values := dir + "/base.yaml" + require.NoError(t, writeFile(values, []byte(` +global: + image: + registry: harbor.corp.example/nvcf +certManager: + acmesolver: + image: + repository: harbor.corp.example/jetstack/cert-manager-acmesolver +`))) + got := EnumerateRegistries("", LoadStackValues([]string{values}), nil) + require.Len(t, got, 1) + assert.Equal(t, "harbor.corp.example", got[0].Registry) + assert.Empty(t, got[0].RepoHint, "one segment is no repository, so no scope") + + require.NoError(t, writeFile(values, []byte(` +global: + image: + registry: harbor.corp.example/nvcf + repository: org +`))) + got = EnumerateRegistries("", LoadStackValues([]string{values}), nil) + require.NotEmpty(t, got) + assert.Equal(t, "nvcf/org", got[0].RepoHint) +} + +// helm pulls the stack's charts from global.helm.sources with the docker +// login. Where the OCI chart source folds to a scope already listed, the +// stack's images' or the validator image's, that row is marked. On any other +// scope, another org, another host, or an org with no team, it gets a row of +// its own, critical for an NGC registry like the stack's images. The +// control-plane validator does not dial a registry only the charts come from, +// since helm pulls them on this machine. An HTTPS chart repository carries +// credentials of its own and gets no row. +func TestEnumerateRegistries_MarksTheScopeHelmPullsTheChartsFrom(t *testing.T) { + dir := t.TempDir() + values := filepath.Join(dir, "base.yaml") + stackWith := func(body string) StackValues { + t.Helper() + require.NoError(t, writeFile(values, []byte("global:\n image:\n registry: nvcr.io/orgb\n"+ + " repository: team\n"+body+"certManager:\n enabled: false\n"))) + return LoadStackValues([]string{values}) + } + charts := func(body, image string) []RegistryEntry { + t.Helper() + return EnumerateRegistries(image, stackWith(body), nil) + } + source := func(registry, repository string) string { + return " helm:\n sources:\n registry: " + registry + "\n repository: " + repository + "\n" + } + sameScope := source("nvcr.io", "orgb/team") + images := RegistryEntry{Registry: "nvcr.io", RepoHint: "orgb/team", Critical: true} + + got := charts(sameScope, "") + require.Len(t, got, 1) + assert.Equal(t, RegistryEntry{Registry: "nvcr.io", RepoHint: "orgb/team", Critical: true, Charts: true}, got[0]) + + got = charts(sameScope, "nvcr.io/nvidia/cv:1.0.0") + require.Len(t, got, 2) + assert.False(t, got[0].Charts, "the validator image's org holds no charts") + assert.True(t, got[1].Charts) + + got = charts(sameScope, "nvcr.io/orgb/team:1.0.0") + require.Len(t, got, 1, "the validator image names the stack's scope") + assert.True(t, got[0].Charts) + + got = charts(source("nvcr.io", "nvidia/cv"), "nvcr.io/nvidia/cv:1.0.0") + require.Len(t, got, 2, "the chart source names the validator image's scope") + assert.True(t, got[0].Charts) + assert.False(t, got[0].ChartsOnly) + + for name, tc := range map[string]struct { + body string + want RegistryEntry + ids []string + }{ + "another org": {body: source("nvcr.io", "orgc/team"), + want: RegistryEntry{Registry: "nvcr.io", RepoHint: "orgc/team", Critical: true, + Charts: true, ChartsOnly: true}, + ids: []string{"registry-cred-nvcr.io", "registry-cred-nvcr.io/orgc/team"}}, + "another host": {body: source("harbor.example.com", "orgb/team"), + want: RegistryEntry{Registry: "harbor.example.com", RepoHint: "orgb/team", Charts: true, ChartsOnly: true}, + ids: []string{"registry-cred-nvcr.io", "registry-cred-harbor.example.com"}}, + "an org with no team": {body: source("nvcr.io", "orgb"), + want: RegistryEntry{Registry: "nvcr.io", Critical: true, Charts: true, ChartsOnly: true}, + ids: []string{"registry-cred-nvcr.io/orgb/team", "registry-cred-nvcr.io"}}, + } { + got = charts(tc.body, "") + require.Len(t, got, 2, name) + assert.Equal(t, images, got[0], name) + assert.Equal(t, tc.want, got[1], name) + + cat := buildRegistryCredentialCategory(PreflightConfig{Registries: got, + RegistryChecker: func(context.Context, string, string, bool, bool) error { return nil }}) + var ids []string + for _, c := range cat.checks { + ids = append(ids, c.ID) + } + assert.Equal(t, tc.ids, ids, name) + + cfg := buildControlPlaneValidatorConfig(got) + assert.Equal(t, 1, strings.Count(cfg, "host: "), name) + assert.Contains(t, cfg, `host: "nvcr.io"`, name) + } + + got = EnumerateRegistries("", stackWith(source("harbor.example.com", "orgb/team")), + []RegistryEntry{{Registry: "harbor.example.com"}}) + require.Len(t, got, 2) + assert.True(t, got[1].Charts) + assert.False(t, got[1].ChartsOnly, "an extra names the chart registry for the in-pod probe") + assert.Contains(t, buildControlPlaneValidatorConfig(got), `host: "harbor.example.com"`) + + for name, body := range map[string]string{ + "https chart repository": " helm:\n sources:\n url: https://helm.example.com/nvcf\n" + + " registry: nvcr.io\n repository: orgc/team\n", + "no chart source": "", + } { + got = charts(body, "") + require.Len(t, got, 1, name) + assert.Equal(t, images, got[0], name) + } +} + +// An org with no team is no repository: nvcr.io answers that scope with 400 +// "malformed token scope", which could only be graded unverifiable. The +// stack's row sends no scope then, so a revoked key still fails the run. +func TestEnumerateRegistries_OrgOnlyStackRepositoryIsNoScope(t *testing.T) { + stack := StackValues{Found: true, ImageRegistry: "nvcr.io", ImageRepository: "myorg"} + got := EnumerateRegistries("", stack, nil) + require.Len(t, got, 1) + assert.Equal(t, RegistryEntry{Registry: "nvcr.io", Critical: true}, got[0]) + + ngc := newFakeNGC(t, "good-key") + dockerHome(t, `{}`) + t.Setenv("NGC_API_KEY", "revoked-key") + ctx := WithRegistryCredentials(context.Background(), NewRegistryCredentials(true)) + r := registryCredentialCheck(probeRegistryCredential, got[0], "nvcr.io", false, nil).Run(ctx) + assert.False(t, r.Passed) + assert.Equal(t, SeverityError, r.Severity, r.Message) + assert.Contains(t, r.Message, "credentials from NGC_API_KEY rejected") + assert.Equal(t, []string{"revoked-key"}, ngc.passwords()) +} + +// A rejected registry value must not count as a source naming a registry: +// otherwise it suppresses the nvcr.io fallback and the category vanishes. +func TestEnumerateRegistries_RejectedValueDoesNotSuppressFallback(t *testing.T) { + dir := t.TempDir() + values := dir + "/base.yaml" + require.NoError(t, writeFile(values, []byte("global:\n image:\n registry: \"nvcr.io@evil.test\"\n"))) + got := EnumerateRegistries("", LoadStackValues([]string{values}), nil) + var names []string + for _, e := range got { + names = append(names, e.Registry) + } + assert.Contains(t, names, ngcRegistry) + assert.NotContains(t, names, "nvcr.io@evil.test") +} + +// quay.io depends on the ACME solver image, which the stack leaves on +// quay.io/jetstack unless it is overridden, not on whether a values file was +// found. +func TestEnumerateRegistries_QuayFollowsTheACMESolverImage(t *testing.T) { + dir := t.TempDir() + values := dir + "/base.yaml" + names := func() []string { + var out []string + for _, e := range EnumerateRegistries("", LoadStackValues([]string{values}), nil) { + out = append(out, e.Registry) + } + return out + } + require.NoError(t, writeFile(values, []byte( + "global:\n image:\n registry: harbor.corp.example\ncertManager:\n enabled: true\n"))) + assert.Contains(t, names(), certManagerRegistry, "a mirrored stack still pulls the default ACME solver from quay.io") + + require.NoError(t, writeFile(values, []byte( + "global:\n image:\n registry: harbor.corp.example\ncertManager:\n enabled: true\n"+ + " acmesolver:\n image:\n repository: harbor.corp.example/jetstack/acmesolver\n"))) + assert.NotContains(t, names(), certManagerRegistry, "a mirrored ACME solver never reaches quay.io") + + // The cert-manager release's condition is a YAML boolean: off, or a + // quoted "true", installs no cert-manager and no solver. + for _, enabled := range []string{"false", `"true"`} { + require.NoError(t, writeFile(values, []byte( + "global:\n image:\n registry: harbor.corp.example\ncertManager:\n enabled: "+enabled+"\n"))) + assert.NotContains(t, names(), certManagerRegistry, "certManager.enabled: %s", enabled) + } +} + +// "[fd00::1]" with no port must reach the ConfigMap unbracketed; the validator +// brackets it itself, and a double bracket is never reachable. +func TestParseRegistryHostPort_StripsIPv6Brackets(t *testing.T) { + host, port := parseRegistryHostPort("[fd00::1]") + assert.Equal(t, "fd00::1", host) + assert.Equal(t, 443, port) + got := EnumerateRegistries("", StackValues{}, parseExtras(t, "[fd00::1]")) + var names []string + for _, e := range got { + names = append(names, e.Registry) + } + assert.Contains(t, names, "[fd00::1]") +} + +// A validator image whose registry fails validation must not count as naming +// a registry either, or the nvcr.io fallback is suppressed with nothing probed. +func TestEnumerateRegistries_RejectedImageRegistryDoesNotSuppressFallback(t *testing.T) { + var names []string + for _, e := range EnumerateRegistries("user@evil.test/nvidia/validator:1", StackValues{}, nil) { + names = append(names, e.Registry) + } + assert.Contains(t, names, ngcRegistry) +} + +// A critical registry whose /v2/ answers 200 lets anyone in, so a configured +// credential cannot be checked there. The result says so instead of +// "credentials valid", and the check still passes. +func TestProbeRegistryCredential_CriticalAnonymousIsNotVerified(t *testing.T) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Docker-Distribution-Api-Version", "registry/2.0") + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + origTransport := http.DefaultTransport + http.DefaultTransport = srv.Client().Transport + t.Cleanup(func() { http.DefaultTransport = origTransport }) + host := strings.TrimPrefix(srv.URL, "https://") + dockerHome(t, inlineDockerConfig(t, host, "u", "p", "")) + + err := probeRegistryCredential(context.Background(), host, "", true, false) + var outcome registryProbeOutcome + require.ErrorAs(t, err, &outcome) + assert.Equal(t, probeNotVerified, outcome.kind) + + r := registryCredentialCheck(probeRegistryCredential, RegistryEntry{Registry: host, Critical: true}, host, + false, nil). + Run(context.Background()) + assert.True(t, r.Passed) + assert.NotContains(t, r.Message, "credentials valid") + assert.Contains(t, r.Message, "not verified") +} + +// The Gateways forwarded are the ones the stack wires NVCF routes to, with +// the gates its template applies: none without ingress.gatewayApi.enabled, +// shared and grpc always, nats only with its route on, and llmGrpc and +// llmQuic only with the LLM worker route on. An environment file can name a +// Gateway whose route stays off, and the stack never creates that Gateway. +func TestLoadStackValues_Gateways(t *testing.T) { + dir := t.TempDir() + base := dir + "/base.yaml" + require.NoError(t, writeFile(base, []byte(` +ingress: + gatewayApi: + enabled: true + gateways: + shared: {name: "", namespace: ""} + grpc: {name: "", namespace: ""} + llmGrpc: {name: "", namespace: ""} +`))) + const named = ` + shared: {name: shared-gw, namespace: envoy-gateway-system} + grpc: {name: grpc-gw, namespace: envoy-gateway-system} + nats: {name: nats-gw, namespace: envoy-gateway-system} + llmGrpc: {name: llm-grpc-gateway, namespace: envoy-gateway} + llmQuic: {name: llm-quic-gateway, namespace: envoy-gateway} +` + for name, tc := range map[string]struct { + env string + want []string + }{ + "routes off": { + env: "ingress:\n gatewayApi:\n gateways:" + named, + want: []string{"envoy-gateway-system/grpc-gw", "envoy-gateway-system/shared-gw"}, + }, + "routes on": { + env: "ingress:\n gatewayApi:\n routes:\n nats: {enabled: true}\n llmWorker: {enabled: true}\n" + + " gateways:" + named, + want: []string{ + "envoy-gateway-system/grpc-gw", "envoy-gateway-system/nats-gw", "envoy-gateway-system/shared-gw", + "envoy-gateway/llm-grpc-gateway", "envoy-gateway/llm-quic-gateway", + }, + }, + "gateway API off": { + env: "ingress:\n gatewayApi:\n enabled: false\n gateways:" + named, + want: nil, + }, + } { + env := dir + "/" + strings.ReplaceAll(name, " ", "-") + ".yaml" + require.NoError(t, writeFile(env, []byte(tc.env)), name) + assert.Equal(t, tc.want, LoadStackValues([]string{base, env}).Gateways, name) + } + + assert.Empty(t, LoadStackValues([]string{base}).Gateways, "an entry left empty is one the install does not use") +} + +// fakeRegistry is a TLS registry whose /v2/ and token endpoint answer as the +// test says. Its host is the registry name, and every client in the process +// trusts its certificate for the test. +type fakeRegistry struct { + host string + tokenCalls int + authSent []string +} + +func newFakeRegistry( + t *testing.T, v2 func(w http.ResponseWriter, realm string), token func(w http.ResponseWriter), +) *fakeRegistry { + t.Helper() + f := &fakeRegistry{} + var srv *httptest.Server + srv = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/token" { + f.tokenCalls++ + f.authSent = append(f.authSent, r.Header.Get("Authorization")) + token(w) + return + } + v2(w, srv.URL+"/token") + })) + t.Cleanup(srv.Close) + prev := http.DefaultTransport + http.DefaultTransport = srv.Client().Transport + t.Cleanup(func() { http.DefaultTransport = prev }) + f.host = strings.TrimPrefix(srv.URL, "https://") + return f +} + +// fakeNGC stands in for nvcr.io: every registry request the test makes, +// whatever its host, reaches it. Its token endpoint refuses a one-segment +// repository scope with 400, as nvcr.io does. It answers 401 to a password it +// does not know, and 403 to a known one asking for a repository outside the +// orgs that password is entitled to. A token it issued lists the tags of any +// repository. +type fakeNGC struct { + mu sync.Mutex + sent []string + // failing is how many token requests still get a 503. + failing int +} + +// passwords returns the Basic auth password of each token request, in order. +func (f *fakeNGC) passwords() []string { + f.mu.Lock() + defer f.mu.Unlock() + return slices.Clone(f.sent) +} + +// newFakeNGC accepts only goodPass, for any repository. +func newFakeNGC(t *testing.T, goodPass string) *fakeNGC { + return newEntitledNGC(t, map[string][]string{goodPass: {""}}) +} + +func bearerChallenge(w http.ResponseWriter, realm string) { + w.Header().Set("Www-Authenticate", `Bearer realm="`+realm+`",service="test"`) + w.WriteHeader(http.StatusUnauthorized) +} + +func tokenStatus(code int) func(http.ResponseWriter) { + return func(w http.ResponseWriter) { + w.WriteHeader(code) + if code == http.StatusOK { + _, _ = w.Write([]byte(`{"token":"t"}`)) + } + } +} + +// Every grading guard of the registry probe, driven through the real probe +// and the real row: exactly one outcome, a credential the token endpoint +// refused, fails a critical registry. A credential that cannot be checked, +// is missing, or meets a registry this probe cannot speak to is a warning or +// an informational pass. +func TestRegistryCredentialCheck_Grading(t *testing.T) { + prevBackoff := registryRetryBackoff + registryRetryBackoff = time.Millisecond + t.Cleanup(func() { registryRetryBackoff = prevBackoff }) + + type want struct { + passed bool + severity Severity + message string + } + for name, tc := range map[string]struct { + v2 func(w http.ResponseWriter, realm string) + token func(w http.ResponseWriter) + withCred bool + want want + }{ + "credential accepted": { + v2: bearerChallenge, token: tokenStatus(http.StatusOK), withCred: true, + want: want{true, SeverityInfo, "credentials valid"}, + }, + "credential rejected with 401": { + v2: bearerChallenge, token: tokenStatus(http.StatusUnauthorized), withCred: true, + want: want{false, SeverityError, "rejected: token exchange"}, + }, + "credential with no access, a 403": { + v2: bearerChallenge, token: tokenStatus(http.StatusForbidden), withCred: true, + want: want{false, SeverityError, "have no access to"}, + }, + "no credential, anonymous token refused": { + v2: bearerChallenge, token: tokenStatus(http.StatusUnauthorized), + want: want{false, SeverityWarning, "no local credentials found"}, + }, + "no credential, anonymous token issued": { + v2: bearerChallenge, token: tokenStatus(http.StatusOK), + want: want{false, SeverityWarning, "no local credentials found"}, + }, + "token service failing": { + v2: bearerChallenge, token: tokenStatus(http.StatusServiceUnavailable), withCred: true, + want: want{false, SeverityWarning, "could not verify credentials from this machine"}, + }, + "token service throttling": { + v2: bearerChallenge, token: tokenStatus(http.StatusTooManyRequests), withCred: true, + want: want{false, SeverityWarning, "could not verify credentials from this machine"}, + }, + "registry failing": { + v2: func(w http.ResponseWriter, _ string) { + w.Header().Set("Docker-Distribution-Api-Version", "registry/2.0") + w.WriteHeader(http.StatusBadGateway) + }, + withCred: true, + want: want{false, SeverityWarning, "could not verify credentials from this machine"}, + }, + "proxy page instead of a registry": { + v2: func(w http.ResponseWriter, _ string) { w.WriteHeader(http.StatusForbidden) }, + withCred: true, + want: want{true, SeverityInfo, "did not identify as an OCI registry"}, + }, + "captive portal 200": { + v2: func(w http.ResponseWriter, _ string) { w.WriteHeader(http.StatusOK) }, + withCred: true, + want: want{true, SeverityInfo, "did not identify as an OCI registry"}, + }, + "basic auth": { + v2: func(w http.ResponseWriter, _ string) { + w.Header().Set("Www-Authenticate", `Basic realm="harbor"`) + w.WriteHeader(http.StatusUnauthorized) + }, + withCred: true, + want: want{true, SeverityInfo, "uses Basic auth"}, + }, + "negotiate then bearer": { + v2: func(w http.ResponseWriter, realm string) { + w.Header().Add("Www-Authenticate", "Negotiate") + w.Header().Add("Www-Authenticate", `Bearer realm="`+realm+`"`) + w.WriteHeader(http.StatusUnauthorized) + }, + token: tokenStatus(http.StatusUnauthorized), withCred: true, + want: want{false, SeverityError, "rejected: token exchange"}, + }, + "realm on another host": { + v2: func(w http.ResponseWriter, _ string) { + bearerChallenge(w, "https://attacker.example/token") + }, + withCred: true, + want: want{true, SeverityInfo, "refusing to send credentials"}, + }, + "no realm off nvcr.io": { + v2: func(w http.ResponseWriter, _ string) { + w.Header().Set("Www-Authenticate", "Bearer") + w.WriteHeader(http.StatusUnauthorized) + }, + withCred: true, + want: want{true, SeverityInfo, "named no Bearer token realm"}, + }, + } { + t.Run(name, func(t *testing.T) { + token := tc.token + if token == nil { + token = func(w http.ResponseWriter) { t.Error("the token endpoint must not be called") } + } + reg := newFakeRegistry(t, tc.v2, token) + if tc.withCred { + dockerHome(t, inlineDockerConfig(t, reg.host, "u", "p", "")) + } else { + dockerHome(t, `{}`) + } + r := registryCredentialCheck(probeRegistryCredential, RegistryEntry{Registry: reg.host, Critical: true}, + reg.host, false, nil).Run(context.Background()) + assert.Equal(t, tc.want.passed, r.Passed, r.Message) + assert.Equal(t, tc.want.severity, r.Severity, r.Message) + assert.Contains(t, r.Message, tc.want.message) + }) + } + + t.Run("ECR", func(t *testing.T) { + const ecr = "123456789012.dkr.ecr.us-west-2.amazonaws.com" + r := registryCredentialCheck(probeRegistryCredential, RegistryEntry{Registry: ecr, Critical: true}, ecr, + false, nil). + Run(context.Background()) + assert.True(t, r.Passed) + assert.Equal(t, SeverityInfo, r.Severity) + assert.Contains(t, r.Message, "aws ecr get-login-password --region us-west-2 | "+ + "docker login --username AWS --password-stdin "+ecr) + }) + + t.Run("unreachable", func(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + host := ln.Addr().String() + require.NoError(t, ln.Close()) + r := registryCredentialCheck(probeRegistryCredential, RegistryEntry{Registry: host, Critical: true}, host, + false, nil). + Run(context.Background()) + assert.False(t, r.Passed) + assert.Equal(t, SeverityWarning, r.Severity, "a workstation that cannot reach a registry does not block") + assert.Contains(t, r.Message, "could not verify credentials from this machine") + }) + + t.Run("non-critical rejection", func(t *testing.T) { + reg := newFakeRegistry(t, bearerChallenge, tokenStatus(http.StatusUnauthorized)) + dockerHome(t, inlineDockerConfig(t, reg.host, "u", "p", "")) + r := registryCredentialCheck(probeRegistryCredential, RegistryEntry{Registry: reg.host}, reg.host, false, nil). + Run(context.Background()) + assert.False(t, r.Passed) + assert.Equal(t, SeverityWarning, r.Severity) + }) +} + +// A 5xx or 429 is retried before it is graded, honoring Retry-After. +func TestProbeRegistryCredential_RetriesTransientFailures(t *testing.T) { + prevBackoff := registryRetryBackoff + registryRetryBackoff = time.Millisecond + t.Cleanup(func() { registryRetryBackoff = prevBackoff }) + // Two 429s in a row, then a token: written out rather than derived from + // registryRetryAttempts, so lowering the default fails here. + calls := 0 + reg := newFakeRegistry(t, bearerChallenge, func(w http.ResponseWriter) { + calls++ + if calls <= 2 { + w.Header().Set("Retry-After", "0") + w.WriteHeader(http.StatusTooManyRequests) + return + } + tokenStatus(http.StatusOK)(w) + }) + dockerHome(t, inlineDockerConfig(t, reg.host, "u", "p", "")) + assert.NoError(t, probeRegistryCredential(context.Background(), reg.host, "", true, false)) + assert.Equal(t, 3, calls) +} + +// clusterHolding is a ClusterPullSecretChecker that answers holder and err, +// and records each credential it was asked about. +func clusterHolding(holder string, err error, asked *[]string) ClusterPullSecretChecker { + return func(_ context.Context, _, _, user, secret string) (string, error) { + if asked != nil { + *asked = append(*asked, user+":"+secret) + } + return holder, err + } +} + +// After install only a refused NGC key, with no docker login refused beside +// it, can be this machine's alone, and only where the cluster's pull secrets +// were read and none holds it. A refused docker login stays an error, as +// does a key the cluster also holds or that could not be looked for. +func TestRegistryCredentialCheck_RefusalAfterInstall(t *testing.T) { + key := registryCredential{user: "$oauthtoken", pass: "revoked", source: "NGC_API_KEY", ngcKey: true} + login := registryCredential{user: "$oauthtoken", pass: "rotated-out", source: "docker config"} + refusedKey := func(context.Context, string, string, bool, bool) error { + return registryProbeOutcome{kind: probeRejected, detail: "credentials from NGC_API_KEY rejected", + refused: key, keyOnly: true} + } + refusedLogin := func(context.Context, string, string, bool, bool) error { + return registryProbeOutcome{kind: probeRejected, detail: "credentials from docker config rejected", + refused: login} + } + entry := RegistryEntry{Registry: "nvcr.io", RepoHint: "nvidia/cv", Critical: true} + for name, tc := range map[string]struct { + checker RegistryCredentialChecker + postInstall bool + cluster ClusterPullSecretChecker + want Severity + message string + }{ + "key, before install": {refusedKey, false, nil, SeverityError, "credentials from NGC_API_KEY rejected"}, + "key, no pull secret holds it": {refusedKey, true, clusterHolding("", nil, nil), SeverityWarning, + "no pull secret the cluster holds for nvcr.io uses it, so this affects only this machine"}, + "key, a pull secret holds it": {refusedKey, true, clusterHolding("nvcf/nvcr-pull-secret", nil, nil), + SeverityError, "the cluster's pull secret nvcf/nvcr-pull-secret holds the same key"}, + "key, pull secrets unreadable": {refusedKey, true, clusterHolding("", errors.New("forbidden"), nil), + SeverityError, "could not check whether the cluster's pull secrets hold the same key: forbidden"}, + "key, no cluster to read": {refusedKey, true, nil, SeverityError, "could not check whether"}, + "docker login, before install": {refusedLogin, false, nil, SeverityError, "rejected"}, + "docker login, after install": {refusedLogin, true, clusterHolding("", nil, nil), SeverityError, + "credentials from docker config rejected"}, + } { + r := registryCredentialCheck(tc.checker, entry, "nvcr.io", tc.postInstall, tc.cluster). + Run(context.Background()) + assert.False(t, r.Passed, name) + assert.Equal(t, tc.want, r.Severity, name+": "+r.Message) + assert.Contains(t, r.Message, tc.message, name) + if tc.want == SeverityError { + assert.NotContains(t, r.Message, "affects only this machine", name) + } + } + + var asked []string + registryCredentialCheck(refusedKey, entry, "nvcr.io", true, clusterHolding("", nil, &asked)). + Run(context.Background()) + assert.Equal(t, []string{"$oauthtoken:revoked"}, asked, "the refused key is what is looked for") +} + +// After install a docker login the NGC key replaced still fails: docker and +// helm on this machine send it. The row also says whether the cluster's pull +// secrets hold it, or that they could not be read, and never downgrades it. +func TestRegistryCredentialCheck_ReplacedLoginAfterInstall(t *testing.T) { + login := registryCredential{user: "$oauthtoken", pass: "rotated-out", source: "docker config"} + replaced := func(context.Context, string, string, bool, bool) error { + return registryProbeOutcome{kind: probeLoginRejected, refused: login, + detail: "the docker login from docker config was rejected; credentials from NGC_API_KEY valid"} + } + for name, tc := range map[string]struct { + cluster ClusterPullSecretChecker + message string + }{ + "held by no pull secret": {clusterHolding("", nil, nil), "credentials from NGC_API_KEY valid"}, + "held by a pull secret": {clusterHolding("nvcf/nvcr-pull-secret", nil, nil), + "the cluster's pull secret nvcf/nvcr-pull-secret holds that docker login"}, + "pull secrets unreadable": {clusterHolding("", errors.New("forbidden"), nil), + "could not check whether the cluster's pull secrets hold that docker login: forbidden"}, + } { + for _, critical := range []bool{true, false} { + entry := RegistryEntry{Registry: "nvcr.io", RepoHint: "nvidia/cv", Critical: critical} + r := registryCredentialCheck(replaced, entry, "nvcr.io", true, tc.cluster).Run(context.Background()) + assert.False(t, r.Passed, name) + assert.Error(t, r.Err, name) + assert.Equal(t, map[bool]Severity{true: SeverityError, false: SeverityWarning}[critical], r.Severity, + name+": "+r.Message) + assert.Contains(t, r.Message, tc.message, name) + assert.NotContains(t, r.Message, "affects only this machine", name) + } + } + var asked []string + entry := RegistryEntry{Registry: "nvcr.io", RepoHint: "nvidia/cv", Critical: true} + registryCredentialCheck(replaced, entry, "nvcr.io", true, clusterHolding("", nil, &asked)). + Run(context.Background()) + assert.Equal(t, []string{"$oauthtoken:rotated-out"}, asked) +} + +// Where the docker login goes first for nvcr.io and nvcr.io rejects it, the +// NGC key is sent before the row is graded. The run then moves to the key, +// so the validator's pull secret is minted from the credential the row found +// accepted, and the row says how to renew the login docker still sends. The +// row still fails after install, where no cluster pull secret holds the login. +func TestProbeRegistryCredential_RejectedLoginGivesWayToTheNGCKey(t *testing.T) { + ngc := newFakeNGC(t, "good-key") + dockerHome(t, inlineDockerConfig(t, "nvcr.io", "$oauthtoken", "rotated-out", "")) + t.Setenv("NGC_API_KEY", "good-key") + entry := RegistryEntry{Registry: "nvcr.io", RepoHint: "nvidia/cv", Critical: true} + rc := NewRegistryCredentials(false) + ctx := WithRegistryCredentials(context.Background(), rc) + var asked []string + r := registryCredentialCheck(probeRegistryCredential, entry, "nvcr.io", true, clusterHolding("", nil, &asked)). + Run(ctx) + assert.False(t, r.Passed, r.Message) + assert.Equal(t, []string{"$oauthtoken:rotated-out"}, asked, "the cluster is searched for the rejected login") + assert.Equal(t, SeverityError, r.Severity) + assert.Contains(t, r.Message, "credentials from NGC_API_KEY valid") + assert.Contains(t, r.Message, "the docker login from docker config") + assert.Contains(t, r.Message, "docker and helm on this machine still send it; "+ + "renew it with: docker login nvcr.io --username '$oauthtoken'") + assert.NotContains(t, r.Message, "cannot pull") + + got := rc.lookup(ctx, "nvcr.io", "nvidia/cv") + require.NoError(t, got.err) + require.True(t, got.ok) + assert.Equal(t, "good-key", got.cred.pass, "later lookups, the pull secret's among them, get the key") + assert.Equal(t, []string{"rotated-out", "good-key"}, ngc.passwords()) +} + +// Before install a docker login nvcr.io rejects fails a critical row even +// though the NGC key works. The key still serves tag discovery and the +// validator's pull secret, and the row names both credentials. On the scope +// helm pulls the stack's charts from, the row says the install cannot pull +// them; on another it makes no such claim. +func TestRegistryCredentialCheck_BeforeInstallGradesTheRejectedLogin(t *testing.T) { + withTempCacheDir(t) + newFakeNGC(t, "good-key") + dockerHome(t, inlineDockerConfig(t, "nvcr.io", "$oauthtoken", "rotated-out", "")) + t.Setenv("NGC_API_KEY", "good-key") + ctx := WithRegistryCredentials(context.Background(), NewRegistryCredentials(false)) + + const charts = "helm on this machine pulls the stack's charts from nvcr.io/nvidia/cv with the docker login, " + + "so the install cannot pull them until it is renewed" + for _, critical := range []bool{true, false} { + for _, stackCharts := range []bool{true, false} { + entry := RegistryEntry{Registry: "nvcr.io", RepoHint: "nvidia/cv", Critical: critical, Charts: stackCharts} + r := registryCredentialCheck(probeRegistryCredential, entry, "nvcr.io", false, nil).Run(ctx) + assert.False(t, r.Passed, r.Message) + assert.Equal(t, map[bool]Severity{true: SeverityError, false: SeverityWarning}[critical], r.Severity) + assert.Contains(t, r.Message, "nvcr.io: the docker login from docker config") + assert.Contains(t, r.Message, "renew it with: docker login nvcr.io --username '$oauthtoken'") + assert.Contains(t, r.Message, "credentials from NGC_API_KEY valid, and the run uses them in its place") + if stackCharts { + assert.Contains(t, r.Message, charts) + } else { + assert.NotContains(t, r.Message, "charts") + } + } + } + + got, ok := ResolveLatestValidatorTag(ctx, "nvcr.io/nvidia/cv") + require.True(t, ok, "tag discovery uses the key") + assert.Equal(t, "nvcr.io/nvidia/cv:1.2.0", got) + client := fake.NewSimpleClientset() + assert.Equal(t, "good-key", mintPullSecret(ctx, t, client, "nvcr.io/nvidia/cv:1.0.0", + clusterValidatorComputePlaneRole), "the validator's pull secret carries the key") +} + +// When nvcr.io rejects the key too, the row is graded on the key, naming both +// credentials. A docker login was rejected as well, so it is an error after +// install too. +func TestProbeRegistryCredential_RejectedLoginAndKey(t *testing.T) { + ngc := newFakeNGC(t, "nothing-matches") + dockerHome(t, inlineDockerConfig(t, "nvcr.io", "$oauthtoken", "rotated-out", "")) + t.Setenv("NGC_API_KEY", "revoked-key") + entry := RegistryEntry{Registry: "nvcr.io", RepoHint: "nvidia/cv", Critical: true} + for _, postInstall := range []bool{true, false} { + ctx := WithRegistryCredentials(context.Background(), NewRegistryCredentials(false)) + r := registryCredentialCheck(probeRegistryCredential, entry, "nvcr.io", postInstall, + clusterHolding("", nil, nil)).Run(ctx) + assert.False(t, r.Passed) + assert.Equal(t, SeverityError, r.Severity, "postInstall=%v", postInstall) + assert.Contains(t, r.Message, "credentials from NGC_API_KEY rejected") + assert.Contains(t, r.Message, "generate a new NGC API key") + assert.Contains(t, r.Message, "the docker login from docker config") + assert.Contains(t, r.Message, "renew it with: docker login nvcr.io") + assert.NotContains(t, r.Message, "cannot pull") + } + assert.Equal(t, []string{"rotated-out", "revoked-key", "rotated-out", "revoked-key"}, ngc.passwords()) +} + +// The stack's org is the probe's scope, so a key without access to it is +// caught even when the validator image comes from another org on the same +// registry: both scopes are probed, each on its own row. +func TestEnumerateRegistries_ProbesTheStacksOrg(t *testing.T) { + stack := StackValues{Found: true, ImageRegistry: "nvcr.io", ImageRepository: "customerorg/team"} + got := EnumerateRegistries("nvcr.io/nvidia/nvcf-byoc/cluster-validator:1.0.0", stack, nil) + var scopes []string + for _, e := range got { + if e.Registry == "nvcr.io" { + assert.True(t, e.Critical) + scopes = append(scopes, e.RepoHint) + } + } + assert.Equal(t, []string{"nvidia/nvcf-byoc/cluster-validator", "customerorg/team"}, scopes) + + cat := buildRegistryCredentialCategory(PreflightConfig{Registries: got, + RegistryChecker: func(context.Context, string, string, bool, bool) error { return nil }}) + var ids []string + for _, c := range cat.checks { + ids = append(ids, c.ID) + } + assert.Contains(t, ids, "registry-cred-nvcr.io") + assert.Contains(t, ids, "registry-cred-nvcr.io/customerorg/team") + + mirrored := StackValues{Found: true, ImageRegistry: "harbor.example.com/nvcf", ImageRepository: "org/team"} + got = EnumerateRegistries("", mirrored, nil) + require.NotEmpty(t, got) + assert.Equal(t, "nvcf/org/team", got[0].RepoHint, "the registry's path and the repository form the scope") + + cfg := buildControlPlaneValidatorConfig(EnumerateRegistries("nvcr.io/nvidia/cv:1", stack, nil)) + assert.Equal(t, 1, strings.Count(cfg, `host: "nvcr.io"`), "one registry is one in-pod endpoint") +} + +// --cluster-validator-registries accepts host[:port][/path]; anything else is +// an error naming the entry rather than a row that silently never appears. +func TestParseRegistryExtra(t *testing.T) { + for in, want := range map[string]RegistryEntry{ + "harbor.example.com": {Registry: "harbor.example.com"}, + "harbor.example.com:443": {Registry: "harbor.example.com"}, + "harbor.example.com:5000/nvcf": {Registry: "harbor.example.com:5000", RepoHint: "nvcf"}, + "harbor.example.com/nvcf/team/": {Registry: "harbor.example.com", RepoHint: "nvcf/team"}, + "[fd00::1]": {Registry: "[fd00::1]"}, + "[fd00::1]:5000": {Registry: "[fd00::1]:5000"}, + } { + got, err := ParseRegistryExtra(in) + require.NoError(t, err, in) + assert.Equal(t, want, got, in) + } + for _, in := range []string{ + "https://harbor.example.com", "harbor.example.com:44x", "nvcr.io:", "nvcr.io:70000", + "harbor.example.com//x", "nvcr.io@attacker.example", "harbor.example.com/a?b", + } { + _, err := ParseRegistryExtra(in) + assert.ErrorContains(t, err, "expected host[:port]", in) + } +} + +// The gates read env values the way the stack does: the ingress release's +// condition is a YAML boolean, and the route gates are template ifs, which +// take a quoted "true" (and a quoted "false") as on. Each gate is toggled on +// its own so one cannot stand in for the other. +func TestLoadStackValues_GatewayGates(t *testing.T) { + dir := t.TempDir() + const gateways = ` + gateways: + shared: {name: shared-gw, namespace: gw} + grpc: {name: grpc-gw, namespace: gw} + nats: {name: nats-gw, namespace: gw} + llmGrpc: {name: llm-grpc-gw, namespace: gw} + llmQuic: {name: llm-quic-gw, namespace: gw} +` + base := []string{"gw/grpc-gw", "gw/shared-gw"} + withNats := []string{"gw/grpc-gw", "gw/nats-gw", "gw/shared-gw"} + withLLM := []string{"gw/grpc-gw", "gw/llm-grpc-gw", "gw/llm-quic-gw", "gw/shared-gw"} + for name, tc := range map[string]struct { + values string + want []string + }{ + "nats only": {"enabled: true\n routes: {nats: {enabled: true}}", withNats}, + "llmWorker only": {"enabled: true\n routes: {llmWorker: {enabled: true}}", withLLM}, + "quoted nats true": {"enabled: true\n routes: {nats: {enabled: \"true\"}}", withNats}, + "quoted nats false": {"enabled: true\n routes: {nats: {enabled: \"false\"}}", withNats}, + "nats null": {"enabled: true\n routes: {nats: {enabled: null}}", base}, + "quoted gateway API": {"enabled: \"true\"", nil}, + "gateway API off": {"enabled: false\n routes: {nats: {enabled: true}}", nil}, + } { + path := filepath.Join(dir, strings.ReplaceAll(name, " ", "-")+".yaml") + require.NoError(t, writeFile(path, []byte("ingress:\n gatewayApi:\n "+tc.values+gateways)), name) + assert.Equal(t, tc.want, LoadStackValues([]string{path}).Gateways, name) + } + + path := filepath.Join(dir, "slash.yaml") + require.NoError(t, writeFile(path, []byte( + "ingress:\n gatewayApi:\n enabled: true\n gateways:\n"+ + " shared: {name: a/b, namespace: gw}\n grpc: {name: grpc-gw, namespace: gw/x}\n"))) + assert.Empty(t, LoadStackValues([]string{path}).Gateways, "a name or namespace with '/' is never forwarded") +} + +// A dependency whose release condition is off is external to the stack, and +// one the values never mention is not reported. +func TestLoadStackValues_ExternalComponents(t *testing.T) { + path := filepath.Join(t.TempDir(), "env.yaml") + require.NoError(t, writeFile(path, []byte( + "nats: {enabled: false}\nopenbao: {enabled: true}\ncassandra: {enabled: \"true\"}\n"))) + assert.Equal(t, []string{"cassandra", "nats"}, LoadStackValues([]string{path}).ExternalComponents) + require.NoError(t, writeFile(path, []byte("global: {}\n"))) + assert.Empty(t, LoadStackValues([]string{path}).ExternalComponents) + + // An environment file layered over base.yaml turns components off. + base, env := filepath.Join(t.TempDir(), "base.yaml"), filepath.Join(t.TempDir(), "prod.yaml") + require.NoError(t, writeFile(base, []byte( + "nats: {enabled: true}\nopenbao: {enabled: true}\ncassandra: {enabled: true}\n"))) + require.NoError(t, writeFile(env, []byte("openbao: {enabled: false}\ncassandra: {enabled: false}\n"))) + assert.Equal(t, []string{"cassandra", "openbao"}, LoadStackValues([]string{base, env}).ExternalComponents) + assert.Empty(t, LoadStackValues([]string{base}).ExternalComponents) +} + +// The Gateways LoadStackValues forwards are the ones the stack renders into +// the ingress release's nvcfGatewayRoutes.gateways, for the default routes +// and with each gated route on, so the CLI's gates cannot drift from the +// template's. Needs helmfile and helm on PATH; skipped without them. bazel +// test runs it on a fixed PATH, where they are found only in /usr/local/bin, +// /usr/bin or /bin unless the run passes --test_env=PATH. +func TestLoadStackValues_GatewaysMatchTheRenderedStack(t *testing.T) { + if testing.Short() { + t.Skip("renders the stack with helmfile") + } + stack := filepath.Join(repoStacksDir(t), "self-managed") + helmfile, err := exec.LookPath("helmfile") + if err != nil { + if os.Getenv("TEST_SRCDIR") != "" { + t.Skip("helmfile is not on bazel test's PATH; run with --test_env=PATH, or with go test") + } + t.Skip("helmfile is not installed") + } + const common = ` +ingress: + gatewayApi: + controllerNamespace: gateway + gateways: + shared: {name: shared-gw, namespace: gateway} + grpc: {name: grpc-gw, namespace: gateway} + nats: {name: nats-gw, namespace: gateway} + llmGrpc: {name: llm-grpc-gw, namespace: llm} + llmQuic: {name: llm-quic-gw, namespace: llm} +` + const llmWorker = ` + routes: + llmWorker: {enabled: true, backend: {namespace: nvcf}} +global: + workerEndpoints: {llmRequestRouterAddress: "https://router.example.invalid:50071"} +addons: + llm: + requestRouter: + backendRouter: + pylonGrpcDialAddress: "https://router.example.invalid:50071" + pylonReverseTunnelDialAddress: "router.example.invalid:50072" + grpcTls: {enabled: true, issuerRef: {name: test-issuer}} +` + for name, env := range map[string]string{ + "default routes": common, + "nats on": common + " routes:\n nats: {enabled: true}\n", + "quoted nats true": common + " routes:\n nats: {enabled: \"true\"}\n", + "llmWorker on": common + llmWorker, + } { + t.Run(name, func(t *testing.T) { + dir := t.TempDir() + envFile := filepath.Join(dir, "env.yaml") + require.NoError(t, writeFile(envFile, []byte(env))) + out := filepath.Join(dir, "ingress-values.yaml") + cmd := exec.Command(helmfile, "--file", "helmfile.d/02-core.yaml.gotmpl", "--environment", "default", + "--state-values-file", envFile, "--selector", "name=ingress", + "write-values", "--output-file-template", out) + cmd.Dir = stack + cmd.Env = append(os.Environ(), "HELMFILE_ENV=base", "HELMFILE_CACHE_HOME="+filepath.Join(dir, "cache")) + output, err := cmd.CombinedOutput() + require.NoError(t, err, string(output)) + body, err := os.ReadFile(out) + require.NoError(t, err) + var rendered struct { + NvcfGatewayRoutes struct { + Gateways map[string]struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + } `json:"gateways"` + } `json:"nvcfGatewayRoutes"` + } + require.NoError(t, yaml.Unmarshal(body, &rendered)) + var want []string + for _, gw := range rendered.NvcfGatewayRoutes.Gateways { + want = append(want, gw.Namespace+"/"+gw.Name) + } + sort.Strings(want) + require.NotEmpty(t, want, "the ingress release renders the shared and grpc Gateways") + base := filepath.Join(stack, "environments", "base.yaml") + assert.Equal(t, want, LoadStackValues([]string{base, envFile}).Gateways) + }) + } +} + +func TestParseExternalComponents(t *testing.T) { + got, err := ParseExternalComponents([]string{" OpenBao ,nats", "nats", ""}) + require.NoError(t, err) + assert.Equal(t, []string{"nats", "openbao"}, got) + got, err = ParseExternalComponents(nil) + require.NoError(t, err) + assert.Nil(t, got) + _, err = ParseExternalComponents([]string{"nats,redis"}) + require.ErrorContains(t, err, `unknown component "redis"`) +} diff --git a/src/clis/nvcf-cli/internal/selfhosted/severity/BUILD.bazel b/src/clis/nvcf-cli/internal/selfhosted/severity/BUILD.bazel new file mode 100644 index 0000000000..25bf1d17c2 --- /dev/null +++ b/src/clis/nvcf-cli/internal/selfhosted/severity/BUILD.bazel @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: Copyright (c) NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "severity", + srcs = ["severity.go"], + importpath = "nvcf-cli/internal/selfhosted/severity", + visibility = ["//src/clis/nvcf-cli:__subpackages__"], +) + +go_test( + name = "severity_test", + srcs = ["severity_test.go"], + embed = [":severity"], +) diff --git a/src/clis/nvcf-cli/internal/selfhosted/severity/severity.go b/src/clis/nvcf-cli/internal/selfhosted/severity/severity.go new file mode 100644 index 0000000000..622f7143d7 --- /dev/null +++ b/src/clis/nvcf-cli/internal/selfhosted/severity/severity.go @@ -0,0 +1,52 @@ +/* +SPDX-FileCopyrightText: Copyright (c) NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package severity grades a check row. Both the check runner and the +// renderers import it, so every count, glyph and exit code follows one rule. +package severity + +// Severity is how much a failed check matters. +type Severity string + +const ( + Info Severity = "info" + Warning Severity = "warning" + Error Severity = "error" +) + +// Grade is how a row counts in every tally. +type Grade int + +const ( + Pass Grade = iota + Warn + Fail +) + +// Of grades one row. A miss blocks the run unless its severity is exactly +// info or warning, so a misspelled or empty severity cannot downgrade a +// failure. +func Of(passed bool, s Severity) Grade { + switch { + case passed: + return Pass + case s == Info || s == Warning: + return Warn + default: + return Fail + } +} diff --git a/src/clis/nvcf-cli/internal/selfhosted/severity/severity_test.go b/src/clis/nvcf-cli/internal/selfhosted/severity/severity_test.go new file mode 100644 index 0000000000..c6cee52d66 --- /dev/null +++ b/src/clis/nvcf-cli/internal/selfhosted/severity/severity_test.go @@ -0,0 +1,42 @@ +/* +SPDX-FileCopyrightText: Copyright (c) NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package severity + +import "testing" + +// A miss fails closed: only the two named non-blocking severities warn. +func TestOf_FailsClosed(t *testing.T) { + for _, tc := range []struct { + passed bool + sev Severity + want Grade + }{ + {true, Error, Pass}, + {true, "", Pass}, + {false, Info, Warn}, + {false, Warning, Warn}, + {false, Error, Fail}, + {false, "Error", Fail}, + {false, "warn", Fail}, + {false, "", Fail}, + } { + if got := Of(tc.passed, tc.sev); got != tc.want { + t.Errorf("Of(%v, %q) = %v, want %v", tc.passed, tc.sev, got, tc.want) + } + } +} diff --git a/src/clis/nvcf-cli/internal/selfhosted/stack.go b/src/clis/nvcf-cli/internal/selfhosted/stack.go index 0b4838ef9a..61b5d4c873 100644 --- a/src/clis/nvcf-cli/internal/selfhosted/stack.go +++ b/src/clis/nvcf-cli/internal/selfhosted/stack.go @@ -169,17 +169,11 @@ func splitGitRef(ref string) (url, branch string) { // without the org.opencontainers.image.title layer annotation (which plain // "oras pull" requires to save files to disk). func resolveOCI(ctx context.Context, ref, cacheDir string) (*ResolvedStack, error) { - if cacheDir == "" { - home, err := os.UserCacheDir() - if err != nil { - return nil, fmt.Errorf("resolving cache dir: %w", err) - } - cacheDir = filepath.Join(home, "nvcf-cli", "stacks") + dst, err := ociStackCacheDir(ref, cacheDir) + if err != nil { + return nil, err } - digest := digestFromRef(ref) - dst := filepath.Join(cacheDir, "oci-"+digest) - // Cache hit: sentinel file exists (written only after successful extraction). if _, err := os.Stat(filepath.Join(dst, ".extraction-complete")); err == nil { return &ResolvedStack{Path: dst, Source: "oci", OCIRef: ref}, nil @@ -232,6 +226,37 @@ func resolveOCI(ctx context.Context, ref, cacheDir string) (*ResolvedStack, erro return &ResolvedStack{Path: dst, Source: "oci", OCIRef: ref}, nil } +// ociStackCacheDir is where resolveOCI extracts ref under cacheDir, the +// default cache root when cacheDir is empty. +func ociStackCacheDir(ref, cacheDir string) (string, error) { + if cacheDir == "" { + home, err := os.UserCacheDir() + if err != nil { + return "", fmt.Errorf("resolving cache dir: %w", err) + } + cacheDir = filepath.Join(home, "nvcf-cli", "stacks") + } + return filepath.Join(cacheDir, "oci-"+digestFromRef(ref)), nil +} + +// ExtractedOCIStack returns the directory an earlier ResolveStack extracted +// the oci:// reference ref into, or "" when ref is not an oci:// reference or +// was never extracted. It never fetches: a later ResolveStack reuses that +// extraction as it is, so it holds exactly what the install read. +func ExtractedOCIStack(ref string) string { + if !strings.HasPrefix(ref, "oci://") { + return "" + } + dir, err := ociStackCacheDir(ref, "") + if err != nil { + return "" + } + if _, err := os.Stat(filepath.Join(dir, ".extraction-complete")); err != nil { + return "" + } + return dir +} + // ociManifest is a minimal OCI manifest used to extract layer descriptors. type ociManifest struct { Layers []struct { diff --git a/src/clis/nvcf-cli/internal/selfhosted/stack_namespaces.go b/src/clis/nvcf-cli/internal/selfhosted/stack_namespaces.go new file mode 100644 index 0000000000..42eb23a293 --- /dev/null +++ b/src/clis/nvcf-cli/internal/selfhosted/stack_namespaces.go @@ -0,0 +1,238 @@ +/* +SPDX-FileCopyrightText: Copyright (c) NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package selfhosted + +import ( + "os" + "path/filepath" + "regexp" + "slices" + "sort" + "strings" +) + +// stackNamespace is a namespace stack releases deploy into. gates are the +// values keys of the condition: on those releases; none when one of them +// always installs. on is the default the stack's environments/base.yaml gives +// them, used only when the stack's values set none of the gates. +type stackNamespace struct { + name string + gates []string + on bool +} + +// deployed reports whether the stack installs a release into the namespace, +// given its layered values. +func (n stackNamespace) deployed(values map[string]any) bool { + if len(n.gates) == 0 { + return true + } + set := false + for _, gate := range n.gates { + v, ok := digAny(values, strings.Split(gate, ".")...).(bool) + if v { + return true + } + set = set || ok + } + return !set && n.on +} + +// runtimeOwnedNamespaces are created at runtime by NVCA rather than by a +// stack release, and hold live work. No stack list may probe them, whatever a +// helmfile declares. +var runtimeOwnedNamespaces = map[string]bool{"nvcf-backend": true, "nvca-system": true} + +var ( + // releaseItemRE matches the line that opens a list entry at the release + // level, capturing the rest of the line as the entry's first key. + releaseItemRE = regexp.MustCompile(`^ {0,2}- (.*)$`) + + // releaseKeyRE matches a release-level key. Indentation is capped at 4 + // spaces: keys nested inside a release's values: block sit at 6 or more + // and are chart configuration, not the release's own namespace. + releaseKeyRE = regexp.MustCompile(`^ {0,4}(namespace|condition|installed):\s*(.*?)\s*$`) + + // literalNamespaceRE accepts only a bare DNS label. A value containing a + // Go template action is operator-configurable (the gateway controller + // namespace is one), so its rendered value is not knowable from the + // source. + literalNamespaceRE = regexp.MustCompile(`^[a-z0-9][a-z0-9.-]*$`) + + // conditionRE is a values path such as addons.kaiScheduler.enabled, + // optionally followed by a comment. + conditionRE = regexp.MustCompile(`^([A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)+)\s*(?:#.*)?$`) + + templateOpenRE = regexp.MustCompile(`\{\{-?\s*(?:if|with|range)\b`) + templateCloseRE = regexp.MustCompile(`\{\{-?\s*end\b`) +) + +// helmfileRelease is what the stack check reads from one release entry. +// opaque is set when its gate cannot be read from the source: the entry sits +// inside a template block, or its condition: or installed: is templated. +type helmfileRelease struct { + namespace string + condition string + disabled bool + opaque bool +} + +// helmfileReleases reads the release entries of one helmfile fragment. +func helmfileReleases(body string) []helmfileRelease { + var out []helmfileRelease + depth := 0 + for _, line := range strings.Split(body, "\n") { + if m := releaseItemRE.FindStringSubmatch(line); m != nil { + out = append(out, helmfileRelease{opaque: depth > 0}) + line = " " + m[1] + } + depth += len(templateOpenRE.FindAllString(line, -1)) - len(templateCloseRE.FindAllString(line, -1)) + if len(out) == 0 { + continue + } + cur := &out[len(out)-1] + m := releaseKeyRE.FindStringSubmatch(line) + if m == nil { + continue + } + switch value := strings.Trim(m[2], `"'`); m[1] { + case "namespace": + if literalNamespaceRE.MatchString(value) { + cur.namespace = value + } + case "condition": + if c := conditionRE.FindStringSubmatch(value); c != nil { + cur.condition = c[1] + } else { + cur.opaque = true + } + case "installed": + switch value { + case "true": + case "false": + cur.disabled = true + default: + cur.opaque = true + } + } + } + return out +} + +// stackReleaseNamespaces returns the namespaces that helmfile releases deploy +// into for the stack rooted at stackDir, each with the conditions that gate +// it, or nil when the stack is not readable. +// +// This is the authoritative answer to "which namespaces does the stack own": +// a hardcoded list that drifts either misses a real leftover or names a +// namespace the stack does not own. +// +// A release whose gate cannot be read from the source (a template block +// around it, a templated condition: or installed:) is left out, as is one +// installed: false. Only a namespace the stack is known to deploy into may +// fail the check. +// +// Callers fall back to the static list when this returns nil, which is the +// normal case for a released binary with no stack checkout alongside it. +func stackReleaseNamespaces(stackDir string) []stackNamespace { + if stackDir == "" { + return nil + } + // helmfile accepts plain .yaml fragments as well as .yaml.gotmpl. + var entries []string + for _, pattern := range []string{"*.yaml.gotmpl", "*.yaml"} { + matches, err := filepath.Glob(filepath.Join(stackDir, "helmfile.d", pattern)) + if err != nil { + return nil + } + entries = append(entries, matches...) + } + if len(entries) == 0 { + return nil + } + + gates := make(map[string][]string) + always := make(map[string]bool) + for _, path := range entries { + body, err := os.ReadFile(path) + if err != nil { + // Bail out rather than deriving a partial list. A single + // unreadable fragment (a root-owned 0600 file left by "sudo + // helmfile", say) would otherwise silently drop a third of the + // namespaces, and the caller prefers any non-empty derived list + // over the complete static fallback. That turns a truncated scan + // into an affirmative all-clear. + return nil + } + for _, r := range helmfileReleases(string(body)) { + if r.namespace == "" || r.opaque || r.disabled { + continue + } + if r.condition == "" { + always[r.namespace] = true + } else if !slices.Contains(gates[r.namespace], r.condition) { + gates[r.namespace] = append(gates[r.namespace], r.condition) + } + } + } + + var out []stackNamespace + for ns := range always { + out = append(out, stackNamespace{name: ns}) + } + for ns, g := range gates { + if !always[ns] { + sort.Strings(g) + out = append(out, stackNamespace{name: ns, gates: g}) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].name < out[j].name }) + return out +} + +// stackValues layers the stack's environments/base.yaml and the environment's +// own file the way helmfile does. It reports false when neither was read. +func stackValues(stackDir, env string) (map[string]any, bool) { + if stackDir == "" { + return nil, false + } + files := []string{filepath.Join(stackDir, "environments", "base.yaml")} + if env != "" { + files = append(files, filepath.Join(stackDir, "environments", env+".yaml")) + } + return loadValuesFiles(files) +} + +// resolveStackNamespaces returns the namespaces to probe for the given plane: +// those the static list and the stack's own helmfile.d deploy into, given the +// gates the stack's values for env set. +func resolveStackNamespaces(stackDir, env string, static []stackNamespace) []string { + values, _ := stackValues(stackDir, env) + var out []string + // Union, not replace. The derived list follows the stack, but it cannot see + // namespaces behind a helmfiles: include, so on its own it can still be + // short. Merging keeps the static list as a floor. + for _, list := range [][]stackNamespace{static, stackReleaseNamespaces(stackDir)} { + for _, ns := range list { + if ns.deployed(values) { + out = append(out, ns.name) + } + } + } + return mergeNamespaces(out) +} diff --git a/src/clis/nvcf-cli/internal/selfhosted/stack_namespaces_test.go b/src/clis/nvcf-cli/internal/selfhosted/stack_namespaces_test.go new file mode 100644 index 0000000000..4fcf669b00 --- /dev/null +++ b/src/clis/nvcf-cli/internal/selfhosted/stack_namespaces_test.go @@ -0,0 +1,317 @@ +/* +SPDX-FileCopyrightText: Copyright (c) NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package selfhosted + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// writeStack lays out a minimal helmfile.d matching the real stack's shape. +func writeStack(t *testing.T, files map[string]string) string { + t.Helper() + dir := t.TempDir() + hd := filepath.Join(dir, "helmfile.d") + require.NoError(t, os.MkdirAll(hd, 0o755)) + for name, body := range files { + require.NoError(t, os.WriteFile(filepath.Join(hd, name), []byte(body), 0o600)) + } + return dir +} + +func TestStackReleaseNamespaces_ReadsLiteralNamespaces(t *testing.T) { + dir := writeStack(t, map[string]string{ + "01-dependencies.yaml.gotmpl": ` +releases: + - name: nats + namespace: nats-system + - name: cert-manager + namespace: cert-manager +`, + "02-core.yaml.gotmpl": ` +releases: + - name: api + namespace: nvcf + - name: ui + namespace: nvcf-ui +`, + }) + + got := namespaceNames(stackReleaseNamespaces(dir)) + assert.Equal(t, []string{"cert-manager", "nats-system", "nvcf", "nvcf-ui"}, got, + "namespaces must come from the stack, de-duplicated and sorted") +} + +// The gateway controller namespace is templated from a values key, so its +// rendered value is not knowable from the source. Including the raw template +// text would produce a garbage namespace to probe and, on a hit, to delete. +func TestStackReleaseNamespaces_SkipsTemplatedNamespaces(t *testing.T) { + dir := writeStack(t, map[string]string{ + "02-core.yaml.gotmpl": ` +releases: + - name: api + namespace: nvcf + - name: ingress + namespace: {{ .Values.ingress.gatewayApi.controllerNamespace }} +`, + }) + + got := namespaceNames(stackReleaseNamespaces(dir)) + assert.Equal(t, []string{"nvcf"}, got, "a templated namespace must not be probed") +} + +func TestResolveStackNamespaces_FallsBackWhenNoStack(t *testing.T) { + fallback := []stackNamespace{{name: "nvcf"}, {name: "sis"}} + want := []string{"nvcf", "sis"} + + assert.Equal(t, want, resolveStackNamespaces("", "", fallback), + "no stack path must use the static list") + assert.Equal(t, want, resolveStackNamespaces(t.TempDir(), "local", fallback), + "a directory with no helmfile.d must use the static list") +} + +// The derived list is additive, not a replacement: it cannot see namespaces +// behind a helmfiles: include, so on its own it can still be short. The static +// list stays as a floor. +func TestResolveStackNamespaces_UnionsStackWithStatic(t *testing.T) { + dir := writeStack(t, map[string]string{ + "01.yaml.gotmpl": "releases:\n - name: a\n namespace: from-stack\n", + }) + got := resolveStackNamespaces(dir, "", []stackNamespace{{name: "static"}}) + assert.Contains(t, got, "from-stack", "the stack's namespaces must be picked up") + assert.Contains(t, got, "static", "the static list must remain a floor") +} + +// An unreadable fragment must not yield a truncated list that then overrides +// the complete static one, which would report an affirmative all-clear over a +// scan missing a third of its namespaces. +func TestStackReleaseNamespaces_UnreadableFragmentYieldsNothing(t *testing.T) { + dir := writeStack(t, map[string]string{ + "01.yaml.gotmpl": "releases:\n - name: a\n namespace: one\n", + "02.yaml.gotmpl": "releases:\n - name: b\n namespace: two\n", + }) + // A directory, not a chmod 000 file: root bypasses the permission bits, so + // a mode-based fixture passes locally and fails in CI, where tests run as + // root. os.ReadFile returns EISDIR for every UID, so the test means the + // same thing everywhere rather than being skipped where it matters most. + unreadable := filepath.Join(dir, "helmfile.d", "02.yaml.gotmpl") + require.NoError(t, os.Remove(unreadable)) + require.NoError(t, os.Mkdir(unreadable, 0o755)) + + assert.Nil(t, stackReleaseNamespaces(dir), + "a partial parse must not masquerade as the stack's full namespace set") +} + +// helmfile accepts plain .yaml fragments too. +func TestStackReleaseNamespaces_ReadsPlainYAMLFragments(t *testing.T) { + dir := writeStack(t, map[string]string{ + "01.yaml": "releases:\n - name: a\n namespace: plain-yaml\n", + }) + assert.Contains(t, namespaceNames(stackReleaseNamespaces(dir)), "plain-yaml") +} + +// A `namespace:` key nested inside a release's values block is chart +// configuration, not a namespace the stack owns. Reading it as one would add a +// probe target whose remediation is deletion. +func TestStackReleaseNamespaces_SkipsNestedValuesKeys(t *testing.T) { + dir := writeStack(t, map[string]string{ + "02-core.yaml.gotmpl": ` +releases: + - name: api + namespace: nvcf + values: + - serviceMonitor: + namespace: monitoring + - someChart: + namespace: kube-system +`, + }) + + got := namespaceNames(stackReleaseNamespaces(dir)) + assert.Equal(t, []string{"nvcf"}, got, + "only release-level namespaces may be probed; nested values keys must be ignored") +} + +// writeEnv adds environments/ to a stack laid out by writeStack. +func writeEnv(t *testing.T, dir, name, body string) { + t.Helper() + envDir := filepath.Join(dir, "environments") + require.NoError(t, os.MkdirAll(envDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(envDir, name), []byte(body), 0o600)) +} + +// Keys from 5 spaces in sit inside a release's values: and are chart +// configuration; 4 is the release level. +func TestStackReleaseNamespaces_IndentCap(t *testing.T) { + dir := writeStack(t, map[string]string{ + "01.yaml.gotmpl": "releases:\n - name: a\n namespace: four\n" + + " - name: b\n namespace: five\n - name: c\n namespace: six\n", + }) + assert.Equal(t, []string{"four"}, namespaceNames(stackReleaseNamespaces(dir))) +} + +// A release behind a condition: deploys its namespace only when the stack's +// values turn it on. Without that, a default-off addon namespace the stack +// never installs, such as a platform team's own kai-scheduler, fails the run. +func TestResolveStackNamespaces_FollowsReleaseConditions(t *testing.T) { + dir := writeStack(t, map[string]string{ + "01.yaml.gotmpl": ` +releases: + - name: kai-scheduler + condition: addons.kaiScheduler.enabled + namespace: kai-scheduler + - name: nvca-operator + namespace: nvca-operator + - name: grove-operator + condition: addons.groveOperator.enabled # comment + namespace: grove-system +`, + }) + writeEnv(t, dir, "base.yaml", + "addons:\n kaiScheduler:\n enabled: false\n groveOperator:\n enabled: false\n") + assert.Equal(t, []string{"nvca-operator"}, resolveStackNamespaces(dir, "prod", nil), + "base.yaml alone keeps the addons off") + + writeEnv(t, dir, "prod.yaml", "addons:\n kaiScheduler:\n enabled: true\n") + assert.Equal(t, []string{"kai-scheduler", "nvca-operator"}, resolveStackNamespaces(dir, "prod", nil), + "the environment file layered over base.yaml turns kai-scheduler on") + assert.Equal(t, []string{"nvca-operator"}, resolveStackNamespaces(dir, "staging", nil), + "another environment's file does not count") +} + +// The static lists follow the same gates: the default when the stack is not +// readable, the stack's values when it is. +func TestResolveStackNamespaces_GatesTheStaticList(t *testing.T) { + assert.Equal(t, []string{"nvca-operator"}, ComputePlaneStaleNamespaces("", ""), + "the scheduling addons are off by default") + cp := ControlPlaneStaleNamespaces("", "") + assert.Contains(t, cp, "cassandra-system", "default-on gates keep their namespace") + assert.NotContains(t, cp, "nvcf-ui", "the UI addon is off by default") + + dir := t.TempDir() + writeEnv(t, dir, "base.yaml", "cassandra:\n enabled: true\naddons:\n kaiScheduler:\n enabled: false\n") + writeEnv(t, dir, "prod.yaml", "cassandra:\n enabled: false\naddons:\n kaiScheduler:\n enabled: true\n") + assert.NotContains(t, ControlPlaneStaleNamespaces(dir, "prod"), "cassandra-system", + "an external Cassandra's namespace is not the stack's") + assert.Contains(t, ComputePlaneStaleNamespaces(dir, "prod"), "kai-scheduler") + assert.NotContains(t, ComputePlaneStaleNamespaces(dir, "prod"), "grove-system") +} + +// A release whose gate cannot be read from the source is not proof the stack +// installs into its namespace; installed: false is proof it does not. +func TestStackReleaseNamespaces_SkipsUnreadableGates(t *testing.T) { + dir := writeStack(t, map[string]string{ + "01.yaml.gotmpl": ` +releases: + - name: always + namespace: always +{{- if $managedIssuer }} + - name: in-template + namespace: in-template + needs: +{{- if dig "x" "enabled" true .Values }} + - a/b +{{- end }} +{{- end }} + - name: after-template + namespace: after-template + - name: templated-installed + installed: {{ and $a $b }} + namespace: templated-installed + - name: not-installed + installed: false + namespace: not-installed + - name: installed + installed: true + namespace: installed +`, + }) + assert.Equal(t, []string{"after-template", "always", "installed"}, + namespaceNames(stackReleaseNamespaces(dir))) +} + +// No helmfile declaration can put a namespace NVCA creates at runtime on the +// list: it holds live work. +func TestResolveStackNamespaces_NeverProbesRuntimeOwnedNamespaces(t *testing.T) { + dir := writeStack(t, map[string]string{ + "01.yaml.gotmpl": "releases:\n - name: a\n namespace: nvcf-backend\n" + + " - name: b\n namespace: nvca-system\n", + }) + assert.Empty(t, resolveStackNamespaces(dir, "", nil)) +} + +// The static lists are what a released binary probes with no stack checkout, +// so they must agree with the stacks: the same namespaces, gated by the same +// conditions, with base.yaml's defaults. +func TestStaticNamespaces_MatchTheStacks(t *testing.T) { + root := repoStacksDir(t) + for stack, static := range map[string][]stackNamespace{ + "self-managed": nvcfControlPlaneNamespaces, + "nvcf-compute-plane": nvcfComputePlaneNamespaces, + } { + dir := filepath.Join(root, stack) + derived := stackReleaseNamespaces(dir) + require.NotEmpty(t, derived, stack) + base, found := loadValuesFiles([]string{filepath.Join(dir, "environments", "base.yaml")}) + require.True(t, found, stack) + + want := make(map[string]stackNamespace, len(derived)) + for _, ns := range derived { + want[ns.name] = ns + } + assert.ElementsMatch(t, namespaceNames(derived), namespaceNames(static), stack) + for _, ns := range static { + assert.Equal(t, want[ns.name].gates, ns.gates, "%s: %s", stack, ns.name) + for _, gate := range ns.gates { + on, _ := digAny(base, strings.Split(gate, ".")...).(bool) + assert.Equal(t, on, ns.on, "%s: %s default", stack, ns.name) + } + } + } +} + +// repoStacksDir returns the repository's deploy/stacks, found at or above the +// package directory. Bazel stages it as the test's data, so a run under Bazel +// fails without it rather than skipping the tests that pin the CLI to the +// stacks; elsewhere, as in a copy of the module alone, the test skips. +func repoStacksDir(t *testing.T) string { + t.Helper() + dir, err := os.Getwd() + require.NoError(t, err) + for { + stacks := filepath.Join(dir, "deploy", "stacks") + if _, err := os.Stat(filepath.Join(stacks, "self-managed", "helmfile.d")); err == nil { + return stacks + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + if os.Getenv("TEST_SRCDIR") != "" { + t.Fatal("deploy/stacks is not in the test's runfiles; add //:nvcf-cli-stacks to its data") + } + t.Skip("deploy/stacks is not reachable from the test's working directory") + return "" +} diff --git a/src/clis/nvcf-cli/internal/selfhosted/stale_namespace.go b/src/clis/nvcf-cli/internal/selfhosted/stale_namespace.go new file mode 100644 index 0000000000..e1b9ac39f8 --- /dev/null +++ b/src/clis/nvcf-cli/internal/selfhosted/stale_namespace.go @@ -0,0 +1,544 @@ +/* +SPDX-FileCopyrightText: Copyright (c) NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package selfhosted + +import ( + "context" + "fmt" + "net/http" + "os" + "slices" + "strings" + "sync" + "time" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +// nvcfControlPlaneNamespaces lists namespaces that a helmfile release deploys +// into on the control-plane cluster, per deploy/stacks/self-managed/helmfile.d, +// with the condition: that gates their releases and its base.yaml default. +// probeStaleNamespaces says which of them are left over from a failed or +// partial teardown. +// +// Only namespaces that actually host a release belong here: a namespace +// populated by something other than Helm (nvcf-backend, created at runtime by +// NVCA for worker pods) would be reported on a healthy cluster. +// +// The gateway controller namespace is deliberately absent: the release templates +// it from .Values.ingress.gatewayApi.controllerNamespace, so hardcoding +// envoy-gateway-system would probe a namespace the stack may not own. +// TestStaticNamespaces_MatchTheStacks pins this list to the stack. +var nvcfControlPlaneNamespaces = []stackNamespace{ + {name: "api-keys"}, + {name: "cassandra-system", gates: []string{"cassandra.enabled"}, on: true}, + {name: "cert-manager", gates: []string{"certManager.enabled"}, on: true}, + {name: "ess"}, + {name: "nats-system"}, + {name: "nvcf"}, + {name: "nvcf-ui", gates: []string{"addons.nvcfUi.enabled"}}, + {name: "sis", gates: []string{"icms.enabled"}, on: true}, + {name: "vault-system", gates: []string{"openbao.enabled"}, on: true}, +} + +// nvcfComputePlaneNamespaces lists namespaces that a helmfile release deploys +// into on the compute-plane cluster, per +// deploy/stacks/nvcf-compute-plane/helmfile.d. nvca-system is deliberately +// absent: it is operator-created and hosts no release. The scheduling addons +// are off by default, so their namespaces are probed only when the stack's +// values turn them on: a platform team's own KAI install is not NVCF's. +var nvcfComputePlaneNamespaces = []stackNamespace{ + {name: "dynamo-system", gates: []string{"addons.dynamoOperator.enabled"}}, + {name: "grove-system", gates: []string{"addons.groveOperator.enabled"}}, + {name: "kai-scheduler", gates: []string{"addons.kaiScheduler.enabled"}}, + {name: "nvca-operator"}, +} + +// Reasons a namespace is reported. Only StaleStuckTerminating fails the run. +const ( + // StaleStuckTerminating is a namespace Terminating past + // namespaceStuckAfter, or one the namespace controller reports it cannot + // finish deleting. + StaleStuckTerminating = "stuck Terminating" + // StaleTerminating is a namespace still within a normal deletion. + StaleTerminating = "Terminating" + // StaleReleaseMidOperation is a Helm release left pending or uninstalling. + StaleReleaseMidOperation = "Helm release mid-operation" + // StaleNoHelmRelease is a namespace holding what a removed install leaves. + StaleNoHelmRelease = "no Helm release" +) + +// namespaceStuckAfter is how long a namespace may stay Terminating before it +// is called stuck. Volume detach and PVC protection routinely hold a deleted +// namespace for tens of seconds, and `up` waits this long for one to go. +const namespaceStuckAfter = 2 * time.Minute + +// StaleNamespace describes a single NVCF stack namespace that appears to be a +// leftover from a failed or partial teardown. +type StaleNamespace struct { + Name string + Reason string // one of the Stale* reasons + // Detail qualifies Reason: how long the namespace has been deleting and + // why it is held, or the releases left mid-operation and their status. + Detail string + // Releases names the releases behind StaleReleaseMidOperation. + Releases []string +} + +// StaleNamespaceProber inspects the given namespaces and returns those that +// appear stale. The probe is read-only; it never deletes or modifies anything. +// A non-nil error lists the namespaces that could not be read; the returned +// slice still holds everything found in the others. +type StaleNamespaceProber func(ctx context.Context, kubeContext string, namespaces []string) ([]StaleNamespace, error) + +// NewStaleNamespaceProber returns a StaleNamespaceProber backed by client-go. +// Chart-independent: works before any Helm release exists, and uses the +// operator's kubeconfig context to talk to the target cluster. +func NewStaleNamespaceProber() StaleNamespaceProber { + return func(ctx context.Context, kubeContext string, namespaces []string) ([]StaleNamespace, error) { + // The first contact with each visited cluster, so a cluster that + // cannot be reached at all is reported here. + clock := &apiserverClock{} + client, err := connectCluster(ctx, kubeContext, clock.observe) + if err != nil { + return nil, err + } + return probeStaleNamespaces(ctx, client, namespaces, clock.now) + } +} + +// apiserverClock follows the API server's clock through the Date header of +// its responses. How long a namespace has been deleting is measured on it, as +// the validator's sweeps measure age, so a workstation clock minutes off +// neither calls a fresh deletion stuck nor a stuck one fresh. +type apiserverClock struct { + mu sync.Mutex + // date is the latest response's Date, and received when it arrived. + date, received time.Time +} + +// observe wraps a client's transport to record the Date of each response. +func (c *apiserverClock) observe(rt http.RoundTripper) http.RoundTripper { + return dateRecorder{next: rt, clock: c} +} + +// now is the API server's time: the latest Date plus the time since it +// arrived, or this machine's time until a response carried one. +func (c *apiserverClock) now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + if c.date.IsZero() { + return time.Now() + } + return c.date.Add(time.Since(c.received)) +} + +type dateRecorder struct { + next http.RoundTripper + clock *apiserverClock +} + +func (d dateRecorder) RoundTrip(req *http.Request) (*http.Response, error) { + resp, err := d.next.RoundTrip(req) + if err != nil { + return resp, err + } + if date, perr := http.ParseTime(resp.Header.Get("Date")); perr == nil { + d.clock.mu.Lock() + d.clock.date, d.clock.received = date, time.Now() + d.clock.mu.Unlock() + } + return resp, nil +} + +const ( + // helmReleaseListPageSize bounds each page of the owner=helm scan. A + // namespace holds at most a handful of release objects, so this is only a + // ceiling on how much is pulled per round trip while paging past + // non-matching objects. + helmReleaseListPageSize = 100 + + // helmReleaseListMaxPages stops the scan even if the server keeps handing + // back a Continue token. At the page size above this covers 100k objects in + // one namespace, far past anything real, and guarantees termination. + helmReleaseListMaxPages = 1000 + + // liveReleaseSelector matches the release records `helm upgrade --install` + // builds on. An uninstalled record (kept with --keep-history) is not one. + liveReleaseSelector = "owner=helm,status in (deployed,failed,superseded)" + + // midOperationReleaseSelector matches a release an interrupted install, + // upgrade, rollback or teardown left behind. The next install fails on + // it: "another operation is in progress", or "has no deployed releases". + midOperationReleaseSelector = "owner=helm,status in (pending-install,pending-upgrade,pending-rollback,uninstalling)" +) + +// helmReleaseLister lists one page of release objects and returns the labels +// of each. +type helmReleaseLister func(metav1.ListOptions) (labels []map[string]string, cont string, err error) + +// helmRelease is one release object's name and status labels. +type helmRelease struct{ name, status string } + +// findHelmReleases returns the release objects matching selector, paging +// until the server reports no more results, or until the first match when +// firstOnly is set. +// +// A single page with Limit set is not a valid existence test: the apiserver +// applies the label selector after paging, so a page can legitimately return +// zero items alongside a Continue token. A namespace like nvcf holds dozens of +// ServiceAccount tokens and TLS secrets that sort before sh.helm.release.v1.*, +// so the first page is routinely empty on a perfectly healthy install. +func findHelmReleases(list helmReleaseLister, selector string, firstOnly bool) ([]helmRelease, error) { + opts := metav1.ListOptions{LabelSelector: selector, Limit: helmReleaseListPageSize} + var found []helmRelease + for page := 0; page < helmReleaseListMaxPages; page++ { + items, cont, err := list(opts) + if err != nil { + return nil, err + } + for _, l := range items { + found = append(found, helmRelease{name: l["name"], status: l["status"]}) + } + if cont == "" || (firstOnly && len(found) > 0) { + return found, nil + } + opts.Continue = cont + } + return nil, fmt.Errorf("gave up after %d pages scanning for Helm releases", helmReleaseListMaxPages) +} + +// helmReleaseListers returns one lister per in-cluster Helm storage driver: +// Secrets (the default) and ConfigMaps (HELM_DRIVER=configmap). Both label +// release objects owner=helm, name and status. +func helmReleaseListers(ctx context.Context, client kubernetes.Interface, ns string) map[string]helmReleaseLister { + return map[string]helmReleaseLister{ + "secrets": func(opts metav1.ListOptions) ([]map[string]string, string, error) { + l, err := client.CoreV1().Secrets(ns).List(ctx, opts) + if err != nil { + return nil, "", err + } + out := make([]map[string]string, 0, len(l.Items)) + for i := range l.Items { + out = append(out, l.Items[i].Labels) + } + return out, l.Continue, nil + }, + "configmaps": func(opts metav1.ListOptions) ([]map[string]string, string, error) { + l, err := client.CoreV1().ConfigMaps(ns).List(ctx, opts) + if err != nil { + return nil, "", err + } + out := make([]map[string]string, 0, len(l.Items)) + for i := range l.Items { + out = append(out, l.Items[i].Labels) + } + return out, l.Continue, nil + }, + } +} + +// hasLiveWorkload reports whether namespace ns runs a workload: a Pending or +// Running pod that is not terminating and not a Job's. An install rendered +// with helm template, as Argo CD does, records no Helm release but runs these. +// Job pods are left out because Helm never deletes its hook Jobs, so a +// removed install leaves their Completed pods behind. +func hasLiveWorkload(ctx context.Context, client kubernetes.Interface, ns string) (bool, error) { + pods, err := client.CoreV1().Pods(ns).List(ctx, metav1.ListOptions{}) + if err != nil { + return false, fmt.Errorf("list pods: %w", err) + } + for i := range pods.Items { + p := &pods.Items[i] + if p.DeletionTimestamp != nil || ownedByJob(p) { + continue + } + if p.Status.Phase == corev1.PodPending || p.Status.Phase == corev1.PodRunning { + return true, nil + } + } + return false, nil +} + +func ownedByJob(p *corev1.Pod) bool { + for _, ref := range p.OwnerReferences { + if ref.Kind == "Job" { + return true + } + } + return false +} + +// holdsInstallLeftovers reports whether namespace ns holds what a removed +// install leaves behind: volume claims, which helm never deletes and a +// reinstall would silently reattach, or workload objects such as the hook +// Jobs Helm also leaves. A namespace holding only what is created ahead of an +// install (pull and TLS Secrets, CA ConfigMaps, labels), or nothing at all, +// is a fresh one. One kind found is enough; an error means none was seen and +// at least one kind could not be read. +func holdsInstallLeftovers(ctx context.Context, client kubernetes.Interface, ns string) (bool, error) { + one := metav1.ListOptions{Limit: 1} + kinds := []struct { + name string + count func() (int, error) + }{ + {"persistentvolumeclaims", func() (int, error) { + l, err := client.CoreV1().PersistentVolumeClaims(ns).List(ctx, one) + if err != nil { + return 0, err + } + return len(l.Items), nil + }}, + {"jobs", func() (int, error) { + l, err := client.BatchV1().Jobs(ns).List(ctx, one) + if err != nil { + return 0, err + } + return len(l.Items), nil + }}, + {"statefulsets", func() (int, error) { + l, err := client.AppsV1().StatefulSets(ns).List(ctx, one) + if err != nil { + return 0, err + } + return len(l.Items), nil + }}, + {"deployments", func() (int, error) { + l, err := client.AppsV1().Deployments(ns).List(ctx, one) + if err != nil { + return 0, err + } + return len(l.Items), nil + }}, + {"daemonsets", func() (int, error) { + l, err := client.AppsV1().DaemonSets(ns).List(ctx, one) + if err != nil { + return 0, err + } + return len(l.Items), nil + }}, + } + var errs []error + for _, kind := range kinds { + n, err := kind.count() + if err != nil { + errs = append(errs, fmt.Errorf("list %s: %w", kind.name, err)) + continue + } + if n > 0 { + return true, nil + } + } + return false, joinErrors(errs) +} + +// joinedErrors keeps every error for errors.Is and errors.As, and renders +// them on one line, as a check row's message is. +type joinedErrors []error + +func (e joinedErrors) Error() string { + msgs := make([]string, 0, len(e)) + for _, err := range e { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +func (e joinedErrors) Unwrap() []error { return e } + +func joinErrors(errs []error) error { + if len(errs) == 0 { + return nil + } + return joinedErrors(errs) +} + +// helmReleasesAreInCluster reports whether Helm stores release state as +// in-cluster Secrets or ConfigMaps, the drivers this probe can see. +func helmReleasesAreInCluster() bool { + return !strings.EqualFold(strings.TrimSpace(os.Getenv("HELM_DRIVER")), "sql") +} + +// namespaceDeletionFailures are the conditions the namespace controller sets +// when it cannot finish deleting a namespace's content on its own. +var namespaceDeletionFailures = []corev1.NamespaceConditionType{ + corev1.NamespaceDeletionDiscoveryFailure, + corev1.NamespaceDeletionContentFailure, + corev1.NamespaceDeletionGVParsingFailure, +} + +// terminatingNamespace grades a namespace that is being deleted. It is stuck +// once it has been Terminating past namespaceStuckAfter, or as soon as the +// namespace controller reports a deletion failure; before that it is a normal +// deletion still draining volumes and finalizers. +func terminatingNamespace(ns *corev1.Namespace, now time.Time) StaleNamespace { + out := StaleNamespace{Name: ns.Name, Reason: StaleTerminating} + var details []string + if ns.DeletionTimestamp != nil { + // Never negative: Date has one-second resolution. + age := max(now.Sub(ns.DeletionTimestamp.Time).Truncate(time.Second), 0) + details = append(details, "deleting for "+age.String()) + if age >= namespaceStuckAfter { + out.Reason = StaleStuckTerminating + } + } + for _, c := range ns.Status.Conditions { + if c.Status == corev1.ConditionTrue && slices.Contains(namespaceDeletionFailures, c.Type) { + out.Reason = StaleStuckTerminating + details = append(details, string(c.Type)+": "+c.Message) + } + } + out.Detail = strings.Join(details, "; ") + return out +} + +// probeStaleNamespaces is the testable core that accepts a kubernetes.Interface +// so callers can inject fake.NewSimpleClientset in unit tests. +// +// A namespace is reported when: +// - it is being deleted (DeletionTimestamp set or phase Terminating), graded +// by terminatingNamespace; +// - a Helm release in it was left pending or uninstalling; or +// - it holds no live Helm release, runs no workload, and still holds what a +// removed install leaves (volume claims, or Jobs, StatefulSets, +// Deployments or DaemonSets): a partial helm uninstall or a teardown that +// removed the release but not its data. +// +// One namespace that cannot be read does not stop the others from being +// probed: every error is returned, joined and naming its namespace, next to +// everything that was found. Once ctx is done the probe stops, and the +// returned error carries ctx's error. now is the API server's time. +func probeStaleNamespaces( + ctx context.Context, client kubernetes.Interface, namespaces []string, now func() time.Time, +) ([]StaleNamespace, error) { + var stale []StaleNamespace + var errs []error + // HELM_DRIVER=sql keeps release state in a database, so no in-cluster + // object says whether a release exists, and every namespace of a healthy + // install would look stale. Inferring the driver from "no owner=helm + // object anywhere" hid exactly the state `down` leaves behind: every + // release destroyed, every namespace and PVC kept. + inCluster := helmReleasesAreInCluster() + for _, name := range namespaces { + if ctx.Err() != nil { + break + } + ns, err := client.CoreV1().Namespaces().Get(ctx, name, metav1.GetOptions{}) + if err != nil { + if apierrors.IsNotFound(err) { + continue // absent = healthy; the check only fires on unexpected presence + } + errs = append(errs, fmt.Errorf("get namespace %s: %w", name, err)) + continue + } + + if ns.DeletionTimestamp != nil || ns.Status.Phase == corev1.NamespaceTerminating { + stale = append(stale, terminatingNamespace(ns, now())) + continue + } + if !inCluster { + continue + } + + midOperation, live, err := probeHelmReleases(ctx, client, name) + if err != nil { + errs = append(errs, fmt.Errorf("%s: %w", name, err)) + continue + } + if len(midOperation) > 0 { + stale = append(stale, midOperationNamespace(name, midOperation)) + continue + } + if live { + continue + } + // No release is not yet a leftover. The documented install pre-creates + // namespaces, labelled and holding pull or TLS Secrets, so before the + // first install none has a release; and an install rendered with + // `helm template` (Argo CD) never records one but runs pods. What + // `down` leaves behind runs nothing, and holds volume claims or the + // hook Jobs Helm never deletes. A namespace that cannot be read is + // listed as such rather than reported as a leftover nobody saw. + live, err = hasLiveWorkload(ctx, client, name) + if err != nil { + errs = append(errs, fmt.Errorf("%s: %w", name, err)) + continue + } + if live { + continue + } + leftovers, err := holdsInstallLeftovers(ctx, client, name) + if err != nil { + errs = append(errs, fmt.Errorf("%s: %w", name, err)) + continue + } + if leftovers { + stale = append(stale, StaleNamespace{Name: name, Reason: StaleNoHelmRelease}) + } + } + if err := ctx.Err(); err != nil { + errs = append(errs, err) + } + return stale, joinErrors(errs) +} + +// probeHelmReleases returns the releases in namespace ns left pending or +// uninstalling, and whether a live release occupies it. +func probeHelmReleases(ctx context.Context, client kubernetes.Interface, ns string) ([]helmRelease, bool, error) { + listers := helmReleaseListers(ctx, client, ns) + var midOperation []helmRelease + for _, kind := range []string{"secrets", "configmaps"} { + found, err := findHelmReleases(listers[kind], midOperationReleaseSelector, false) + if err != nil { + return nil, false, fmt.Errorf("list Helm %s: %w", kind, err) + } + midOperation = append(midOperation, found...) + } + if len(midOperation) > 0 { + return midOperation, false, nil + } + for _, kind := range []string{"secrets", "configmaps"} { + found, err := findHelmReleases(listers[kind], liveReleaseSelector, true) + if err != nil { + return nil, false, fmt.Errorf("list Helm %s: %w", kind, err) + } + if len(found) > 0 { + return nil, true, nil + } + } + return nil, false, nil +} + +// midOperationNamespace reports namespace ns for the releases in it left +// pending or uninstalling, each named once with its status. +func midOperationNamespace(ns string, releases []helmRelease) StaleNamespace { + out := StaleNamespace{Name: ns, Reason: StaleReleaseMidOperation} + var details []string + for _, r := range releases { + if r.name == "" || slices.Contains(out.Releases, r.name) { + continue + } + out.Releases = append(out.Releases, r.name) + details = append(details, r.name+" "+r.status) + } + out.Detail = strings.Join(details, ", ") + return out +} diff --git a/src/clis/nvcf-cli/internal/selfhosted/stale_namespace_test.go b/src/clis/nvcf-cli/internal/selfhosted/stale_namespace_test.go new file mode 100644 index 0000000000..dbb5e74711 --- /dev/null +++ b/src/clis/nvcf-cli/internal/selfhosted/stale_namespace_test.go @@ -0,0 +1,1039 @@ +/* +SPDX-FileCopyrightText: Copyright (c) NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package selfhosted + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/kubernetes/fake" + ktesting "k8s.io/client-go/testing" +) + +// -- probeStaleNamespaces -- + +func TestProbeStaleNamespaces_AbsentIsHealthy(t *testing.T) { + // A namespace that doesn't exist is not stale - it has simply never been + // created or was already fully deleted. + client := fake.NewSimpleClientset() + stale, err := probeStaleNamespaces(context.Background(), client, []string{"nvcf", "sis"}, time.Now) + require.NoError(t, err) + assert.Empty(t, stale, "absent namespaces must not be reported as stale") +} + +func TestProbeStaleNamespaces_TerminatingIsByDeletionTimestamp(t *testing.T) { + now := metav1.Now() + client := fake.NewSimpleClientset(&corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: "nvcf", DeletionTimestamp: &now}, + Status: corev1.NamespaceStatus{Phase: corev1.NamespaceActive}, + }) + stale, err := probeStaleNamespaces(context.Background(), client, []string{"nvcf"}, time.Now) + require.NoError(t, err) + require.Len(t, stale, 1) + assert.Equal(t, "nvcf", stale[0].Name) + assert.Contains(t, stale[0].Reason, "Terminating") +} + +func TestProbeStaleNamespaces_TerminatingIsByPhase(t *testing.T) { + client := fake.NewSimpleClientset(&corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: "sis"}, + Status: corev1.NamespaceStatus{Phase: corev1.NamespaceTerminating}, + }) + stale, err := probeStaleNamespaces(context.Background(), client, []string{"sis"}, time.Now) + require.NoError(t, err) + require.Len(t, stale, 1) + assert.Equal(t, "sis", stale[0].Name) + assert.Contains(t, stale[0].Reason, "Terminating") +} + +func TestProbeStaleNamespaces_NoReleaseWithInstallDataIsStale(t *testing.T) { + // Namespace exists and is Active, holds no Helm release, and still holds a + // volume claim -> leftover of a partial helm uninstall. The second + // namespace carries a release and is not reported. + client := fake.NewSimpleClientset( + &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: "nvcf"}, + Status: corev1.NamespaceStatus{Phase: corev1.NamespaceActive}, + }, + &corev1.PersistentVolumeClaim{ObjectMeta: metav1.ObjectMeta{Name: "data", Namespace: "nvcf"}}, + &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: "sis"}, + Status: corev1.NamespaceStatus{Phase: corev1.NamespaceActive}, + }, + &corev1.Secret{ObjectMeta: metav1.ObjectMeta{ + Name: "sh.helm.release.v1.sis.v1", + Namespace: "sis", + Labels: map[string]string{"owner": "helm", "name": "sis", "status": "deployed"}, + }}, + ) + stale, err := probeStaleNamespaces(context.Background(), client, []string{"nvcf", "sis"}, time.Now) + require.NoError(t, err) + require.Len(t, stale, 1) + assert.Equal(t, "nvcf", stale[0].Name) + assert.Contains(t, stale[0].Reason, "Helm release") +} + +func TestProbeStaleNamespaces_HealthyReleaseNotStale(t *testing.T) { + // Namespace exists and carries an owner=helm secret -> active Helm release, + // not stale. + client := fake.NewSimpleClientset( + &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: "nvcf"}, + Status: corev1.NamespaceStatus{Phase: corev1.NamespaceActive}, + }, + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "sh.helm.release.v1.nvcf.v1", + Namespace: "nvcf", + Labels: map[string]string{"owner": "helm", "name": "nvcf", "status": "deployed"}, + }, + }, + ) + stale, err := probeStaleNamespaces(context.Background(), client, []string{"nvcf"}, time.Now) + require.NoError(t, err) + assert.Empty(t, stale, "namespace with an active Helm release must not be stale") +} + +func TestProbeStaleNamespaces_HealthyReleaseConfigMapDriverNotStale(t *testing.T) { + // HELM_DRIVER=configmap stores release objects as ConfigMaps with owner=helm. + // A namespace with an owner=helm ConfigMap and no Helm Secret must not be + // reported as stale. + client := fake.NewSimpleClientset( + &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: "nvcf"}, + Status: corev1.NamespaceStatus{Phase: corev1.NamespaceActive}, + }, + &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "nvcf.v1", + Namespace: "nvcf", + Labels: map[string]string{"owner": "helm", "name": "nvcf", "status": "deployed"}, + }, + }, + ) + stale, err := probeStaleNamespaces(context.Background(), client, []string{"nvcf"}, time.Now) + require.NoError(t, err) + assert.Empty(t, stale, "namespace with an owner=helm ConfigMap (configmap driver) must not be stale") +} + +func TestProbeStaleNamespaces_MixedNamespaces(t *testing.T) { + // One absent, one healthy, one terminating, one left with its data, and + // one empty, as before a first install. + now := metav1.Now() + client := fake.NewSimpleClientset( + // "sis" - healthy with a Helm release + &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: "sis"}, + Status: corev1.NamespaceStatus{Phase: corev1.NamespaceActive}, + }, + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "sh.helm.release.v1.sis.v1", Namespace: "sis", + Labels: map[string]string{"owner": "helm"}, + }, + }, + // "nvcf" - stuck terminating + &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: "nvcf", DeletionTimestamp: &now}, + Status: corev1.NamespaceStatus{Phase: corev1.NamespaceTerminating}, + }, + // "api-keys" - no release, its volume claim left behind + &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: "api-keys"}, + Status: corev1.NamespaceStatus{Phase: corev1.NamespaceActive}, + }, + &corev1.PersistentVolumeClaim{ObjectMeta: metav1.ObjectMeta{Name: "data", Namespace: "api-keys"}}, + // "ess" - empty, created ahead of a first install + &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: "ess"}, + Status: corev1.NamespaceStatus{Phase: corev1.NamespaceActive}, + }, + // "cassandra-system" - absent (not present in fake) + ) + + namespaces := []string{"cassandra-system", "sis", "nvcf", "api-keys", "ess"} + stale, err := probeStaleNamespaces(context.Background(), client, namespaces, time.Now) + require.NoError(t, err) + require.Len(t, stale, 2, "only nvcf (terminating) and api-keys (data left behind) should be stale") + + staleNames := make(map[string]string, 2) + for _, s := range stale { + staleNames[s.Name] = s.Reason + } + assert.Contains(t, staleNames, "nvcf") + assert.Contains(t, staleNames["nvcf"], "Terminating") + assert.Contains(t, staleNames, "api-keys") + assert.Contains(t, staleNames["api-keys"], "Helm release") +} + +// -- staleNamespaceCheck binaryCheckSpec -- + +// pinCurrentKubeContext keeps hint assertions independent of the developer's +// kubeconfig: without it, a machine with a current-context makes every hint +// carry a --context flag the test did not ask for. +func pinCurrentKubeContext(t *testing.T, name string) { + t.Helper() + prev := currentKubeContextNameFn + currentKubeContextNameFn = func() string { return name } + t.Cleanup(func() { currentKubeContextNameFn = prev }) +} + +func TestStaleNamespaceCheck_ProberErrorDegradestoWarning(t *testing.T) { + pinCurrentKubeContext(t, "") + // A prober that cannot contact the cluster must not fail the overall check + // at error severity - it would produce false failures on transient network + // issues or misconfigured kubeconfigs. + prober := func(_ context.Context, _ string, _ []string) ([]StaleNamespace, error) { + return nil, fmt.Errorf("cluster unreachable") + } + r := staleNamespaceCheck(prober, "", []string{"nvcf"}).Run(context.Background()) + assert.False(t, r.Passed) + assert.Equal(t, SeverityWarning, r.Severity, + "prober errors must degrade to warning so transient failures do not block the operator") + assert.Contains(t, r.Message, "cluster unreachable") +} + +func TestStaleNamespaceCheck_StaleIsError(t *testing.T) { + pinCurrentKubeContext(t, "") + // A successfully detected stale namespace must fail at error severity so + // anyFailed trips the non-zero exit code. + prober := func(_ context.Context, _ string, _ []string) ([]StaleNamespace, error) { + return []StaleNamespace{{Name: "nvcf", Reason: "stuck Terminating"}}, nil + } + r := staleNamespaceCheck(prober, "", []string{"nvcf"}).Run(context.Background()) + assert.False(t, r.Passed) + assert.Equal(t, SeverityError, r.Severity, + "detected stale namespaces must use error severity so the exit code is non-zero") + assert.Contains(t, r.Message, "nvcf") + assert.Contains(t, r.Message, "/api/v1/namespaces/nvcf/finalize", + "the remediation must use the finalize subresource; a plain patch is silently reverted") + inspect := strings.Index(r.Message, "xargs -n1 kubectl get -n nvcf") + force := strings.Index(r.Message, "/finalize") + require.GreaterOrEqual(t, inspect, 0) + assert.Less(t, inspect, force, "inspecting the namespace comes before force-clearing its finalizers") +} + +func TestStaleNamespaceCheck_CleanPasses(t *testing.T) { + pinCurrentKubeContext(t, "") + prober := func(_ context.Context, _ string, _ []string) ([]StaleNamespace, error) { + return nil, nil + } + r := staleNamespaceCheck(prober, "", []string{"nvcf", "sis"}).Run(context.Background()) + assert.True(t, r.Passed) + assert.Equal(t, SeverityInfo, r.Severity, "a clean pass must not carry the error severity it starts with") +} + +func TestStaleNamespaceCheck_MessageNamesAllStaleNamespaces(t *testing.T) { + pinCurrentKubeContext(t, "") + // The remediation command must name every stale namespace so the operator + // can copy-paste it without having to cross-reference the check output. + prober := func(_ context.Context, _ string, _ []string) ([]StaleNamespace, error) { + return []StaleNamespace{ + {Name: "nvcf", Reason: "stuck Terminating"}, + {Name: "api-keys", Reason: "no Helm release"}, + }, nil + } + r := staleNamespaceCheck(prober, "", []string{"nvcf", "api-keys"}).Run(context.Background()) + assert.Equal(t, SeverityError, r.Severity, + "a namespace stuck Terminating fails the run whatever else is reported beside it") + assert.Contains(t, r.Message, "nvcf") + assert.Contains(t, r.Message, "api-keys") + assert.Contains(t, r.Message, "/api/v1/namespaces/nvcf/finalize", + "the remediation must use the finalize subresource; a plain patch is silently reverted") + assert.Contains(t, r.Message, "xargs -n1 kubectl get -n api-keys --show-kind --ignore-not-found") + assert.NotContains(t, r.Message, "get all", "get all omits PVCs, Secrets and custom resources") +} + +// A namespace on a real install holds many non-Helm Secrets (ServiceAccount +// tokens, TLS, pull secrets) that sort before sh.helm.release.v1.*. The +// apiserver applies the label selector after paging, so the first page can +// legitimately come back empty with a Continue token. Treating that as "no +// release" reports a live namespace stale and tells the operator to delete it. +func TestProbeStaleNamespaces_PagesPastNonMatchingObjects(t *testing.T) { + for _, resource := range []string{"secrets", "configmaps"} { + t.Run(resource, func(t *testing.T) { + client := fake.NewSimpleClientset(&corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: "nvcf"}, + Status: corev1.NamespaceStatus{Phase: corev1.NamespaceActive}, + }, &corev1.PersistentVolumeClaim{ObjectMeta: metav1.ObjectMeta{Name: "data", Namespace: "nvcf"}}) + + // First page: empty with a Continue token. Second page: the + // release object. + calls := 0 + release := metav1.ObjectMeta{ + Name: "sh.helm.release.v1.nvcf.v1", Namespace: "nvcf", + Labels: map[string]string{"owner": "helm", "name": "nvcf", "status": "deployed"}, + } + client.PrependReactor("list", resource, func(a ktesting.Action) (bool, runtime.Object, error) { + if !listSelects(a, "deployed") { + return false, nil, nil + } + calls++ + if calls == 1 { + if resource == "secrets" { + return true, &corev1.SecretList{ListMeta: metav1.ListMeta{Continue: "next"}}, nil + } + return true, &corev1.ConfigMapList{ListMeta: metav1.ListMeta{Continue: "next"}}, nil + } + if resource == "secrets" { + return true, &corev1.SecretList{Items: []corev1.Secret{{ObjectMeta: release}}}, nil + } + return true, &corev1.ConfigMapList{Items: []corev1.ConfigMap{{ObjectMeta: release}}}, nil + }) + + stale, err := probeStaleNamespaces(context.Background(), client, []string{"nvcf"}, time.Now) + require.NoError(t, err) + assert.Empty(t, stale, + "an empty first page with a Continue token must not be read as 'no Helm release'") + assert.Equal(t, 2, calls, "the probe must follow the Continue token") + }) + } +} + +// The remediation for a hit is "delete this namespace", so the list must only +// contain namespaces a helmfile release actually owns. nvcf-backend is created +// at runtime by NVCA and holds live worker pods. +func TestControlPlaneNamespaceList_ExcludesRuntimeOwnedNamespaces(t *testing.T) { + cp := namespaceNames(nvcfControlPlaneNamespaces) + for _, ns := range []string{"nvcf-backend", "openbao-system"} { + assert.NotContains(t, cp, ns, + "%s hosts no Helm release; probing it yields a destructive false positive", ns) + } + for _, ns := range []string{"cert-manager", "nvcf-ui"} { + assert.Contains(t, cp, ns, "%s is a real stack namespace and must be probed", ns) + } + assert.NotContains(t, namespaceNames(nvcfComputePlaneNamespaces), "nvca-system", + "nvca-system is operator-created and hosts no release") +} + +func namespaceNames(list []stackNamespace) []string { + out := make([]string, 0, len(list)) + for _, ns := range list { + out = append(out, ns.name) + } + return out +} + +// TestStaleNamespaceCheck_HintsPinTheProbedContext guards the remediation +// hints in split mode. The two callers probe different clusters, so a bare +// kubectl command in a hint runs against whatever context is current, and +// these hints delete namespaces. +func TestStaleNamespaceCheck_HintsPinTheProbedContext(t *testing.T) { + pinCurrentKubeContext(t, "") + prober := func(_ context.Context, _ string, _ []string) ([]StaleNamespace, error) { + return []StaleNamespace{ + {Name: "nvcf", Reason: "stuck Terminating"}, + {Name: "api-keys", Reason: "no Helm release"}, + }, nil + } + r := staleNamespaceCheck(prober, "cp-ctx", []string{"nvcf", "api-keys"}).Run(context.Background()) + + // Every kubectl invocation, including the one piped into xargs, has to + // carry the flag: one unpinned command is enough to hit the wrong cluster. + for _, cmd := range strings.Split(r.Message, "kubectl ")[1:] { + assert.True(t, strings.HasPrefix(cmd, "--context cp-ctx "), + "unpinned kubectl invocation in hint: kubectl %s", cmd) + } + assert.Contains(t, r.Message, "xargs -n1 kubectl --context cp-ctx get -n api-keys") +} + +func TestStaleNamespaceCheck_HintsQuoteTheContext(t *testing.T) { + pinCurrentKubeContext(t, "") + prober := func(_ context.Context, _ string, _ []string) ([]StaleNamespace, error) { + return []StaleNamespace{{Name: "nvcf", Reason: "no Helm release"}}, nil + } + r := staleNamespaceCheck(prober, "my ctx", []string{"nvcf"}).Run(context.Background()) + assert.Contains(t, r.Message, "--context 'my ctx' get -n nvcf", + "a context name with a space must be quoted so the pasted command does not split it") +} + +// TestStaleNamespaceCheck_HintsSubstituteTheNamespace guards against a bare +// `` placeholder in a pasteable command: the shell reads `<` and `>` as +// redirection, so the command fails before kubectl runs. +func TestStaleNamespaceCheck_HintsSubstituteTheNamespace(t *testing.T) { + pinCurrentKubeContext(t, "") + prober := func(_ context.Context, _ string, _ []string) ([]StaleNamespace, error) { + return []StaleNamespace{ + {Name: "nvcf", Reason: "stuck Terminating"}, + {Name: "sis", Reason: "stuck Terminating"}, + }, nil + } + r := staleNamespaceCheck(prober, "", []string{"nvcf", "sis"}).Run(context.Background()) + + assert.NotContains(t, r.Message, "", + "a shell-redirection placeholder must not appear in a copy-pasteable command") + assert.Contains(t, r.Message, "xargs -n1 kubectl get -n nvcf ") + assert.Contains(t, r.Message, "xargs -n1 kubectl get -n sis ") +} + +// TestStaleNamespaceCheck_HintsNameTheCurrentContext covers the single-cluster +// path. The probe followed the current-context, so the hint has to name it: +// the operator can switch context between reading this and pasting it, and +// these commands delete namespaces. +func TestStaleNamespaceCheck_HintsNameTheCurrentContext(t *testing.T) { + pinCurrentKubeContext(t, "k3d-local") + prober := func(_ context.Context, _ string, _ []string) ([]StaleNamespace, error) { + return []StaleNamespace{{Name: "nvcf", Reason: "no Helm release"}}, nil + } + r := staleNamespaceCheck(prober, "", []string{"nvcf"}).Run(context.Background()) + assert.Contains(t, r.Message, "xargs -n1 kubectl --context k3d-local get -n nvcf") +} + +// An unreadable kubeconfig must not break the hint; it degrades to no flag. +func TestStaleNamespaceCheck_HintsOmitUnknownContext(t *testing.T) { + pinCurrentKubeContext(t, "") + prober := func(_ context.Context, _ string, _ []string) ([]StaleNamespace, error) { + return []StaleNamespace{{Name: "nvcf", Reason: "no Helm release"}}, nil + } + r := staleNamespaceCheck(prober, "", []string{"nvcf"}).Run(context.Background()) + assert.Contains(t, r.Message, "xargs -n1 kubectl get -n nvcf") +} + +// The probe must run against the same context the hints name. Resolving the +// current-context after probing leaves a window where it changes in between, +// which would have the delete hints target a cluster that was never read. +func TestStaleNamespaceCheck_ProbesTheResolvedContext(t *testing.T) { + pinCurrentKubeContext(t, "k3d-local") + var probed string + prober := func(_ context.Context, kctx string, _ []string) ([]StaleNamespace, error) { + probed = kctx + return nil, nil + } + staleNamespaceCheck(prober, "", []string{"nvcf"}).Run(context.Background()) + assert.Equal(t, "k3d-local", probed, + "the probe must target the resolved context, not whatever is current when it runs") +} + +// `down` destroys every release but keeps the namespaces and their PVCs, so no +// owner=helm object exists anywhere. With the default driver that is exactly +// the stale state to report; a reinstall would silently reattach the old +// Cassandra and OpenBao volumes. Helm never deletes hook Jobs, so their +// Completed pods are still there, and they are not a live install. +func TestProbeStaleNamespaces_AfterDownReportsEveryNamespace(t *testing.T) { + t.Setenv("HELM_DRIVER", "") + var objects []runtime.Object + for _, ns := range []string{"cassandra-system", "vault-system"} { + objects = append(objects, + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: ns}}, + &corev1.PersistentVolumeClaim{ObjectMeta: metav1.ObjectMeta{Name: "data-0", Namespace: ns}}, + &batchv1.Job{ObjectMeta: metav1.ObjectMeta{Name: "initialize-cluster", Namespace: ns}}, + &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "initialize-cluster-x1", Namespace: ns, + OwnerReferences: []metav1.OwnerReference{{Kind: "Job", Name: "initialize-cluster"}}}, + Status: corev1.PodStatus{Phase: corev1.PodSucceeded}, + }, + ) + } + client := fake.NewSimpleClientset(objects...) + got, err := probeStaleNamespaces(context.Background(), client, + []string{"cassandra-system", "vault-system"}, time.Now) + require.NoError(t, err) + assert.Len(t, got, 2) +} + +// Only a Pending or Running pod that is neither terminating nor a Job's is a +// live workload. A hook Job's pod, even one still running, and a pod being +// deleted do not keep a namespace holding install data from being reported. +func TestProbeStaleNamespaces_OnlyALiveWorkloadIsAnInstall(t *testing.T) { + now := metav1.Now() + pod := func(ns string, phase corev1.PodPhase, mutate func(*corev1.Pod)) *corev1.Pod { + p := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "p", Namespace: ns}, Status: corev1.PodStatus{Phase: phase}} + if mutate != nil { + mutate(p) + } + return p + } + cases := map[string]struct { + pod *corev1.Pod + stale bool + }{ + "running": {pod("a", corev1.PodRunning, nil), false}, + "pending": {pod("b", corev1.PodPending, nil), false}, + "succeeded": {pod("c", corev1.PodSucceeded, nil), true}, + "terminating": {pod("d", corev1.PodRunning, func(p *corev1.Pod) { p.DeletionTimestamp = &now }), true}, + "hook job's": {pod("e", corev1.PodRunning, func(p *corev1.Pod) { + p.OwnerReferences = []metav1.OwnerReference{{Kind: "Job", Name: "migrations"}} + }), true}, + } + for name, tc := range cases { + ns := tc.pod.Namespace + client := fake.NewSimpleClientset( + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: ns}}, + &corev1.PersistentVolumeClaim{ObjectMeta: metav1.ObjectMeta{Name: "data", Namespace: ns}}, + tc.pod, + ) + got, err := probeStaleNamespaces(context.Background(), client, []string{ns}, time.Now) + require.NoError(t, err, name) + assert.Equal(t, tc.stale, len(got) == 1, name) + } +} + +// HELM_DRIVER=sql keeps release state in a database, so the absence of an +// in-cluster release object says nothing and must not be reported. +func TestProbeStaleNamespaces_SQLDriverSkipsTheNoReleaseSignal(t *testing.T) { + t.Setenv("HELM_DRIVER", "sql") + client := fake.NewSimpleClientset( + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "nvcf"}}, + ) + got, err := probeStaleNamespaces(context.Background(), client, []string{"nvcf"}, time.Now) + require.NoError(t, err) + assert.Empty(t, got) +} + +// A namespace stuck Terminating is still reported even when no Helm release +// object exists anywhere: that signal does not depend on the storage driver. +func TestProbeStaleNamespaces_TerminatingReportedWithoutHelmObjects(t *testing.T) { + t.Setenv("HELM_DRIVER", "sql") + deleted := metav1.NewTime(time.Now().Add(-10 * time.Minute)) + client := fake.NewSimpleClientset( + &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: "nvcf", DeletionTimestamp: &deleted}, + Status: corev1.NamespaceStatus{Phase: corev1.NamespaceTerminating}, + }, + ) + got, err := probeStaleNamespaces(context.Background(), client, []string{"nvcf"}, time.Now) + require.NoError(t, err) + require.Len(t, got, 1) + assert.Equal(t, "stuck Terminating", got[0].Reason) +} + +// A conditionally-disabled component installed the upstream way is healthy. +// Reporting it at error severity turns a supported shape into exit 2, and the +// old remediation would have destroyed every Certificate in the cluster. +func TestStaleNamespaceCheck_NoHelmReleaseWarnsAndDoesNotSuggestDelete(t *testing.T) { + pinCurrentKubeContext(t, "") + prober := func(_ context.Context, _ string, _ []string) ([]StaleNamespace, error) { + return []StaleNamespace{{Name: "cert-manager", Reason: "no Helm release"}}, nil + } + r := staleNamespaceCheck(prober, "", []string{"cert-manager"}).Run(context.Background()) + + assert.Equal(t, SeverityWarning, r.Severity, + "a namespace with no Helm release must not fail the run") + for _, nudge := range []string{" delete ", "remove", "To resolve"} { + assert.NotContains(t, r.Message, nudge, + "no Helm release is an observation; the hint must not frame removing the namespace as the fix") + } + assert.Contains(t, r.Message, "xargs -n1 kubectl get -n cert-manager --show-kind --ignore-not-found") +} + +// A namespace stuck Terminating does block the run. +func TestStaleNamespaceCheck_TerminatingIsStillAnError(t *testing.T) { + pinCurrentKubeContext(t, "") + prober := func(_ context.Context, _ string, _ []string) ([]StaleNamespace, error) { + return []StaleNamespace{{Name: "nvcf", Reason: "stuck Terminating"}}, nil + } + r := staleNamespaceCheck(prober, "", []string{"nvcf"}).Run(context.Background()) + assert.Equal(t, SeverityError, r.Severity) + assert.False(t, r.Transient, "a stuck namespace does not clear by waiting") +} + +// kubectl keeps only the last -n it is given, so a joined command inspects one +// namespace and reports the rest as empty, which is the opposite of what an +// operator deciding whether to delete them needs. +func TestStaleNamespaceCheck_HintsOneCommandPerNamespace(t *testing.T) { + pinCurrentKubeContext(t, "") + prober := func(_ context.Context, _ string, _ []string) ([]StaleNamespace, error) { + return []StaleNamespace{ + {Name: "api-keys", Reason: "no Helm release"}, + {Name: "ess", Reason: "no Helm release"}, + }, nil + } + r := staleNamespaceCheck(prober, "", []string{"api-keys", "ess"}).Run(context.Background()) + + assert.NotContains(t, r.Message, "-n api-keys -n ess", + "a joined namespace list silently inspects only the last one") + assert.Contains(t, r.Message, "get -n api-keys --show-kind") + assert.Contains(t, r.Message, "get -n ess --show-kind") +} + +// The documented install pre-creates namespaces before the first install, so +// none has a release yet: labelled and empty, or holding a pull Secret, a TLS +// Secret, or the CA ConfigMap a mesh or the platform adds. None of that is a +// leftover. A namespace that still holds volume claims or workload objects, as +// `down` leaves them, is. +func TestProbeStaleNamespaces_PreCreatedNamespaceIsNotStale(t *testing.T) { + ns := func(name string) *corev1.Namespace { + return &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: name, Labels: map[string]string{"nvcf/platform": "true"}}, + Status: corev1.NamespaceStatus{Phase: corev1.NamespaceActive}} + } + secret := func(ns, name string, typ corev1.SecretType) *corev1.Secret { + return &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns}, Type: typ} + } + configMap := func(ns, name string) *corev1.ConfigMap { + return &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns}} + } + client := fake.NewSimpleClientset( + ns("api-keys"), + ns("ess"), secret("ess", "nvcr-pull-secret", corev1.SecretTypeDockerConfigJson), + ns("nvcf"), secret("nvcf", "stargate-quic-tls", corev1.SecretTypeTLS), configMap("nvcf", "kube-root-ca.crt"), + ns("sis"), configMap("sis", "istio-ca-root-cert"), configMap("sis", "openshift-service-ca.crt"), + ns("cassandra-system"), secret("cassandra-system", "nvcr-pull-secret", corev1.SecretTypeDockerConfigJson), + &corev1.PersistentVolumeClaim{ObjectMeta: metav1.ObjectMeta{Name: "data-cassandra-0", Namespace: "cassandra-system"}}, + ns("vault-system"), secret("vault-system", "vault-unseal", corev1.SecretTypeOpaque), + &appsv1.StatefulSet{ObjectMeta: metav1.ObjectMeta{Name: "openbao-server", Namespace: "vault-system"}}, + ) + stale, err := probeStaleNamespaces(context.Background(), client, + []string{"api-keys", "ess", "nvcf", "sis", "cassandra-system", "vault-system"}, time.Now) + require.NoError(t, err) + var names []string + for _, s := range stale { + names = append(names, s.Name) + } + assert.ElementsMatch(t, []string{"cassandra-system", "vault-system"}, names, + "only the namespaces that still hold volume claims or workload objects") +} + +// An install rendered with `helm template` (Argo CD) records no Helm release +// but runs pods. That is a live install, not a leftover, though it holds what +// a leftover would. +func TestProbeStaleNamespaces_NamespaceRunningPodsIsNotStale(t *testing.T) { + client := fake.NewSimpleClientset( + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "nats-system"}, + Status: corev1.NamespaceStatus{Phase: corev1.NamespaceActive}}, + &appsv1.StatefulSet{ObjectMeta: metav1.ObjectMeta{Name: "nats", Namespace: "nats-system"}}, + &corev1.PersistentVolumeClaim{ObjectMeta: metav1.ObjectMeta{Name: "nats-js-nats-0", Namespace: "nats-system"}}, + &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "nats-0", Namespace: "nats-system"}, + Status: corev1.PodStatus{Phase: corev1.PodRunning}}, + &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "nats-auth", Namespace: "nats-system"}, + Type: corev1.SecretTypeOpaque}, + ) + stale, err := probeStaleNamespaces(context.Background(), client, []string{"nats-system"}, time.Now) + require.NoError(t, err) + assert.Empty(t, stale) +} + +// listSelects reports whether a list action's label selector names value. +func listSelects(a ktesting.Action, value string) bool { + return strings.Contains(a.(ktesting.ListAction).GetListRestrictions().Labels.String(), value) +} + +func forbidden(resource, name string) error { + return apierrors.NewForbidden(schema.GroupResource{Resource: resource}, name, errors.New("denied")) +} + +// One namespace that cannot be read must not hide what the others show: a +// namespace stuck Terminating is still reported, before or after the read +// that failed, and the failures name their namespaces. +func TestProbeStaleNamespaces_KeepsProbingPastAReadError(t *testing.T) { + t.Setenv("HELM_DRIVER", "") + deleted := metav1.NewTime(time.Now().Add(-10 * time.Minute)) + client := fake.NewSimpleClientset( + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "nvcf"}}, + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "cassandra-system", DeletionTimestamp: &deleted}}, + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "vault-system"}}, + ) + client.PrependReactor("get", "namespaces", func(a ktesting.Action) (bool, runtime.Object, error) { + if a.(ktesting.GetAction).GetName() == "nvcf" { + return true, nil, forbidden("namespaces", "nvcf") + } + return false, nil, nil + }) + client.PrependReactor("list", "secrets", func(a ktesting.Action) (bool, runtime.Object, error) { + return true, nil, forbidden("secrets", "") + }) + + stale, err := probeStaleNamespaces(context.Background(), client, + []string{"nvcf", "cassandra-system", "vault-system"}, time.Now) + require.Len(t, stale, 1, "the namespace read after the failed one must still be probed") + assert.Equal(t, "cassandra-system", stale[0].Name) + assert.Equal(t, StaleStuckTerminating, stale[0].Reason) + require.Error(t, err) + assert.Contains(t, err.Error(), "get namespace nvcf") + assert.Contains(t, err.Error(), "; vault-system: list Helm secrets") + assert.NotContains(t, err.Error(), "\n", "the errors are rendered on the row's one line") + assert.True(t, apierrors.IsForbidden(err), "the underlying API errors stay reachable") +} + +// A stuck namespace found next to a probe error keeps the row at error, and +// the row names both. Grading the pair as the probe error alone let `check +// --pre` pass over a namespace `up` would then fail to create into. +func TestStaleNamespaceCheck_StuckStaysAnErrorBesideAProbeError(t *testing.T) { + pinCurrentKubeContext(t, "") + readErr := errors.New("vault-system: list Helm secrets: forbidden") + prober := func(_ context.Context, _ string, _ []string) ([]StaleNamespace, error) { + return []StaleNamespace{{Name: "cassandra-system", Reason: StaleStuckTerminating}}, readErr + } + r := staleNamespaceCheck(prober, "", nil).Run(context.Background()) + assert.False(t, r.Passed) + assert.Equal(t, SeverityError, r.Severity) + assert.Contains(t, r.Message, "cassandra-system (stuck Terminating)") + assert.Contains(t, r.Message, "could not probe: vault-system") + assert.NoError(t, r.Err, "the stuck namespace is a finding, so the row is not graded as a probe that failed") + + // Without a stuck namespace the same pair is a warning that still lists + // what was found. + prober = func(_ context.Context, _ string, _ []string) ([]StaleNamespace, error) { + return []StaleNamespace{{Name: "api-keys", Reason: StaleNoHelmRelease}}, readErr + } + r = staleNamespaceCheck(prober, "", nil).Run(context.Background()) + assert.Equal(t, SeverityWarning, r.Severity) + assert.Contains(t, r.Message, "api-keys (no Helm release)") + assert.Contains(t, r.Message, "could not probe: vault-system") +} + +// A namespace a moment into a normal deletion is draining volumes and +// finalizers; `up` waits for it. Only past the bound, or once the namespace +// controller reports a deletion failure, is it stuck. +func TestProbeStaleNamespaces_StuckOnlyPastTheBoundOrOnAFailure(t *testing.T) { + t.Setenv("HELM_DRIVER", "sql") + ago := func(d time.Duration) *metav1.Time { ts := metav1.NewTime(time.Now().Add(-d)); return &ts } + cond := func(t corev1.NamespaceConditionType) []corev1.NamespaceCondition { + return []corev1.NamespaceCondition{{Type: t, Status: corev1.ConditionTrue, Message: "held"}} + } + soon, late := ago(5*time.Second), ago(namespaceStuckAfter+time.Second) + cases := map[string]struct { + deleted *metav1.Time + conditions []corev1.NamespaceCondition + want string + }{ + "just deleted": {soon, nil, StaleTerminating}, + "finalizers draining": {soon, cond(corev1.NamespaceFinalizersRemaining), StaleTerminating}, + "past the bound": {late, nil, StaleStuckTerminating}, + "content failure": {soon, cond(corev1.NamespaceDeletionContentFailure), StaleStuckTerminating}, + "discovery failure": {soon, cond(corev1.NamespaceDeletionDiscoveryFailure), StaleStuckTerminating}, + "group-version failure": {soon, cond(corev1.NamespaceDeletionGVParsingFailure), StaleStuckTerminating}, + "phase only, no failure": {nil, nil, StaleTerminating}, + "phase only, and failing": {nil, cond(corev1.NamespaceDeletionContentFailure), StaleStuckTerminating}, + } + for name, tc := range cases { + client := fake.NewSimpleClientset(&corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: "nvcf", DeletionTimestamp: tc.deleted}, + Status: corev1.NamespaceStatus{Phase: corev1.NamespaceTerminating, Conditions: tc.conditions}, + }) + got, err := probeStaleNamespaces(context.Background(), client, []string{"nvcf"}, time.Now) + require.NoError(t, err, name) + require.Len(t, got, 1, name) + assert.Equal(t, tc.want, got[0].Reason, name) + } +} + +// How long a namespace has been deleting is measured on the API server's +// clock, read from the Date of its responses, so a workstation clock minutes +// off neither calls a fresh deletion stuck nor a stuck one fresh. +func TestNewStaleNamespaceProber_AgesDeletionsOnTheServerClock(t *testing.T) { + for name, tc := range map[string]struct { + skew, deleting time.Duration + want string + }{ + "workstation 3m fast, deleted 10s ago": {-3 * time.Minute, 10 * time.Second, StaleTerminating}, + "workstation 10m slow, deleting for 8m": {10 * time.Minute, 8 * time.Minute, StaleStuckTerminating}, + } { + t.Run(name, func(t *testing.T) { + serverNow := time.Now().Add(tc.skew) + deleted := metav1.NewTime(serverNow.Add(-tc.deleting)) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Date", serverNow.UTC().Format(http.TimeFormat)) + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/version" { + _, _ = w.Write([]byte(`{"major":"1","minor":"30"}`)) + return + } + _ = json.NewEncoder(w).Encode(corev1.Namespace{ + TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Namespace"}, + ObjectMeta: metav1.ObjectMeta{Name: "nvcf", DeletionTimestamp: &deleted}, + Status: corev1.NamespaceStatus{Phase: corev1.NamespaceTerminating}, + }) + })) + t.Cleanup(srv.Close) + writeKubeconfig(t, srv.URL) + + got, err := NewStaleNamespaceProber()(context.Background(), "test", []string{"nvcf"}) + require.NoError(t, err) + require.Len(t, got, 1) + assert.Equal(t, tc.want, got[0].Reason, got[0].Detail) + assert.NotContains(t, got[0].Detail, "-", "an age is never negative") + }) + } +} + +// A namespace still being deleted is a warning with a read-only hint: no +// /finalize, which would orphan the volumes it is still detaching. +func TestStaleNamespaceCheck_TerminatingWarnsWithoutTheFinalizeHint(t *testing.T) { + pinCurrentKubeContext(t, "") + prober := func(_ context.Context, _ string, _ []string) ([]StaleNamespace, error) { + return []StaleNamespace{{Name: "nvcf", Reason: StaleTerminating, Detail: "deleting for 5s"}}, nil + } + r := staleNamespaceCheck(prober, "", nil).Run(context.Background()) + assert.Equal(t, SeverityWarning, r.Severity) + assert.True(t, r.Transient, "a deletion in progress clears by itself, so --wait polls it") + assert.Contains(t, r.Message, "nvcf (Terminating: deleting for 5s)") + assert.Contains(t, r.Message, "kubectl get ns nvcf -o jsonpath='{.status.conditions}'") + assert.NotContains(t, r.Message, "finalize") +} + +// The stuck hint reads the namespace's conditions before anything else. +func TestStaleNamespaceCheck_StuckHintReadsConditionsFirst(t *testing.T) { + pinCurrentKubeContext(t, "") + prober := func(_ context.Context, _ string, _ []string) ([]StaleNamespace, error) { + return []StaleNamespace{{Name: "nvcf", Reason: StaleStuckTerminating}}, nil + } + r := staleNamespaceCheck(prober, "", nil).Run(context.Background()) + conditions := strings.Index(r.Message, "get ns nvcf -o jsonpath='{.status.conditions}'") + inspect := strings.Index(r.Message, "xargs -n1 kubectl get -n nvcf") + require.GreaterOrEqual(t, conditions, 0) + assert.Less(t, conditions, inspect) +} + +func helmRecord(ns, name, status string) *corev1.Secret { + return &corev1.Secret{ObjectMeta: metav1.ObjectMeta{ + Name: "sh.helm.release.v1." + name + ".v1", Namespace: ns, + Labels: map[string]string{"owner": "helm", "name": name, "status": status}, + }} +} + +// Only a deployed, failed or superseded record is a release `helm upgrade +// --install` builds on. One an interrupted install, upgrade or teardown left +// behind makes the next install fail, so it is reported by name and status; +// an uninstalled record kept by --keep-history is no release at all. +func TestProbeStaleNamespaces_GradesReleasesByStatus(t *testing.T) { + t.Setenv("HELM_DRIVER", "") + const mid = StaleReleaseMidOperation + cases := map[string]struct { + statuses []string + reason string + detail string + }{ + "deployed": {[]string{"deployed"}, "", ""}, + "failed": {[]string{"failed"}, "", ""}, + "superseded": {[]string{"superseded"}, "", ""}, + "uninstalling": {[]string{"uninstalling"}, mid, "cassandra uninstalling"}, + "pending-install": {[]string{"pending-install"}, mid, "cassandra pending-install"}, + "pending-upgrade": {[]string{"deployed", "pending-upgrade"}, mid, "cassandra pending-upgrade"}, + "pending-rollback": {[]string{"pending-rollback"}, mid, "cassandra pending-rollback"}, + "uninstalled (kept)": {[]string{"uninstalled"}, StaleNoHelmRelease, ""}, + } + for name, tc := range cases { + objects := []runtime.Object{ + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "cassandra-system"}}, + &corev1.PersistentVolumeClaim{ObjectMeta: metav1.ObjectMeta{Name: "data", Namespace: "cassandra-system"}}, + } + for i, status := range tc.statuses { + rec := helmRecord("cassandra-system", "cassandra", status) + rec.Name = fmt.Sprintf("%s%d", rec.Name, i) + objects = append(objects, rec) + } + got, err := probeStaleNamespaces(context.Background(), fake.NewSimpleClientset(objects...), + []string{"cassandra-system"}, time.Now) + require.NoError(t, err, name) + if tc.reason == "" { + assert.Empty(t, got, name) + continue + } + require.Len(t, got, 1, name) + assert.Equal(t, tc.reason, got[0].Reason, name) + assert.Equal(t, tc.detail, got[0].Detail, name) + } +} + +// The ConfigMap driver's records are graded the same way. +func TestProbeStaleNamespaces_ConfigMapReleaseMidOperation(t *testing.T) { + t.Setenv("HELM_DRIVER", "") + client := fake.NewSimpleClientset( + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "nats-system"}}, + &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "nats.v2", Namespace: "nats-system", + Labels: map[string]string{"owner": "helm", "name": "nats", "status": "pending-upgrade"}}}, + ) + got, err := probeStaleNamespaces(context.Background(), client, []string{"nats-system"}, time.Now) + require.NoError(t, err) + require.Len(t, got, 1) + assert.Equal(t, []string{"nats"}, got[0].Releases) +} + +// A release left mid-operation is a warning, since an install may still be +// running, and its hint reads the history on the probed context first. +func TestStaleNamespaceCheck_ReleaseMidOperationHint(t *testing.T) { + pinCurrentKubeContext(t, "") + prober := func(_ context.Context, _ string, _ []string) ([]StaleNamespace, error) { + return []StaleNamespace{{Name: "cassandra-system", Reason: StaleReleaseMidOperation, + Detail: "cassandra uninstalling", Releases: []string{"cassandra"}}}, nil + } + r := staleNamespaceCheck(prober, "cp ctx", nil).Run(context.Background()) + assert.Equal(t, SeverityWarning, r.Severity) + assert.Contains(t, r.Message, "cassandra-system (Helm release mid-operation: cassandra uninstalling)") + assert.Contains(t, r.Message, "helm --kube-context 'cp ctx' history cassandra -n cassandra-system") +} + +// A namespace whose pods or leftovers cannot be listed is listed as unread, +// not reported as a leftover nobody saw, and not passed over in silence. +func TestProbeStaleNamespaces_UnreadableWorkloadIsListedNotReported(t *testing.T) { + t.Setenv("HELM_DRIVER", "") + for _, resource := range []string{"pods", "persistentvolumeclaims"} { + client := fake.NewSimpleClientset( + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "api-keys"}}, + &corev1.PersistentVolumeClaim{ObjectMeta: metav1.ObjectMeta{Name: "data", Namespace: "api-keys"}}, + ) + client.PrependReactor("list", resource, func(ktesting.Action) (bool, runtime.Object, error) { + return true, nil, forbidden(resource, "") + }) + got, err := probeStaleNamespaces(context.Background(), client, []string{"api-keys"}, time.Now) + assert.Empty(t, got, resource) + require.Error(t, err, resource) + assert.Contains(t, err.Error(), "api-keys: list "+resource, resource) + } +} + +// Each leftover kind is enough on its own, and one kind seen outweighs another +// that could not be read. +func TestHoldsInstallLeftovers_EachKindAndTheErrorDirection(t *testing.T) { + ns := "vault-system" + meta := metav1.ObjectMeta{Name: "x", Namespace: ns} + kinds := map[string]runtime.Object{ + "persistentvolumeclaims": &corev1.PersistentVolumeClaim{ObjectMeta: meta}, + "jobs": &batchv1.Job{ObjectMeta: meta}, + "statefulsets": &appsv1.StatefulSet{ObjectMeta: meta}, + "deployments": &appsv1.Deployment{ObjectMeta: meta}, + "daemonsets": &appsv1.DaemonSet{ObjectMeta: meta}, + } + for kind, obj := range kinds { + got, err := holdsInstallLeftovers(context.Background(), fake.NewSimpleClientset(obj), ns) + require.NoError(t, err, kind) + assert.True(t, got, kind) + + // Every other kind unreadable: what was seen still counts. + client := fake.NewSimpleClientset(obj) + for other := range kinds { + if other != kind { + client.PrependReactor("list", other, func(ktesting.Action) (bool, runtime.Object, error) { + return true, nil, forbidden(other, "") + }) + } + } + got, err = holdsInstallLeftovers(context.Background(), client, ns) + require.NoError(t, err, kind) + assert.True(t, got, kind) + } + + client := fake.NewSimpleClientset() + client.PrependReactor("list", "jobs", func(ktesting.Action) (bool, runtime.Object, error) { + return true, nil, forbidden("jobs", "") + }) + got, err := holdsInstallLeftovers(context.Background(), client, ns) + assert.False(t, got, "a kind that could not be read is not a leftover that was seen") + assert.Error(t, err) + + got, err = holdsInstallLeftovers(context.Background(), fake.NewSimpleClientset(), ns) + assert.False(t, got) + assert.NoError(t, err) +} + +func TestHasLiveWorkload_ReadErrorIsNotAWorkload(t *testing.T) { + client := fake.NewSimpleClientset() + client.PrependReactor("list", "pods", func(ktesting.Action) (bool, runtime.Object, error) { + return true, nil, forbidden("pods", "") + }) + live, err := hasLiveWorkload(context.Background(), client, "nvcf") + assert.False(t, live) + assert.Error(t, err) +} + +// The budget running out mid-scan has to reach the result as the context's +// error, so the runner grades the row as cut short (exit 5) rather than as a +// clean pass or a warning. +func TestProbeStaleNamespaces_BudgetCutOffIsReturned(t *testing.T) { + t.Setenv("HELM_DRIVER", "") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + client := fake.NewSimpleClientset( + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "nvcf"}}, + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "sis"}}, + ) + client.PrependReactor("list", "pods", func(ktesting.Action) (bool, runtime.Object, error) { + cancel() + return true, nil, context.Canceled + }) + _, err := probeStaleNamespaces(ctx, client, []string{"nvcf", "sis"}, time.Now) + require.ErrorIs(t, err, context.Canceled) + assert.NotContains(t, err.Error(), "sis", "the scan stops once the context is done") + + // A budget that runs out between two namespaces fails no call, and the + // namespaces never read must still turn the scan into an error. + ctx, cancel = context.WithCancel(context.Background()) + defer cancel() + client = fake.NewSimpleClientset( + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "nvcf"}}, + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "sis"}}, + ) + client.PrependReactor("list", "pods", func(ktesting.Action) (bool, runtime.Object, error) { + cancel() + return true, &corev1.PodList{}, nil + }) + _, err = probeStaleNamespaces(ctx, client, []string{"nvcf", "sis"}, time.Now) + require.ErrorIs(t, err, context.Canceled, "an unfinished scan is never a clean one") + + deadline := fmt.Errorf("list pods: %w", context.DeadlineExceeded) + prober := func(context.Context, string, []string) ([]StaleNamespace, error) { + return []StaleNamespace{{Name: "api-keys", Reason: StaleNoHelmRelease}}, deadline + } + pinCurrentKubeContext(t, "") + r := staleNamespaceCheck(prober, "", nil).Run(context.Background()) + assert.ErrorIs(t, r.Err, context.DeadlineExceeded, + "findings beside a spent budget must still carry the budget error to the runner") +} + +func TestFindHelmReleases_PageCapIsAnError(t *testing.T) { + calls := 0 + list := func(metav1.ListOptions) ([]map[string]string, string, error) { + calls++ + return nil, "more", nil + } + got, err := findHelmReleases(list, liveReleaseSelector, true) + require.Error(t, err, "running out of pages is not proof that no release exists") + assert.Nil(t, got) + assert.Equal(t, helmReleaseListMaxPages, calls) +} + +func TestHelmReleasesAreInCluster_NormalisesTheDriver(t *testing.T) { + for driver, want := range map[string]bool{ + "": true, "secret": true, "configmap": true, "sql": false, " SQL ": false, "Sql\n": false, + } { + t.Setenv("HELM_DRIVER", driver) + assert.Equal(t, want, helmReleasesAreInCluster(), "%q", driver) + } +} + +// Under HELM_DRIVER=sql the release lists say nothing, so they are not read: +// an error from them cannot cut the probe short. +func TestProbeStaleNamespaces_SQLDriverSkipsTheReleaseLists(t *testing.T) { + t.Setenv("HELM_DRIVER", "sql") + client := fake.NewSimpleClientset(&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "nvcf"}}) + for _, resource := range []string{"secrets", "configmaps", "pods"} { + client.PrependReactor("list", resource, func(ktesting.Action) (bool, runtime.Object, error) { + return true, nil, forbidden(resource, "") + }) + } + got, err := probeStaleNamespaces(context.Background(), client, []string{"nvcf"}, time.Now) + require.NoError(t, err) + assert.Empty(t, got) +} diff --git a/src/clis/nvcf-cli/internal/selfhosted/teardown/drain_test.go b/src/clis/nvcf-cli/internal/selfhosted/teardown/drain_test.go index 3452e2724d..a8cb5c70c1 100644 --- a/src/clis/nvcf-cli/internal/selfhosted/teardown/drain_test.go +++ b/src/clis/nvcf-cli/internal/selfhosted/teardown/drain_test.go @@ -34,7 +34,7 @@ import ( type fakeSIS struct { deployments []ActiveDeployment removeErr error - removeCalled []string // IDs passed to RemoveDeployment + removeCalled []string // IDs passed to RemoveDeployment statusResults map[string]string // ID → state string statusCallCnt int32 } @@ -73,7 +73,7 @@ func (s *recordSink) Close() error { return nil } type discardSink struct{} func (d *discardSink) Emit(_ context.Context, _ progress.Event) error { return nil } -func (d *discardSink) Close() error { return nil } +func (d *discardSink) Close() error { return nil } // TestDrain_NoActiveDeploymentsIsNoop: empty list → returns nil, no events. func TestDrain_NoActiveDeploymentsIsNoop(t *testing.T) { diff --git a/src/clis/nvcf-cli/internal/selfhosted/testdata/rbac_inventory.yaml b/src/clis/nvcf-cli/internal/selfhosted/testdata/rbac_inventory.yaml new file mode 100644 index 0000000000..18492386ee --- /dev/null +++ b/src/clis/nvcf-cli/internal/selfhosted/testdata/rbac_inventory.yaml @@ -0,0 +1,99 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Every Kubernetes API request the cluster-validator makes, by check set +# (VALIDATOR_ROLE). This file is the one RBAC contract for every launcher: +# +# - TestRBACInventory_* in this package fail when a run makes a request +# that is not listed here, or when a listed request is never made. +# - deploy/helm/nvca-operator/tests/cluster_validator_test.sh renders the +# chart and requires its validator RBAC to grant exactly these rules. +# - nvcf-cli launches the compute-plane and control-plane sets with +# VALIDATOR_PREFLIGHT, enforcement off and no networkPolicies pairs, so its +# per-run roles grant the subset of these rules that launch makes. +# +# scope: cluster rules belong in a ClusterRole. scope: namespace rules only +# touch the validator's own namespace (its config and summary ConfigMaps), so a +# namespaced Role grants them. Discovery (/api, /apis) is not listed: every +# authenticated user can read it. +rules: + - roles: [compute-plane, control-plane] + scope: cluster + apiGroup: "" + resources: [nodes] + verbs: [list] + - roles: [compute-plane, control-plane] + scope: cluster + apiGroup: "" + resources: [namespaces] + verbs: [get, list, create, delete] + - roles: [compute-plane, control-plane] + scope: cluster + apiGroup: "" + resources: [pods] + verbs: [get, list, create, delete] + - roles: [control-plane] + scope: cluster + apiGroup: "" + resources: [services, events] + verbs: [list] + - roles: [compute-plane, control-plane] + scope: cluster + apiGroup: admissionregistration.k8s.io + resources: [mutatingwebhookconfigurations, validatingwebhookconfigurations] + verbs: [list] + - roles: [compute-plane, control-plane] + scope: cluster + apiGroup: networking.k8s.io + resources: [networkpolicies] + verbs: [list, create, update, delete] + - roles: [compute-plane] + scope: cluster + apiGroup: storage.k8s.io + resources: [csidrivers] + verbs: [get] + - roles: [control-plane] + scope: cluster + apiGroup: storage.k8s.io + resources: [storageclasses] + verbs: [get, list] + - roles: [compute-plane] + scope: cluster + apiGroup: apps + resources: [deployments] + verbs: [get, list] + - roles: [control-plane] + scope: cluster + apiGroup: apps + resources: [deployments, replicasets, statefulsets] + verbs: [list] + - roles: [control-plane] + scope: cluster + apiGroup: apps + resources: [daemonsets] + verbs: [list, create, delete] + - roles: [control-plane] + scope: cluster + apiGroup: apps + resources: [controllerrevisions] + verbs: [get] + - roles: [control-plane] + scope: cluster + apiGroup: gateway.networking.k8s.io + resources: [gatewayclasses, gateways, httproutes, grpcroutes, tcproutes, udproutes] + verbs: [list] + - roles: [compute-plane, control-plane] + scope: namespace + apiGroup: "" + resources: [configmaps] + verbs: [get, create] + - roles: [compute-plane, control-plane] + scope: namespace + apiGroup: "" + resources: [configmaps] + resourceNames: [cluster-validator-summary] + verbs: [update] + - roles: [compute-plane, control-plane] + scope: cluster + nonResourceURLs: [/readyz, /version] + verbs: [get] diff --git a/src/clis/nvcf-cli/internal/selfhosted/testdata/validator_network_checks.golden.yaml b/src/clis/nvcf-cli/internal/selfhosted/testdata/validator_network_checks.golden.yaml new file mode 100644 index 0000000000..94d30ef44b --- /dev/null +++ b/src/clis/nvcf-cli/internal/selfhosted/testdata/validator_network_checks.golden.yaml @@ -0,0 +1,25 @@ +reachability: + endpoints: + - name: "nvcr.io" + host: "nvcr.io" + port: 443 + protocol: tcp+tls + critical: false + - name: "harbor.example.com:8443" + host: "harbor.example.com" + port: 8443 + protocol: tcp+tls + critical: false + - name: "[fd00::1]:5000" + host: "fd00::1" + port: 5000 + protocol: tcp+tls + critical: false +enforcement: + # Disabled for preflight. VALIDATOR_PREFLIGHT only suppresses the summary + # write, so enforcement would still run: it creates netpol-validation + # namespaces, server and client pods and NetworkPolicies, and pulls + # busybox from Docker Hub. Doing that on a virgin or air-gapped cluster, + # before anything is installed and with no flag to turn it off, is not + # something a read-only readiness check should do. + enabled: false diff --git a/src/clis/nvcf-cli/internal/selfhosted/validator_config_parity_test.go b/src/clis/nvcf-cli/internal/selfhosted/validator_config_parity_test.go new file mode 100644 index 0000000000..c027278a64 --- /dev/null +++ b/src/clis/nvcf-cli/internal/selfhosted/validator_config_parity_test.go @@ -0,0 +1,92 @@ +/* +SPDX-FileCopyrightText: Copyright (c) NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package selfhosted + +import ( + "encoding/json" + "flag" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sigs.k8s.io/yaml" +) + +var updateGolden = flag.Bool("update-golden", false, "rewrite golden files") + +// networkCheckConfig mirrors NetworkCheckConfig in the validator's +// internal/clustervalidator/config.go, the schema its LoadNetworkCheckConfig +// decodes the ConfigMap into. That decode is not strict: a misspelt key is +// dropped silently, and a renamed endpoints header would lose every +// reachability endpoint with no error. +type networkCheckConfig struct { + Reachability *struct { + Endpoints []struct { + Name string `json:"name"` + Host string `json:"host"` + Port int `json:"port"` + Protocol string `json:"protocol"` + URL string `json:"url,omitempty"` + Critical bool `json:"critical,omitempty"` + } `json:"endpoints"` + } `json:"reachability,omitempty"` + NetworkPolicies *struct { + Pairs []json.RawMessage `json:"pairs"` + } `json:"networkPolicies,omitempty"` + Enforcement *struct { + Enabled bool `json:"enabled"` + TestImage string `json:"testImage,omitempty"` + TimeoutSeconds int `json:"timeoutSeconds,omitempty"` + Critical bool `json:"critical,omitempty"` + } `json:"enforcement,omitempty"` +} + +// The ConfigMap the CLI writes decodes strictly into the validator's schema, +// with every endpoint passing the validator's own checks, every one of them +// non-critical, and enforcement disabled. The golden file is the fixture a +// validator-side test can load with LoadNetworkCheckConfig. +func TestBuildControlPlaneValidatorConfig_MatchesTheValidatorsSchema(t *testing.T) { + registries := []RegistryEntry{{Registry: "nvcr.io"}, {Registry: "harbor.example.com:8443"}, + {Registry: "[fd00::1]:5000"}} + got := buildControlPlaneValidatorConfig(registries) + + const golden = "testdata/validator_network_checks.golden.yaml" + if *updateGolden { + require.NoError(t, os.WriteFile(golden, []byte(got), 0o644)) + } + want, err := os.ReadFile(golden) + require.NoError(t, err) + assert.Equal(t, string(want), got, "golden mismatch (run with -update-golden to refresh): %s", golden) + + var cfg networkCheckConfig + require.NoError(t, yaml.UnmarshalStrict([]byte(got), &cfg), "every key must be one the validator reads") + require.NotNil(t, cfg.Reachability) + require.Len(t, cfg.Reachability.Endpoints, len(registries)) + for _, ep := range cfg.Reachability.Endpoints { + assert.NotEmpty(t, ep.Name) + assert.NotEmpty(t, ep.Host) + assert.True(t, ep.Port > 0 && ep.Port <= 65535, ep.Name) + assert.Contains(t, []string{"https", "tcp", "tcp+tls"}, strings.ToLower(ep.Protocol), ep.Name) + assert.False(t, ep.Critical, "an in-pod dial has no proxy, so a registry is never critical: %s", ep.Name) + } + require.NotNil(t, cfg.Enforcement) + assert.False(t, cfg.Enforcement.Enabled, "the CLI path never runs the enforcement test") + assert.Nil(t, cfg.NetworkPolicies) +} diff --git a/src/clis/nvcf-cli/internal/selfhosted/validatortag.go b/src/clis/nvcf-cli/internal/selfhosted/validatortag.go index f6750bbf35..d134730294 100644 --- a/src/clis/nvcf-cli/internal/selfhosted/validatortag.go +++ b/src/clis/nvcf-cli/internal/selfhosted/validatortag.go @@ -18,16 +18,20 @@ limitations under the License. package selfhosted import ( + "cmp" "context" - "encoding/base64" "encoding/json" + "errors" "fmt" "io" + "net" "net/http" + "net/url" "os" "path/filepath" "regexp" "sort" + "strconv" "strings" "time" @@ -38,12 +42,14 @@ const ( // Wall-clock budget for cache freshness. Preflight is rarely re-run more // often than this, and the cluster-validator's rc cadence is multi-day. validatorTagCacheTTL = 1 * time.Hour - - // Hard upper bound on the registry round-trip so a slow NGC can't stall - // preflight; we fall back to the const if this trips. - validatorTagFetchTimeout = 5 * time.Second ) +// validatorTagFetchTimeout is a hard upper bound on the registry round trips +// of tag discovery, so a slow registry cannot stall preflight. When it trips, +// an untagged image is reported as unresolved rather than launched. A var so +// tests can shorten it. +var validatorTagFetchTimeout = 5 * time.Second + // Restrict to X.Y.Z or X.Y.Z-rc.N tags; excludes sigstore metadata // (sha256-*.sig/sbom/vex) and commit-SHA pre-releases (X.Y.Z-vSHA). var validatorTagPattern = regexp.MustCompile(`^\d+\.\d+\.\d+(-rc\.\d+)?$`) @@ -52,21 +58,24 @@ var validatorTagPattern = regexp.MustCompile(`^\d+\.\d+\.\d+(-rc\.\d+)?$`) // substituted, sourced from the OCI registry hosting baseImage. Prefers // stable releases over rc; falls back to the highest rc when no stable // exists. Returns ("", false) on any failure (network, auth, parse, no -// matching tags) so the caller can fall back to the input value as-is. +// matching tags), and the caller then reports an untagged image as +// unresolved. // // Discovery only runs when baseImage has no tag. A pinned tag -// (image:vX.Y.Z) is honored verbatim — operators who specify a tag +// (image:vX.Y.Z) is honored verbatim: operators who specify a tag // must get exactly that tag, never a registry-side override. // // Reads and writes a 1h-TTL cache at ~/.cache/nvcf-cli/validator-tag.json // so back-to-back preflight runs share the result without re-hitting the // registry. func ResolveLatestValidatorTag(ctx context.Context, baseImage string) (string, bool) { + // parseImageRef refuses a registry that is not a bare host[:port], so no + // URL below is built from one that moves the request elsewhere. registry, repo, tag, ok := parseImageRef(baseImage) if !ok { return "", false } - // Operator pinned an explicit tag — respect it, no discovery. The + // Operator pinned an explicit tag: respect it, no discovery. The // caller's fallback path uses baseImage unchanged in this case. if tag != "" { return "", false @@ -76,9 +85,7 @@ func ResolveLatestValidatorTag(ctx context.Context, baseImage string) (string, b return fmt.Sprintf("%s/%s:%s", registry, repo, cached), true } - fetchCtx, cancel := context.WithTimeout(ctx, validatorTagFetchTimeout) - defer cancel() - tags, err := fetchValidatorTags(fetchCtx, registry, repo) + tags, err := fetchValidatorTags(ctx, registry, repo) if err != nil || len(tags) == 0 { return "", false } @@ -90,33 +97,67 @@ func ResolveLatestValidatorTag(ctx context.Context, baseImage string) (string, b return fmt.Sprintf("%s/%s:%s", registry, repo, best), true } -// parseImageRef splits "registry/repo:tag" or "registry/repo@digest" into -// its parts. The registry must contain a '.' or ':' to distinguish a -// real hostname from a Docker Hub library shorthand. Returns ok=false on -// any shape we don't recognize. -func parseImageRef(image string) (registry, repo, tag string, ok bool) { +// ImageRefIsPinned reports whether ref names a tag or a digest. An unpinned +// reference is pulled as :latest, which a registry need not have. +func ImageRefIsPinned(ref string) bool { + ref = strings.TrimSpace(ref) + if strings.Contains(ref, "@") { + return true + } + return strings.Contains(ref[strings.LastIndex(ref, "/")+1:], ":") +} + +// parseImageRef splits an image reference into its registry, repository and +// tag or digest, the way the container runtime reads it. A first component +// with a '.' or ':', or "localhost", is the registry; anything else is a +// Docker Hub name. In "repo:tag@digest" the digest pins the image and the tag +// is dropped, so it never leaks into a token scope. ok is false for a +// registry that is not a bare host[:port] and for an empty repository: every +// caller builds a URL from the result. +func parseImageRef(image string) (registry, repo, ref string, ok bool) { image = strings.TrimSpace(image) - slash := strings.Index(image, "/") - if slash <= 0 { + registry, rest := dockerHubRegistry, image + if head, tail, found := strings.Cut(image, "/"); found && + (strings.ContainsAny(head, ".:") || head == "localhost") { + registry, rest = head, tail + } + if host, _ := parseRegistryHostPort(registry); host == "" { return "", "", "", false } - head := image[:slash] - if !strings.ContainsAny(head, ".:") && head != "localhost" { + if name, digest, found := strings.Cut(rest, "@"); found { + rest, ref = name, digest + if i := strings.LastIndex(rest, ":"); i > strings.LastIndex(rest, "/") { + rest = rest[:i] + } + } else if i := strings.LastIndex(rest, ":"); i > strings.LastIndex(rest, "/") { + rest, ref = rest[:i], rest[i+1:] + } + if !isRepositoryPath(rest) { return "", "", "", false } - rest := image[slash+1:] - if at := strings.Index(rest, "@"); at != -1 { - return head, rest[:at], rest[at+1:], true + if registry == dockerHubRegistry && !strings.Contains(rest, "/") { + rest = "library/" + rest } - if colon := strings.LastIndex(rest, ":"); colon != -1 { - return head, rest[:colon], rest[colon+1:], true + return registry, rest, ref, true +} + +// isRepositoryPath reports whether s can be a repository path: non-empty +// '/'-separated components with nothing that would change a URL built from it. +func isRepositoryPath(s string) bool { + if s == "" { + return false + } + for _, part := range strings.Split(s, "/") { + if part == "" || !isBareRegistryHost(part) || strings.Contains(part, ":") { + return false + } } - return head, rest, "", true + return true } // fetchValidatorTags walks the OCI tag-list endpoint for a single repo. -// Handles the standard NGC bearer-token exchange: try anonymous, on 401 -// re-auth using credentials from ~/.docker/config.json or NGC_API_KEY. +// Handles the standard Bearer token exchange: try anonymous, then on 401 +// re-auth with the credential the run settled for the repository. func fetchValidatorTags(ctx context.Context, registry, repo string) ([]string, error) { tagsURL := fmt.Sprintf("https://%s/v2/%s/tags/list", registry, repo) body, err := fetchWithBearer(ctx, tagsURL, registry, repo) @@ -132,15 +173,21 @@ func fetchValidatorTags(ctx context.Context, registry, repo string) ([]string, e return doc.Tags, nil } -func fetchWithBearer(ctx context.Context, url, registry, repo string) ([]byte, error) { - client := &http.Client{Timeout: validatorTagFetchTimeout} +// fetchWithBearer GETs rawURL, anonymously first and, on a 401, with a Bearer +// token for the credential the run settled for repo. Its registry round trips +// share validatorTagFetchTimeout. Reading the credential does not spend it: a +// helper waiting on a keychain prompt or a pinentry is bounded on its own, and +// the NGC key it gives way to must still have time to reach the registry. +func fetchWithBearer(ctx context.Context, rawURL, registry, repo string) ([]byte, error) { + client := newRegistryHTTPClient(validatorTagFetchTimeout) + start := time.Now() + anonCtx, cancelAnon := context.WithTimeout(ctx, validatorTagFetchTimeout) + defer cancelAnon() // First attempt without auth so anonymous-pullable registries work. - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, err - } - resp, err := client.Do(req) + resp, err := doRegistryRequest(anonCtx, client, func() (*http.Request, error) { + return http.NewRequestWithContext(anonCtx, http.MethodGet, rawURL, nil) + }) if err != nil { return nil, err } @@ -152,21 +199,33 @@ func fetchWithBearer(ctx context.Context, url, registry, repo string) ([]byte, e resp.Body.Close() return nil, fmt.Errorf("registry returned %s", resp.Status) } + // Capture the auth challenge before closing. + wwwAuth := selectAuthChallenge(resp.Header.Values("Www-Authenticate")) resp.Body.Close() + spent := time.Since(start) - // Bearer-token exchange. Realm and scope come from the Www-Authenticate - // header; for NGC the realm is /proxy_auth and scope is repository::pull. - token, err := exchangeBearerToken(ctx, client, registry, repo) + // The read runs outside the budget; settling the scope, a registry round + // trip, runs inside it. + creds := registryCredentialsFrom(ctx) + creds.read(ctx, registry) + authCtx, cancelAuth := context.WithTimeout(ctx, validatorTagFetchTimeout-spent) + defer cancelAuth() + var credential *registryCredential + if settled := creds.lookupChallenged(authCtx, registry, repo, wwwAuth); settled.ok { + credential = &settled.cred + } + token, err := exchangeBearerToken(authCtx, client, registry, repo, wwwAuth, credential) if err != nil { return nil, err } - req, err = http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, err - } - req.Header.Set("Authorization", "Bearer "+token) - resp, err = client.Do(req) + resp, err = doRegistryRequest(authCtx, client, func() (*http.Request, error) { + req, err := http.NewRequestWithContext(authCtx, http.MethodGet, rawURL, nil) + if err == nil { + req.Header.Set("Authorization", "Bearer "+token) + } + return req, err + }) if err != nil { return nil, err } @@ -177,97 +236,395 @@ func fetchWithBearer(ctx context.Context, url, registry, repo string) ([]byte, e return io.ReadAll(resp.Body) } -// exchangeBearerToken does the NGC token exchange using credentials -// resolved from ~/.docker/config.json or NGC env vars. +// newRegistryHTTPClient is the one client every registry call uses, so none +// can follow a redirect to plain http with the operator's credentials. +func newRegistryHTTPClient(timeout time.Duration) *http.Client { + return &http.Client{Timeout: timeout, CheckRedirect: refuseInsecureRedirect} +} + +// errRefusedRedirect is a redirect the registry client would not follow. +type errRefusedRedirect struct{ msg string } + +func (e errRefusedRedirect) Error() string { return e.msg } + +// refuseInsecureRedirect rejects any redirect hop that is not https. // -// NGC-specific: this constructs the token URL using NGC's /proxy_auth -// realm rather than parsing the Www-Authenticate header from the 401 -// response (the generic OAuth2 distribution flow). Non-NGC registries -// (GCR, ECR, GHCR, Harbor, etc.) will return a non-200 here and the -// caller falls back to using the configured image reference as-is. -// Acceptable for v1 since the validator image only ships from NGC. -func exchangeBearerToken(ctx context.Context, client *http.Client, registry, repo string) (string, error) { - user, pass, ok := ngcCredentials(registry) - if !ok { - return "", fmt.Errorf("no NGC credentials for %s", registry) +// Go strips the Authorization header only when a redirect changes host, and +// that comparison ignores the scheme. Without this, an https realm that 302s to +// http on the same host would re-send the operator's credentials in cleartext, +// defeating the https-only realm check in exchangeBearerToken. +func refuseInsecureRedirect(req *http.Request, via []*http.Request) error { + if req.URL.Scheme != "https" { + return errRefusedRedirect{msg: fmt.Sprintf("refusing redirect to non-https URL %q", req.URL.Redacted())} + } + if len(via) >= 10 { + return errRefusedRedirect{msg: fmt.Sprintf("stopped after %d redirects", len(via))} + } + return nil +} + +// registryRetryAttempts and registryRetryBackoff bound the retries of a +// registry call that failed transiently: a connection error, a 5xx or a 429. +// Vars so tests need not wait out the backoff. +var ( + registryRetryAttempts = 3 + registryRetryBackoff = 500 * time.Millisecond +) + +// registryRetryAfterCap bounds how long a Retry-After header can hold a retry. +const registryRetryAfterCap = 5 * time.Second + +// doRegistryRequest sends the request build returns, retrying a transient +// failure while ctx leaves time for another attempt. A refused redirect is +// not retried. The last response or error is returned as is. +func doRegistryRequest( + ctx context.Context, client *http.Client, build func() (*http.Request, error), +) (*http.Response, error) { + for attempt := 1; ; attempt++ { + req, err := build() + if err != nil { + return nil, err + } + resp, err := client.Do(req) + var refused errRefusedRedirect + retry := err != nil && ctx.Err() == nil && !errors.As(err, &refused) + wait := time.Duration(attempt) * registryRetryBackoff + if err == nil && isTransientStatus(resp.StatusCode) { + retry = true + wait = max(wait, retryAfter(resp.Header.Get("Retry-After"))) + } + if !retry || attempt >= registryRetryAttempts || !allowsRetryAfter(ctx, wait) { + return resp, err + } + if resp != nil { + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10)) + resp.Body.Close() + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(wait): + } } - tokenURL := fmt.Sprintf("https://%s/proxy_auth?service=%s&scope=repository:%s:pull", registry, registry, repo) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, tokenURL, nil) - if err != nil { - return "", err +} + +// isTransientStatus reports a status a retry may clear. +func isTransientStatus(code int) bool { + return code == http.StatusTooManyRequests || code >= http.StatusInternalServerError +} + +// retryAfter reads a Retry-After header in seconds or as an HTTP date, +// capped at registryRetryAfterCap. +func retryAfter(v string) time.Duration { + v = strings.TrimSpace(v) + if v == "" { + return 0 + } + var d time.Duration + if secs, err := strconv.Atoi(v); err == nil { + d = time.Duration(secs) * time.Second + } else if at, err := http.ParseTime(v); err == nil { + d = time.Until(at) + } + return min(max(d, 0), registryRetryAfterCap) +} + +// allowsRetryAfter reports whether ctx allows waiting d and then trying once more. +func allowsRetryAfter(ctx context.Context, d time.Duration) bool { + deadline, ok := ctx.Deadline() + return !ok || time.Until(deadline) > d +} + +// tokenExchangeError is a token exchange that produced no token. +type tokenExchangeError struct { + // status is the token endpoint's HTTP status; 0 when none was read. + status int + // credentialed reports that a credential was sent. + credentialed bool + // refused marks an exchange the CLI declined before sending anything, + // for a realm it will not send credentials to. + refused bool + msg string +} + +func (e *tokenExchangeError) Error() string { return e.msg } + +// rejected reports that the registry refused a credential it was sent: +// a 401 or 403 from the token endpoint. Nothing else is evidence about the +// credential. +func (e *tokenExchangeError) rejected() bool { + return e.credentialed && (e.status == http.StatusUnauthorized || e.status == http.StatusForbidden) +} + +// exchangeBearerToken implements the OCI Distribution Spec Bearer token flow, +// parsing realm/service/scope from the WWW-Authenticate header. cred is the +// credential to send, or nil for an anonymous token. A challenge with no +// realm falls back to NGC's /proxy_auth, for nvcr.io only. Failures are a +// *tokenExchangeError. +func exchangeBearerToken( + ctx context.Context, client *http.Client, registry, repo, wwwAuthenticate string, cred *registryCredential, +) (string, error) { + realm, service, scope := parseWWWAuthenticate(wwwAuthenticate) + + if realm == "" { + if isNGCKeyRegistry(registry) { + return exchangeNGCBearerToken(ctx, client, registry, repo, cred) + } + return "", &tokenExchangeError{refused: true, msg: fmt.Sprintf("%s named no Bearer token realm", registry)} } - req.SetBasicAuth(user, pass) - resp, err := client.Do(req) + + u, err := url.Parse(realm) + // Reject non-HTTPS or relative realms before attaching credentials. + if err != nil || u.Scheme != "https" || u.Host == "" { + return "", &tokenExchangeError{refused: true, + msg: fmt.Sprintf("refusing token exchange at insecure or relative realm %q for %s", realm, registry)} + } + if !realmAuthorized(registry, u) { + return "", &tokenExchangeError{refused: true, + msg: fmt.Sprintf("refusing to send credentials to realm host %q; not authorized for registry %s", + u.Hostname(), registry)} + } + q := u.Query() + if service != "" { + q.Set("service", service) + } + // Use scope from the WWW-Authenticate header when present. + // When scope is empty and a repo is provided, synthesize the standard + // pull scope. When neither is present (credential probe, no specific + // repo needed), omit scope entirely: most registries issue a valid + // token and the absence of a resource scope avoids org-level 403s for + // non-existent repositories. + if scope == "" && repo != "" { + scope = "repository:" + repo + ":pull" + } + if scope != "" { + q.Set("scope", scope) + } + u.RawQuery = q.Encode() + return requestToken(ctx, client, u.String(), realm, cred) +} + +// realmAuthorized reports whether credentials for registry may go to the +// token realm u. The realm comes from a registry-controlled response header, +// so without this a registry could name realm="https://attacker.example/token" +// and receive the operator's credentials. Allowed: the registry's own host, +// a subdomain of it (auth.registry.example.com for registry.example.com), or +// a documented delegation in trustedRealmDelegations. NVIDIA hosts get no +// wider grant: nvcr.io issues its tokens itself. +func realmAuthorized(registry string, u *url.URL) bool { + realmHost := strings.ToLower(u.Hostname()) + // Reject an empty realm host explicitly. "https://:443/token" has a + // non-empty u.Host (":443"), but Hostname() is "", and an empty + // trustedRealmDelegations lookup would then compare equal. + if realmHost == "" { + return false + } + // Brackets off, to match u.Hostname(): "[fd00::1]" with no port fails + // SplitHostPort and would otherwise reject its own token server. + regHost := strings.ToLower(strings.TrimSuffix(strings.TrimPrefix(registry, "["), "]")) + if h, _, err := net.SplitHostPort(registry); err == nil { + regHost = strings.ToLower(h) + } + return realmHost == regHost || + strings.HasSuffix(realmHost, "."+regHost) || + trustedRealmDelegations[regHost] == realmHost +} + +// requestToken asks tokenURL for a Bearer token, sending cred as Basic auth +// when it is set. label names the endpoint in errors. +func requestToken( + ctx context.Context, client *http.Client, tokenURL, label string, cred *registryCredential, +) (string, error) { + resp, err := doRegistryRequest(ctx, client, func() (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, tokenURL, nil) + if err == nil && cred != nil { + req.SetBasicAuth(cred.user, cred.pass) + } + return req, err + }) if err != nil { - return "", err + var refused errRefusedRedirect + return "", &tokenExchangeError{ + credentialed: cred != nil, refused: errors.As(err, &refused), + msg: fmt.Sprintf("token exchange at %s: %v", label, err), + } } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("token exchange returned %s", resp.Status) + return "", &tokenExchangeError{ + status: resp.StatusCode, credentialed: cred != nil, + msg: fmt.Sprintf("token exchange at %s returned %s", label, resp.Status), + } } + // Both "token" (OCI spec) and "access_token" (Docker Hub variant) are valid. var doc struct { - Token string `json:"token"` + Token string `json:"token"` + AccessToken string `json:"access_token"` } - if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { - return "", err + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&doc); err != nil { + return "", &tokenExchangeError{credentialed: cred != nil, + msg: fmt.Sprintf("decode token response from %s: %v", label, err)} } - if doc.Token == "" { - return "", fmt.Errorf("empty token in response") + if tok := cmp.Or(doc.Token, doc.AccessToken); tok != "" { + return tok, nil } - return doc.Token, nil + return "", &tokenExchangeError{credentialed: cred != nil, msg: fmt.Sprintf("empty token in response from %s", label)} } -// ngcCredentials resolves (username, password) for an NGC-hosted registry. -// Checks ~/.docker/config.json first; falls back to NGC_API_KEY env vars -// with the literal "$oauthtoken" sentinel username NGC expects. -func ngcCredentials(registry string) (string, string, bool) { - if u, p, ok := credsFromDockerConfig(registry); ok { - return u, p, true - } - if key := firstNonEmptyEnv(ngcAPIKeyEnvNames...); key != "" { - return "$oauthtoken", key, true - } - return "", "", false +// exchangeNGCBearerToken is NGC's /proxy_auth token exchange, used when +// nvcr.io sends a challenge with no realm. It refuses every other registry, +// so the NGC key never reaches an unrelated /proxy_auth endpoint. +func exchangeNGCBearerToken( + ctx context.Context, client *http.Client, registry, repo string, cred *registryCredential, +) (string, error) { + if !isNGCKeyRegistry(registry) { + return "", &tokenExchangeError{refused: true, + msg: fmt.Sprintf("NGC token fallback not applicable for registry %s", registry)} + } + // url.Values omits scope entirely when repo is empty rather than sending + // scope= with an empty value. An empty scope validates the API key + // without org-scoped access checks. + q := url.Values{"service": {registry}} + if repo != "" { + q.Set("scope", "repository:"+repo+":pull") + } + return requestToken(ctx, client, "https://"+registry+"/proxy_auth?"+q.Encode(), "NGC /proxy_auth", cred) } -func credsFromDockerConfig(registry string) (string, string, bool) { - home, err := os.UserHomeDir() - if err != nil { - return "", "", false - } - body, err := os.ReadFile(filepath.Join(home, ".docker", "config.json")) - if err != nil { - return "", "", false - } - var doc struct { - Auths map[string]struct { - Auth string `json:"auth"` - Username string `json:"username"` - Password string `json:"password"` - } `json:"auths"` - } - if err := json.Unmarshal(body, &doc); err != nil { - return "", "", false - } - entry, ok := doc.Auths[registry] - if !ok { - return "", "", false - } - if entry.Username != "" && entry.Password != "" { - return entry.Username, entry.Password, true +// parseWWWAuthenticate extracts realm, service, and scope from a standard +// Bearer challenge header: +// +// Bearer realm="https://auth.example.com/token",service="reg.example.com",scope="repository:lib:pull" +// +// Returns empty strings when the header is absent, not a Bearer challenge, or +// cannot be parsed. The parser handles quoted values that might contain commas. +func parseWWWAuthenticate(header string) (realm, service, scope string) { + // Split scheme from parameters on the first whitespace. HTTP auth scheme + // names are case-insensitive (RFC 7235 s2.1), so compare with EqualFold. + idx := strings.IndexByte(header, ' ') + if idx < 0 || !strings.EqualFold(header[:idx], "Bearer") { + return + } + params := strings.TrimSpace(header[idx+1:]) + for len(params) > 0 { + // Find key= + eq := strings.IndexByte(params, '=') + if eq < 0 { + break + } + key := strings.TrimSpace(params[:eq]) + params = params[eq+1:] + + // Read value (quoted or unquoted) + var val string + if strings.HasPrefix(params, `"`) { + end := strings.IndexByte(params[1:], '"') + if end < 0 { + break + } + val = params[1 : end+1] + params = strings.TrimPrefix(strings.TrimSpace(params[end+2:]), ",") + } else { + comma := strings.IndexByte(params, ',') + if comma < 0 { + val = strings.TrimSpace(params) + params = "" + } else { + val = strings.TrimSpace(params[:comma]) + params = params[comma+1:] + } + } + + switch strings.ToLower(key) { + case "realm": + realm = val + case "service": + service = val + case "scope": + scope = val + } } - if entry.Auth != "" { - raw, err := base64.StdEncoding.DecodeString(entry.Auth) - if err != nil { - return "", "", false + return +} + +// trustedRealmDelegations maps a registry host to its authorized token host +// when the registry uses a separate host for token exchange. Only add entries +// here for registries with publicly documented auth architectures; this list +// extends the fail-closed realm validation and must not grow without a clear +// trust basis. +var trustedRealmDelegations = map[string]string{ + // Docker Hub documents this split explicitly: the pull host and the auth + // host are distinct (docs.docker.com/registry/spec/auth/token/). + "registry-1.docker.io": "auth.docker.io", + "docker.io": "auth.docker.io", +} + +// ngcApprovedHosts is the set of exact hostnames (without port) that are +// considered NGC-hosted. Dot-prefixed entries match any subdomain. +var ngcApprovedHosts = []string{ + "nvcr.io", + ".nvcr.io", + "nvidia.com", + ".nvidia.com", + "ngc.nvidia", + ".ngc.nvidia", +} + +// isBareRegistryHost reports whether s is a plain host[:port], with nothing in +// it that could move the request somewhere else once concatenated into a URL. +// +// This matters because the registry string is interpolated directly into +// "https://" + registry + "/...". A value like "evil.com/x.nvcr.io" ends with a +// trusted suffix but parses to host evil.com, so a suffix check alone would +// authorize sending credentials to an attacker. IPv6 literals keep their +// brackets and are allowed. +func isBareRegistryHost(s string) bool { + if s == "" { + return false + } + // "/" and "@" move the host; "?" and "#" truncate it; whitespace and + // control characters have no place in a hostname. + if strings.ContainsAny(s, "/@?#\\") { + return false + } + for _, r := range s { + if r <= ' ' || r == 0x7f { + return false } - colon := strings.IndexByte(string(raw), ':') - if colon == -1 { - return "", "", false + } + return true +} + +// isNGCRegistry returns true when the registry host belongs to an NVIDIA / NGC +// domain. The check strips any port from the registry string before matching +// so that nvcr.io:443 is handled correctly, and uses dot-boundary matching to +// reject deceptive suffixes such as evilnvcr.io or nvidia.com.invalid. +func isNGCRegistry(registry string) bool { + // Never treat a value we cannot safely place in a URL as an NGC host: the + // caller uses this to decide whether to forward the NGC API key. + if !isBareRegistryHost(registry) { + return false + } + host := registry + // Strip port if present (e.g. nvcr.io:5000 -> nvcr.io). + if h, _, err := net.SplitHostPort(registry); err == nil { + host = h + } + host = strings.ToLower(host) + for _, approved := range ngcApprovedHosts { + if strings.HasPrefix(approved, ".") { + // Subdomain match: host must end with ".suffix" or equal "suffix". + suffix := approved[1:] // strip the leading dot + if host == suffix || strings.HasSuffix(host, approved) { + return true + } + } else { + if host == approved { + return true + } } - return string(raw[:colon]), string(raw[colon+1:]), true } - return "", "", false + return false } // pickBestValidatorTag filters to recognized validator tags and returns diff --git a/src/clis/nvcf-cli/internal/selfhosted/validatortag_test.go b/src/clis/nvcf-cli/internal/selfhosted/validatortag_test.go index 0bd57306d2..2123f777c7 100644 --- a/src/clis/nvcf-cli/internal/selfhosted/validatortag_test.go +++ b/src/clis/nvcf-cli/internal/selfhosted/validatortag_test.go @@ -19,9 +19,16 @@ package selfhosted import ( "context" + "encoding/base64" "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" "os" "path/filepath" + "strings" + "sync" "testing" "time" @@ -31,20 +38,29 @@ import ( func TestParseImageRef(t *testing.T) { cases := []struct { - name string - in string - reg string - repo string - tag string - wantOK bool + name string + in string + reg string + repo string + tag string + wantOK bool }{ - {"full tag", "stg.nvcr.io/nvidia/nvcf-byoc/cluster-validator:3.0.0-rc.26", "stg.nvcr.io", "nvidia/nvcf-byoc/cluster-validator", "3.0.0-rc.26", true}, + {"full tag", "example.nvcr.io/nvidia/nvcf-byoc/cluster-validator:3.0.0-rc.26", "example.nvcr.io", "nvidia/nvcf-byoc/cluster-validator", "3.0.0-rc.26", true}, {"digest", "nvcr.io/foo/bar@sha256:abc", "nvcr.io", "foo/bar", "sha256:abc", true}, - {"no tag", "stg.nvcr.io/foo/bar", "stg.nvcr.io", "foo/bar", "", true}, + {"no tag", "example.nvcr.io/foo/bar", "example.nvcr.io", "foo/bar", "", true}, {"localhost with port", "localhost:5000/foo:latest", "localhost:5000", "foo", "latest", true}, - {"docker hub shorthand", "foo/bar:latest", "", "", "", false}, + {"docker hub shorthand", "foo/bar:latest", "registry-1.docker.io", "foo/bar", "latest", true}, + {"docker hub library image", "busybox:1.36", "registry-1.docker.io", "library/busybox", "1.36", true}, + {"tag and digest", "nvcr.io/nvidia/cv:1.2.0@sha256:abc", "nvcr.io", "nvidia/cv", "sha256:abc", true}, + {"port and tag and digest", "reg.example.com:5000/a/b:1@sha256:abc", "reg.example.com:5000", "a/b", + "sha256:abc", true}, {"empty", "", "", "", "", false}, - {"only host", "stg.nvcr.io", "", "", "", false}, + {"dotted single name is a Docker Hub name", "example.nvcr.io", "registry-1.docker.io", "library/example.nvcr.io", "", true}, + {"only a registry", "example.nvcr.io/", "", "", "", false}, + {"userinfo moves the host", "nvcr.io@attacker.example/nvidia/cv", "", "", "", false}, + {"fragment moves the host", "attacker.example#.nvcr.io/nvidia/cv", "", "", "", false}, + {"bad port", "nvcr.io:44x/nvidia/cv", "", "", "", false}, + {"query in the repository", "nvcr.io/nvidia/cv?x=1", "", "", "", false}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -64,8 +80,8 @@ func TestPickBestValidatorTag_StablePreferred(t *testing.T) { tags := []string{ "3.0.0-rc.11", "3.0.0-rc.26", - "3.0.0", // stable; should win over any rc - "3.1.0-rc.1", // higher major but pre-release: must lose to 3.0.0 + "3.0.0", // stable; should win over any rc + "3.1.0-rc.1", // higher major but pre-release: must lose to 3.0.0 "sha256-abc.sig", "3.0.0-v50ca53a0", // commit-SHA: filtered out by pattern } @@ -118,7 +134,7 @@ func TestResolveLatestValidatorTag_HonorsPinnedTag(t *testing.T) { // Pre-populate cache to prove discovery is short-circuited regardless. // If the function ever consulted the cache for a tagged input, this // test would fail by returning the cached-substituted value. - const baseImage = "stg.nvcr.io/nvidia/nvcf-byoc/cluster-validator:3.0.0-rc.5" + const baseImage = "example.nvcr.io/nvidia/nvcf-byoc/cluster-validator:3.0.0-rc.5" require.NoError(t, writeValidatorTagCache(baseImage, "3.0.0-rc.99")) got, ok := ResolveLatestValidatorTag(context.Background(), baseImage) @@ -131,12 +147,12 @@ func TestResolveLatestValidatorTag_TaglessTriggersDiscoveryFromCache(t *testing. // Mirror of the test above for the tagless case: discovery (here // from cache) should run and substitute the resolved tag. withTempCacheDir(t) - const baseImage = "stg.nvcr.io/nvidia/nvcf-byoc/cluster-validator" + const baseImage = "example.nvcr.io/nvidia/nvcf-byoc/cluster-validator" require.NoError(t, writeValidatorTagCache(baseImage, "3.0.0-rc.99")) got, ok := ResolveLatestValidatorTag(context.Background(), baseImage) require.True(t, ok) - assert.Equal(t, "stg.nvcr.io/nvidia/nvcf-byoc/cluster-validator:3.0.0-rc.99", got) + assert.Equal(t, "example.nvcr.io/nvidia/nvcf-byoc/cluster-validator:3.0.0-rc.99", got) } // withTempCacheDir redirects HOME so the cache lives in a temp dir for the @@ -151,7 +167,7 @@ func withTempCacheDir(t *testing.T) { func TestValidatorTagCache_RoundTrip(t *testing.T) { withTempCacheDir(t) - const baseImage = "stg.nvcr.io/nvidia/nvcf-byoc/cluster-validator:3.0.0-rc.26" + const baseImage = "example.nvcr.io/nvidia/nvcf-byoc/cluster-validator:3.0.0-rc.26" _, ok := readValidatorTagCache(baseImage) assert.False(t, ok, "empty cache must miss") @@ -166,7 +182,7 @@ func TestValidatorTagCache_RoundTrip(t *testing.T) { func TestValidatorTagCache_TTLExpiry(t *testing.T) { withTempCacheDir(t) - const baseImage = "stg.nvcr.io/nvidia/nvcf-byoc/cluster-validator:3.0.0-rc.26" + const baseImage = "example.nvcr.io/nvidia/nvcf-byoc/cluster-validator:3.0.0-rc.26" require.NoError(t, writeValidatorTagCache(baseImage, "3.0.0-rc.99")) // Rewrite cache file with a stale fetched_at timestamp. @@ -189,7 +205,7 @@ func TestValidatorTagCache_TTLExpiry(t *testing.T) { func TestValidatorTagCache_PerImageKeying(t *testing.T) { withTempCacheDir(t) - const imgA = "stg.nvcr.io/nvidia/nvcf-byoc/cluster-validator:3.0.0-rc.26" + const imgA = "example.nvcr.io/nvidia/nvcf-byoc/cluster-validator:3.0.0-rc.26" const imgB = "nvcr.io/nvidia/other/cluster-validator:1.0.0" require.NoError(t, writeValidatorTagCache(imgA, "3.0.0-rc.99")) @@ -221,7 +237,7 @@ func TestCredsFromDockerConfig(t *testing.T) { cfg := map[string]any{ "auths": map[string]any{ - "stg.nvcr.io": map[string]any{ + "example.nvcr.io": map[string]any{ // base64("$oauthtoken:fake-key") "auth": "JG9hdXRodG9rZW46ZmFrZS1rZXk=", }, @@ -230,25 +246,465 @@ func TestCredsFromDockerConfig(t *testing.T) { body, _ := json.MarshalIndent(cfg, "", " ") require.NoError(t, os.WriteFile(filepath.Join(dockerDir, "config.json"), body, 0o600)) - user, pass, ok := credsFromDockerConfig("stg.nvcr.io") + user, pass, ok := credsFor(t, "example.nvcr.io") require.True(t, ok) assert.Equal(t, "$oauthtoken", user) assert.Equal(t, "fake-key", pass) - _, _, ok = credsFromDockerConfig("does-not-exist.example.com") + _, _, ok = credsFor(t, "does-not-exist.example.com") assert.False(t, ok, "unknown registry must miss") } func TestNGCCredentials_EnvFallback(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", dir) // no docker config in this HOME - for _, n := range ngcAPIKeyEnvNames { - t.Setenv(n, "") - } t.Setenv("NGC_API_KEY", "from-env") - user, pass, ok := ngcCredentials("stg.nvcr.io") + for _, prefer := range []bool{true, false} { + cred, ok, err := readCredential(context.Background(), NewRegistryCredentials(prefer), "nvcr.io") + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, "$oauthtoken", cred.user) + assert.Equal(t, "from-env", cred.pass) + } +} + +// NGC_API_KEY is for nvcr.io, the one registry up mints its pull secrets for. +// Other NVIDIA registries, staging NGC and internal ones, take their own +// docker login, and the key is never sent to them. +func TestCredentialsForRegistry_NGCKeyOnlyForNvcrIO(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + t.Setenv("NGC_API_KEY", "ngc-key") + login := base64.StdEncoding.EncodeToString([]byte("robot:own-login")) + auths := map[string]any{} + for _, host := range []string{"nvcr.io", "example.nvcr.io", "registry.example.nvidia.com:5000"} { + auths[host] = map[string]string{"auth": login} + } + cfg, err := json.Marshal(map[string]any{"auths": auths}) + require.NoError(t, err) + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".docker"), 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".docker", "config.json"), cfg, 0o600)) + + for host, wantPass := range map[string]string{ + "nvcr.io": "ngc-key", + "NVCR.IO:443": "ngc-key", + "example.nvcr.io": "own-login", + "registry.example.nvidia.com:5000": "own-login", + } { + cred, ok, _ := readCredential(context.Background(), NewRegistryCredentials(true), host) + require.True(t, ok, host) + assert.Equal(t, wantPass, cred.pass, host) + } +} + +// -- parseWWWAuthenticate -- + +func TestParseWWWAuthenticate_Standard(t *testing.T) { + header := `Bearer realm="https://auth.docker.io/token",service="registry-1.docker.io",scope="repository:library/ubuntu:pull"` + realm, service, scope := parseWWWAuthenticate(header) + assert.Equal(t, "https://auth.docker.io/token", realm) + assert.Equal(t, "registry-1.docker.io", service) + assert.Equal(t, "repository:library/ubuntu:pull", scope) +} + +func TestParseWWWAuthenticate_GHCR(t *testing.T) { + header := `Bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:owner/image:pull"` + realm, service, scope := parseWWWAuthenticate(header) + assert.Equal(t, "https://ghcr.io/token", realm) + assert.Equal(t, "ghcr.io", service) + assert.Equal(t, "repository:owner/image:pull", scope) +} + +func TestParseWWWAuthenticate_NoBearer(t *testing.T) { + // Basic auth challenge - should return empty strings. + realm, service, scope := parseWWWAuthenticate(`Basic realm="My Registry"`) + assert.Empty(t, realm) + assert.Empty(t, service) + assert.Empty(t, scope) +} + +func TestParseWWWAuthenticate_Empty(t *testing.T) { + realm, service, scope := parseWWWAuthenticate("") + assert.Empty(t, realm) + assert.Empty(t, service) + assert.Empty(t, scope) +} + +func TestParseWWWAuthenticate_RealmOnly(t *testing.T) { + // Some registries omit service/scope in the initial challenge. + realm, service, scope := parseWWWAuthenticate(`Bearer realm="https://example.com/auth"`) + assert.Equal(t, "https://example.com/auth", realm) + assert.Empty(t, service) + assert.Empty(t, scope) +} + +func TestParseWWWAuthenticate_MixedCaseParams(t *testing.T) { + // Auth parameter names are parsed with mixed case in the wild. + realm, service, scope := parseWWWAuthenticate(`Bearer Realm="https://auth.example.com/token",Service="reg.example.com",Scope="repository:foo:pull"`) + assert.Equal(t, "https://auth.example.com/token", realm) + assert.Equal(t, "reg.example.com", service) + assert.Equal(t, "repository:foo:pull", scope) +} + +func TestParseWWWAuthenticate_CaseInsensitiveBearer(t *testing.T) { + // HTTP auth scheme names are case-insensitive (RFC 7235 s2.1). + for _, header := range []string{ + `bearer realm="https://auth.example.com/token",service="reg.example.com"`, + `BEARER realm="https://auth.example.com/token",service="reg.example.com"`, + `Bearer realm="https://auth.example.com/token",service="reg.example.com"`, + } { + realm, service, _ := parseWWWAuthenticate(header) + assert.Equal(t, "https://auth.example.com/token", realm, "header: %s", header) + assert.Equal(t, "reg.example.com", service, "header: %s", header) + } +} + +// -- isNGCRegistry -- + +func TestIsNGCRegistry(t *testing.T) { + // Valid NGC registries. + assert.True(t, isNGCRegistry("nvcr.io")) + assert.True(t, isNGCRegistry("example.nvcr.io")) + assert.True(t, isNGCRegistry("registry.example.nvidia.com")) + assert.True(t, isNGCRegistry("nvcr.io:443"), "port must be stripped before matching") + + // Non-NGC registries must be rejected. + assert.False(t, isNGCRegistry("ghcr.io")) + assert.False(t, isNGCRegistry("quay.io")) + assert.False(t, isNGCRegistry("harbor.company.internal")) + + // Deceptive suffixes must be rejected. + assert.False(t, isNGCRegistry("evilnvcr.io"), "suffix match without dot boundary must be rejected") + assert.False(t, isNGCRegistry("nvidia.com.invalid"), "deceptive TLD must be rejected") + assert.False(t, isNGCRegistry("fakenvidia.com"), "partial host match must be rejected") +} + +// -- exchangeBearerToken realm host authorization -- + +// recordingTransport records every request it sees and reports whether any +// carried an Authorization header, so a test can assert that no credential +// escaped rather than only that an error was returned. +type recordingTransport struct { + mu sync.Mutex + requests []*http.Request + withAuth []string + inner http.RoundTripper +} + +func (rt *recordingTransport) RoundTrip(r *http.Request) (*http.Response, error) { + rt.mu.Lock() + rt.requests = append(rt.requests, r) + if r.Header.Get("Authorization") != "" { + rt.withAuth = append(rt.withAuth, r.URL.String()) + } + rt.mu.Unlock() + if rt.inner == nil { + return nil, fmt.Errorf("no inner transport") + } + return rt.inner.RoundTrip(r) +} + +func TestExchangeBearerToken_RejectsAttackerRealm(t *testing.T) { + // A malicious registry returns a realm on an attacker-controlled host. The + // realm check must reject it, and crucially must do so before any request + // carrying the operator's credentials leaves the process. + // + // Credentials have to be configured for the assertion to mean anything: if + // none were present, no request could carry an Authorization header whether + // the control works or not. NGC_API_KEY is not one: it only applies to NGC + // registries, so it left this assertion vacuous. + dockerHome(t, inlineDockerConfig(t, "harbor.company.internal", "u", "p", "")) + + rec := &recordingTransport{inner: http.DefaultTransport} + client := &http.Client{Transport: rec} + + const wwwAuth = `Bearer realm="https://attacker.example.com/token",service="harbor.company.internal"` + _, err := exchangeBearerToken(context.Background(), client, + "harbor.company.internal", "myrepo/image", wwwAuth, ®istryCredential{user: "u", pass: "p"}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "not authorized for registry") + assert.Empty(t, rec.withAuth, + "no request carrying an Authorization header may be issued once the realm is rejected") + for _, r := range rec.requests { + assert.NotContains(t, r.URL.Host, "attacker", + "no request at all may reach the attacker host") + } +} + +// An empty realm host must fail closed. "https://:443/token" has a non-empty +// u.Host (":443") so it clears the relative-realm guard, but Hostname() is "", +// and an empty trustedRealmDelegations lookup would compare equal to it. +func TestExchangeBearerToken_RejectsEmptyRealmHost(t *testing.T) { + dockerHome(t, inlineDockerConfig(t, "harbor.company.internal", "u", "p", "")) + rec := &recordingTransport{inner: http.DefaultTransport} + client := &http.Client{Transport: rec} + + _, err := exchangeBearerToken(context.Background(), client, + "harbor.company.internal", "myrepo/image", `Bearer realm="https://:443/token"`, + ®istryCredential{user: "u", pass: "p"}) + + require.Error(t, err) + assert.Empty(t, rec.withAuth, "an empty realm host must not receive credentials") +} + +// -- exchangeNGCBearerToken -- + +// spyTransport is an http.RoundTripper that fails the test if called. +type spyTransport struct{ t *testing.T } + +func (s *spyTransport) RoundTrip(_ *http.Request) (*http.Response, error) { + s.t.Fatal("HTTP request must not be issued for non-NGC registry") + return nil, nil +} + +// roundTripperFunc adapts a function to the http.RoundTripper interface. +type roundTripperFunc func(*http.Request) (*http.Response, error) + +func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestExchangeBearerToken_DockerHubDelegatedRealm(t *testing.T) { + // Docker Hub uses registry-1.docker.io as the pull host and auth.docker.io + // for token exchange. The realm check must authorize that delegation, so + // drive exchangeBearerToken and assert the request actually reached the + // auth host rather than comparing the delegation map against itself. + t.Setenv("NGC_API_KEY", "test-key") + + rec := &recordingTransport{inner: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Host != "auth.docker.io" { + return nil, fmt.Errorf("unexpected host %s", r.URL.Host) + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"token":"dockerhub-token"}`)), + Header: make(http.Header), + }, nil + })} + client := &http.Client{Transport: rec} + + const wwwAuth = `Bearer realm="https://auth.docker.io/token",service="registry.docker.io",` + + `scope="repository:library/ubuntu:pull"` + // The credential comes from the run's lookup, which must not hand the NGC + // key to Docker Hub. + cred, ok, _ := readCredential(context.Background(), NewRegistryCredentials(true), "registry-1.docker.io") + var sent *registryCredential + if ok { + sent = &cred + } + tok, err := exchangeBearerToken(context.Background(), client, + "registry-1.docker.io", "library/ubuntu", wwwAuth, sent) + + require.NoError(t, err, "the documented Docker Hub delegation must be authorized") + assert.Equal(t, "dockerhub-token", tok) + require.NotEmpty(t, rec.requests, "the token exchange must actually be issued") + assert.Equal(t, "auth.docker.io", rec.requests[0].URL.Host) + assert.Empty(t, rec.withAuth, "the NGC key must not reach Docker Hub's token service") +} + +func TestExchangeNGCBearerToken_RejectsNonNGCRegistry(t *testing.T) { + // A non-NGC registry must be rejected before any HTTP request is made, + // even when NGC credentials are configured. The spy transport fails the + // test immediately if RoundTrip is called, ensuring the isNGCRegistry + // guard fires before any network activity. + t.Setenv("NGC_API_KEY", "test-key") // configure a credential so a missing guard would reach the transport + client := &http.Client{Transport: &spyTransport{t: t}} + for _, reg := range []string{"harbor.company.internal", "example.nvcr.io", "nvcr.io:443@attacker.example"} { + _, err := exchangeNGCBearerToken(context.Background(), client, reg, "myrepo/image", + ®istryCredential{user: "$oauthtoken", pass: "test-key", ngcKey: true}) + require.Error(t, err, "%s must be rejected without issuing a request", reg) + assert.Contains(t, err.Error(), "not applicable") + } +} + +// The registry string is concatenated into "https://" + registry + "/...", so a +// suffix match alone is not enough to authorize forwarding the NGC API key: +// "evil.com/x.nvcr.io" ends with a trusted suffix but parses to host evil.com. +func TestIsNGCRegistry_RejectsHostConfusion(t *testing.T) { + for _, tc := range []struct { + registry string + want bool + why string + }{ + {"nvcr.io", true, "the canonical host"}, + {"example.nvcr.io", true, "a real subdomain"}, + {"evil.com/x.nvcr.io", false, "path component: the request host is evil.com"}, + {"evil.com@nvcr.io", false, "userinfo: not a bare host"}, + {"nvcr.io?x=.nvcr.io", false, "query truncates the host"}, + {"nvcr.io#.nvcr.io", false, "fragment truncates the host"}, + {"nvcr.io.evil.com", false, "deceptive suffix"}, + {"nvcr.io evil.com", false, "whitespace"}, + } { + assert.Equal(t, tc.want, isNGCRegistry(tc.registry), + "isNGCRegistry(%q): %s", tc.registry, tc.why) + } +} + +// A non-bare registry must never reach the NGC token exchange, which attaches +// the API key with basic auth. +func TestExchangeNGCBearerToken_RejectsHostConfusion(t *testing.T) { + t.Setenv("NGC_API_KEY", "test-key") + rec := &recordingTransport{inner: http.DefaultTransport} + client := &http.Client{Transport: rec} + + _, err := exchangeNGCBearerToken(context.Background(), client, "evil.com/x.nvcr.io", "repo/img", + ®istryCredential{user: "$oauthtoken", pass: "test-key", ngcKey: true}) + + require.Error(t, err) + assert.Empty(t, rec.withAuth, "the NGC API key must not be sent to a confused host") + assert.Empty(t, rec.requests, "no request may be issued at all") +} + +// A bracketed IPv6 registry on the default port must accept its own token +// server: u.Hostname() drops the brackets, so the registry side must too. +func TestExchangeBearerToken_AcceptsBracketedIPv6Registry(t *testing.T) { + dockerHome(t, inlineDockerConfig(t, "[fd00::1]", "u", "p", "")) + rec := &recordingTransport{inner: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"token":"t"}`)), + Header: make(http.Header), + }, nil + })} + tok, err := exchangeBearerToken(context.Background(), &http.Client{Transport: rec}, + "[fd00::1]", "repo", `Bearer realm="https://[fd00::1]/token",service="r"`, + ®istryCredential{user: "u", pass: "p"}) + require.NoError(t, err) + assert.Equal(t, "t", tok) +} + +// A redirect may not downgrade to cleartext: Go keeps the Authorization header +// across a same-host redirect whatever the scheme. +func TestRefuseInsecureRedirect(t *testing.T) { + plain, err := http.NewRequest(http.MethodGet, "http://reg.example.com/token", nil) + require.NoError(t, err) + assert.Error(t, refuseInsecureRedirect(plain, nil)) + secure, err := http.NewRequest(http.MethodGet, "https://reg.example.com/token", nil) + require.NoError(t, err) + assert.NoError(t, refuseInsecureRedirect(secure, nil)) +} + +// nvcr.io issues its own tokens, so an NVIDIA-family realm that is neither the +// registry's host nor a subdomain of it is refused like any other: the NGC key +// and an internal login go nowhere but their own registry. +func TestExchangeBearerToken_NoNVIDIAWideRealmGrant(t *testing.T) { + rec := &recordingTransport{inner: http.DefaultTransport} + client := &http.Client{Transport: rec} + for _, tc := range []struct{ registry, realm string }{ + {"nvcr.io", "https://authn.nvidia.com/token"}, + {"example.nvcr.io", "https://nvcr.io/proxy_auth"}, + {"nvcr.io", "https://x.ngc.nvidia/token"}, + {"registry.example.nvidia.com:5000", "https://nvcr.io/proxy_auth"}, + } { + _, err := exchangeBearerToken(context.Background(), client, tc.registry, "repo", + `Bearer realm="`+tc.realm+`"`, ®istryCredential{user: "$oauthtoken", pass: "k"}) + var te *tokenExchangeError + require.ErrorAs(t, err, &te, tc.realm) + assert.True(t, te.refused, tc.realm) + } + assert.Empty(t, rec.requests, "no request may leave for a refused realm") +} + +// Every registry client refuses a redirect to plain http, and an https realm +// that redirects there gets no request on the second hop, with or without +// the Authorization header. +func TestRegistryClient_RefusesCleartextRedirect(t *testing.T) { + require.NotNil(t, newRegistryHTTPClient(time.Second).CheckRedirect) + + var plainHits []string + plain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + plainHits = append(plainHits, r.Header.Get("Authorization")) + w.WriteHeader(http.StatusOK) + })) + defer plain.Close() + realm := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, plain.URL+"/token", http.StatusFound) + })) + defer realm.Close() + client := newRegistryHTTPClient(5 * time.Second) + client.Transport = realm.Client().Transport + + host := strings.TrimPrefix(realm.URL, "https://") + _, err := exchangeBearerToken(context.Background(), client, host, "repo", + `Bearer realm="`+realm.URL+`/token"`, ®istryCredential{user: "u", pass: "p"}) + var te *tokenExchangeError + require.ErrorAs(t, err, &te) + assert.True(t, te.refused, "a refused redirect is a refused realm, not a rejected credential") + assert.Empty(t, plainHits, "the cleartext hop must not be requested") +} + +// A realm on plain http is refused before anything is sent. +func TestExchangeBearerToken_RefusesHTTPRealm(t *testing.T) { + rec := &recordingTransport{inner: http.DefaultTransport} + _, err := exchangeBearerToken(context.Background(), &http.Client{Transport: rec}, "harbor.example.com", "repo", + `Bearer realm="http://harbor.example.com/token"`, ®istryCredential{user: "u", pass: "p"}) + var te *tokenExchangeError + require.ErrorAs(t, err, &te) + assert.True(t, te.refused) + assert.Empty(t, rec.requests) +} + +// isNGCKeyRegistry accepts nvcr.io alone, on any port, and nothing that +// moves the host. +func TestIsNGCKeyRegistry(t *testing.T) { + for reg, want := range map[string]bool{ + "nvcr.io": true, "NVCR.io:443": true, "nvcr.io:5000": true, + "example.nvcr.io": false, "nvcr.io:443@attacker.example": false, "attacker.example/nvcr.io": false, + "nvcr.io#.attacker.example": false, "nvcr.io.attacker.example": false, + } { + assert.Equal(t, want, isNGCKeyRegistry(reg), reg) + } +} + +// Tag discovery never sends a request for an image whose registry is not a +// bare host, and so never writes the tag cache from such a reply. +func TestResolveLatestValidatorTag_NoRequestForAHostMovingRef(t *testing.T) { + withTempCacheDir(t) + rec := &recordingTransport{} + prev := http.DefaultTransport + http.DefaultTransport = rec + t.Cleanup(func() { http.DefaultTransport = prev }) + for _, ref := range []string{"nvcr.io@attacker.example/nvidia/cv", "attacker.example#.nvcr.io/nvidia/cv"} { + _, ok := ResolveLatestValidatorTag(context.Background(), ref) + assert.False(t, ok, ref) + } + assert.Empty(t, rec.requests) +} + +// Tag discovery moves to the NGC key when nvcr.io rejects the docker login, +// as the credential row does. +func TestResolveLatestValidatorTag_RejectedLoginGivesWayToTheNGCKey(t *testing.T) { + withTempCacheDir(t) + ngc := newFakeNGC(t, "good-key") + dockerHome(t, inlineDockerConfig(t, "nvcr.io", "$oauthtoken", "rotated-out", "")) + t.Setenv("NGC_API_KEY", "good-key") + ctx := WithRegistryCredentials(context.Background(), NewRegistryCredentials(false)) + got, ok := ResolveLatestValidatorTag(ctx, "nvcr.io/nvidia/cv") require.True(t, ok) - assert.Equal(t, "$oauthtoken", user) - assert.Equal(t, "from-env", pass) + assert.Equal(t, "nvcr.io/nvidia/cv:1.2.0", got) + assert.Equal(t, []string{"rotated-out", "good-key"}, ngc.passwords()) +} + +// A credential helper that outlasts the round trips' budget does not spend +// it: once the helper's own bound ends, the NGC key it gives way to still has +// the whole budget to reach the registry. +func TestResolveLatestValidatorTag_SlowHelperDoesNotSpendTheBudget(t *testing.T) { + withTempCacheDir(t) + ngc := newFakeNGC(t, "good-key") + dockerHome(t, `{"credsStore":"hang"}`) + bin := t.TempDir() + hang := filepath.Join(bin, "docker-credential-hang") + require.NoError(t, os.WriteFile(hang, []byte("#!/bin/sh\nsleep 30\n"), 0o755)) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("NGC_API_KEY", "good-key") + credentialHelperTimeout = time.Second + prev := validatorTagFetchTimeout + validatorTagFetchTimeout = 500 * time.Millisecond + t.Cleanup(func() { validatorTagFetchTimeout = prev }) + + ctx := WithRegistryCredentials(context.Background(), NewRegistryCredentials(false)) + got, ok := ResolveLatestValidatorTag(ctx, "nvcr.io/nvidia/cv") + require.True(t, ok) + assert.Equal(t, "nvcr.io/nvidia/cv:1.2.0", got) + assert.Equal(t, []string{"good-key"}, ngc.passwords()) }