-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcloud-migration-pingcli.sh
More file actions
executable file
·1167 lines (1016 loc) · 46.8 KB
/
Copy pathcloud-migration-pingcli.sh
File metadata and controls
executable file
·1167 lines (1016 loc) · 46.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env bash
# This script relies on bash-only features (indirect variable expansion,
# arrays, [[ ]], process substitution, etc.) and will not run correctly under
# a POSIX shell — including bash itself when invoked as `sh`/`/bin/sh`, which
# on macOS is bash running in POSIX-compatibility mode (BASH_VERSION is still
# set there, but process substitution and other extensions are disabled) and
# on most Linux distros is dash or BusyBox ash (no BASH_VERSION at all). Fail
# fast with a clear message instead of hitting confusing syntax errors deeper
# in the script. This check is intentionally written in plain POSIX sh syntax
# (no [[ ]], no `local`) so it runs correctly under every shell it needs to
# detect, on macOS, Linux, and BusyBox-based containers alike.
if [ -z "${BASH_VERSION:-}" ]; then
echo "ERROR: this script must be run with bash, not sh/dash/ash. Try: bash $0" >&2
exit 1
fi
if shopt -q -o posix 2>/dev/null; then
echo "ERROR: bash is running in POSIX mode (invoked as 'sh' or '/bin/sh'), which disables features this script needs. Try: bash $0" >&2
exit 1
fi
set -uo pipefail
# ─────────────────────────────────────────────────────────────────────────────
# PingOne pingcli Migration Script
#
# Generated by pingmigrate. This script applies the JSON payloads under the
# sibling "pingcli/" directory (produced by the "Download pingcli Zip" button
# on the Migration tab) to a target PingOne environment via `pingcli`.
#
# Requires: pingcli (authenticated: `pingcli pingone auth login`), jq
#
# Compatibility note: this script avoids bash 4+ associative arrays
# (`declare -A`) and `mapfile` because macOS ships bash 3.2 by default. ID
# lookups use dynamically-named variables (`printf -v`) with indirect
# expansion (`${!varname}`); environment/profile pickers build plain indexed
# arrays with `while read` loops — all of which work on bash 3.2+.
#
# Error handling note: this script intentionally does NOT use `set -e`.
# Each pingcli call is checked explicitly so a single resource failure
# prints the full JSON error response and skips to the next resource,
# instead of the whole run dying silently on the first failure (which is
# what happens under `set -e` + `pipefail` when a command inside a pipe
# returns non-zero — the pipeline result never gets echoed anywhere).
# Failures are counted; the script exits non-zero at the end if any occurred.
#
# Usage:
# ./cloud-migration-pingcli.sh --env-id <id> | --env-name <name>
# ./cloud-migration-pingcli.sh --delete-all [--force] --env-id <id> | --env-name <name>
# ./cloud-migration-pingcli.sh
#
# The target environment must be given explicitly via --env-id or
# --env-name (bare positional arguments are not accepted). If neither
# flag is given, PINGONE_ENV_ID or PINGONE_ENV_NAME (looked up via
# `pingcli pingone environments list`) is used instead.
#
# If none of the above are given AND this is running in an interactive
# terminal, the script instead drops into an interactive menu — pick a
# pingcli profile, pick a target environment, then choose an action
# (display details, apply, delete-all) from a menu, looping until you quit.
# This mirrors cloud-migration-pingone.sh's menu-driven experience.
#
# --delete-all deletes every resource this script has created or updated
# in the target environment, tracked in a per-environment manifest file
# (see MANIFEST notes below). Prompts for confirmation unless --force is
# also given.
#
# Notes:
# - This script has no state file (unlike Terraform). Re-running it is safe
# for resources with a dedicated `apply` command (idempotent create-or-
# update by name), but resources applied via `pingcli pingone api` or
# `application-grants create` are plain POST calls and WILL create
# duplicates if run twice.
# - Placeholder tokens (__KEY_ID_x__, __MIGRATION_KEY__) and internal
# "_parentApp" / "_parentResource" / "_grantResourceName" fields are
# resolved/stripped below before each payload is sent — they are not
# valid PingOne API fields.
# - pingcli's `-O json` output wraps results in an envelope object
# (e.g. `{"data": {...}, "status": "..."}`), not a bare object/array.
# All jq filters below use `(.data // .)` so they work whether or not
# a given command happens to wrap its result.
# - MANIFEST: every resource, application, and key this script applies or
# creates is appended to ".pingcli-manifest-<env-id>.txt" (tab-separated
# type/id/name), stored next to this script. --delete-all reads that
# file back rather than re-deriving names, since imported keys get a
# server-assigned name pingcli never controls and can't be reliably
# re-matched by name alone. Deleting an application or a custom resource
# cascades its grants/attributes/scopes automatically — no separate
# delete pass is needed for those.
# ─────────────────────────────────────────────────────────────────────────────
SCRIPT_VERSION="1.0.0"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/pingcli"
MIGRATION_KEY="${MIGRATION_KEY:-}" # PKCS12 import password (or export before running)
FAILURES=0
DELETE_ALL=0
FORCE=0
ENV_ID_ARG=""
ENV_NAME_ARG=""
ENV_ID=""
ENV_NAME=""
MANIFEST_FILE=""
print_usage() {
cat <<'USAGE'
cloud-migration-pingcli.sh — apply (or remove) a pingmigrate PingCLI export against PingOne
USAGE:
./cloud-migration-pingcli.sh (--env-id <id> | --env-name <name>)
./cloud-migration-pingcli.sh --delete-all [--force] (--env-id <id> | --env-name <name>)
./cloud-migration-pingcli.sh
./cloud-migration-pingcli.sh --help
Running with no --env-id/--env-name flags and no PINGONE_ENV_ID/
PINGONE_ENV_NAME environment variables set drops into an interactive
menu (profile pick, environment pick, then an action menu) — see
INTERACTIVE MODE below.
OPTIONS:
--env-id <id> Target PingOne environment ID (UUID).
--env-name <name> Target PingOne environment name, looked up via
`pingcli pingone environments list`.
--delete-all Delete every resource this script has previously created
or updated in the target environment (reads the
manifest file written during a normal run — see
MANIFEST below). Prompts for confirmation unless
--force is also given.
--force Skip the "type 'delete' to confirm" prompt with
--delete-all. Has no effect without --delete-all.
-h, --help Show this help and exit.
Exactly one of --env-id or --env-name is required, unless PINGONE_ENV_ID
or PINGONE_ENV_NAME is set instead (see ENVIRONMENT VARIABLES below), or
unless you let the script prompt you interactively (see below).
ENVIRONMENT VARIABLES:
PINGONE_ENV_ID Target environment ID. Used only if --env-id and
--env-name are both omitted. Takes priority over
PINGONE_ENV_NAME.
PINGONE_ENV_NAME Target environment name, looked up via
`pingcli pingone environments list`. Used only if
--env-id, --env-name, and PINGONE_ENV_ID are all unset.
MIGRATION_KEY PKCS12 import password, substituted for the
__MIGRATION_KEY__ placeholder in any exported key JSON.
Only needed if the export includes a PKCS12 key file.
REQUIRES:
pingcli (authenticated: run `pingcli pingone auth login` first)
jq
INTERACTIVE MODE:
Invoke the script with no arguments and no PINGONE_ENV_ID/PINGONE_ENV_NAME
set, from a terminal, and it will:
1. List configured pingcli profiles and let you pick one (Enter keeps
the currently active profile).
2. List PingOne environments visible to that profile and let you pick
one by number (or paste an environment ID directly).
3. Show a menu to display export details, display the target
environment, apply the migration, delete everything the script has
applied so far, switch profile/environment, or quit — looping until
you quit.
EXAMPLES:
# Apply using an environment ID
./cloud-migration-pingcli.sh --env-id 5b2ac72c-caaa-4b4c-827a-bfd8b4d93d16
# Apply using an environment name instead of an ID
./cloud-migration-pingcli.sh --env-name "My Test Environment"
# Apply, resolving the target environment from an env var (e.g. in CI)
PINGONE_ENV_ID=5b2ac72c-caaa-4b4c-827a-bfd8b4d93d16 ./cloud-migration-pingcli.sh
# Apply and import a PKCS12 signing key that requires a password
MIGRATION_KEY='s3cret' ./cloud-migration-pingcli.sh --env-id 5b2ac72c-caaa-4b4c-827a-bfd8b4d93d16
# Preview + confirm interactively before deleting everything this script created
./cloud-migration-pingcli.sh --delete-all --env-id 5b2ac72c-caaa-4b4c-827a-bfd8b4d93d16
# Delete everything without the interactive prompt (e.g. in CI/automation)
./cloud-migration-pingcli.sh --delete-all --force --env-id 5b2ac72c-caaa-4b4c-827a-bfd8b4d93d16
# Just run it and answer the prompts
./cloud-migration-pingcli.sh
NOTES:
- No state file (unlike Terraform). Re-running is safe for resources with a
dedicated `apply` command (idempotent create-or-update by name), but
resources applied via `pingcli pingone api` or `application-grants
create` are plain POST calls and WILL create duplicates if run twice.
- MANIFEST: every resource, application, and key this script applies or
creates is tracked in ".pingcli-manifest-<env-id>.txt" next to this
script. --delete-all reads that file to know exactly what to remove.
USAGE
}
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help) print_usage; exit 0 ;;
--delete-all) DELETE_ALL=1; shift ;;
--force) FORCE=1; shift ;;
--env-id)
if [[ $# -lt 2 ]]; then
echo "ERROR: --env-id requires a value." >&2
exit 1
fi
ENV_ID_ARG="$2"; shift 2 ;;
--env-name)
if [[ $# -lt 2 ]]; then
echo "ERROR: --env-name requires a value." >&2
exit 1
fi
ENV_NAME_ARG="$2"; shift 2 ;;
*)
echo "ERROR: Unrecognized argument: '$1'. Environment must be given via --env-id or --env-name — see --help." >&2
exit 1
;;
esac
done
if ! command -v pingcli >/dev/null 2>&1; then
echo "ERROR: pingcli is not installed or not on PATH. See https://github.com/pingidentity/pingcli" >&2
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
echo "ERROR: jq is required. Install via 'brew install jq' or your package manager." >&2
exit 1
fi
# ── Helpers ────────────────────────────────────────────────────────────────────
# Strips internal bookkeeping fields that are not part of the PingOne API payload.
strip_internal_fields() {
jq 'del(._parentApp, ._parentResource, ._grantResourceName)' "$1"
}
# Converts a resource file's base name (e.g. "saml-myapp") into a bash-safe
# variable name suffix (e.g. "saml_myapp").
var_suffix() {
echo "$1" | tr '-' '_'
}
# Dynamic ID lookup table, keyed by "<prefix>_<var_suffix name>" (e.g.
# "APP_ID_saml_myapp"). Bash-3.2-compatible replacement for associative
# arrays: writes go through printf -v, reads through indirect expansion.
set_id() {
local prefix="$1" name="$2" value="$3"
printf -v "${prefix}_$(var_suffix "$name")" '%s' "$value"
}
get_id() {
local prefix="$1" name="$2" varname
varname="${prefix}_$(var_suffix "$name")"
echo "${!varname:-}"
}
# Returns the JSON array of existing application-grants for the given
# application, fetching via `application-grants list` on first use and
# caching per application name (same set_id/get_id mechanism as ID lookups)
# so multiple grant files for the same app (e.g. default + custom scopes)
# only issue the list API call once instead of once per grant file.
get_app_grants_json() {
local parent="$1" app_id="$2" cached json
cached=$(get_id GRANTS_JSON "$parent")
if [[ -n "$cached" ]]; then
echo "$cached"
return 0
fi
json=$(pingcli pingone applications application-grants list \
--environment-id "$ENV_ID" --application-id "$app_id" -O json 2>/dev/null | jq -c '(.data // .)')
[[ -n "$json" ]] || json="[]"
set_id GRANTS_JSON "$parent" "$json"
echo "$json"
}
# Returns the JSON array of resource-scopes for the given resource, fetching
# via `resource-scopes list` on first use and caching per resource ID. Every
# scope name in every grant file under a given resource (e.g. "openid", which
# nearly every application grants against) would otherwise re-list the same
# resource's scopes once per scope name per grant file.
get_resource_scopes_json() {
local resource_id="$1" cached json
cached=$(get_id SCOPES_JSON "$resource_id")
if [[ -n "$cached" ]]; then
echo "$cached"
return 0
fi
json=$(pingcli pingone resources resource-scopes list \
--environment-id "$ENV_ID" --resource-id "$resource_id" -O json 2>/dev/null | jq -c '(.data // .)')
[[ -n "$json" ]] || json="[]"
set_id SCOPES_JSON "$resource_id" "$json"
echo "$json"
}
# Runs a pingcli command whose stdout is a `-O json` envelope, prints the
# raw response and returns non-zero on failure (status != "success" or
# pingcli's own exit code), otherwise echoes the extracted `.data.<field>`.
# Usage: run_and_extract <label> <field-jq-expr> -- <pingcli command...>
run_and_extract() {
local label="$1" field="$2" out rc
shift 2
[[ "$1" == "--" ]] && shift
out=$("$@" 2>&1)
rc=$?
if [[ $rc -ne 0 ]] || ! echo "$out" | jq -e '(.status // "success") == "success"' > /dev/null 2>&1; then
echo " FAILED: $label" >&2
echo "$out" >&2
FAILURES=$((FAILURES + 1))
return 1
fi
echo "$out" | jq -r "$field"
}
# Resolves the PingOne management API base URL (e.g. "https://api.pingone.com/v1")
# for the active profile/environment, by reading it back off the environment's
# own `self` link rather than hardcoding a domain — works across regions
# (.com/.eu/.asia/etc.) and custom domains without extra flags.
get_pingone_api_base_url() {
local self_href
self_href=$(pingcli pingone environments get --environment-id "$ENV_ID" -O json 2>/dev/null \
| jq -r '(.data // .)._links.self.href // empty')
if [[ -z "$self_href" ]]; then
return 1
fi
# Strip the trailing "/environments/$ENV_ID" to get the bare API root.
echo "${self_href%/environments/*}"
}
# Imports a PKCS12 file as a new signing/encryption key via a raw multipart/
# form-data POST — see:
# https://developer.pingidentity.com/pingone-api/platform/certificate-management/create-key-with-pkcs12-file.html
#
# pingcli's own `pingone api` command only supports a JSON body (--data/
# --data-raw), not multipart/form-data, so this shells out to curl directly.
# Auth reuses the active pingcli session's bearer token (`pingcli pingone auth
# token`) rather than requiring separate credentials.
#
# Usage: import_pkcs12_key <label> <base64-pkcs12> <password> [usage-type]
# usage-type defaults to "SIGNING" (the only key type this migration tool
# currently emits); pass "ENCRYPTION" explicitly if that ever changes.
# Echoes the created key's id on success, returns non-zero (with the raw
# error body on stderr) on failure.
import_pkcs12_key() {
local label="$1" pkcs12_base64="$2" password="$3" usage_type="${4:-SIGNING}"
local api_base token tmp_p12 out rc http_status body
api_base=$(get_pingone_api_base_url)
if [[ -z "$api_base" ]]; then
echo " FAILED: $label (could not resolve PingOne API base URL)" >&2
FAILURES=$((FAILURES + 1))
return 1
fi
token=$(pingcli pingone auth token 2>/dev/null)
if [[ -z "$token" ]]; then
echo " FAILED: $label (could not obtain an auth token — run 'pingcli pingone auth login')" >&2
FAILURES=$((FAILURES + 1))
return 1
fi
tmp_p12=$(mktemp)
echo "$pkcs12_base64" | base64 -d > "$tmp_p12" 2>/dev/null
if [[ ! -s "$tmp_p12" ]]; then
echo " FAILED: $label (could not decode PKCS12 data)" >&2
rm -f "$tmp_p12"
FAILURES=$((FAILURES + 1))
return 1
fi
out=$(curl -sS -w '\n%{http_code}' \
--location \
--request POST "${api_base}/environments/${ENV_ID}/keys" \
--header "Authorization: Bearer ${token}" \
--form "file=@${tmp_p12};type=application/x-pkcs12" \
--form "usageType=${usage_type}" \
${password:+--form "password=${password}"} \
2>&1)
rc=$?
rm -f "$tmp_p12"
http_status="${out##*$'\n'}"
body="${out%$'\n'*}"
if [[ $rc -ne 0 || "$http_status" -lt 200 || "$http_status" -ge 300 ]]; then
echo " FAILED: $label (HTTP ${http_status:-?})" >&2
echo "$body" >&2
FAILURES=$((FAILURES + 1))
return 1
fi
echo "$body" | jq -r '.id'
}
# Sets MANIFEST_FILE for the currently-selected ENV_ID. Kept per-environment
# so running against multiple environments from the same downloaded zip
# doesn't cross-contaminate.
set_manifest_file() {
MANIFEST_FILE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.pingcli-manifest-${ENV_ID}.txt"
}
# Records a resource in the manifest as "type<TAB>id<TAB>name", deduping by
# (type, id) so re-running an apply doesn't grow the file unboundedly.
record_manifest() {
local type="$1" id="$2" name="$3"
[[ -n "$id" ]] || return 0
touch "$MANIFEST_FILE"
grep -qF "$(printf '%s\t%s\t' "$type" "$id")" "$MANIFEST_FILE" 2>/dev/null && return 0
printf '%s\t%s\t%s\n' "$type" "$id" "$name" >> "$MANIFEST_FILE"
}
# ── Interactive helpers ─────────────────────────────────────────────────────
# Prints file counts under $SCRIPT_DIR, mirroring cloud-migration-pingone.sh's
# "Display Terraform Details" option.
display_resource_count() {
local glob="$1" label="$2" count
count=$(compgen -G "$SCRIPT_DIR/$glob" 2>/dev/null | wc -l | tr -d ' ')
printf "%30s: %5s\n" "$label" "${count:-0}"
}
display_pingcli_details() {
echo "###########################################"
echo "# PingCLI Export Details #"
echo "###########################################"
if [[ ! -d "$SCRIPT_DIR" ]]; then
echo " No 'pingcli/' directory found next to this script: $SCRIPT_DIR"
return 0
fi
display_resource_count "resources/*.json" "Resources"
display_resource_count "resources/scopes/*.json" "Resource Scopes"
display_resource_count "keys/*.json" "Signing Keys"
display_resource_count "applications/*.json" "Applications"
display_resource_count "applications/attributes/*.json" "Attribute Mappings"
display_resource_count "applications/metadata/*.json" "Application Metadata"
display_resource_count "applications/grants/*.json" "Resource Grants"
}
display_pingone_environment() {
echo "###########################################"
echo "# PingOne Environment #"
echo "###########################################"
local out
out=$(pingcli pingone environments get --environment-id "$ENV_ID" -O json 2>&1)
if ! echo "$out" | jq -e '(.status // "success") == "success"' > /dev/null 2>&1; then
echo " Could not fetch environment details:" >&2
echo "$out" >&2
return 1
fi
echo "$out" | jq -r '(.data // .) | [
" Environment Name: " + (.name // "unknown"),
" Environment ID: " + (.id // "unknown"),
" Region: " + (.region // "unknown"),
" Type: " + (.type // "unknown")
] | .[]'
echo ""
echo " Of the export's resources, how many already exist in this environment"
echo " (matched by name — entities not in the export are ignored):"
display_bom_match_count \
"$SCRIPT_DIR/applications/*.json" \
'.oidc.name // .saml.name // .wsfed.name // .externalLink.name // empty' \
"pingcli pingone applications list --environment-id \"\$ENV_ID\"" \
"Applications"
display_bom_match_count \
"$SCRIPT_DIR/resources/*.json" \
'.name // empty' \
"pingcli pingone resources list --environment-id \"\$ENV_ID\"" \
"Resources"
# PKCS12-import keys have no `.name` (the server assigns one on import,
# per cloud-migration-pingcli.sh's key-import notes) so they can never be
# name-matched against an existing key — only generated keys are counted.
display_bom_match_count \
"$SCRIPT_DIR/keys/*.json" \
'if .pkcs12FileBase64 then empty else (.name // empty) end' \
"pingcli pingone api \"environments/\$ENV_ID/keys\"" \
"Signing/Encryption Keys"
}
# Counts, out of the export files matching $glob, how many have a name (per
# $name_filter, a jq expression run against each file) that matches an
# existing entity's name in the live environment (fetched via $list_cmd).
# Prints "<matched>/<total in export>", or "?/<total>" if the live list call
# fails. This intentionally ignores anything in the live environment that
# isn't referenced by the export — the question is "how many of what we're
# about to apply already exists," not "what's the environment's total count."
#
# Handles both response shapes pingcli returns: `... list` commands return
# .data as a flat array, while `pingone api` (used for resource types with no
# dedicated list subcommand, e.g. keys) returns a HAL-style .data._embedded
# object whose single key holds the array.
display_bom_match_count() {
local glob="$1" name_filter="$2" list_cmd="$3" label="$4"
local bom_names="" bom_total=0 f n out live_names matched
for f in $glob; do
[[ -e "$f" ]] || continue
bom_total=$((bom_total + 1))
n=$(jq -r "$name_filter" "$f" 2>/dev/null)
[[ -n "$n" ]] && bom_names="$bom_names"$'\n'"$n"
done
if [[ "$bom_total" -eq 0 ]]; then
printf "%30s: %5s\n" "$label" "0/0"
return 0
fi
out=$(eval "$list_cmd -O json" 2>&1)
if ! echo "$out" | jq -e '(.status // "success") == "success"' > /dev/null 2>&1; then
printf "%30s: %5s\n" "$label" "?/$bom_total"
return 0
fi
live_names=$(echo "$out" | jq -r '
(if (.data | type) == "object"
then (.data._embedded // {} | to_entries | if length > 0 then .[0].value else [] end)
else (.data // [])
end) | .[].name // empty')
matched=0
while IFS= read -r n; do
[[ -n "$n" ]] || continue
echo "$live_names" | grep -qxF "$n" && matched=$((matched + 1))
done <<< "$bom_names"
printf "%30s: %5s\n" "$label" "$matched/$bom_total"
}
# Lists configured pingcli profiles and lets the user pick one (Enter keeps
# the active profile). Bash-3.2-safe: builds plain indexed arrays with
# `while read` loops instead of `mapfile`/associative arrays.
select_profile_interactive() {
local list_json
list_json=$(pingcli config profiles list -O json 2>&1)
if ! echo "$list_json" | jq -e '(.status // "success") == "success"' > /dev/null 2>&1; then
echo "ERROR: Could not list pingcli profiles:" >&2
echo "$list_json" >&2
return 1
fi
local names=() actives=() count active_idx=0 i
while IFS= read -r line; do names+=("$line"); done < <(echo "$list_json" | jq -r '(.data // .)[].name')
while IFS= read -r line; do actives+=("$line"); done < <(echo "$list_json" | jq -r '(.data // .)[].active')
count=${#names[@]}
if [[ "$count" -eq 0 ]]; then
echo "No pingcli profiles configured — continuing with whatever profile is active." >&2
return 0
fi
echo ""
for ((i = 0; i < count; i++)); do
if [[ "${actives[$i]}" == "true" ]]; then
printf " %2d) %s (active)\n" "$((i + 1))" "${names[$i]}"
active_idx=$((i + 1))
else
printf " %2d) %s\n" "$((i + 1))" "${names[$i]}"
fi
done
echo ""
local selection
read -r -p "Select a profile by number, or press Enter to keep the active profile: " selection
if [[ -z "$selection" ]]; then
return 0
fi
if [[ "$selection" =~ ^[0-9]+$ ]] && (( selection >= 1 && selection <= count )); then
if [[ "$selection" -ne "$active_idx" ]]; then
if run_and_extract "activate profile ${names[$((selection - 1))]}" '.' -- \
pingcli config profiles use "${names[$((selection - 1))]}" -O json > /dev/null; then
echo "Active profile set to: ${names[$((selection - 1))]}"
fi
fi
else
echo "Invalid selection — keeping active profile." >&2
fi
}
# Lists PingOne environments visible to the active profile and lets the user
# pick one by number, or paste an environment ID directly. Sets ENV_ID/
# ENV_NAME/MANIFEST_FILE on success.
select_environment_interactive() {
echo "Fetching PingOne environments..."
local list_json
list_json=$(pingcli pingone environments list -O json 2>&1)
if ! echo "$list_json" | jq -e '(.status // "success") == "success"' > /dev/null 2>&1; then
echo "ERROR: Could not list PingOne environments:" >&2
echo "$list_json" >&2
return 1
fi
local ids=() names=() count i
while IFS= read -r line; do ids+=("$line"); done < <(echo "$list_json" | jq -r '(.data // .)[].id')
while IFS= read -r line; do names+=("$line"); done < <(echo "$list_json" | jq -r '(.data // .)[].name')
count=${#ids[@]}
if [[ "$count" -eq 0 ]]; then
echo "No PingOne environments found for the active profile." >&2
return 1
fi
echo ""
for ((i = 0; i < count; i++)); do
printf " %2d) %s (%s)\n" "$((i + 1))" "${names[$i]}" "${ids[$i]}"
done
echo ""
local selection
while true; do
read -r -p "Select an environment by number, or paste an environment ID directly: " selection
if [[ "$selection" =~ ^[0-9]+$ ]]; then
if (( selection >= 1 && selection <= count )); then
ENV_ID="${ids[$((selection - 1))]}"
ENV_NAME="${names[$((selection - 1))]}"
break
fi
echo "Invalid selection: $selection is out of range (1-$count). Try again." >&2
continue
fi
if [[ -n "$selection" ]]; then
ENV_ID="$selection"
ENV_NAME=""
break
fi
echo "No selection made." >&2
return 1
done
set_manifest_file
echo "Target environment set to: ${ENV_NAME:-$ENV_ID} ($ENV_ID)"
}
# ── Apply / delete-all (shared by one-shot flag mode and the interactive menu) ─
do_apply() {
FAILURES=0
if [[ ! -d "$SCRIPT_DIR" ]]; then
echo "ERROR: Expected a 'pingcli/' directory next to this script (from the downloaded zip). Not found: $SCRIPT_DIR" >&2
return 1
fi
echo "Migrating to environment: $ENV_ID"
# ── PHASE 1: Custom "cloud_migration" resource (parent for custom scopes/grants) ─
echo "── Ensuring cloud_migration custom resource exists"
CLOUD_MIGRATION_RESOURCE_ID=$(pingcli pingone resources list --environment-id "$ENV_ID" -O json \
| jq -r '(.data // .)[] | select(.name=="cloud_migration") | .id' | head -n1)
if [[ -z "$CLOUD_MIGRATION_RESOURCE_ID" || "$CLOUD_MIGRATION_RESOURCE_ID" == "null" ]]; then
CLOUD_MIGRATION_RESOURCE_ID=$(run_and_extract "create cloud_migration resource" '(.data // .).id' -- \
pingcli pingone resources create --environment-id "$ENV_ID" \
--from-file <(echo '{"name":"cloud_migration","description":"Custom scopes/grants created by pingmigrate"}') -O json)
[[ -n "$CLOUD_MIGRATION_RESOURCE_ID" ]] && echo " Created cloud_migration resource: $CLOUD_MIGRATION_RESOURCE_ID"
else
echo " Found existing cloud_migration resource: $CLOUD_MIGRATION_RESOURCE_ID"
fi
set_id RESOURCE_ID "cloud_migration" "$CLOUD_MIGRATION_RESOURCE_ID"
record_manifest resource "$CLOUD_MIGRATION_RESOURCE_ID" "cloud_migration"
# Also resolve the built-in "openid" resource, used as the parent for default OIDC scope grants.
OPENID_RESOURCE_ID=$(pingcli pingone resources list --environment-id "$ENV_ID" -O json \
| jq -r '(.data // .)[] | select(.name=="openid") | .id' | head -n1)
set_id RESOURCE_ID "openid" "$OPENID_RESOURCE_ID"
# ── PHASE 2: Custom PingOne resources (e.g. migrated OAuth Access Token Managers) ─
if compgen -G "$SCRIPT_DIR/resources/*.json" > /dev/null; then
echo "── Applying resources"
for f in "$SCRIPT_DIR"/resources/*.json; do
[[ -e "$f" ]] || continue
name=$(basename "$f" .json)
tmp_body=$(mktemp)
strip_internal_fields "$f" > "$tmp_body"
id=$(run_and_extract "resource $name" '(.data // .).id' -- \
pingcli pingone resources apply --environment-id "$ENV_ID" --from-file "$tmp_body" -O json)
rm -f "$tmp_body"
if [[ -n "$id" ]]; then
set_id RESOURCE_ID "$name" "$id"
record_manifest resource "$id" "$name"
echo " Resource applied: $name -> $id"
fi
done
fi
# ── PHASE 3: Resource scopes (custom scopes on cloud_migration or other resources) ─
if compgen -G "$SCRIPT_DIR/resources/scopes/*.json" > /dev/null; then
echo "── Applying resource scopes"
for f in "$SCRIPT_DIR"/resources/scopes/*.json; do
[[ -e "$f" ]] || continue
name=$(basename "$f" .json)
parent=$(jq -r '._parentResource // empty' "$f")
parent_id=$(get_id RESOURCE_ID "${parent:-cloud_migration}")
if [[ -z "$parent_id" ]]; then
echo " WARNING: Could not resolve parent resource '${parent:-cloud_migration}' for scope '$name'. Skipping." >&2
FAILURES=$((FAILURES + 1))
continue
fi
tmp_body=$(mktemp)
strip_internal_fields "$f" > "$tmp_body"
if run_and_extract "resource scope $name" '.' -- \
pingcli pingone resources resource-scopes apply \
--environment-id "$ENV_ID" --resource-id "$parent_id" --from-file "$tmp_body" -O json > /dev/null; then
echo " Scope applied: $name (resource: ${parent:-cloud_migration})"
fi
rm -f "$tmp_body"
done
fi
# ── PHASE 4: Keys ──────────────────────────────────────────────────────────
# Two distinct request shapes, per PingOne's API:
# - A PKCS12 file upload (pkcs12FileBase64 present) is a multipart/form-data
# POST — see import_pkcs12_key() above. pingcli's `pingone api` command has
# no multipart support, so this shells out to curl directly.
# - Generating a brand new key from metadata (no PKCS12) is a plain JSON POST,
# which pingcli's `pingone api` handles natively — no dedicated subcommand
# for keys exists, so this is used as-is.
#
# KEY_NAMES tracks the raw (hyphen-intact) key file names seen, since the
# __KEY_ID_x__ placeholder tokens embedded in application JSON use the raw
# resource name — not the bash-safe variable name var_suffix() derives from it.
KEY_NAMES=""
if compgen -G "$SCRIPT_DIR/keys/*.json" > /dev/null; then
echo "── Importing keys"
for f in "$SCRIPT_DIR"/keys/*.json; do
[[ -e "$f" ]] || continue
name=$(basename "$f" .json)
pkcs12_base64=$(jq -r '.pkcs12FileBase64 // empty' "$f")
if [[ -n "$pkcs12_base64" ]]; then
id=$(import_pkcs12_key "key $name" "$pkcs12_base64" "$MIGRATION_KEY")
else
tmp_body=$(mktemp)
strip_internal_fields "$f" > "$tmp_body"
id=$(run_and_extract "key $name" '(.data // .).id' -- \
pingcli pingone api --http-method POST --data "$tmp_body" -O json "environments/$ENV_ID/keys")
rm -f "$tmp_body"
fi
if [[ -n "$id" ]]; then
set_id KEY_ID "$name" "$id"
KEY_NAMES="$KEY_NAMES $name"
record_manifest key "$id" "$name"
echo " Key imported: $name -> $id"
fi
done
fi
# ── PHASE 5: Applications ─────────────────────────────────────────────────────
if compgen -G "$SCRIPT_DIR/applications/*.json" > /dev/null; then
echo "── Applying applications"
for f in "$SCRIPT_DIR"/applications/*.json; do
[[ -e "$f" ]] || continue
name=$(basename "$f" .json)
# Substitute __KEY_ID_x__ placeholders with the real key IDs captured above.
# Iterate over the raw key names (KEY_NAMES), not the bash-safe KEY_ID_*
# variable names — the placeholder token in the JSON uses the raw name
# (e.g. "signing-cert" with its hyphen intact), which would never match a
# name already mangled by var_suffix()'s hyphen-to-underscore conversion.
resolved=$(strip_internal_fields "$f")
for keyname in $KEY_NAMES; do
key_id=$(get_id KEY_ID "$keyname")
[[ -n "$key_id" ]] && resolved="${resolved//__KEY_ID_${keyname}__/${key_id}}"
done
tmp_body=$(mktemp)
echo "$resolved" > "$tmp_body"
id=$(run_and_extract "application $name" '(.data // .).id' -- \
pingcli pingone applications apply --environment-id "$ENV_ID" --from-file "$tmp_body" -O json)
rm -f "$tmp_body"
if [[ -n "$id" ]]; then
set_id APP_ID "$name" "$id"
record_manifest application "$id" "$name"
echo " Application applied: $name -> $id"
fi
done
fi
# ── PHASE 6: Application attribute mappings (via pingone api — no dedicated subcommand) ─
# PingOne auto-creates CORE attribute mappings (e.g. "saml_subject") the moment
# a SAML application is created, and re-running this script hits previously
# created CUSTOM mappings too — so this must look up by name and PUT (update)
# an existing attribute rather than always POST (create), or every rerun fails
# with a UNIQUENESS_VIOLATION.
if compgen -G "$SCRIPT_DIR/applications/attributes/*.json" > /dev/null; then
echo "── Applying application attribute mappings"
for f in "$SCRIPT_DIR"/applications/attributes/*.json; do
[[ -e "$f" ]] || continue
name=$(basename "$f" .json)
parent=$(jq -r '._parentApp // empty' "$f")
parent_id=$(get_id APP_ID "$parent")
if [[ -z "$parent_id" ]]; then
echo " WARNING: Could not resolve parent application '$parent' for attribute mapping '$name'. Skipping." >&2
FAILURES=$((FAILURES + 1))
continue
fi
attr_name=$(jq -r '.name' "$f")
existing_id=$(pingcli pingone api -O json "environments/$ENV_ID/applications/$parent_id/attributes" \
| jq -r --arg name "$attr_name" '(.data._embedded.attributes // .data // [])[] | select(.name==$name) | .id' | head -n1)
tmp_body=$(mktemp)
strip_internal_fields "$f" > "$tmp_body"
if [[ -n "$existing_id" && "$existing_id" != "null" ]]; then
if run_and_extract "attribute mapping $name" '.' -- \
pingcli pingone api --http-method PUT --data "$tmp_body" -O json \
"environments/$ENV_ID/applications/$parent_id/attributes/$existing_id" > /dev/null; then
echo " Attribute updated: $name (application: $parent)"
fi
else
if run_and_extract "attribute mapping $name" '.' -- \
pingcli pingone api --http-method POST --data "$tmp_body" -O json \
"environments/$ENV_ID/applications/$parent_id/attributes" > /dev/null; then
echo " Attribute mapped: $name (application: $parent)"
fi
fi
rm -f "$tmp_body"
done
fi
# ── PHASE 6b: Application migration metadata ──────────────────────────────────
# Written to the application's /metadata endpoint so migration history (rule
# results, auto-cleaned changes, user edits, concerns) is visible from within
# PingOne without needing to re-run an assessment. This is a plain PUT — no
# dedicated subcommand exists for application metadata.
if compgen -G "$SCRIPT_DIR/applications/metadata/*.json" > /dev/null; then
echo "── Applying application metadata"
for f in "$SCRIPT_DIR"/applications/metadata/*.json; do
[[ -e "$f" ]] || continue
name=$(basename "$f" .json)
parent=$(jq -r '._parentApp // empty' "$f")
parent_id=$(get_id APP_ID "$parent")
if [[ -z "$parent_id" ]]; then
echo " WARNING: Could not resolve parent application '$parent' for metadata '$name'. Skipping." >&2
FAILURES=$((FAILURES + 1))
continue
fi
tmp_body=$(mktemp)
jq 'del(._parentApp) | {metadata: .}' "$f" > "$tmp_body"
if run_and_extract "metadata $name" '.' -- \
pingcli pingone api --http-method PUT --data "$tmp_body" -O json \
"environments/$ENV_ID/applications/$parent_id/metadata" > /dev/null; then
echo " Metadata applied: $name (application: $parent)"
fi
rm -f "$tmp_body"
done
fi
# ── PHASE 7: Application resource grants ──────────────────────────────────────
# `application-grants` has no `apply` subcommand (only create/replace), and a
# grant already exists per (application, resource) pair — attempting to
# `create` a second grant to the same resource is rejected. This looks up any
# existing grant for the resolved resource ID first and `replace`s it, only
# falling back to `create` when no grant for that resource exists yet.
if compgen -G "$SCRIPT_DIR/applications/grants/*.json" > /dev/null; then
echo "── Applying application resource grants"
for f in "$SCRIPT_DIR"/applications/grants/*.json; do
[[ -e "$f" ]] || continue
name=$(basename "$f" .json)
parent=$(jq -r '._parentApp // empty' "$f")
parent_id=$(get_id APP_ID "$parent")
if [[ -z "$parent_id" ]]; then
echo " WARNING: Could not resolve parent application '$parent' for grant '$name'. Skipping." >&2
FAILURES=$((FAILURES + 1))
continue
fi
grant_resource=$(jq -r '._grantResourceName // empty' "$f")
resource_id=$(get_id RESOURCE_ID "${grant_resource:-openid}")
if [[ -z "$resource_id" ]]; then
echo " WARNING: Could not resolve resource '${grant_resource:-openid}' for grant '$name'. Skipping." >&2
FAILURES=$((FAILURES + 1))
continue
fi
# Resolve each scope name to its scope ID under the resolved resource, then
# build the real API payload: { resource: { id }, scopes: [{ id }, ...] }
scope_names=$(jq -r '.scopes[]?.name' "$f")
resource_scopes_json=$(get_resource_scopes_json "$resource_id")
scope_ids_json="[]"
scope_lookup_failed=0
while IFS= read -r scope_name; do
[[ -n "$scope_name" ]] || continue
scope_id=$(echo "$resource_scopes_json" \
| jq -r --arg name "$scope_name" '.[] | select(.name==$name) | .id' | head -n1)
if [[ -z "$scope_id" || "$scope_id" == "null" ]]; then
echo " WARNING: Could not resolve scope '$scope_name' under resource '${grant_resource:-openid}' for grant '$name'." >&2
scope_lookup_failed=1
continue
fi
scope_ids_json=$(echo "$scope_ids_json" | jq --arg id "$scope_id" '. + [{id: $id}]')
done <<< "$scope_names"
if [[ "$scope_ids_json" == "[]" ]]; then
echo " WARNING: No scopes resolved for grant '$name'. Skipping." >&2
FAILURES=$((FAILURES + 1))
continue
fi
body=$(jq -n --arg rid "$resource_id" --argjson scopes "$scope_ids_json" '{resource: {id: $rid}, scopes: $scopes}')
tmp_body=$(mktemp)
echo "$body" > "$tmp_body"
existing_grants_json=$(get_app_grants_json "$parent" "$parent_id")
existing_grant_id=$(echo "$existing_grants_json" \
| jq -r --arg rid "$resource_id" '.[] | select(.resource.id==$rid) | .id' | head -n1)
applied=0
if [[ -n "$existing_grant_id" && "$existing_grant_id" != "null" ]]; then
verb="Grant updated"
if run_and_extract "grant $name" '.' -- \
pingcli pingone applications application-grants replace \
--environment-id "$ENV_ID" --application-id "$parent_id" \
--application-grant-id "$existing_grant_id" --from-file "$tmp_body" -O json > /dev/null; then
applied=1
fi
else
verb="Grant applied"
new_grant_id=$(run_and_extract "grant $name" '(.data // .).id' -- \
pingcli pingone applications application-grants create \
--environment-id "$ENV_ID" --application-id "$parent_id" --from-file "$tmp_body" -O json)
if [[ -n "$new_grant_id" ]]; then
applied=1
# Refresh the cache so a later grant file for the same app (e.g. the
# custom-scope grant right after the default-scope one) sees this grant.
set_id GRANTS_JSON "$parent" "$(echo "$existing_grants_json" \
| jq --arg rid "$resource_id" --arg id "$new_grant_id" '. + [{id: $id, resource: {id: $rid}}]')"
fi
fi
if [[ "$applied" -eq 1 ]]; then
if [[ "$scope_lookup_failed" -eq 1 ]]; then
echo " $verb with partial scopes: $name (application: $parent)"
else
echo " $verb: $name (application: $parent)"
fi
fi
rm -f "$tmp_body"
done
fi
echo ""
if [[ "$FAILURES" -gt 0 ]]; then
echo "✗ pingcli migration finished with $FAILURES failure(s) — see FAILED lines above."
return 1
else
echo "✓ pingcli migration complete"
return 0
fi
}
do_delete_all() {
FAILURES=0
if [[ ! -f "$MANIFEST_FILE" ]]; then
echo "No manifest found for environment $ENV_ID ($MANIFEST_FILE) — nothing to delete." >&2
return 0
fi
echo "The following resources tracked in $MANIFEST_FILE will be PERMANENTLY DELETED from environment $ENV_ID:"
echo ""
# Applications and resources cascade-delete their grants/attributes/scopes,
# so only these three types are ever recorded in the manifest.
awk -F'\t' '{printf " [%s] %s (%s)\n", $1, $3, $2}' "$MANIFEST_FILE"
echo ""
if [[ "$FORCE" -ne 1 ]]; then