Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd3fc5cea7 | ||
|
|
719bab7358 | ||
|
|
95f72e1248 | ||
|
|
f9a9f677ca | ||
|
|
dd0c5df25c | ||
|
|
4fc3244049 | ||
|
|
5faf830d46 | ||
|
|
d2cf82b85c | ||
|
|
fb0020c395 | ||
|
|
e55ea0b629 | ||
|
|
9234dbae70 | ||
|
|
27136eaa71 | ||
|
|
6554af1164 | ||
|
|
437d0e686d | ||
|
|
25b801826d | ||
|
|
123818dc70 | ||
|
|
dafcd046d7 | ||
|
|
accf9faf60 | ||
|
|
6bb237e78c | ||
|
|
bbb5808875 | ||
|
|
905a8f1951 | ||
|
|
f39961bbf5 | ||
|
|
2bf09011fb | ||
|
|
013aa2e570 | ||
|
|
953e35bcd6 | ||
|
|
e138b06fd3 | ||
|
|
2ef6b0549d | ||
|
|
1c49303432 | ||
|
|
f31b1e975d | ||
|
|
48d0087141 | ||
|
|
58cc9e56fc | ||
|
|
189a453e8b | ||
|
|
6fca90747c | ||
|
|
92f5a65c00 | ||
|
|
d8744e81bc | ||
|
|
136baf8260 | ||
|
|
ef47c163d0 | ||
|
|
39e5e3406b | ||
|
|
91d8d61afb | ||
|
|
b1d9153efe | ||
|
|
fda1f56ad1 | ||
|
|
fa18bfff01 | ||
|
|
72f7c8a92d | ||
|
|
838bc37243 | ||
|
|
866972c6c6 | ||
|
|
b19d76f249 | ||
|
|
35ccaeba56 | ||
|
|
bf134072e5 | ||
|
|
0bd152e0b5 | ||
|
|
928ca5409c | ||
|
|
0f846eab2c | ||
|
|
14ed74dd3d | ||
|
|
701d19bd18 | ||
|
|
240a544d82 | ||
|
|
b4f3741979 | ||
|
|
396bf0a43f | ||
|
|
3225ccda10 | ||
|
|
ad2c1ba2ad | ||
|
|
ea591df0bb | ||
|
|
72639bd6a3 | ||
|
|
31f0044c0f | ||
|
|
d0924f7e6d | ||
|
|
d2616acf36 | ||
|
|
dfd116bd56 | ||
|
|
830acef847 | ||
|
|
ee7c916494 | ||
|
|
46d477ea5b | ||
|
|
177b4af695 | ||
|
|
d2b8805605 | ||
|
|
39dedc68ec | ||
|
|
bd67756cda | ||
|
|
dd37fa4270 | ||
|
|
3c5c048152 | ||
|
|
dd6e4ef2d0 | ||
|
|
bce79f3488 | ||
|
|
74793a2399 | ||
|
|
6b120b053a | ||
|
|
1d0d62337e | ||
|
|
df8bfc42bb | ||
|
|
ff087738fc | ||
|
|
27567c88c9 | ||
|
|
9077a747d6 | ||
|
|
b359d9f525 | ||
|
|
ad7bd2e52d | ||
|
|
36aed9588b | ||
|
|
f8c81a29c4 | ||
|
|
c91771e152 | ||
|
|
3b7873302b | ||
|
|
d79bd54f0b | ||
|
|
51dc9a0e49 | ||
|
|
b49e55d400 | ||
|
|
33cb7e0a50 | ||
|
|
9edc90306e | ||
|
|
0a75e05b28 | ||
|
|
4a179ac3e3 | ||
|
|
42e32f9568 | ||
|
|
0223755b2a | ||
|
|
30a5f2a701 | ||
|
|
d2d351c9f7 | ||
|
|
e1696e9a4d | ||
|
|
25f015b179 | ||
|
|
9e263379a7 | ||
|
|
4c7ea161cd | ||
|
|
dc026c44a1 | ||
|
|
cb8b21c10c | ||
|
|
47c1beadcd | ||
|
|
8f7eb2abea | ||
|
|
c2b0f321de | ||
|
|
ba7b8b8824 | ||
|
|
9e612cf83d | ||
|
|
2154251c30 | ||
|
|
2d38266609 | ||
|
|
d414df4ca4 | ||
|
|
eab93cf6c5 | ||
|
|
3304def44a | ||
|
|
a4bf02b21b | ||
|
|
e0982da744 | ||
|
|
8abed1a809 | ||
|
|
a9a6518b23 | ||
|
|
1bafbe1ad2 | ||
|
|
a26074a3b8 | ||
|
|
90fd2f1447 | ||
|
|
e59690e703 | ||
|
|
bf06e9f654 | ||
|
|
84359d8e79 | ||
|
|
7801429530 | ||
|
|
dc9376a460 | ||
|
|
1f4076d8d4 | ||
|
|
f202d0f533 | ||
|
|
4344dcbb36 | ||
|
|
87d6bbae2a | ||
|
|
7a42bf456e | ||
|
|
a51f1eee8e | ||
|
|
5b72a916e4 | ||
|
|
42013a919f | ||
|
|
3918f82e14 | ||
|
|
6e79a2a133 | ||
|
|
088deaed3a | ||
|
|
7504e7710c | ||
|
|
bb7352ca54 | ||
|
|
b42ecbff36 | ||
|
|
5fd9f0216a | ||
|
|
942ea99459 | ||
|
|
990e2d6b9d | ||
|
|
f776ba0617 | ||
|
|
fb57965457 | ||
|
|
b99fce7d55 | ||
|
|
0a2d743ea2 | ||
|
|
fbd613084d | ||
|
|
4cd56f8ae5 | ||
|
|
c0d41c0a62 | ||
|
|
389c635030 | ||
|
|
d93a0c170c | ||
|
|
aa18f599ce | ||
|
|
aa7eb2e844 | ||
|
|
8e31adb9db | ||
|
|
f0feb9e563 | ||
|
|
44624e3820 | ||
|
|
225c94b853 | ||
|
|
bd1b8542eb | ||
|
|
c92e93ab1c | ||
|
|
1358727832 | ||
|
|
9bc84a2c59 | ||
|
|
7f00a83b19 | ||
|
|
19426cfd51 | ||
|
|
30c05eb834 | ||
|
|
f1d334b848 | ||
|
|
961f497b6b | ||
|
|
89f33f3714 | ||
|
|
4bad52f43a | ||
|
|
fd6cd3ddf7 | ||
|
|
adf7f8603b | ||
|
|
2a2e4350ee | ||
|
|
f563a4ef45 | ||
|
|
37b6ef6d3f | ||
|
|
96ff0ddf27 | ||
|
|
746862a897 | ||
|
|
5d6e65f206 | ||
|
|
6f9a464302 | ||
|
|
abf7866292 | ||
|
|
10c98c66e7 | ||
|
|
c1f7fe2ddd | ||
|
|
7f4586ed9e | ||
|
|
0b6049c8b0 | ||
|
|
2d347171a9 | ||
|
|
a085d4d4f7 | ||
|
|
7e2f3e3df3 | ||
|
|
3899daea7c | ||
|
|
3d1ee732b4 | ||
|
|
899d7beee0 | ||
|
|
4c7f3fad75 | ||
|
|
b8eb2c3c9b | ||
|
|
f5d612c8b5 | ||
|
|
4020f2d4e2 | ||
|
|
5794cfc7b2 | ||
|
|
1284a16978 | ||
|
|
f255a470cd | ||
|
|
f738e71657 | ||
|
|
beeee851ac | ||
|
|
eb365f0d39 | ||
|
|
57e614e8db | ||
|
|
738fa1f15f | ||
|
|
4c959ab297 | ||
|
|
e685295d04 | ||
|
|
204bd10e04 | ||
|
|
5a302b0bcc | ||
|
|
daa23f66c4 | ||
|
|
b06c396086 | ||
|
|
5555603a5c | ||
|
|
7c3b6d6d9e | ||
|
|
ce87d7728c | ||
|
|
e4da851172 | ||
|
|
e3433ec1f7 | ||
|
|
96f30d1b31 | ||
|
|
f4a48467e3 | ||
|
|
bada405511 | ||
|
|
96d7c055b0 | ||
|
|
a747614a74 | ||
|
|
84856407ee | ||
|
|
9bfd624713 | ||
|
|
b2a6e4d47f | ||
|
|
308240be89 | ||
|
|
c12edc7f73 | ||
|
|
44bd5ee1c6 | ||
|
|
8f3cccce32 | ||
|
|
1ef7d9b5a7 | ||
|
|
c445f95aa4 | ||
|
|
96db738f4b | ||
|
|
6181df4c69 | ||
|
|
376787fda1 | ||
|
|
80ffa856bd | ||
|
|
eb9fe573b8 | ||
|
|
e222d5b909 | ||
|
|
1c774aa783 | ||
|
|
04b51da60f | ||
|
|
28ddec6898 | ||
|
|
d987f844de | ||
|
|
b0d26fe1e6 | ||
|
|
e96131a933 | ||
|
|
3d58745bf1 | ||
|
|
9560148253 | ||
|
|
ec00efb656 | ||
|
|
ba34307918 | ||
|
|
28d8afae2e | ||
|
|
2e507be454 | ||
|
|
dbf3617224 | ||
|
|
200a7944e6 | ||
|
|
8dafc2b9a0 | ||
|
|
b7559c1f4e | ||
|
|
dd8e1864db | ||
|
|
d85a84980f | ||
|
|
4295966b7f | ||
|
|
a87c079ade | ||
|
|
b3604e3e40 | ||
|
|
6be8130329 | ||
|
|
ca3972c04f | ||
|
|
4d98729cde | ||
|
|
185af6f00e | ||
|
|
c5353d6e5e | ||
|
|
20a05dc96a | ||
|
|
30028b5ba0 | ||
|
|
3bafb708ea | ||
|
|
24d86a230a | ||
|
|
620f822504 | ||
|
|
93a57f26ee | ||
|
|
40ec4493ea | ||
|
|
de83d59bde | ||
|
|
a937c5e8de | ||
|
|
b820bb19d7 | ||
|
|
36db2d3f1d | ||
|
|
5786b86521 | ||
|
|
c6a5f93b44 | ||
|
|
8814e2eacb | ||
|
|
9b1e4a8291 | ||
|
|
01c2468f86 | ||
|
|
af705718ff | ||
|
|
e22ef12529 | ||
|
|
62510d9243 | ||
|
|
0fd25a6bb4 | ||
|
|
a268a7b75e | ||
|
|
3dded0a7bc | ||
|
|
ab3140be0a | ||
|
|
0fcd58c080 | ||
|
|
52f0d9f9e8 | ||
|
|
6585fa8e41 | ||
|
|
bef9b3b276 | ||
|
|
af411b854a | ||
|
|
a697dba5b9 | ||
|
|
1203081ec7 | ||
|
|
2db63ae2f8 | ||
|
|
9dd1617b57 | ||
|
|
834058ff45 | ||
|
|
0f41a0a576 | ||
|
|
1025cbfb87 | ||
|
|
ca3c1c7623 | ||
|
|
69d127a89e | ||
|
|
b4d9cd5bb7 | ||
|
|
4b1327db24 | ||
|
|
2f2ceed24e | ||
|
|
de0f20809d | ||
|
|
b1f5857cac | ||
|
|
ddbd1ca163 | ||
|
|
777e40fff8 | ||
|
|
68460331f5 | ||
|
|
32e9fe8b6a | ||
|
|
b3a2460b3f | ||
|
|
3260bf1b2a | ||
|
|
e69c6456d1 | ||
|
|
f8b3792e62 | ||
|
|
01c34c0a79 | ||
|
|
825751239f | ||
|
|
2465739060 | ||
|
|
e6834d2033 | ||
|
|
924100df4c | ||
|
|
1ac7d1e08b | ||
|
|
7becccd628 | ||
|
|
dff5cac4fe | ||
|
|
ecd0c1de20 | ||
|
|
878cb17b62 | ||
|
|
706981c231 | ||
|
|
d212cf7c40 | ||
|
|
19b6e5154f | ||
|
|
89263918ea | ||
|
|
e8fc6d163b | ||
|
|
34a670f8d7 | ||
|
|
6a46e2dd57 | ||
|
|
5e0fa7c744 | ||
|
|
474ce17a13 | ||
|
|
f00eed8633 | ||
|
|
42bcefa068 | ||
|
|
29bde998ed | ||
|
|
e29a83ec25 | ||
|
|
ddfcbde7d4 | ||
|
|
42e31d3ece | ||
|
|
cd8480bc67 | ||
|
|
06dc05be08 | ||
|
|
d3fda81af3 | ||
|
|
f58d345b63 | ||
|
|
f35382a4e0 | ||
|
|
8087430dd2 | ||
|
|
2a5f1f3668 | ||
|
|
a889cb30e0 | ||
|
|
e411c0ab7b | ||
|
|
a136371c1f | ||
|
|
83fa213854 | ||
|
|
750cc1ecae | ||
|
|
e25a854d24 | ||
|
|
3b32195271 | ||
|
|
f9411794c4 | ||
|
|
c5fdbfd62d | ||
|
|
f12ae90aba | ||
|
|
20c4bbcee2 | ||
|
|
f3232f34c2 | ||
|
|
8fedb23399 | ||
|
|
4ca519ce62 | ||
|
|
6d4768272a | ||
|
|
e8cc22879f | ||
|
|
b287683a61 | ||
|
|
ea43ee99d6 | ||
|
|
48db68e7ac | ||
|
|
7807b7a280 | ||
|
|
ad0e223742 | ||
|
|
c9a74f2800 | ||
|
|
570ed61edb | ||
|
|
835b302c19 | ||
|
|
a0fad9874a |
No files matched your search
@@ -13,7 +13,10 @@ project {
|
||||
"**/node_modules/**",
|
||||
"**/dist/**",
|
||||
"**/coverage/**",
|
||||
"**/pnpm-lock.yaml",
|
||||
"**/pnpm-workspace.yaml",
|
||||
"ui/desktop/electron-app/out/**",
|
||||
"ui/desktop/electron-app/ember-dist/**",
|
||||
"e2e-tests/api-client/**"
|
||||
]
|
||||
}
|
||||
@@ -11,21 +11,57 @@ assignees: ''
|
||||
|
||||
For questions, the best place to get answers is on our [discussion forum](https://discuss.hashicorp.com/c/boundary), as they will get more visibility from experienced users than the issue tracker.
|
||||
|
||||
Please note: We take Boundary's security and our users' trust very seriously. If you believe you have found a security issue in Boundary, please responsibly disclose by contacting us at security@hashicorp.com. Our PGP key is available at [our security page](https://www.hashicorp.com/security/).
|
||||
Please note: We take Boundary's security and our users' trust very seriously. If you believe you have found a security issue in Boundary, please responsibly disclose this by contacting us at security@hashicorp.com. Our PGP key is available on [our security page](https://www.hashicorp.com/security/).
|
||||
|
||||
Logging is available on Boundary Desktop Client version >= 2.1.0
|
||||
|
||||
Logs are found at:
|
||||
|
||||
Linux: ~/.config/Boundary/logs/desktop-client.log
|
||||
MacOS: ~/Library/Logs/Boundary/desktop-client.log
|
||||
Windows: $env:USERPROFILE\AppData\Roaming\Boundary\logs\desktop-client.log
|
||||
|
||||
If requests are taking longer than expected, please change the logging level to DEBUG. This will log the cache daemon request times as well.
|
||||
|
||||
-->
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
## :bug: Bug Report
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Run `boundary authenticate ...`
|
||||
2. Run `boundary connect ...`
|
||||
3. See error
|
||||
### **Description**
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
_A clear and concise description of the bug._
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
### **Steps to Reproduce**
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '...'
|
||||
3. Scroll down to '...'
|
||||
4. See the error
|
||||
|
||||
## **Expected behavior**
|
||||
|
||||
_A clear description of what you expected to happen._
|
||||
|
||||
## **Actual behavior**
|
||||
|
||||
_A clear description of what actually happened._
|
||||
|
||||
## **Screenshots (if applicable)**
|
||||
|
||||
_Add screenshots or screen recordings to help explain the issue._
|
||||
|
||||
## **Environment**
|
||||
|
||||
- **OS and Version:** (e.g., Windows 10 (22H2), macOS (14.7.2))
|
||||
- **Architecture:** (e.g., arm64, x86_64)
|
||||
- **Browser/Version:** (if applicable)
|
||||
- **Desktop Client Version:** (if applicable)
|
||||
- **Controller/worker versions:** (if applicable)
|
||||
|
||||
## **Logs & Console Output (if applicable)**
|
||||
|
||||
_Paste any relevant error logs or browser console output here._
|
||||
|
||||
## **Additional Context**
|
||||
|
||||
_Any other details that might be relevant._
|
||||
@@ -0,0 +1,6 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
@@ -12,9 +12,22 @@
|
||||
## Checklist
|
||||
<!-- strikethrough the checklist item that is not relevant to your change -->
|
||||
|
||||
<!-- If you are merging a long lived branch, major feature, or UI altering changes,
|
||||
please add the a11y-tests label. Other cases, like a dependency change or
|
||||
translation update likely does not need an a11y audit -->
|
||||
|
||||
- [ ] I have added before and after screenshots for UI changes
|
||||
- [ ] I have added JSON response output for API changes
|
||||
- [ ] I have added steps to reproduce and test for bug fixes in the description
|
||||
- [ ] I have commented on my code, particularly in hard-to-understand areas
|
||||
- [ ] My changes generate no new warnings
|
||||
- [ ] I have added tests that prove my fix is effective or that my feature works
|
||||
- [ ] I have added `a11y-tests` label to run a11y audit tests if needed
|
||||
|
||||
## PCI review checklist
|
||||
|
||||
<!-- heimdall_github_prtemplate:grc-pci_dss-2024-01-05 -->
|
||||
- [ ] I have documented a clear reason for, and description of, the change I am making.
|
||||
- [ ] If applicable, I've documented a plan to revert these changes if they require more than reverting the pull request.
|
||||
- [ ] If applicable, I've documented the impact of any changes to security controls.
|
||||
Examples of changes to security controls include using new access control methods, adding or removing logging pipelines, etc.
|
||||
@@ -27,7 +27,6 @@ echo "Trying to merge the latest oss commit - ${latest_oss_commit} from oss/${BR
|
||||
if ! errors=$(git merge -m "Merge Boundary UI OSS branch '${BRANCH}' at commit ${latest_oss_commit}" "${latest_oss_commit}"); then
|
||||
# Here we can try to auto resolve conflicts
|
||||
# This logic will vary from project to project. The below is just an example.
|
||||
# We'll try to resolve package.json and yarn.lock conflicts by taking the OSS changes
|
||||
conflicts=$(git diff --name-only --diff-filter=U)
|
||||
printf "Found conflicts:\n${conflicts}\n"
|
||||
# Resolve package.json conflicts by taking oss
|
||||
@@ -36,11 +35,12 @@ if ! errors=$(git merge -m "Merge Boundary UI OSS branch '${BRANCH}' at commit $
|
||||
git checkout --theirs package.json
|
||||
git add package.json
|
||||
fi
|
||||
# Resolve yarn.lock conflicts by taking oss
|
||||
if echo "$conflicts" | grep -q yarn.lock; then
|
||||
echo "Checking out oss yarn.lock..."
|
||||
git checkout --theirs yarn.lock
|
||||
git add yarn.lock
|
||||
# Resolve pnpm-lock.yaml conflicts by re-running pnpm install:
|
||||
# https://pnpm.io/git#merge-conflicts
|
||||
if echo "$conflicts" | grep -q pnpm-lock.yaml; then
|
||||
echo "Re-running pnpm install to fix conflicts by regenerating pnpm-lock.yaml..."
|
||||
pnpm install
|
||||
git add pnpm-lock.yaml
|
||||
fi
|
||||
|
||||
# If we can commit all the changes without conflict, we have resolved everything automatically
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
name: A11y Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled, opened, synchronize, reopened]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
||||
a11y-test:
|
||||
name: Run A11y Tests
|
||||
if: contains(github.event.pull_request.labels.*.name, 'a11y-tests')
|
||||
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- uses: browser-actions/setup-chrome@b94431e051d1c52dcbe9a7092a4f10f827795416 # v2.1.0
|
||||
with:
|
||||
install-dependencies: true
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||
with:
|
||||
run_install: false
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- run: pnpm install
|
||||
- name: Run a11y tests
|
||||
env:
|
||||
# this will generate the test reports but causes tests to pass even when there a11y failures
|
||||
ENABLE_A11Y_MIDDLEWARE_REPORTER: true
|
||||
run: pnpm test-a11y
|
||||
- name: Consolidate ember a11y reports
|
||||
run: |
|
||||
mkdir ember-a11y-reports
|
||||
jq -s 'map(.[])' ./ui/admin/ember-a11y-report/*.json > ember-a11y-reports/admin.json
|
||||
jq -s 'map(.[])' ./ui/desktop/ember-a11y-report/*.json > ember-a11y-reports/desktop.json
|
||||
- name: Output ember a11y test report summary (admin)
|
||||
run: 'cat ember-a11y-reports/admin.json | jq "map({moduleName,testName,routes,urls,violations: .violations | map({id, nodes: .nodes | map({failureSummary, targets: .[].[]?.relatedNodes?.[]?.target? })}) })"'
|
||||
- name: Output ember a11y test report summary (desktop)
|
||||
run: 'cat ember-a11y-reports/desktop.json | jq "map({moduleName,testName,routes,urls,violations: .violations | map({id, nodes: .nodes | map({failureSummary, targets: .[].[]?.relatedNodes?.[]?.target? })}) })"'
|
||||
- name: Upload ember a11y reports
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: ember-a11y-reports
|
||||
path: ./ember-a11y-reports
|
||||
- name: Report a11y test results status
|
||||
if: always()
|
||||
# fail CI when the combined array length of admin and desktop a11y reports is not 0
|
||||
run: |
|
||||
EMBER_A11Y_FAILURE_COUNT=$(jq -s 'map(.[]) | length' ember-a11y-reports/admin.json ember-a11y-reports/desktop.json)
|
||||
echo "$EMBER_A11Y_FAILURE_COUNT ember-a11y test failures"
|
||||
if [ "$EMBER_A11Y_FAILURE_COUNT" != '0' ]; then
|
||||
exit 1
|
||||
fi
|
||||
@@ -9,91 +9,86 @@ on:
|
||||
jobs:
|
||||
dependencies:
|
||||
name: Install dependencies
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
|
||||
id: node-modules-cache
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||
with:
|
||||
path: "**/node_modules"
|
||||
key: node-modules-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: |
|
||||
node-modules-
|
||||
- uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
run_install: false
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version: 20
|
||||
- run: yarn install
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- run: pnpm install --frozen-lockfile
|
||||
build-admin-ui-oss:
|
||||
name: Build Admin UI OSS
|
||||
needs: [dependencies]
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
|
||||
id: node-modules-cache
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||
with:
|
||||
path: "**/node_modules"
|
||||
key: node-modules-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: |
|
||||
node-modules-
|
||||
run_install: false
|
||||
- name: Build
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version: 20
|
||||
- run: yarn run build:ui:admin:oss
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- run: pnpm install --offline
|
||||
- run: pnpm run build:ui:admin:oss
|
||||
- name: Upload artifact admin-ui
|
||||
uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: admin-ui
|
||||
path: ./ui/admin/dist/
|
||||
- name: Upload artifact OSS admin UI
|
||||
uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: admin-ui-oss
|
||||
path: ./ui/admin/dist/
|
||||
build-admin-ui-enterprise:
|
||||
name: Build Admin UI Enterprise
|
||||
needs: [dependencies]
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
|
||||
id: node-modules-cache
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||
with:
|
||||
path: "**/node_modules"
|
||||
key: node-modules-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: |
|
||||
node-modules-
|
||||
run_install: false
|
||||
- name: Build
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version: 20
|
||||
- run: yarn run build:ui:admin:enterprise
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- run: pnpm install --offline
|
||||
- run: pnpm run build:ui:admin:enterprise
|
||||
- name: Upload artifact enterprise admin UI
|
||||
uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: admin-ui-ent
|
||||
path: ./ui/admin/dist/
|
||||
build-admin-ui-hcp:
|
||||
name: Build Admin UI HCP
|
||||
needs: [dependencies]
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
|
||||
id: node-modules-cache
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||
with:
|
||||
path: "**/node_modules"
|
||||
key: node-modules-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: |
|
||||
node-modules-
|
||||
run_install: false
|
||||
- name: Build
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version: 20
|
||||
- run: yarn run build:ui:admin:hcp
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- run: pnpm install --offline
|
||||
- run: pnpm run build:ui:admin:hcp
|
||||
- name: Upload artifact HCP admin UI
|
||||
uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: admin-ui-hcp
|
||||
path: ./ui/admin/dist/
|
||||
@@ -17,7 +17,7 @@ on:
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -27,18 +27,18 @@ jobs:
|
||||
language: ['javascript']
|
||||
# Learn more...
|
||||
# https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection
|
||||
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@3096afedf9873361b2b2f65e1445b13272c83eb8 # codeql-bundle-v2.20.0
|
||||
uses: github/codeql-action/init@70df9def86d22bf0ea4e7f8b956e7b92e7c1ea22 # codeql-bundle-v2.20.7
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||
|
||||
@@ -54,4 +54,4 @@ jobs:
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@3096afedf9873361b2b2f65e1445b13272c83eb8 # codeql-bundle-v2.20.0
|
||||
uses: github/codeql-action/analyze@70df9def86d22bf0ea4e7f8b956e7b92e7c1ea22 # codeql-bundle-v2.20.7
|
||||
@@ -2,14 +2,18 @@ name: Add Copyright Headers
|
||||
on: pull_request
|
||||
jobs:
|
||||
copywrite:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
# Ref and repository aren't normally needed, this is a workaround for a bug in auto commit
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
- uses: hashicorp/setup-copywrite@32638da2d4e81d56a0764aa1547882fc4d209636 # v1.1.3
|
||||
- name: Add missing Copyright Headers
|
||||
run: copywrite headers
|
||||
- uses: stefanzweifel/git-auto-commit-action@8621497c8c39c72f3e2a999a26b4ca1b5058a842 # v5.0.1
|
||||
- uses: stefanzweifel/git-auto-commit-action@28e16e81777b558cc906c8750092100bbb34c5e3 # v7.0.0
|
||||
with:
|
||||
commit_message: Add missing copyright headers
|
||||
@@ -11,7 +11,7 @@ name: Jira Sync
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
||||
name: Jira sync
|
||||
steps:
|
||||
- name: Check if community user
|
||||
|
||||
@@ -4,8 +4,8 @@ on:
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
||||
steps:
|
||||
- uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5.0.0
|
||||
- uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1
|
||||
with:
|
||||
repo-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
@@ -4,71 +4,71 @@ jobs:
|
||||
|
||||
dependencies:
|
||||
name: Install dependencies
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
|
||||
id: node-modules-cache
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||
with:
|
||||
path: '**/node_modules'
|
||||
key: node-modules-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: |
|
||||
node-modules-
|
||||
- uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
run_install: false
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version: 20
|
||||
- run: yarn install
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
compliance:
|
||||
name: License Compliance
|
||||
needs: [dependencies]
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
|
||||
id: node-modules-cache
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||
with:
|
||||
path: '**/node_modules'
|
||||
key: node-modules-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: |
|
||||
node-modules-
|
||||
- uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
run_install: false
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version: 20
|
||||
- run: yarn run compliance:licenses
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- run: pnpm install --offline
|
||||
- run: pnpm run compliance:licenses
|
||||
|
||||
lint:
|
||||
name: Lint
|
||||
needs: [dependencies, compliance]
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
|
||||
id: node-modules-cache
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||
with:
|
||||
path: '**/node_modules'
|
||||
key: node-modules-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: |
|
||||
node-modules-
|
||||
- uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
run_install: false
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version: 20
|
||||
- run: yarn lint
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- run: pnpm install --offline
|
||||
- run: pnpm lint
|
||||
|
||||
test:
|
||||
name: Test
|
||||
needs: [lint]
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
|
||||
id: node-modules-cache
|
||||
- uses: browser-actions/setup-chrome@b94431e051d1c52dcbe9a7092a4f10f827795416 # v2.1.0
|
||||
with:
|
||||
path: '**/node_modules'
|
||||
key: node-modules-${{ hashFiles('**/yarn.lock') }}
|
||||
- uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
install-dependencies: true
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||
with:
|
||||
run_install: false
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version: 20
|
||||
- run: yarn test
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- run: pnpm install --offline
|
||||
- run: pnpm test
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
trigger-oss-merge:
|
||||
# Run this only on merge events in OSS repo
|
||||
if: ${{ github.event.pull_request.merged && github.repository == 'hashicorp/boundary-ui' }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
||||
steps:
|
||||
- name: Trigger Merge
|
||||
env:
|
||||
|
||||
@@ -4,10 +4,10 @@ on:
|
||||
types: [ oss-merge ]
|
||||
jobs:
|
||||
oss-merge:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
token: ${{ secrets.ELEVATED_GITHUB_TOKEN }}
|
||||
- run: |
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
name: Test E2E Admin UI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled, opened, synchronize, reopened]
|
||||
paths:
|
||||
- 'ui/admin/**'
|
||||
- 'e2e-tests/*'
|
||||
- 'e2e-tests/api-client/**'
|
||||
- 'e2e-tests/helpers/**'
|
||||
- 'e2e-tests/admin/**'
|
||||
- '.github/workflows/test-e2e-admin-ui.yaml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
boundary-enterprise-branch:
|
||||
description: 'Branch of boundary-enterprise to test against (default to main)'
|
||||
required: true
|
||||
default: 'main'
|
||||
type: string
|
||||
boundary-branch:
|
||||
description: 'Branch of boundary to test against (default to main)'
|
||||
required: true
|
||||
default: 'main'
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: test
|
||||
runs-on: ${{ fromJSON(vars.RUNNER_UBUNTU_22) }}
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- boundary_edition: community
|
||||
github_repo: hashicorp/boundary
|
||||
folder_name: boundary
|
||||
boundary_edition_short: oss
|
||||
enos_scenario: e2e_ui_docker
|
||||
test_command: admin:ce:docker
|
||||
boundary_branch: ${{ github.event.inputs.boundary-branch || 'main' }}
|
||||
- boundary_edition: enterprise
|
||||
github_repo: hashicorp/boundary-enterprise
|
||||
folder_name: boundary-enterprise
|
||||
boundary_edition_short: ent
|
||||
enos_scenario: e2e_ui_docker_ent
|
||||
test_command: admin:ent:docker
|
||||
boundary_branch: ${{ github.event.inputs.boundary-enterprise-branch || 'main' }}
|
||||
fail-fast: false
|
||||
env:
|
||||
ENOS_VAR_e2e_debug_no_run: true
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version: 20
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: |
|
||||
pnpm-lock.yaml
|
||||
ui/desktop/electron-app/pnpm-lock.yaml
|
||||
|
||||
- name: Set up Terraform
|
||||
uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd
|
||||
|
||||
- name: Set up Enos
|
||||
uses: hashicorp/action-setup-enos@39a8f388434bea5eeba6649b99aa9949a55a1150
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install Vault CLI
|
||||
run: |
|
||||
wget https://releases.hashicorp.com/vault/1.12.2/vault_1.12.2_linux_amd64.zip -O /tmp/vault.zip
|
||||
unzip /tmp/vault.zip -d /usr/local/bin
|
||||
|
||||
- name: GPG setup
|
||||
run: |
|
||||
# Create a GPG key
|
||||
export KEY_PW=boundary
|
||||
gpg --generate-key --batch <<eoGpgConf
|
||||
%echo Started!
|
||||
Key-Type: RSA
|
||||
Key-Length: default
|
||||
Subkey-Type: RSA
|
||||
Name-Real: boundary
|
||||
Name-Comment: default
|
||||
Name-Email: default
|
||||
Expire-Date: 0
|
||||
Passphrase: $KEY_PW
|
||||
%commit
|
||||
%echo Done.
|
||||
eoGpgConf
|
||||
|
||||
# Enable gpg-preset-passphrase so that key passwords can be saved
|
||||
echo "allow-preset-passphrase" >> ~/.gnupg/gpg-agent.conf
|
||||
gpg-connect-agent reloadagent /bye &>/dev/null
|
||||
|
||||
# Get information about the created keys
|
||||
export lines=$(gpg --list-secret-keys --with-colons --with-keygrip)
|
||||
export KEY_ID=""
|
||||
while read -r line
|
||||
do
|
||||
# Save the first key id to be used later
|
||||
if [[ $line =~ "fpr"* ]]; then
|
||||
if [[ $KEY_ID == "" ]]; then
|
||||
KEY_ID="$(echo "$line" | sed -r 's/fpr|://g')"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Cache the passphrases for the keys so passwords do not need to be entered
|
||||
if [[ $line =~ "grp"* ]]; then
|
||||
export KEYGRIP_ID="$(echo "$line" | sed -r 's/grp|://g')"
|
||||
/usr/lib/gnupg/gpg-preset-passphrase --preset -P $KEY_PW $KEYGRIP_ID
|
||||
fi
|
||||
done <<< $lines
|
||||
|
||||
# Trust the key
|
||||
touch /tmp/test.txt
|
||||
gpg -a --encrypt -r $KEY_ID --trust-model always --batch --yes /tmp/test.txt
|
||||
echo "trusted-key $KEY_ID" >> ~/.gnupg/gpg.conf
|
||||
|
||||
# Initialize the password store
|
||||
pass init $KEY_ID &>/dev/null
|
||||
|
||||
- name: Checkout ${{ matrix.folder_name }} repo
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
repository: ${{ matrix.github_repo }}
|
||||
path: support/src/${{ matrix.folder_name }}
|
||||
token: ${{ secrets.ELEVATED_GITHUB_TOKEN }}
|
||||
ref: ${{ matrix.boundary_branch }}
|
||||
|
||||
- name: GH fix for localhost resolution
|
||||
run: |
|
||||
cat /etc/hosts && echo "-----------"
|
||||
sudo sed -i 's/::1 *localhost ip6-localhost ip6-loopback/::1 ip6 -localhost ip6-loopback/g' /etc/hosts
|
||||
cat /etc/hosts
|
||||
ssh -V
|
||||
|
||||
echo "127.0.0.1 localhost boundary" | sudo tee -a /etc/hosts
|
||||
echo "127.0.0.1 localhost worker" | sudo tee -a /etc/hosts
|
||||
echo "127.0.0.1 localhost vault" | sudo tee -a /etc/hosts
|
||||
|
||||
- name: Set git config
|
||||
run: |
|
||||
git config --global url."https://oauth2:${{ secrets.ELEVATED_GITHUB_TOKEN }}@github.com".insteadOf "https://github.com"
|
||||
|
||||
- name: Install node dependencies
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
cd e2e-tests
|
||||
pnpm playwright install --with-deps
|
||||
|
||||
- name: Set up test infra
|
||||
id: infra
|
||||
run: |
|
||||
ssh-keygen -N '' -t ed25519 -f ~/.ssh/github_enos
|
||||
mkdir -p ./enos/terraform-plugin-cache
|
||||
echo ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} > ./support/src/${{matrix.folder_name}}/internal/ui/VERSION
|
||||
export ENOS_VAR_ui_build_override="oss" && \
|
||||
export ENOS_VAR_boundary_edition=${{ matrix.boundary_edition_short }} && \
|
||||
export ENOS_VAR_enos_user=$GITHUB_ACTOR && \
|
||||
export ENOS_VAR_aws_ssh_private_key_path=~/.ssh/github_enos && \
|
||||
export ENOS_VAR_boundary_license="${{ secrets.BOUNDARY_ENT_LICENSE }}" && \
|
||||
enos scenario launch --timeout 60m0s --chdir ./support/src/${{matrix.folder_name}}/enos ${{matrix.enos_scenario}} builder:local
|
||||
|
||||
- name: Move boundary binary
|
||||
run: |
|
||||
mv ./support/src/${{matrix.folder_name}}/bin/boundary /usr/local/bin/
|
||||
boundary version
|
||||
|
||||
- name: Run Tests
|
||||
run: |
|
||||
source <(bash ./support/src/${{matrix.folder_name}}/enos/scripts/test_e2e_env.sh)
|
||||
cd e2e-tests
|
||||
pnpm run ${{ matrix.test_command }} --reporter=html
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: ${{ failure() }}
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: playwright-report-${{ matrix.boundary_edition }}
|
||||
path: e2e-tests/playwright-report
|
||||
|
||||
- name: Clean up test infra
|
||||
if: ${{ always() }}
|
||||
run: |
|
||||
export ENOS_VAR_boundary_edition=${{ matrix.boundary_edition_short }} && \
|
||||
export ENOS_VAR_enos_user=$GITHUB_ACTOR && \
|
||||
export ENOS_VAR_aws_ssh_private_key_path=~/.ssh/github_enos && \
|
||||
enos scenario destroy --timeout 60m0s --chdir ./support/src/${{matrix.folder_name}}/enos ${{matrix.enos_scenario}} builder:local
|
||||
|
||||
- name: Send Slack message on failure
|
||||
uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.1
|
||||
if: ${{ failure() && github.repository == 'hashicorp/boundary-ui' }}
|
||||
with:
|
||||
method: chat.postMessage
|
||||
token: ${{ secrets.SLACK_BOT_TOKEN }}
|
||||
payload: |
|
||||
channel: ${{ secrets.SLACK_CHANNEL_ID }}
|
||||
text: ":x: admin ui e2e tests failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\n*Branch:* ${{ github.repository }}:${{ github.head_ref }}"
|
||||
@@ -0,0 +1,128 @@
|
||||
name: Test E2E Desktop Client
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled, opened, synchronize, reopened]
|
||||
paths:
|
||||
- 'ui/desktop/**'
|
||||
- 'e2e-tests/*'
|
||||
- 'e2e-tests/api-client/**'
|
||||
- 'e2e-tests/helpers/**'
|
||||
- 'e2e-tests/desktop/**'
|
||||
- '.github/workflows/test-e2e-desktop.yaml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: test
|
||||
runs-on: ${{ fromJSON(vars.RUNNER_UBUNTU_22) }}
|
||||
env:
|
||||
ENOS_VAR_boundary_edition: "ent"
|
||||
ENOS_VAR_e2e_debug_no_run: true
|
||||
SETUP_CLI: true
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version: 20
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: |
|
||||
pnpm-lock.yaml
|
||||
ui/desktop/electron-app/pnpm-lock.yaml
|
||||
|
||||
- name: Set up Terraform
|
||||
uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd
|
||||
|
||||
- name: Set up Enos
|
||||
uses: hashicorp/action-setup-enos@39a8f388434bea5eeba6649b99aa9949a55a1150
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install Vault CLI
|
||||
run: |
|
||||
wget https://releases.hashicorp.com/vault/1.12.2/vault_1.12.2_linux_amd64.zip -O /tmp/vault.zip
|
||||
unzip /tmp/vault.zip -d /usr/local/bin
|
||||
|
||||
- name: Checkout boundary-enterprise
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
repository: hashicorp/boundary-enterprise
|
||||
# ref: ${{ github.event.inputs.boundary-enterprise-branch }}
|
||||
path: support/src/boundary-enterprise
|
||||
token: ${{ secrets.ELEVATED_GITHUB_TOKEN }}
|
||||
|
||||
- name: GH fix for localhost resolution
|
||||
run: |
|
||||
cat /etc/hosts && echo "-----------"
|
||||
sudo sed -i 's/::1 *localhost ip6-localhost ip6-loopback/::1 ip6 -localhost ip6-loopback/g' /etc/hosts
|
||||
cat /etc/hosts
|
||||
ssh -V
|
||||
|
||||
echo "127.0.0.1 localhost boundary" | sudo tee -a /etc/hosts
|
||||
echo "127.0.0.1 localhost worker" | sudo tee -a /etc/hosts
|
||||
echo "127.0.0.1 localhost vault" | sudo tee -a /etc/hosts
|
||||
|
||||
# TODO: Build image ourselves in case we don't want to use latest?
|
||||
- name: Get Boundary docker image
|
||||
run: |
|
||||
docker pull hashicorp/boundary-enterprise:latest
|
||||
docker save hashicorp/boundary-enterprise:latest | gzip > /tmp/boundary-enterprise.zip
|
||||
|
||||
- name: Set up test infra
|
||||
id: infra
|
||||
run: |
|
||||
ssh-keygen -N '' -t ed25519 -f ~/.ssh/github_enos
|
||||
mkdir -p ./enos/terraform-plugin-cache
|
||||
export ENOS_VAR_enos_user=$GITHUB_ACTOR && \
|
||||
export ENOS_VAR_aws_ssh_private_key_path=~/.ssh/github_enos && \
|
||||
export ENOS_VAR_boundary_license="${{ secrets.BOUNDARY_ENT_LICENSE }}" && \
|
||||
export ENOS_VAR_boundary_docker_image_file=/tmp/boundary-enterprise.zip && \
|
||||
enos scenario launch --timeout 60m0s --chdir ./support/src/boundary-enterprise/enos e2e_ui_docker_ent builder:crt
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
cd e2e-tests
|
||||
pnpm playwright install --with-deps
|
||||
|
||||
# Runner is headless so we need to enable a display to run the client
|
||||
- name: Run Tests
|
||||
run: |
|
||||
source <(bash ./support/src/boundary-enterprise/enos/scripts/test_e2e_env.sh)
|
||||
cd e2e-tests
|
||||
export DISPLAY=:99
|
||||
sudo Xvfb -ac :99 -screen 0 1280x1024x24 > /dev/null 2>&1 &
|
||||
pnpm run desktop
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: ${{ failure() }}
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: playwright-report
|
||||
path: e2e-tests/playwright-report
|
||||
|
||||
- name: Clean up test infra
|
||||
if: ${{ always() }}
|
||||
run: |
|
||||
export ENOS_VAR_enos_user=$GITHUB_ACTOR && \
|
||||
export ENOS_VAR_aws_ssh_private_key_path=~/.ssh/github_enos && \
|
||||
enos scenario destroy --timeout 60m0s --chdir ./support/src/boundary-enterprise/enos e2e_ui_docker_ent builder:crt
|
||||
|
||||
- name: Send Slack message on failure
|
||||
uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.1
|
||||
if: ${{ failure() && github.repository == 'hashicorp/boundary-ui' }}
|
||||
with:
|
||||
method: chat.postMessage
|
||||
token: ${{ secrets.SLACK_BOT_TOKEN }}
|
||||
payload: |
|
||||
channel: ${{ secrets.SLACK_CHANNEL_ID }}
|
||||
text: ":x: desktop client e2e tests failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\n*Branch:* ${{ github.repository }}:${{ github.head_ref }}"
|
||||
@@ -7,9 +7,9 @@ on:
|
||||
|
||||
jobs:
|
||||
trigger-gha-release:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Release description
|
||||
run: |
|
||||
echo "Triggering release with a TAG ${GITHUB_REF#refs/tags/}"
|
||||
|
||||
@@ -19,7 +19,6 @@ coverage-*
|
||||
libpeerconnection.log
|
||||
npm-debug.log*
|
||||
testem.log
|
||||
yarn-error.log
|
||||
.watchman-cookie*
|
||||
|
||||
### VisualStudioCode ###
|
||||
|
||||
+2
-2
@@ -1,2 +1,2 @@
|
||||
yarn compliance:licenses
|
||||
npx lint-staged
|
||||
pnpm compliance:licenses
|
||||
pnpm lint-staged
|
||||
@@ -21,4 +21,3 @@ else
|
||||
# This is to allow interaction with the terminal for commitizen
|
||||
exec < /dev/tty && npx git-cz --hook || true
|
||||
fi
|
||||
|
||||
@@ -25,7 +25,7 @@ subproject, e.g. running or testing.
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Optional Tooling](#optional-tooling)
|
||||
- [Installation](#installation)
|
||||
- [Yarn Commands](#yarn-commands)
|
||||
- [Pnpm Commands](#pnpm-commands)
|
||||
- [Contributing](#contributing)
|
||||
- [Branching](#branching)
|
||||
- [Building ToC](#building-toc)
|
||||
@@ -33,8 +33,8 @@ subproject, e.g. running or testing.
|
||||
- [Building with a Container](#building-with-a-container)
|
||||
- [Building Desktop UI for Production](#building-desktop-ui-for-production)
|
||||
- [Connect Boundary UI to Boundary local instance](#connect-boundary-ui-to-boundary-local-instance)
|
||||
- [For admin](#for-admin)
|
||||
- [For desktop](#for-desktop)
|
||||
- [For Admin](#for-admin)
|
||||
- [For Desktop](#for-desktop)
|
||||
- [Committing](#committing)
|
||||
- [License Checking](#license-checking)
|
||||
|
||||
@@ -67,8 +67,8 @@ You will need the following things properly installed on your computer.
|
||||
|
||||
* [Git][git]
|
||||
* [Node.js][node]
|
||||
* Supported versions: `v18` and `v20`.
|
||||
* [Yarn][yarn] installed globally
|
||||
* Supported versions: `v20` and `v22`.
|
||||
* [Pnpm][pnpm] installed globally
|
||||
* [Google Chrome][chrome]
|
||||
* [Firefox][firefox]
|
||||
|
||||
@@ -90,18 +90,18 @@ that best suit you and your personal style.
|
||||
|
||||
* `git clone <repository-url>` this repository
|
||||
* `cd boundary-ui`
|
||||
* `yarn install`
|
||||
* `pnpm install`
|
||||
|
||||
## Yarn Commands
|
||||
## Pnpm Commands
|
||||
|
||||
List of available project commands. `yarn run <command-name>`
|
||||
List of available project commands. `pnpm run <command-name>`
|
||||
|
||||
| Command | Description |
|
||||
|---------------------|---|
|
||||
| build | Builds Admin UI for production. |
|
||||
| test | Runs tests in CI mode. |
|
||||
| lint | Runs ember-template-lint on all of the hbs, js, and sass files. |
|
||||
| compliance:audit | Checks for issues using `yarn audit` with moderate and above criticality.|
|
||||
| compliance:audit | Checks for issues using `pnpm audit` with moderate and above criticality.|
|
||||
| compliance:licenses | Checks that all dependencies have OSS-compatible licenses. |
|
||||
| doc:toc | Automatically generates a table of contents for the README file. |
|
||||
|
||||
@@ -114,7 +114,7 @@ We follow [GitHub Flow][github-flow].
|
||||
### Building ToC
|
||||
|
||||
To autogenerate a ToC (table of contents) for this README,
|
||||
run `yarn doc:toc`. Please update the ToC whenever editing the structure
|
||||
run `pnpm doc:toc`. Please update the ToC whenever editing the structure
|
||||
of README.
|
||||
|
||||
### Building Admin UI for Production
|
||||
@@ -126,8 +126,8 @@ necessary for your target environment, as described in the [Environment Variable
|
||||
To quickly produce a production build of Admin UI, run from this folder:
|
||||
|
||||
```bash
|
||||
yarn install
|
||||
yarn build
|
||||
pnpm install
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Assets are saved to `ui/admin/dist/`.
|
||||
@@ -151,20 +151,20 @@ necessary for your target environment, as described in the [Environment Variable
|
||||
To quickly produce a production build of Desktop UI, run from this folder:
|
||||
|
||||
```bash
|
||||
yarn install
|
||||
yarn build:ui:desktop
|
||||
pnpm install
|
||||
pnpm build:ui:desktop
|
||||
```
|
||||
|
||||
In windows, UI is generated using docker to provide a stable UI across platforms.
|
||||
|
||||
```cmd
|
||||
yarn install
|
||||
pnpm install
|
||||
docker-compose -f docker-compose-embedding.yml run build-desktop-production
|
||||
yarn build:ui:desktop:app
|
||||
pnpm build:ui:desktop:app
|
||||
```
|
||||
|
||||
To provide a signing identify for macOS build, use `BOUNDARY_DESKTOP_SIGNING_IDENTITY`
|
||||
environment variable to set signing certificate name (e.g Developer ID Application: * (*)) when building desktop app using `yarn build:ui:desktop:app`.
|
||||
environment variable to set signing certificate name (e.g Developer ID Application: * (*)) when building desktop app using `pnpm build:ui:desktop:app`.
|
||||
|
||||
Assets are saved to `ui/desktop/electron-app/out/make/`.
|
||||
DMG packaged desktop UI is available at asset location as `boundary.dmg`.
|
||||
@@ -180,7 +180,7 @@ This assumes you already have up and running a Boundary dev instance in your loc
|
||||
|
||||
This assumes you are within `boundary-ui/ui/admin`.
|
||||
|
||||
You will need to turn `off` mirage, tell the UI where to find Boundary and run it: `$ ENABLE_MIRAGE=false API_HOST=http://localhost:9200 yarn start`.
|
||||
You will need to turn `off` mirage, tell the UI where to find Boundary and run it: `$ ENABLE_MIRAGE=false API_HOST=http://localhost:9200 pnpm start`.
|
||||
|
||||
Following terminal instructions, open in your browser: `http://localhost:4200/`.
|
||||
|
||||
@@ -190,7 +190,7 @@ Once you open the UI you will see the login screen, authenticate using generated
|
||||
|
||||
This assumes you are within `boundary-ui/ui/desktop`.
|
||||
|
||||
You will need to turn `off` mirage and run it: `$ ENABLE_MIRAGE=false yarn start:desktop`.
|
||||
You will need to turn `off` mirage and run it: `$ ENABLE_MIRAGE=false pnpm start:desktop`.
|
||||
|
||||
In login screen, authenticate using generated admin login name and password.
|
||||
|
||||
@@ -199,7 +199,7 @@ Enter the cluster URL of your Boundary dev instance, by default is: `http://loca
|
||||
### Committing
|
||||
|
||||
We use `husky` and `lint-staged` to ensure linting and other checks pass before a
|
||||
commit is completed. Simply do a `yarn install` to make sure the hooks are installed.
|
||||
commit is completed. Simply do a `pnpm install` to make sure the hooks are installed.
|
||||
|
||||
Use a normal `git commit` to go through the checks, if you need to skip these checks,
|
||||
use `git commit --no-verify` to bypass them. However, a consistent commit message will
|
||||
@@ -218,10 +218,10 @@ will not be accepted.
|
||||
|
||||
[git]: https://git-scm.com/
|
||||
[node]: https://nodejs.org/
|
||||
[yarn]: https://classic.yarnpkg.com/lang/en/
|
||||
[pnpm]: https://pnpm.io
|
||||
[chrome]: https://google.com/chrome/
|
||||
[firefox]: https://firefox.com/
|
||||
[yarn-workspaces]: https://classic.yarnpkg.com/en/docs/workspaces/
|
||||
[pnpm-workspaces]: https://pnpm.io/workspaces
|
||||
[homebrew]: https://brew.sh
|
||||
[warp]: https://www.warp.dev
|
||||
[vscode]: https://code.visualstudio.com/
|
||||
|
||||
@@ -12,8 +12,8 @@ insert_final_newline = true
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.{diff,md}]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.hbs]
|
||||
insert_final_newline = false
|
||||
|
||||
[*.{diff,md}]
|
||||
trim_trailing_whitespace = false
|
||||
@@ -1,12 +1,4 @@
|
||||
{
|
||||
/**
|
||||
Ember CLI sends analytics information by default. The data is completely
|
||||
anonymous, but there are times when you might want to disable this behavior.
|
||||
|
||||
Setting `disableAnalytics` to true will prevent any data from being sent.
|
||||
*/
|
||||
"disableAnalytics": false,
|
||||
|
||||
/**
|
||||
Setting `isTypeScriptProject` to true will force the blueprint generators to generate TypeScript
|
||||
rather than JavaScript by default, when a TypeScript version of a given blueprint is available.
|
||||
|
||||
@@ -1,25 +1,14 @@
|
||||
# unconventional js
|
||||
/blueprints/*/files/
|
||||
/vendor/
|
||||
|
||||
# compiled output
|
||||
/declarations/
|
||||
/dist/
|
||||
/tmp/
|
||||
|
||||
# dependencies
|
||||
/bower_components/
|
||||
/node_modules/
|
||||
|
||||
# misc
|
||||
/coverage/
|
||||
!.*
|
||||
.*/
|
||||
.eslintcache
|
||||
|
||||
# ember-try
|
||||
/.node_modules.ember-try/
|
||||
/bower.json.ember-try
|
||||
/npm-shrinkwrap.json.ember-try
|
||||
/package.json.ember-try
|
||||
/package-lock.json.ember-try
|
||||
/yarn.lock.ember-try
|
||||
@@ -26,6 +26,7 @@ module.exports = {
|
||||
browser: true,
|
||||
},
|
||||
rules: {
|
||||
curly: ['error', 'multi-line', 'consistent'],
|
||||
'ember/no-get': 'off',
|
||||
'ember/no-get-with-default': 'off',
|
||||
'ember/no-computed-properties-in-native-classes': 'off',
|
||||
@@ -45,6 +46,7 @@ module.exports = {
|
||||
'./blueprints/*/index.js',
|
||||
'./config/**/*.js',
|
||||
'./tests/dummy/config/**/*.js',
|
||||
'./broccoli-plugins/**/*.js',
|
||||
],
|
||||
parserOptions: {
|
||||
sourceType: 'script',
|
||||
@@ -56,7 +58,7 @@ module.exports = {
|
||||
extends: ['plugin:n/recommended'],
|
||||
},
|
||||
{
|
||||
// Test files:
|
||||
// test files
|
||||
files: ['tests/**/*-test.{js,ts}'],
|
||||
extends: ['plugin:qunit/recommended'],
|
||||
rules: {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# compiled output
|
||||
/dist/
|
||||
/declarations/
|
||||
|
||||
# dependencies
|
||||
/node_modules/
|
||||
|
||||
# misc
|
||||
/.env*
|
||||
/.pnp*
|
||||
/.eslintcache
|
||||
/coverage/
|
||||
/testem.log
|
||||
|
||||
# ember-try
|
||||
/.node_modules.ember-try/
|
||||
/package.json.ember-try
|
||||
|
||||
# broccoli-debug
|
||||
/DEBUG/
|
||||
+2
-12
@@ -2,11 +2,7 @@
|
||||
/dist/
|
||||
/tmp/
|
||||
|
||||
# dependencies
|
||||
/bower_components/
|
||||
|
||||
# misc
|
||||
/.bowerrc
|
||||
/.editorconfig
|
||||
/.ember-cli
|
||||
/.env*
|
||||
@@ -19,21 +15,15 @@
|
||||
/.prettierignore
|
||||
/.prettierrc.js
|
||||
/.template-lintrc.js
|
||||
/.travis.yml
|
||||
/.watchmanconfig
|
||||
/bower.json
|
||||
/CONTRIBUTING.md
|
||||
/ember-cli-build.js
|
||||
/testem.js
|
||||
/tests/
|
||||
/yarn-error.log
|
||||
/yarn.lock
|
||||
/tsconfig.declarations.json
|
||||
/tsconfig.json
|
||||
.gitkeep
|
||||
|
||||
# ember-try
|
||||
/.node_modules.ember-try/
|
||||
/bower.json.ember-try
|
||||
/npm-shrinkwrap.json.ember-try
|
||||
/package.json.ember-try
|
||||
/package-lock.json.ember-try
|
||||
/yarn.lock.ember-try
|
||||
@@ -1,25 +1,13 @@
|
||||
# unconventional js
|
||||
/blueprints/*/files/
|
||||
/vendor/
|
||||
|
||||
# compiled output
|
||||
/dist/
|
||||
/tmp/
|
||||
|
||||
# dependencies
|
||||
/bower_components/
|
||||
/node_modules/
|
||||
|
||||
# misc
|
||||
/coverage/
|
||||
!.*
|
||||
.eslintcache
|
||||
.lint-todo/
|
||||
.*/
|
||||
|
||||
# ember-try
|
||||
/.node_modules.ember-try/
|
||||
/bower.json.ember-try
|
||||
/npm-shrinkwrap.json.ember-try
|
||||
/package.json.ember-try
|
||||
/package-lock.json.ember-try
|
||||
/yarn.lock.ember-try
|
||||
@@ -3,6 +3,8 @@
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
overrides: [
|
||||
{
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"ignore_dirs": ["tmp", "dist"]
|
||||
"ignore_dirs": ["dist"]
|
||||
}
|
||||
+15
-15
@@ -8,7 +8,7 @@ This addon contains the API data access layer for Boundary.
|
||||
|
||||
- [Add API to an App](#add-api-to-an-app)
|
||||
- [Installation](#installation)
|
||||
- [Yarn Commands](#yarn-commands)
|
||||
- [Pnpm Commands](#pnpm-commands)
|
||||
- [Linting](#linting)
|
||||
- [Formatting](#formatting)
|
||||
- [Running tests](#running-tests)
|
||||
@@ -21,7 +21,7 @@ This addon contains the API data access layer for Boundary.
|
||||
## Add API to an App
|
||||
|
||||
Add this addon to an Ember application's `devDependencies` as:
|
||||
`"api": "*"`, for applications included in this monorepo.
|
||||
`"api": "workspace:*"`, for applications included in this monorepo.
|
||||
|
||||
Since this addon also includes Mirage mocks, be sure to install
|
||||
`ember-cli-mirage` and add the following config to your UI project:
|
||||
@@ -38,9 +38,9 @@ See monorepo README for installation instructions.
|
||||
|
||||
When manually installing addon, ensure `ember-data-fragments@5.0.0-beta.*` is installed to use api models with data fragments.
|
||||
|
||||
## Yarn Commands
|
||||
## Pnpm Commands
|
||||
|
||||
List of available project commands. `yarn run <command-name>`
|
||||
List of available project commands. `pnpm run <command-name>`
|
||||
|
||||
| Command | Description |
|
||||
| ------- | ----------- |
|
||||
@@ -64,28 +64,28 @@ Additional commands in the monorepo package may affect this projects.
|
||||
|
||||
## Linting
|
||||
|
||||
* `yarn lint:fix`
|
||||
* `yarn lint:hbs`
|
||||
* `yarn lint:hbs:fix`
|
||||
* `yarn lint:js`
|
||||
* `yarn lint:js:fix`
|
||||
* `pnpm lint:fix`
|
||||
* `pnpm lint:hbs`
|
||||
* `pnpm lint:hbs:fix`
|
||||
* `pnpm lint:js`
|
||||
* `pnpm lint:js:fix`
|
||||
|
||||
## Formatting
|
||||
|
||||
Before submitting your work, be sure to run auto-formatters
|
||||
(see commands above). This helps to ensure consistency among authors.
|
||||
|
||||
* `yarn format`
|
||||
* `pnpm format`
|
||||
|
||||
## Running tests
|
||||
|
||||
* `yarn test` – Runs the test suite on the current Ember version
|
||||
* `yarn test --server` – Runs the test suite in "watch mode"
|
||||
* `yarn test:all` – Runs the test suite against multiple Ember versions
|
||||
* `pnpm test` – Runs the test suite on the current Ember version
|
||||
* `pnpm test --server` – Runs the test suite in "watch mode"
|
||||
* `pnpm test:all` – Runs the test suite against multiple Ember versions
|
||||
|
||||
## Running the dummy application
|
||||
|
||||
* `yarn start`
|
||||
* `pnpm start`
|
||||
* Visit the dummy application at [http://localhost:4200](http://localhost:4200).
|
||||
|
||||
## Contributing
|
||||
@@ -95,5 +95,5 @@ See monorepo README for more contribution instructions.
|
||||
### Building ToC
|
||||
|
||||
To autogenerate a ToC (table of contents) for this README,
|
||||
run `yarn doc:toc`. Please update the ToC whenever editing the structure
|
||||
run `pnpm doc:toc`. Please update the ToC whenever editing the structure
|
||||
of README.
|
||||
@@ -56,32 +56,33 @@ export default function setupStubs(hooks) {
|
||||
stubTypes = types;
|
||||
|
||||
types.forEach((type, i) => {
|
||||
let models;
|
||||
let resourceName;
|
||||
if (typeOf(type) === 'object') {
|
||||
models = type.func();
|
||||
resourceName = type.resource;
|
||||
} else {
|
||||
models = this.server.schema[type].all().models;
|
||||
resourceName = type;
|
||||
}
|
||||
|
||||
this.ipcStub
|
||||
.withArgs('searchCacheDaemon')
|
||||
.onCall(i)
|
||||
.returns({
|
||||
[underscore(resourceNames[singularize(resourceName)])]: models.map(
|
||||
(model) => {
|
||||
// Use internal serializer to serialize the model correctly
|
||||
// according to our mirage serializers
|
||||
const modelData =
|
||||
this.server.serializerOrRegistry.serialize(model);
|
||||
.callsFake(() => {
|
||||
let models;
|
||||
let resourceName;
|
||||
if (typeOf(type) === 'object') {
|
||||
models = type.func();
|
||||
resourceName = type.resource;
|
||||
} else {
|
||||
models = this.server.schema[type].all().models;
|
||||
resourceName = type;
|
||||
}
|
||||
|
||||
// Serialize the data properly to standard JSON as that is what
|
||||
// we're expecting from the cache daemon response
|
||||
return JSON.parse(JSON.stringify(modelData));
|
||||
},
|
||||
),
|
||||
return {
|
||||
[underscore(resourceNames[singularize(resourceName)])]:
|
||||
models.map((model) => {
|
||||
// Use internal serializer to serialize the model correctly
|
||||
// according to our mirage serializers
|
||||
const modelData =
|
||||
this.server.serializerOrRegistry.serialize(model);
|
||||
|
||||
// Serialize the data properly to standard JSON as that is what
|
||||
// we're expecting from the cache daemon response
|
||||
return JSON.parse(JSON.stringify(modelData));
|
||||
}),
|
||||
};
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
/**
|
||||
* This test helper can help set up and cleanup the database in your tests.
|
||||
*
|
||||
* ```js
|
||||
* import { setupIndexedDb } from 'api/test-support/helpers/indexed-db';
|
||||
*
|
||||
* module('Acceptance | my test', function(hooks) {
|
||||
* setupApplicationTest(hooks);
|
||||
* setupIndexedDb(hooks);
|
||||
*
|
||||
* // add your actual tests here
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* @param hooks
|
||||
*/
|
||||
export function setupIndexedDb(hooks) {
|
||||
hooks.beforeEach(function () {
|
||||
const indexedDb = this.owner.lookup('service:indexed-db');
|
||||
indexedDb.setup(`test-indexed-db-${Date.now()}`);
|
||||
});
|
||||
|
||||
hooks.afterEach(async function () {
|
||||
const indexedDb = this.owner.lookup('service:indexed-db');
|
||||
|
||||
await indexedDb.db.delete();
|
||||
});
|
||||
}
|
||||
|
||||
export default setupIndexedDb;
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
/**
|
||||
* This test helper can help set up and cleanup the database in your tests.
|
||||
*
|
||||
* ```js
|
||||
* import { setupSqlite } from 'api/test-support/helpers/sqlite';
|
||||
*
|
||||
* module('Acceptance | my test', function(hooks) {
|
||||
* setupApplicationTest(hooks);
|
||||
* setupSqlite(hooks);
|
||||
*
|
||||
* // add your actual tests here
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* @param hooks
|
||||
*/
|
||||
export function setupSqlite(hooks) {
|
||||
hooks.beforeEach(async function () {
|
||||
const sqliteDb = this.owner.lookup('service:sqlite');
|
||||
await sqliteDb.setup(`test-sqlite-${uuidv4()}`);
|
||||
});
|
||||
|
||||
hooks.afterEach(async function () {
|
||||
const sqliteDb = this.owner.lookup('service:sqlite');
|
||||
|
||||
await sqliteDb.deleteDatabase();
|
||||
});
|
||||
}
|
||||
|
||||
export default setupSqlite;
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import ModelAbility from './model';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
/**
|
||||
* Provides abilities for channel recordings.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import ModelAbility from './model';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { service } from '@ember/service';
|
||||
/**
|
||||
* Provides abilities for credential store.
|
||||
*/
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import ModelAbility from './model';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
class InvalidRolePrincipalTypeError extends Error {
|
||||
name = 'InvalidRolePrincipalTypeError';
|
||||
@@ -59,10 +59,11 @@ export default class RoleAbility extends ModelAbility {
|
||||
const type = this.model.constructor.modelName;
|
||||
const typeIsValid =
|
||||
type === 'user' || type === 'group' || type === 'managed-group';
|
||||
if (!typeIsValid)
|
||||
if (!typeIsValid) {
|
||||
throw new InvalidRolePrincipalTypeError(
|
||||
`Expected a role principal of type 'user', 'group', or 'managed-group'. Got '${type}'.`,
|
||||
);
|
||||
}
|
||||
return this.can.can(`read ${type}`, this.model);
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,8 @@ export default class TargetAbility extends ModelAbility {
|
||||
*/
|
||||
get canAddInjectedApplicationCredentialSources() {
|
||||
return (
|
||||
this.model.isSSH && this.hasAuthorizedAction('add-credential-sources')
|
||||
(this.model.isSSH || this.model.isRDP) &&
|
||||
this.hasAuthorizedAction('add-credential-sources')
|
||||
);
|
||||
}
|
||||
/**
|
||||
|
||||
@@ -115,7 +115,7 @@ export default class ApplicationAdapter extends RESTAdapter.extend(
|
||||
* @return {Promise} promise
|
||||
*/
|
||||
async query(store, schema, query) {
|
||||
const queryObject = structuredClone(query);
|
||||
const { batchLimit, ...queryObject } = query;
|
||||
let result;
|
||||
let data = [];
|
||||
|
||||
@@ -126,8 +126,10 @@ export default class ApplicationAdapter extends RESTAdapter.extend(
|
||||
result = await super.query(store, schema, query);
|
||||
return prenormalizeArrayResponse(result);
|
||||
}
|
||||
|
||||
// Run this loop as long as the response_type is delta,
|
||||
// which indicates that there are more items in the list
|
||||
// or the batch size has not been reached
|
||||
do {
|
||||
result = await super.query(store, schema, queryObject);
|
||||
//add the result items to a data array
|
||||
@@ -136,7 +138,10 @@ export default class ApplicationAdapter extends RESTAdapter.extend(
|
||||
//pass in the list token for subsequent calls to fetch the remaining list items
|
||||
queryObject.list_token = result.list_token;
|
||||
}
|
||||
} while (result && result.response_type === 'delta');
|
||||
} while (
|
||||
result?.response_type === 'delta' &&
|
||||
(!batchLimit || data.length < batchLimit)
|
||||
);
|
||||
|
||||
result.items = data;
|
||||
return prenormalizeArrayResponse(result);
|
||||
@@ -163,7 +168,7 @@ export default class ApplicationAdapter extends RESTAdapter.extend(
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides default ajax method by rewritting PUT requests as PATCH.
|
||||
* Overrides default ajax method by rewriting PUT requests as PATCH.
|
||||
* PATCH is the request method used by our API for updates.
|
||||
*
|
||||
* @override
|
||||
@@ -291,9 +296,7 @@ export default class ApplicationAdapter extends RESTAdapter.extend(
|
||||
const errors = fieldErrors.map((error) => ({
|
||||
detail: error.description,
|
||||
// Going forward, nested attributes will be hoisted. Thus "attributes."
|
||||
// must be stripped from the error name key. Until all models are
|
||||
// transitioned away from model fragments, however, both this and the
|
||||
// prior error key mappings are necessary.
|
||||
// must be stripped from the error name key.
|
||||
source: {
|
||||
pointer: `/data/attributes/${error.name.replace('attributes.', '')}`,
|
||||
},
|
||||
|
||||
@@ -81,9 +81,10 @@ export default class GeneratedCredentialLibraryModel extends BaseModel {
|
||||
})
|
||||
credential_type;
|
||||
|
||||
@attr('object-as-array', {
|
||||
@attr('object', {
|
||||
for: 'vault-generic',
|
||||
description: 'It indicates the credential mapping overrides.',
|
||||
emptyObjectIfMissing: true,
|
||||
})
|
||||
credential_mapping_overrides;
|
||||
|
||||
|
||||
@@ -51,17 +51,17 @@ export default class GeneratedCredentialModel extends BaseModel {
|
||||
})
|
||||
version;
|
||||
|
||||
// =attributes (username_password, ssh_private_key)
|
||||
// =attributes (username_password, ssh_private_key, username_password_domain)
|
||||
@attr('string', {
|
||||
for: ['username_password', 'ssh_private_key'],
|
||||
for: ['username_password', 'ssh_private_key', 'username_password_domain'],
|
||||
isNestedAttribute: true,
|
||||
description: 'The username for credential.',
|
||||
})
|
||||
username;
|
||||
|
||||
// =attributes (username_password)
|
||||
// =attributes (username_password, username_password_domain, password)
|
||||
@attr('string', {
|
||||
for: 'username_password',
|
||||
for: ['username_password', 'username_password_domain', 'password'],
|
||||
isNestedAttribute: true,
|
||||
isSecret: true,
|
||||
description: 'The password for credential.',
|
||||
@@ -92,4 +92,12 @@ export default class GeneratedCredentialModel extends BaseModel {
|
||||
description: 'The secret associated with credential.',
|
||||
})
|
||||
json_object;
|
||||
|
||||
// =attributes (username_password_domain)
|
||||
@attr('string', {
|
||||
for: 'username_password_domain',
|
||||
isNestedAttribute: true,
|
||||
description: 'The domain for credential.',
|
||||
})
|
||||
domain;
|
||||
}
|
||||
@@ -88,6 +88,7 @@ export default class GeneratedHostSetModel extends BaseModel {
|
||||
name: ['aws', 'azure', 'gcp'],
|
||||
},
|
||||
emptyArrayIfMissing: true,
|
||||
trimWhitespace: true,
|
||||
})
|
||||
preferred_endpoints;
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ export default class GeneratedStorageBucketModel extends BaseModel {
|
||||
@attr('string', {
|
||||
isNestedAttribute: true,
|
||||
description: 'The AWS region to use.',
|
||||
trimWhitespace: true,
|
||||
})
|
||||
region;
|
||||
|
||||
@@ -109,6 +110,7 @@ export default class GeneratedStorageBucketModel extends BaseModel {
|
||||
@attr('string', {
|
||||
isNestedAttribute: true,
|
||||
description: 'The role ARN to use.',
|
||||
trimWhitespace: true,
|
||||
})
|
||||
role_arn;
|
||||
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
/* global __electronLog */
|
||||
import { inject as service } from '@ember/service';
|
||||
import { service } from '@ember/service';
|
||||
import { getOwner, setOwner } from '@ember/application';
|
||||
import { generateMQLExpression } from '../utils/mql-query';
|
||||
import { paginateResults } from '../utils/paginate-results';
|
||||
import { underscore } from '@ember/string';
|
||||
|
||||
const { __electronLog } = globalThis;
|
||||
|
||||
/**
|
||||
* Not all types are yet supported by the cache daemon so we'll
|
||||
* just whitelist the ones we need to start with. Keys are the resource
|
||||
@@ -42,7 +43,10 @@ const fetchControllerData = async (context, next) => {
|
||||
// If we get an error or cache daemon is unavailable, fall back to calling the API
|
||||
context.request.data.query = remainingQuery;
|
||||
const results = await next(context.request);
|
||||
const models = results.content.toArray();
|
||||
|
||||
// As a result of https://rfcs.emberjs.com/id/0846-ember-data-deprecate-proxies/ we cannot use proxy array methods like .toArray
|
||||
// use slice() instead as indicated in the RFC.
|
||||
const models = results.content.slice();
|
||||
|
||||
const paginatedResult = paginateResults(models, page, pageSize);
|
||||
paginatedResult.meta = { totalItems: models.length };
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { modelIndexes } from 'api/services/indexed-db';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { getOwner, setOwner } from '@ember/application';
|
||||
import { queryIndexedDb } from '../utils/indexed-db-query';
|
||||
import { paginateResults } from '../utils/paginate-results';
|
||||
import { hashCode } from '../utils/hash-code';
|
||||
|
||||
/**
|
||||
* Handler to sit in front of the API request layer
|
||||
* so we can handle any caching with indexedDb first
|
||||
*/
|
||||
export default class IndexedDbHandler {
|
||||
@service indexedDb;
|
||||
|
||||
constructor(context) {
|
||||
setOwner(this, getOwner(context));
|
||||
}
|
||||
|
||||
async request(context, next) {
|
||||
switch (context.request.op) {
|
||||
case 'query': {
|
||||
const { store, data } = context.request;
|
||||
// TODO: Remove storeToken option as this is a temporary fix for auth-methods.
|
||||
const {
|
||||
type,
|
||||
query,
|
||||
options: {
|
||||
pushToStore = true,
|
||||
peekIndexedDB = false,
|
||||
storeToken = true,
|
||||
} = {},
|
||||
} = data;
|
||||
const supportedModels = Object.keys(modelIndexes);
|
||||
const { db: indexedDb } = this.indexedDb ?? {};
|
||||
|
||||
// Go through normal flow if we don't yet support the model
|
||||
// or if we don't have an indexedDb instance
|
||||
if (!supportedModels.includes(type) || !indexedDb) {
|
||||
return next(context.request);
|
||||
}
|
||||
|
||||
let { page, pageSize, query: queryObj, ...remainingQuery } = query;
|
||||
|
||||
const adapter = store.adapterFor(type);
|
||||
const schema = store.modelFor(type);
|
||||
const serializer = store.serializerFor(type);
|
||||
|
||||
if (!peekIndexedDB) {
|
||||
const tokenKey = `${type}-${hashCode(remainingQuery)}`;
|
||||
|
||||
let listToken;
|
||||
if (storeToken) {
|
||||
listToken = await indexedDb.token.get(tokenKey);
|
||||
}
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = await adapter.query(store, schema, {
|
||||
...remainingQuery,
|
||||
list_token: listToken?.token,
|
||||
});
|
||||
} catch (e) {
|
||||
// If we get an invalid list token, we'll delete the token and
|
||||
// clear the DB and try again without a token.
|
||||
if (
|
||||
e?.errors[0].status === 400 &&
|
||||
e?.errors[0].code === 'invalid list token'
|
||||
) {
|
||||
await indexedDb[type].clear();
|
||||
// Delete all tokens of the same resource type so we don't keep clearing the DB.
|
||||
// Even if other tokens may still be valid, we might have cleared data that they
|
||||
// depended on so we should clear all tokens of the same type when clearing the DB.
|
||||
const tokenKeys = await indexedDb.token
|
||||
.where(':id')
|
||||
.startsWith(type)
|
||||
.primaryKeys();
|
||||
|
||||
await indexedDb.token.bulkDelete(tokenKeys);
|
||||
|
||||
payload = await adapter.query(store, schema, {
|
||||
...remainingQuery,
|
||||
});
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Store the token we just got back from the payload if it exists
|
||||
if (payload.list_token && storeToken) {
|
||||
await indexedDb.token.put({
|
||||
id: tokenKey,
|
||||
token: payload.list_token,
|
||||
});
|
||||
}
|
||||
|
||||
// Remove any records from the DB if the API indicates they've been deleted
|
||||
if (payload.removed_ids?.length > 0) {
|
||||
await indexedDb[type].bulkDelete(payload.removed_ids);
|
||||
}
|
||||
// This is a temporary fix of clearing the DB (specifically for auth-methods)
|
||||
// since we are not storing the token we do not get back a list of removed_ids
|
||||
// from the API call and we do not want deleted items from showing in list view.
|
||||
if (!storeToken) {
|
||||
await indexedDb[type].clear();
|
||||
}
|
||||
|
||||
const normalizedPayload = serializer.normalizeResponse(
|
||||
store,
|
||||
schema,
|
||||
payload,
|
||||
null,
|
||||
'query',
|
||||
);
|
||||
|
||||
const { data: payloadData } = normalizedPayload;
|
||||
|
||||
// Store the new data we just got back from the API refresh
|
||||
const items = payloadData.map((datum) =>
|
||||
this.indexedDb.normalizeData({
|
||||
data: datum,
|
||||
cleanData: true,
|
||||
schema,
|
||||
serializer,
|
||||
}),
|
||||
);
|
||||
|
||||
await indexedDb[type].bulkPut(items);
|
||||
}
|
||||
|
||||
const indexedDbResults = await queryIndexedDb(
|
||||
indexedDb,
|
||||
type,
|
||||
queryObj,
|
||||
);
|
||||
|
||||
const dbRecords = paginateResults(indexedDbResults, page, pageSize).map(
|
||||
(item) =>
|
||||
this.indexedDb.normalizeData({
|
||||
data: item,
|
||||
cleanData: false,
|
||||
schema,
|
||||
serializer,
|
||||
}),
|
||||
);
|
||||
|
||||
// Return the raw data if we don't push to the store.
|
||||
let records = dbRecords.map((record) => ({
|
||||
...record.attributes,
|
||||
id: record.id,
|
||||
}));
|
||||
|
||||
if (pushToStore) {
|
||||
records = store.push({ data: dbRecords });
|
||||
}
|
||||
|
||||
// Set a meta property on the array to store the total items of the results.
|
||||
// This isn't conventional but is better than returning an ArrayProxy
|
||||
// or EmberArray since the ember store query method asserts it has to be an array
|
||||
// so we can't just return an object.
|
||||
records.meta = { totalItems: indexedDbResults.length };
|
||||
return records;
|
||||
}
|
||||
default:
|
||||
return next(context.request);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { getOwner, setOwner } from '@ember/owner';
|
||||
import { service } from '@ember/service';
|
||||
import { modelMapping } from 'api/services/sqlite';
|
||||
import { get } from '@ember/object';
|
||||
import { typeOf } from '@ember/utils';
|
||||
import { hashCode } from '../utils/hash-code';
|
||||
import { generateSQLExpressions } from '../utils/sqlite-query';
|
||||
|
||||
const isISODateString = (str) =>
|
||||
typeOf(str) === 'string' &&
|
||||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z?$/.test(str);
|
||||
|
||||
export default class SqliteHandler {
|
||||
@service sqlite;
|
||||
|
||||
batchLimit = 5_000;
|
||||
|
||||
constructor(context) {
|
||||
setOwner(this, getOwner(context));
|
||||
}
|
||||
|
||||
async request(context, next) {
|
||||
switch (context.request.op) {
|
||||
case 'query': {
|
||||
const { store, data } = context.request;
|
||||
const {
|
||||
type,
|
||||
query,
|
||||
options: {
|
||||
pushToStore = true,
|
||||
peekDb = false,
|
||||
storeToken = true,
|
||||
} = {},
|
||||
} = data;
|
||||
const supportedModels = Object.keys(modelMapping);
|
||||
|
||||
// Go through normal flow if we don't yet support the model
|
||||
// or if we don't have a sqlite db instance
|
||||
if (!supportedModels.includes(type) || !this.sqlite.worker) {
|
||||
return next(context.request);
|
||||
}
|
||||
|
||||
const adapter = store.adapterFor(type);
|
||||
const schema = store.modelFor(type);
|
||||
const serializer = store.serializerFor(type);
|
||||
|
||||
let {
|
||||
page,
|
||||
pageSize,
|
||||
select,
|
||||
query: queryObj,
|
||||
...remainingQuery
|
||||
} = query;
|
||||
let payload,
|
||||
listToken,
|
||||
writeToDbPromise,
|
||||
totalInsert = 0;
|
||||
|
||||
if (!peekDb) {
|
||||
const tokenKey = `${type}-${hashCode(remainingQuery)}`;
|
||||
if (storeToken) {
|
||||
const [tokenObj] = await this.sqlite.fetchResource({
|
||||
...generateSQLExpressions('token', {
|
||||
filters: { id: [{ equals: tokenKey }] },
|
||||
}),
|
||||
});
|
||||
listToken = tokenObj?.token;
|
||||
} else {
|
||||
// This is a temporary fix of clearing the DB (specifically for auth-methods)
|
||||
// since we are not storing the token we do not get back a list of removed_ids
|
||||
// from the API call and we do not want deleted items from showing in list view.
|
||||
await this.sqlite.deleteResource(type);
|
||||
}
|
||||
|
||||
do {
|
||||
try {
|
||||
payload = await adapter.query(store, schema, {
|
||||
...remainingQuery,
|
||||
list_token: listToken,
|
||||
batchLimit: this.batchLimit,
|
||||
});
|
||||
totalInsert += payload.items?.length ?? 0;
|
||||
|
||||
// await the previous writeToDbPromise before writing to db again
|
||||
if (writeToDbPromise) {
|
||||
await writeToDbPromise;
|
||||
}
|
||||
} catch (err) {
|
||||
payload = await this.retryQueryFailure({
|
||||
err,
|
||||
type,
|
||||
adapter,
|
||||
store,
|
||||
schema,
|
||||
remainingQuery,
|
||||
});
|
||||
totalInsert += payload.items?.length ?? 0;
|
||||
}
|
||||
listToken = payload.list_token;
|
||||
|
||||
writeToDbPromise = this.writeToDb(
|
||||
payload,
|
||||
storeToken,
|
||||
tokenKey,
|
||||
pushToStore,
|
||||
serializer,
|
||||
store,
|
||||
schema,
|
||||
type,
|
||||
);
|
||||
} while (payload.response_type === 'delta');
|
||||
|
||||
await writeToDbPromise;
|
||||
}
|
||||
|
||||
if (totalInsert > 0) {
|
||||
// If there were any inserts, let sqlite handle running analyze on the DB
|
||||
await this.sqlite.analyzeDatabase();
|
||||
}
|
||||
|
||||
const { sql, parameters } = generateSQLExpressions(type, queryObj, {
|
||||
page,
|
||||
pageSize,
|
||||
select: select ?? [{ field: 'data' }],
|
||||
});
|
||||
|
||||
const rows = await this.sqlite.fetchResource({
|
||||
sql,
|
||||
parameters,
|
||||
});
|
||||
|
||||
const { sql: countSql, parameters: countParams } =
|
||||
generateSQLExpressions(type, queryObj, {
|
||||
select: [{ field: '*', isCount: true, alias: 'total' }],
|
||||
});
|
||||
const count = await this.sqlite.fetchResource({
|
||||
sql: countSql,
|
||||
parameters: countParams,
|
||||
});
|
||||
|
||||
const results = rows.map((item) =>
|
||||
JSON.parse(item.data, (key, value) =>
|
||||
// Reviver function to convert any ISO strings back to date objects
|
||||
// as we had to initially convert them to strings when stored in SQLite
|
||||
isISODateString(value) ? new Date(value) : value,
|
||||
),
|
||||
);
|
||||
|
||||
// If we are not pushing to the store, use the raw data with id property
|
||||
const records = pushToStore
|
||||
? store.push({ data: results })
|
||||
: results.map((record) => ({
|
||||
...record.attributes,
|
||||
id: record.id,
|
||||
}));
|
||||
|
||||
// Set a meta property on the array to store the total items of the results.
|
||||
// This isn't conventional but is better than returning an ArrayProxy
|
||||
// or EmberArray since the ember store query method asserts it has to be an array
|
||||
// so we can't just return an object.
|
||||
records.meta = { totalItems: count[0].total };
|
||||
return records;
|
||||
}
|
||||
default:
|
||||
return next(context.request);
|
||||
}
|
||||
}
|
||||
|
||||
async retryQueryFailure({
|
||||
err,
|
||||
type,
|
||||
adapter,
|
||||
store,
|
||||
schema,
|
||||
remainingQuery,
|
||||
}) {
|
||||
// If we get an invalid list token, we'll delete the token and
|
||||
// clear the DB and try again without a token.
|
||||
if (
|
||||
err?.errors?.[0].status === 400 &&
|
||||
err?.errors?.[0].code === 'invalid list token'
|
||||
) {
|
||||
await this.sqlite.deleteResource(type);
|
||||
// Delete all tokens of the same resource type so we don't keep clearing the DB.
|
||||
// Even if other tokens may still be valid, we might have cleared data that they
|
||||
// depended on so we should clear all tokens of the same type when clearing the DB.
|
||||
const { sql, parameters } = generateSQLExpressions('token', {
|
||||
filters: { id: [{ contains: type }] },
|
||||
});
|
||||
const tokenRows = await this.sqlite.fetchResource({
|
||||
sql,
|
||||
parameters,
|
||||
});
|
||||
const tokenIds = tokenRows.map((row) => row.id);
|
||||
|
||||
if (tokenIds.length > 0) {
|
||||
await this.sqlite.deleteResource('token', tokenIds);
|
||||
}
|
||||
|
||||
return await adapter.query(store, schema, {
|
||||
...remainingQuery,
|
||||
});
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async writeToDb(
|
||||
payload,
|
||||
storeToken,
|
||||
tokenKey,
|
||||
pushToStore,
|
||||
serializer,
|
||||
store,
|
||||
schema,
|
||||
type,
|
||||
) {
|
||||
if (payload.list_token && storeToken) {
|
||||
await this.sqlite.insertResource('token', [
|
||||
[tokenKey, payload.list_token],
|
||||
]);
|
||||
}
|
||||
|
||||
const normalizedPayload = serializer.normalizeResponse(
|
||||
store,
|
||||
schema,
|
||||
payload,
|
||||
null,
|
||||
'query',
|
||||
);
|
||||
|
||||
const { data: payloadData } = normalizedPayload;
|
||||
|
||||
// Remove any records from the DB if the API indicates they've been deleted
|
||||
// Additionally remove deleted records from ember data store for symmetry
|
||||
if (payload.removed_ids?.length > 0) {
|
||||
await this.sqlite.deleteResource(type, payload.removed_ids);
|
||||
|
||||
if (pushToStore) {
|
||||
payload.removed_ids.forEach((id) => {
|
||||
const record = store.peekRecord(type, id);
|
||||
store.unloadRecord(record);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Store the new data we just got back from the API refresh
|
||||
const items = payloadData.map((datum) => {
|
||||
const params = Object.values(modelMapping[type]).map((key) => {
|
||||
let value;
|
||||
|
||||
if (key === 'id') {
|
||||
value = datum.id;
|
||||
} else {
|
||||
value = get(datum, `attributes.${key}`);
|
||||
}
|
||||
|
||||
if (typeOf(value) === 'date') {
|
||||
return value.toISOString();
|
||||
}
|
||||
return value;
|
||||
});
|
||||
|
||||
return [...params, JSON.stringify(datum)];
|
||||
});
|
||||
|
||||
await this.sqlite.insertResource(type, items);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
import Mixin from '@ember/object/mixin';
|
||||
|
||||
/**
|
||||
* Overrides URL-building methods in an adpater for greater flexibility.
|
||||
* Overrides URL-building methods in an adapter for greater flexibility.
|
||||
* Specifically enhances calls to `urlPrefix` with `modelName`, `id`, and
|
||||
* `snapshot`, which aren't normally passed. And adds support for a
|
||||
* `urlSuffix` method, the return value of which is appended to the end of
|
||||
@@ -22,7 +22,7 @@ export default Mixin.create({
|
||||
|
||||
/**
|
||||
* This override behaves identically to the default method by default.
|
||||
* It exists only to document the additional paramters to the method.
|
||||
* It exists only to document the additional parameters to the method.
|
||||
* @override
|
||||
* @param {string} path
|
||||
* @param {string} parentURL
|
||||
|
||||
@@ -47,8 +47,8 @@ export default class AccountModel extends GeneratedAccountModel {
|
||||
* @type {string}
|
||||
*/
|
||||
get accountName() {
|
||||
const { email, full_name, login_name } = this;
|
||||
return email || full_name || login_name;
|
||||
const { email, full_name, login_name, subject } = this;
|
||||
return email || full_name || login_name || subject;
|
||||
}
|
||||
|
||||
// =methods
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import Model from '@ember-data/model';
|
||||
import { attr } from '@ember-data/model';
|
||||
import { computed } from '@ember/object';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
/**
|
||||
* Base model class provides common functionality for all models.
|
||||
|
||||
@@ -4,14 +4,24 @@
|
||||
*/
|
||||
|
||||
import GeneratedCredentialLibraryModel from '../generated/models/credential-library';
|
||||
|
||||
import {
|
||||
TYPE_CREDENTIAL_SSH_PRIVATE_KEY,
|
||||
TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN,
|
||||
TYPE_CREDENTIAL_USERNAME_PASSWORD,
|
||||
TYPE_CREDENTIAL_PASSWORD,
|
||||
} from 'api/models/credential';
|
||||
/**
|
||||
* Enum options for credential library.
|
||||
*/
|
||||
export const options = {
|
||||
http_method: ['GET', 'POST'],
|
||||
key_type: ['ed25519', 'ecdsa', 'rsa'],
|
||||
credential_types: ['ssh_private_key', 'username_password'],
|
||||
credential_types: [
|
||||
TYPE_CREDENTIAL_SSH_PRIVATE_KEY,
|
||||
TYPE_CREDENTIAL_USERNAME_PASSWORD,
|
||||
TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN,
|
||||
TYPE_CREDENTIAL_PASSWORD,
|
||||
],
|
||||
mapping_overrides: {
|
||||
username_password: ['username_attribute', 'password_attribute'],
|
||||
ssh_private_key: [
|
||||
@@ -19,12 +29,19 @@ export const options = {
|
||||
'private_key_passphrase_attribute',
|
||||
'username_attribute',
|
||||
],
|
||||
username_password_domain: [
|
||||
'username_attribute',
|
||||
'password_attribute',
|
||||
'domain_attribute',
|
||||
],
|
||||
password: ['password_attribute'],
|
||||
},
|
||||
};
|
||||
|
||||
export const TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC = 'vault-generic';
|
||||
export const TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE =
|
||||
'vault-ssh-certificate';
|
||||
export const TYPE_CREDENTIAL_LIBRARY_VAULT_LDAP = 'vault-ldap';
|
||||
|
||||
/**
|
||||
* Supported Credential Library types.
|
||||
@@ -32,6 +49,7 @@ export const TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE =
|
||||
export const TYPES_CREDENTIAL_LIBRARY = Object.freeze([
|
||||
TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC,
|
||||
TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE,
|
||||
TYPE_CREDENTIAL_LIBRARY_VAULT_LDAP,
|
||||
]);
|
||||
|
||||
export default class CredentialLibraryModel extends GeneratedCredentialLibraryModel {
|
||||
@@ -52,7 +70,8 @@ export default class CredentialLibraryModel extends GeneratedCredentialLibraryMo
|
||||
get isVault() {
|
||||
return (
|
||||
this.type === TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC ||
|
||||
this.type === TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE
|
||||
this.type === TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE ||
|
||||
this.type === TYPE_CREDENTIAL_LIBRARY_VAULT_LDAP
|
||||
);
|
||||
}
|
||||
|
||||
@@ -71,4 +90,12 @@ export default class CredentialLibraryModel extends GeneratedCredentialLibraryMo
|
||||
get isVaultSSHCertificate() {
|
||||
return this.type === TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE;
|
||||
}
|
||||
|
||||
/**
|
||||
* True if credential is a vault ldap type.
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isVaultLDAP() {
|
||||
return this.type === TYPE_CREDENTIAL_LIBRARY_VAULT_LDAP;
|
||||
}
|
||||
}
|
||||
@@ -12,11 +12,16 @@ import GeneratedCredentialModel from '../generated/models/credential';
|
||||
export const TYPE_CREDENTIAL_USERNAME_PASSWORD = 'username_password';
|
||||
export const TYPE_CREDENTIAL_SSH_PRIVATE_KEY = 'ssh_private_key';
|
||||
export const TYPE_CREDENTIAL_JSON = 'json';
|
||||
export const TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN =
|
||||
'username_password_domain';
|
||||
export const TYPE_CREDENTIAL_PASSWORD = 'password';
|
||||
|
||||
export const TYPES_CREDENTIAL = Object.freeze([
|
||||
TYPE_CREDENTIAL_USERNAME_PASSWORD,
|
||||
TYPE_CREDENTIAL_SSH_PRIVATE_KEY,
|
||||
TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN,
|
||||
TYPE_CREDENTIAL_JSON,
|
||||
TYPE_CREDENTIAL_PASSWORD,
|
||||
]);
|
||||
|
||||
export default class CredentialModel extends GeneratedCredentialModel {
|
||||
|
||||
@@ -7,7 +7,7 @@ import GeneratedGroupModel from '../generated/models/group';
|
||||
import { attr } from '@ember-data/model';
|
||||
import { computed } from '@ember/object';
|
||||
import { A } from '@ember/array';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
export default class GroupModel extends GeneratedGroupModel {
|
||||
// =services
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import GeneratedHostCatalogModel from '../generated/models/host-catalog';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
|
||||
export const TYPE_HOST_CATALOG_STATIC = 'static';
|
||||
export const TYPE_HOST_CATALOG_DYNAMIC = 'plugin';
|
||||
@@ -40,7 +41,8 @@ export const STATIC_CREDENTIAL_FIELDS = ['access_key_id, secret_access_key'];
|
||||
export default class HostCatalogModel extends GeneratedHostCatalogModel {
|
||||
// =attributes
|
||||
|
||||
#credentialType;
|
||||
@tracked _credentialTypeValue;
|
||||
|
||||
/**
|
||||
* True if the host catalog is static.
|
||||
* @type {boolean}
|
||||
@@ -62,14 +64,14 @@ export default class HostCatalogModel extends GeneratedHostCatalogModel {
|
||||
* @type {string}
|
||||
*/
|
||||
get credentialType() {
|
||||
if (!this.#credentialType) {
|
||||
if (!this._credentialTypeValue) {
|
||||
if (DYNAMIC_CREDENTIAL_FIELDS.some((field) => this[field])) {
|
||||
this.#credentialType = TYPE_CREDENTIAL_DYNAMIC;
|
||||
return TYPE_CREDENTIAL_DYNAMIC;
|
||||
} else {
|
||||
this.#credentialType = TYPE_CREDENTIAL_STATIC;
|
||||
return TYPE_CREDENTIAL_STATIC;
|
||||
}
|
||||
}
|
||||
return this.#credentialType;
|
||||
return this._credentialTypeValue;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,7 +79,7 @@ export default class HostCatalogModel extends GeneratedHostCatalogModel {
|
||||
* @type {string}
|
||||
*/
|
||||
set credentialType(type) {
|
||||
this.#credentialType = type;
|
||||
this._credentialTypeValue = type;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import GeneratedRoleModel from '../generated/models/role';
|
||||
import { attr } from '@ember-data/model';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
export const GRANT_SCOPE_THIS = 'this';
|
||||
export const GRANT_SCOPE_CHILDREN = 'children';
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import GeneratedSessionRecordingModel from '../generated/models/session-recording';
|
||||
import { hasMany } from '@ember-data/model';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
export const TYPE_SESSION_RECORDING_SSH = 'ssh';
|
||||
export const TYPES_SESSION_RECORDING = Object.freeze([
|
||||
@@ -71,7 +71,15 @@ export default class SessionRecordingModel extends GeneratedSessionRecordingMode
|
||||
}
|
||||
|
||||
/**
|
||||
* Reapply policy dates via the `reapply-staorage-policy` method.
|
||||
* Returns true if retention of session-recording is forever.
|
||||
* @type {boolean}
|
||||
*/
|
||||
get retainForever() {
|
||||
return this.retain_until?.toISOString() === '9999-12-31T23:23:23.999Z';
|
||||
}
|
||||
|
||||
/**
|
||||
* Reapply policy dates via the `reapply-storage-policy` method.
|
||||
* @param {object} options
|
||||
* @param {object} options.adapterOptions
|
||||
* @return {Promise}
|
||||
|
||||
@@ -7,7 +7,7 @@ import GeneratedSessionModel from '../generated/models/session';
|
||||
import { equal } from '@ember/object/computed';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { A } from '@ember/array';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { service } from '@ember/service';
|
||||
import { flattenObject } from '../utils/flatten-nested-object';
|
||||
import { TYPES_CREDENTIAL_LIBRARY } from 'api/models/credential-library';
|
||||
import { TYPE_CREDENTIAL_JSON } from 'api/models/credential';
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import GeneratedStorageBucketModel from '../generated/models/storage-bucket';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
|
||||
export const TYPE_STORAGE_BUCKET_PLUGIN = 'plugin';
|
||||
export const TYPES_STORAGE_BUCKET = Object.freeze([TYPE_STORAGE_BUCKET_PLUGIN]);
|
||||
@@ -26,7 +27,8 @@ export const TYPES_CREDENTIALS = Object.freeze([
|
||||
export default class StorageBucketModel extends GeneratedStorageBucketModel {
|
||||
// attributes
|
||||
|
||||
#credentialType;
|
||||
@tracked _credentialTypeValue;
|
||||
|
||||
/**
|
||||
* True if the storage bucket is a plugin.
|
||||
* @type {boolean}
|
||||
@@ -40,14 +42,14 @@ export default class StorageBucketModel extends GeneratedStorageBucketModel {
|
||||
* @type {string}
|
||||
*/
|
||||
get credentialType() {
|
||||
if (!this.#credentialType) {
|
||||
if (!this._credentialTypeValue) {
|
||||
if (this.role_arn) {
|
||||
this.#credentialType = TYPE_CREDENTIAL_DYNAMIC;
|
||||
return TYPE_CREDENTIAL_DYNAMIC;
|
||||
} else {
|
||||
this.#credentialType = TYPE_CREDENTIAL_STATIC;
|
||||
return TYPE_CREDENTIAL_STATIC;
|
||||
}
|
||||
}
|
||||
return this.#credentialType;
|
||||
return this._credentialTypeValue;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,8 +57,9 @@ export default class StorageBucketModel extends GeneratedStorageBucketModel {
|
||||
* @type {string}
|
||||
*/
|
||||
set credentialType(type) {
|
||||
this.#credentialType = type;
|
||||
this._credentialTypeValue = type;
|
||||
}
|
||||
|
||||
/**
|
||||
* True if the storage bucket is an unknown type.
|
||||
* @type {boolean}
|
||||
|
||||
@@ -5,11 +5,17 @@
|
||||
|
||||
import GeneratedTargetModel from '../generated/models/target';
|
||||
import { attr } from '@ember-data/model';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
export const TYPE_TARGET_TCP = 'tcp';
|
||||
export const TYPE_TARGET_SSH = 'ssh';
|
||||
export const TYPES_TARGET = Object.freeze([TYPE_TARGET_TCP, TYPE_TARGET_SSH]);
|
||||
export const TYPE_TARGET_RDP = 'rdp';
|
||||
|
||||
export const TYPES_TARGET = Object.freeze([
|
||||
TYPE_TARGET_TCP,
|
||||
TYPE_TARGET_SSH,
|
||||
TYPE_TARGET_RDP,
|
||||
]);
|
||||
|
||||
export default class TargetModel extends GeneratedTargetModel {
|
||||
// =services
|
||||
@@ -347,4 +353,12 @@ export default class TargetModel extends GeneratedTargetModel {
|
||||
get isSSH() {
|
||||
return this.type === TYPE_TARGET_SSH;
|
||||
}
|
||||
|
||||
/**
|
||||
* True if the target type is rdp.
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isRDP() {
|
||||
return this.type === TYPE_TARGET_RDP;
|
||||
}
|
||||
}
|
||||
@@ -98,8 +98,9 @@ export default class AccountSerializer extends ApplicationSerializer {
|
||||
serialized.attributes = {};
|
||||
}
|
||||
// New record case
|
||||
if (password && snapshot?.record?.isNew)
|
||||
if (password && snapshot?.record?.isNew) {
|
||||
serialized.attributes.password = password;
|
||||
}
|
||||
// Set password custom method
|
||||
if (snapshot?.adapterOptions?.method === 'set-password') {
|
||||
serialized = this.serializeForSetPassword(snapshot, password);
|
||||
|
||||
@@ -60,6 +60,18 @@ export default class ApplicationSerializer extends RESTSerializer {
|
||||
// Do not serialize `disabled` fields.
|
||||
// TODO: disabled is temporarily disabled
|
||||
if (key === 'disabled') delete json[key];
|
||||
|
||||
// Before we transform our json
|
||||
// Let's trim the values in the json obj
|
||||
// Currently, json values will only ever be a string or an array
|
||||
if (options.trimWhitespace && json[key]) {
|
||||
if (typeOf(json[key]) === 'array') {
|
||||
json[key] = json[key].map((str) => str.trim());
|
||||
} else if (typeOf(json[key] === 'string')) {
|
||||
json[key] = json[key].trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Push nested attributes down into the attributes key
|
||||
if (options.isNestedAttribute && json[key] !== undefined) {
|
||||
if (!json.attributes) json.attributes = {};
|
||||
@@ -232,12 +244,10 @@ export default class ApplicationSerializer extends RESTSerializer {
|
||||
* @return {object}
|
||||
*/
|
||||
normalizeMissingArrays(store, primaryModelClass, payload) {
|
||||
const attrDefs = store
|
||||
.getSchemaDefinitionService()
|
||||
.attributesDefinitionFor({ type: primaryModelClass.modelName });
|
||||
const attrDefs = store.schema.fields({ type: primaryModelClass.modelName });
|
||||
if (attrDefs) {
|
||||
Object.keys(attrDefs).forEach((key) => {
|
||||
if (!payload[key] && attrDefs[key]?.options?.emptyArrayIfMissing) {
|
||||
attrDefs.keys().forEach((key) => {
|
||||
if (!payload[key] && attrDefs.get(key)?.options?.emptyArrayIfMissing) {
|
||||
payload[key] = [];
|
||||
}
|
||||
});
|
||||
@@ -256,12 +266,10 @@ export default class ApplicationSerializer extends RESTSerializer {
|
||||
* @return {object}
|
||||
*/
|
||||
normalizeMissingObjects(store, primaryModelClass, payload) {
|
||||
const attrDefs = store
|
||||
.getSchemaDefinitionService()
|
||||
.attributesDefinitionFor({ type: primaryModelClass.modelName });
|
||||
const attrDefs = store.schema.fields({ type: primaryModelClass.modelName });
|
||||
if (attrDefs) {
|
||||
Object.keys(attrDefs).forEach((key) => {
|
||||
if (!payload[key] && attrDefs[key]?.options?.emptyObjectIfMissing) {
|
||||
attrDefs.keys().forEach((key) => {
|
||||
if (!payload[key] && attrDefs.get(key)?.options?.emptyObjectIfMissing) {
|
||||
payload[key] = {};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ export default class CredentialLibrarySerializer extends ApplicationSerializer {
|
||||
case TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC:
|
||||
return this.serializeVaultGeneric(...arguments);
|
||||
case TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE:
|
||||
return this.serializeVaultSshCertificate(...arguments);
|
||||
default:
|
||||
return super.serialize(...arguments);
|
||||
}
|
||||
@@ -43,22 +44,45 @@ export default class CredentialLibrarySerializer extends ApplicationSerializer {
|
||||
if (!serialized.attributes?.http_method?.match(/post/i)) {
|
||||
serialized.attributes.http_request_body = null;
|
||||
}
|
||||
const { credential_type, credential_mapping_overrides } = serialized;
|
||||
this.handleCredentialMappingOverrides(serialized, isNew);
|
||||
return serialized;
|
||||
}
|
||||
}
|
||||
|
||||
// API expects to send null to fields if it is undefined or deleted
|
||||
if (credential_mapping_overrides && !isNew) {
|
||||
serialized.credential_mapping_overrides = options.mapping_overrides[
|
||||
credential_type
|
||||
].reduce((obj, key) => {
|
||||
if (credential_mapping_overrides[key]) {
|
||||
obj[key] = credential_mapping_overrides[key];
|
||||
} else {
|
||||
obj[key] = null;
|
||||
}
|
||||
return obj;
|
||||
}, {});
|
||||
serializeVaultSshCertificate() {
|
||||
const serialized = super.serialize(...arguments);
|
||||
if (serialized.attributes) {
|
||||
const { key_type } = serialized.attributes;
|
||||
if (key_type !== 'rsa' && key_type !== 'ecdsa') {
|
||||
// If the key type is not RSA or ECDSA, set key_bits to null
|
||||
serialized.attributes.key_bits = null;
|
||||
}
|
||||
return serialized;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle credential mapping overrides for serialization.
|
||||
* @param {Object} serialized - The serialized object.
|
||||
* @param {boolean} isNew - Indicates if the record is new.
|
||||
*/
|
||||
handleCredentialMappingOverrides(serialized, isNew) {
|
||||
const { credential_type, credential_mapping_overrides } = serialized;
|
||||
if (Object.keys(credential_mapping_overrides).length === 0) {
|
||||
serialized.credential_mapping_overrides = null;
|
||||
}
|
||||
// API expects to send null to fields if it is undefined or deleted
|
||||
if (credential_mapping_overrides && !isNew) {
|
||||
serialized.credential_mapping_overrides = options.mapping_overrides[
|
||||
credential_type
|
||||
].reduce((obj, key) => {
|
||||
if (credential_mapping_overrides[key]) {
|
||||
obj[key] = credential_mapping_overrides[key];
|
||||
} else {
|
||||
obj[key] = null;
|
||||
}
|
||||
return obj;
|
||||
}, {});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,8 +28,9 @@ export default class CredentialStoreSerializer extends ApplicationSerializer {
|
||||
if (
|
||||
!serialized.attributes?.client_certificate_key &&
|
||||
serialized.attributes?.client_certificate
|
||||
)
|
||||
) {
|
||||
delete serialized.attributes.client_certificate_key;
|
||||
}
|
||||
}
|
||||
return serialized;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export default class HostCatalogSerializer extends ApplicationSerializer {
|
||||
) {
|
||||
if (options.isNestedAttribute) {
|
||||
json.attributes[key] = null;
|
||||
} else if (options.isNestedSecret && json.secrets[key]) {
|
||||
} else if (options.isNestedSecret && json.secrets?.[key]) {
|
||||
delete json.secrets[key];
|
||||
if (Object.keys(json.secrets).length === 0) {
|
||||
delete json.secrets;
|
||||
|
||||
@@ -95,4 +95,18 @@ export default class HostSetSerializer extends ApplicationSerializer {
|
||||
|
||||
return super.normalize(typeClass, normalizedHash, ...rest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts errors from the payload and transforms specific keys if needed.
|
||||
* @override
|
||||
* @return {object}
|
||||
*/
|
||||
extractErrors() {
|
||||
const errors = super.extractErrors(...arguments);
|
||||
if (errors?.filter) {
|
||||
errors.filter_string = errors.filter;
|
||||
delete errors.filter;
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
@@ -53,4 +53,18 @@ export default class ManagedGroupSerializer extends ApplicationSerializer {
|
||||
|
||||
return super.normalize(typeClass, normalizedHash, ...rest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts errors from the payload and transforms specific keys if needed.
|
||||
* @override
|
||||
* @return {object}
|
||||
*/
|
||||
extractErrors() {
|
||||
const errors = super.extractErrors(...arguments);
|
||||
if (errors?.filter) {
|
||||
errors.filter_string = errors.filter;
|
||||
delete errors.filter;
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
@@ -72,12 +72,14 @@ export default class StorageBucketSerializer extends ApplicationSerializer {
|
||||
const value = super.serializeAttribute(...arguments);
|
||||
const { credentialType } = snapshot.record;
|
||||
const { options } = attribute;
|
||||
|
||||
// This deletes any fields that don't belong to the record's credential type
|
||||
if (options.isNestedAttribute && json.attributes) {
|
||||
// The key must be included in the fieldsByType list above
|
||||
if (!fieldsByCredentialType[credentialType].includes(key))
|
||||
if (!fieldsByCredentialType[credentialType].includes(key)) {
|
||||
// API requires these fields to be null
|
||||
json.attributes[key] = null;
|
||||
}
|
||||
}
|
||||
|
||||
//json.secrets is only present, if there are any updates to the secret fields
|
||||
|
||||
@@ -33,16 +33,18 @@ export default class TargetSerializer extends ApplicationSerializer {
|
||||
const injectedApplicationCredentialSourceIDs =
|
||||
snapshot?.adapterOptions?.injectedApplicationCredentialSourceIDs;
|
||||
|
||||
if (brokeredCredentialSourceIDs)
|
||||
if (brokeredCredentialSourceIDs) {
|
||||
serialized = this.serializeWithBrokeredCredentialSources(
|
||||
snapshot,
|
||||
brokeredCredentialSourceIDs,
|
||||
);
|
||||
if (injectedApplicationCredentialSourceIDs)
|
||||
}
|
||||
if (injectedApplicationCredentialSourceIDs) {
|
||||
serialized = this.serializeWithInjectedApplicationCredentialSources(
|
||||
snapshot,
|
||||
injectedApplicationCredentialSourceIDs,
|
||||
);
|
||||
}
|
||||
|
||||
if (isNew && serialized?.with_aliases) {
|
||||
// API expects scope id along with every alias value
|
||||
|
||||
@@ -18,8 +18,9 @@ export default class UserSerializer extends ApplicationSerializer {
|
||||
serialize(snapshot) {
|
||||
let serialized = super.serialize(...arguments);
|
||||
const accountIDs = snapshot?.adapterOptions?.accountIDs;
|
||||
if (accountIDs)
|
||||
if (accountIDs) {
|
||||
serialized = this.serializewithAccounts(snapshot, accountIDs);
|
||||
}
|
||||
return serialized;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Service, { service } from '@ember/service';
|
||||
import { modelMapping } from 'api/services/sqlite';
|
||||
import { assert } from '@ember/debug';
|
||||
import { generateSQLExpressions } from '../utils/sqlite-query';
|
||||
|
||||
/**
|
||||
* Database service for querying Boundary resources from storage.
|
||||
* Provides a high-level interface for executing queries against the local database.
|
||||
*/
|
||||
export default class DbService extends Service {
|
||||
@service sqlite;
|
||||
|
||||
/**
|
||||
* Query resources from the database by type.
|
||||
*
|
||||
* @param {string} type - The resource type (e.g., 'scope', 'user', 'target').
|
||||
* Must be a supported model type defined in modelMapping.
|
||||
* @param {Object} query - Query configuration object
|
||||
* @returns {Promise<Object>} Promise resolving to query results from sqlite service
|
||||
* @throws {Error} Assertion error if resource type is not supported
|
||||
*/
|
||||
query(type, query) {
|
||||
const supportedModels = Object.keys(modelMapping);
|
||||
assert('Resource type is not supported.', supportedModels.includes(type));
|
||||
|
||||
let { page, pageSize, select, query: queryObj } = query;
|
||||
|
||||
const { sql, parameters } = generateSQLExpressions(type, queryObj, {
|
||||
page,
|
||||
pageSize,
|
||||
select,
|
||||
});
|
||||
|
||||
return this.sqlite.fetchResource({
|
||||
sql,
|
||||
parameters,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Service from '@ember/service';
|
||||
import Dexie from 'dexie';
|
||||
import { typeOf } from '@ember/utils';
|
||||
|
||||
// List of resources we support to be cached and searched in
|
||||
// indexedDb. Any field that can be searched upon should be added to
|
||||
// this index. Increment the version number of the database whenever
|
||||
// indexes change.
|
||||
export const modelIndexes = {
|
||||
token: '&id, token',
|
||||
target:
|
||||
'&id, attributes.created_time, attributes.type, attributes.name, attributes.description, attributes.address, attributes.scope.scope_id',
|
||||
session:
|
||||
'&id, attributes.created_time, attributes.type, attributes.status, attributes.endpoint, attributes.target_id, attributes.user_id, attributes.scope.scope_id',
|
||||
user: '&id, attributes.created_time, attributes.name, attributes.description, attributes.scope.scope_id',
|
||||
group:
|
||||
'&id, attributes.created_time, attributes.name, attributes.description, attributes.scope.scope_id',
|
||||
role: '&id, attributes.created_time, attributes.name, attributes.description, attributes.scope.scope_id',
|
||||
scope:
|
||||
'&id, attributes.created_time, attributes.type, attributes.name, attributes.description, attributes.scope.scope_id',
|
||||
'credential-store':
|
||||
'&id, attributes.created_time, attributes.name, attributes.description, attributes.type, attributes.scope.scope_id',
|
||||
'host-catalog':
|
||||
'&id, attributes.created_time, attributes.type, attributes.name, attributes.description, attributes.scope.scope_id, attributes.plugin.name',
|
||||
'auth-method':
|
||||
'&id, attributes.created_time, attributes.type, attributes.name, attributes.description, attributes.is_primary, attributes.scope.scope_id',
|
||||
'session-recording':
|
||||
'&id, attributes.created_time, attributes.type, attributes.state, attributes.start_time, attributes.end_time, attributes.duration, attributes.scope.scope_id, attributes.create_time_values.user.id, attributes.create_time_values.user.name, attributes.create_time_values.target.id, attributes.create_time_values.target.name, attributes.create_time_values.target.scope.id, attributes.create_time_values.target.scope.name',
|
||||
alias:
|
||||
'&id, attributes.created_time, attributes.type, attributes.value, attributes.name, attributes.description, attributes.destination_id, attributes.scope.scope_id',
|
||||
};
|
||||
|
||||
export const formatDbName = (userId, clusterUrl) =>
|
||||
`boundary-${userId}-${clusterUrl}`;
|
||||
|
||||
/**
|
||||
* Recursively copy over the object and convert any booleans to "true" or "false" so they can be indexed.
|
||||
* Also converts any ember-like arrays back to a standard array.
|
||||
* @param result
|
||||
* @param object
|
||||
* @returns {*}
|
||||
*/
|
||||
const convertFields = (result, object) => {
|
||||
Object.entries(object).forEach(([key, value]) => {
|
||||
if (typeOf(value) === 'object') {
|
||||
result[key] = convertFields({}, value);
|
||||
} else if (typeOf(value) === 'boolean') {
|
||||
result[key] = value ? 'true' : 'false';
|
||||
} else if (typeOf(value) === 'array') {
|
||||
result[key] = [...value];
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Recursively copy over the object and convert any string "true" or "false" back to booleans.
|
||||
* Will also convert any array fields back using their model transformation.
|
||||
* @param result
|
||||
* @param object
|
||||
* @param schema
|
||||
* @param serializer
|
||||
* @returns {*}
|
||||
*/
|
||||
const unconvertFields = (result, object, schema, serializer) => {
|
||||
Object.entries(object).forEach(([key, value]) => {
|
||||
if (typeOf(value) === 'object') {
|
||||
result[key] = unconvertFields({}, value, schema, serializer);
|
||||
} else if (value === 'true' || value === 'false') {
|
||||
result[key] = value === 'true';
|
||||
} else if (typeOf(value) === 'array') {
|
||||
const attribute = schema.attributes.get(key);
|
||||
|
||||
// If attribute type is array, we transform it back to a tracked array
|
||||
if (attribute?.type === 'array') {
|
||||
const transformer = serializer.transformFor(attribute.type);
|
||||
result[key] = transformer.deserialize(value, attribute.options);
|
||||
return;
|
||||
}
|
||||
|
||||
result[key] = value;
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Service to encapsulate the IndexedDB implementation. To use this service, call
|
||||
* `setup` from the application root and
|
||||
*/
|
||||
export default class IndexedDbService extends Service {
|
||||
// =attributes
|
||||
#db;
|
||||
get db() {
|
||||
return this.#db;
|
||||
}
|
||||
|
||||
setup(dbName) {
|
||||
// Don't run setup again if we already have one or if we didn't get a name
|
||||
if (this.#db || !dbName) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#db = new Dexie(dbName);
|
||||
this.#db.version(2).stores(modelIndexes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup the data by converting all fields to plain javascript objects.
|
||||
* We will also convert booleans to "false" or "true".
|
||||
* IndexedDB doesn't support indexes on boolean values so we have to
|
||||
* convert them to be able to support using boolean values.
|
||||
*
|
||||
* Undo the operation by setting `convertFields` to false.
|
||||
*
|
||||
* @param {Object} data
|
||||
* @param {Boolean} cleanData
|
||||
* @param {Object} schema
|
||||
* @param {Object} serializer
|
||||
* @return {{id, type, attributes: {}, relationships: {}}}
|
||||
*/
|
||||
normalizeData({ data, cleanData, schema, serializer }) {
|
||||
if (!data) {
|
||||
return data;
|
||||
}
|
||||
|
||||
let { id, type, attributes = {}, relationships = {} } = data;
|
||||
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
attributes: cleanData
|
||||
? convertFields({}, attributes)
|
||||
: unconvertFields({}, attributes, schema, serializer),
|
||||
relationships: cleanData
|
||||
? convertFields({}, relationships)
|
||||
: unconvertFields({}, relationships, schema, serializer),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import Service from '@ember/service';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
/**
|
||||
* Takes a POJO representing a query and converts it to a boolean expression
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Service from '@ember/service';
|
||||
import { PWBHost } from 'promise-worker-bi';
|
||||
|
||||
export const formatDbName = (userId, clusterUrl) =>
|
||||
`boundary-${userId}-${clusterUrl}`;
|
||||
|
||||
// A mapping of columns to their expected model attributes for supported models
|
||||
// The JSON data column is not included here but is assumed to always be last.
|
||||
export const modelMapping = {
|
||||
target: {
|
||||
id: 'id',
|
||||
type: 'type',
|
||||
name: 'name',
|
||||
description: 'description',
|
||||
address: 'address',
|
||||
scope_id: 'scope.scope_id',
|
||||
created_time: 'created_time',
|
||||
},
|
||||
alias: {
|
||||
id: 'id',
|
||||
type: 'type',
|
||||
name: 'name',
|
||||
description: 'description',
|
||||
destination_id: 'destination_id',
|
||||
value: 'value',
|
||||
scope_id: 'scope.scope_id',
|
||||
created_time: 'created_time',
|
||||
},
|
||||
group: {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
description: 'description',
|
||||
scope_id: 'scope.scope_id',
|
||||
created_time: 'created_time',
|
||||
},
|
||||
role: {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
description: 'description',
|
||||
scope_id: 'scope.scope_id',
|
||||
created_time: 'created_time',
|
||||
},
|
||||
user: {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
description: 'description',
|
||||
scope_id: 'scope.scope_id',
|
||||
created_time: 'created_time',
|
||||
},
|
||||
'credential-store': {
|
||||
id: 'id',
|
||||
type: 'type',
|
||||
name: 'name',
|
||||
description: 'description',
|
||||
scope_id: 'scope.scope_id',
|
||||
created_time: 'created_time',
|
||||
},
|
||||
scope: {
|
||||
id: 'id',
|
||||
type: 'type',
|
||||
name: 'name',
|
||||
description: 'description',
|
||||
scope_id: 'scope.scope_id',
|
||||
created_time: 'created_time',
|
||||
},
|
||||
'auth-method': {
|
||||
id: 'id',
|
||||
type: 'type',
|
||||
name: 'name',
|
||||
description: 'description',
|
||||
is_primary: 'is_primary',
|
||||
scope_id: 'scope.scope_id',
|
||||
created_time: 'created_time',
|
||||
},
|
||||
'host-catalog': {
|
||||
id: 'id',
|
||||
type: 'type',
|
||||
name: 'name',
|
||||
description: 'description',
|
||||
plugin_name: 'plugin.name',
|
||||
scope_id: 'scope.scope_id',
|
||||
created_time: 'created_time',
|
||||
},
|
||||
'session-recording': {
|
||||
id: 'id',
|
||||
type: 'type',
|
||||
state: 'state',
|
||||
start_time: 'start_time',
|
||||
end_time: 'end_time',
|
||||
duration: 'duration',
|
||||
scope_id: 'scope.scope_id',
|
||||
user_id: 'create_time_values.user.id',
|
||||
user_name: 'create_time_values.user.name',
|
||||
target_id: 'create_time_values.target.id',
|
||||
target_name: 'create_time_values.target.name',
|
||||
target_scope_id: 'create_time_values.target.scope.id',
|
||||
target_scope_name: 'create_time_values.target.scope.name',
|
||||
target_scope_parent_scope_id:
|
||||
'create_time_values.target.scope.parent_scope_id',
|
||||
created_time: 'created_time',
|
||||
},
|
||||
session: {
|
||||
id: 'id',
|
||||
type: 'type',
|
||||
status: 'status',
|
||||
endpoint: 'endpoint',
|
||||
target_id: 'target_id',
|
||||
user_id: 'user_id',
|
||||
scope_id: 'scope.scope_id',
|
||||
created_time: 'created_time',
|
||||
},
|
||||
};
|
||||
|
||||
export default class SqliteDbService extends Service {
|
||||
// =attributes
|
||||
|
||||
// We need a handle on the original web worker to properly clean it up.
|
||||
webWorker;
|
||||
// This will be the worker we interact with that is wrapped by PWBHost.
|
||||
worker;
|
||||
|
||||
setup(dbName) {
|
||||
if (this.worker) {
|
||||
// If the worker already exists, we don't need to do anything.
|
||||
return;
|
||||
}
|
||||
|
||||
// In production, our JS files will be fingerprinted with an MD5 hash
|
||||
// so we need a way to map the worker file name to be able to find it.
|
||||
//
|
||||
// Ember is automagically replacing any URL path with the fingerprinted
|
||||
// asset, and this is done by broccoli-asset-rev.
|
||||
//
|
||||
// The problem is it's a bit finicky as seen in this issue
|
||||
// https://github.com/ember-cli/broccoli-asset-rewrite/issues/66
|
||||
// and will not work properly if it's part of a template string or interpolated.
|
||||
// Therefore, we need the URL path in a string somewhere to be replaced properly.
|
||||
this.webWorker = new Worker('/workers/sqlite-worker.js', {
|
||||
type: 'module',
|
||||
});
|
||||
|
||||
// We use PWBHost to simplify communication so we can just send and receive
|
||||
// messages with promises rather than having to manually set up event listeners
|
||||
this.worker = new PWBHost(this.webWorker);
|
||||
this.worker.register(async ({ method }) => {
|
||||
if (method === 'getDatabaseName') {
|
||||
return dbName;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fetchResource({ sql, parameters }) {
|
||||
return this.worker.postMessage({
|
||||
method: 'fetchResource',
|
||||
payload: { sql, parameters },
|
||||
});
|
||||
}
|
||||
|
||||
insertResource(resource, items) {
|
||||
return this.worker.postMessage({
|
||||
method: 'insertResource',
|
||||
payload: { resource, items, modelMapping },
|
||||
});
|
||||
}
|
||||
|
||||
deleteResource(resource, ids) {
|
||||
return this.worker.postMessage({
|
||||
method: 'deleteResource',
|
||||
payload: { resource, ids },
|
||||
});
|
||||
}
|
||||
|
||||
analyzeDatabase() {
|
||||
return this.worker.postMessage({ method: 'analyzeDatabase' });
|
||||
}
|
||||
|
||||
async downloadDatabase() {
|
||||
const byteArray = await this.worker.postMessage({
|
||||
method: 'downloadDatabase',
|
||||
});
|
||||
|
||||
const blob = new Blob([byteArray.buffer], {
|
||||
type: 'application/x-sqlite3',
|
||||
});
|
||||
|
||||
// Create temporary elements to initialize the download
|
||||
const a = window.document.createElement('a');
|
||||
window.document.body.appendChild(a);
|
||||
a.href = window.URL.createObjectURL(blob);
|
||||
a.download = 'boundary.db';
|
||||
a.addEventListener('click', function () {
|
||||
setTimeout(function () {
|
||||
window.URL.revokeObjectURL(a.href);
|
||||
a.remove();
|
||||
}, 500);
|
||||
});
|
||||
a.click();
|
||||
}
|
||||
|
||||
async clearDatabase() {
|
||||
await this.worker.postMessage({ method: 'clearDatabase' });
|
||||
}
|
||||
|
||||
async deleteDatabase() {
|
||||
await this.worker.postMessage({ method: 'deleteDatabase' });
|
||||
}
|
||||
|
||||
willDestroy() {
|
||||
this.webWorker?.terminate();
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,7 @@ class MemoryStorage {
|
||||
*
|
||||
* @example
|
||||
* import Route from '@ember/routing/route';
|
||||
* import { inject as service } from '@ember/service';
|
||||
* import { service } from '@ember/service';
|
||||
* export default class ApplicationRoute extends Route {
|
||||
* @service storage;
|
||||
* afterModel() {
|
||||
|
||||
@@ -3,10 +3,14 @@
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Store, { CacheHandler } from '@ember-data/store';
|
||||
// Importing 'Store' from 'ember-data/store' to get default configuration automatically.
|
||||
// TODO: Remove eslint-disable after upgrading to eslint-plugin-ember >= v12.2.1
|
||||
// eslint-disable-next-line ember/use-ember-data-rfc-395-imports
|
||||
import Store from 'ember-data/store';
|
||||
import { CacheHandler } from '@ember-data/store';
|
||||
import RequestManager from '@ember-data/request';
|
||||
import { LegacyNetworkHandler } from '@ember-data/legacy-compat';
|
||||
import IndexedDbHandler from 'api/handlers/indexed-db-handler';
|
||||
import SqliteHandler from 'api/handlers/sqlite-handler';
|
||||
|
||||
export default class extends Store {
|
||||
requestManager = new RequestManager();
|
||||
@@ -14,9 +18,9 @@ export default class extends Store {
|
||||
constructor(args) {
|
||||
super(args);
|
||||
|
||||
const indexedDbHandler = new IndexedDbHandler(this);
|
||||
const sqliteHandler = new SqliteHandler(this);
|
||||
|
||||
this.requestManager.use([indexedDbHandler, LegacyNetworkHandler]);
|
||||
this.requestManager.use([sqliteHandler, LegacyNetworkHandler]);
|
||||
this.requestManager.useCache(CacheHandler);
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,7 @@
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Transform from '@ember-data/serializer/transform';
|
||||
|
||||
export default class AccountValueMapArrayTransform extends Transform {
|
||||
export default class AccountValueMapArrayTransform {
|
||||
deserialize(serialized) {
|
||||
const accountValues = serialized || [];
|
||||
return accountValues.map((accountValueString) => ({
|
||||
@@ -18,4 +16,8 @@ export default class AccountValueMapArrayTransform extends Transform {
|
||||
const accountValues = deserialized || [];
|
||||
return accountValues.map(({ key, value }) => `${key}=${value}`);
|
||||
}
|
||||
|
||||
static create() {
|
||||
return new this();
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,9 @@
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Transform from '@ember-data/serializer/transform';
|
||||
import { TrackedArray } from 'tracked-built-ins';
|
||||
|
||||
export default class ArrayTransform extends Transform {
|
||||
export default class ArrayTransform {
|
||||
deserialize(serialized) {
|
||||
const arr = new TrackedArray(serialized || []);
|
||||
return arr;
|
||||
@@ -16,4 +15,8 @@ export default class ArrayTransform extends Transform {
|
||||
const arr = deserialized || [];
|
||||
return arr;
|
||||
}
|
||||
|
||||
static create() {
|
||||
return new this();
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,7 @@
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Transform from '@ember-data/serializer/transform';
|
||||
|
||||
export default class DurationTransform extends Transform {
|
||||
export default class DurationTransform {
|
||||
/**
|
||||
* Normalize a duration string in the format "3.0001s" to milliseconds.
|
||||
* The backend returns a precision of nanoseconds but we round to whole
|
||||
@@ -37,4 +35,8 @@ export default class DurationTransform extends Transform {
|
||||
maximumFractionDigits: 9,
|
||||
})}s`;
|
||||
}
|
||||
|
||||
static create() {
|
||||
return new this();
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,7 @@
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Transform from '@ember-data/serializer/transform';
|
||||
|
||||
export default class HostSourceIdArrayTransform extends Transform {
|
||||
export default class HostSourceIdArrayTransform {
|
||||
deserialize(serialized) {
|
||||
const stringValues = serialized || [];
|
||||
return stringValues.map(({ id: host_source_id, ...obj }) => ({
|
||||
@@ -18,4 +16,8 @@ export default class HostSourceIdArrayTransform extends Transform {
|
||||
const strings = deserialized || [];
|
||||
return strings.map(({ host_source_id: id, ...obj }) => ({ id, ...obj }));
|
||||
}
|
||||
|
||||
static create() {
|
||||
return new this();
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,12 @@
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Transform from '@ember-data/serializer/transform';
|
||||
|
||||
export default class ObjectAsArrayTransform extends Transform {
|
||||
export default class ObjectAsArrayTransform {
|
||||
deserialize(serialized) {
|
||||
const obj = serialized || {};
|
||||
return Object.entries(obj).map(([key, value]) => ({ key, value }));
|
||||
}
|
||||
|
||||
serialize(deserialized) {
|
||||
const array = deserialized || [];
|
||||
|
||||
@@ -24,4 +23,8 @@ export default class ObjectAsArrayTransform extends Transform {
|
||||
return result;
|
||||
}, {});
|
||||
}
|
||||
|
||||
static create() {
|
||||
return new this();
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,9 @@
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Transform from '@ember-data/serializer/transform';
|
||||
import { TrackedObject } from 'tracked-built-ins';
|
||||
|
||||
export default class ObjectTransform extends Transform {
|
||||
export default class ObjectTransform {
|
||||
/**
|
||||
* @param serialized
|
||||
* @returns {object}
|
||||
@@ -26,4 +25,8 @@ export default class ObjectTransform extends Transform {
|
||||
}
|
||||
return deserialized;
|
||||
}
|
||||
|
||||
static create() {
|
||||
return new this();
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,7 @@
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Transform from '@ember-data/serializer/transform';
|
||||
|
||||
export default class StringArrayTransform extends Transform {
|
||||
export default class StringArrayTransform {
|
||||
deserialize(serialized) {
|
||||
const stringValues = serialized || [];
|
||||
return stringValues.map((value) => ({ value }));
|
||||
@@ -15,4 +13,8 @@ export default class StringArrayTransform extends Transform {
|
||||
const strings = deserialized || [];
|
||||
return strings.map(({ value }) => value);
|
||||
}
|
||||
|
||||
static create() {
|
||||
return new this();
|
||||
}
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { get } from '@ember/object';
|
||||
import { modelIndexes } from '../services/indexed-db';
|
||||
|
||||
/**
|
||||
* Takes a POJO representing a filter query and builds a query to
|
||||
* query indexedDb with. Note that the first filter is important as that
|
||||
* will use the actual index in indexedDb while any subsequent ones will be less efficient.
|
||||
*
|
||||
* @example
|
||||
* const filter = queryIndexedDb(indexedDb, type, {
|
||||
* search: generated',
|
||||
* filters: {
|
||||
* scope_id: {
|
||||
* logicalOperator: 'and',
|
||||
* values: [{ notEquals: 'scope1' }, { notEquals: 'scope2' }],
|
||||
* },
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export async function queryIndexedDb(indexedDb, resource, query) {
|
||||
let { search, filters = {} } = query ?? {};
|
||||
let filterCollection = indexedDb[resource];
|
||||
|
||||
search = getSearchObject(search, resource);
|
||||
filters = cleanObject(filters);
|
||||
|
||||
if (Object.keys(filters).length) {
|
||||
// Remove the first item to use as the initial collection and then reduce the remaining ones
|
||||
const entries = Object.entries(filters);
|
||||
const [firstKey, firstFilterArrayOrObject] = entries.shift();
|
||||
|
||||
filterCollection = await entries.reduce(
|
||||
async (currCollection, [key, filterArrayOrObject]) => {
|
||||
// Grab the results from the set of one filters to be `AND` together with the rest of the filters
|
||||
const filterResults = await buildInitialWhereClause({
|
||||
table: indexedDb[resource],
|
||||
key,
|
||||
filterArrayOrObject,
|
||||
}).primaryKeys();
|
||||
const resultIdsSet = new Set(filterResults);
|
||||
|
||||
return (await currCollection).and((record) => {
|
||||
return resultIdsSet.has(record.id);
|
||||
});
|
||||
},
|
||||
buildInitialWhereClause({
|
||||
key: firstKey,
|
||||
table: indexedDb[resource],
|
||||
filterArrayOrObject: firstFilterArrayOrObject,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (search) {
|
||||
const { text, fields } = search;
|
||||
|
||||
filterCollection = filterCollection.filter((record) =>
|
||||
fields
|
||||
.map((key) => get(record, key))
|
||||
.some((element) => {
|
||||
if (typeof element === 'string') {
|
||||
return element?.toLowerCase().includes(text?.toLowerCase());
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// OrderBy has to be used on where clauses while if it's a
|
||||
// collection already we have to use sortBy. We'll also sort by
|
||||
// descending order based on created time to match the API as any
|
||||
// "or" clause can change the order
|
||||
if (filterCollection.orderBy) {
|
||||
return filterCollection.orderBy(getKey('created_time')).reverse().toArray();
|
||||
}
|
||||
return filterCollection.reverse().sortBy(getKey('created_time'));
|
||||
}
|
||||
|
||||
const buildInitialWhereClause = ({ filterArrayOrObject, table, key }) => {
|
||||
const filterValueArray = Array.isArray(filterArrayOrObject)
|
||||
? filterArrayOrObject
|
||||
: filterArrayOrObject.values;
|
||||
|
||||
const { logicalOperator } = filterArrayOrObject;
|
||||
|
||||
// Short circuit if all the filter types were equals or notEquals since indexedDb
|
||||
// can more efficiently query if we pass all values at once
|
||||
const checkAllFilterType = (type) =>
|
||||
filterValueArray.every((obj) => Object.keys(obj)[0] === type);
|
||||
if (checkAllFilterType('equals')) {
|
||||
return table
|
||||
.where(getKey(key))
|
||||
.anyOf(filterValueArray.map((obj) => obj['equals']));
|
||||
}
|
||||
if (checkAllFilterType('notEquals')) {
|
||||
return table
|
||||
.where(getKey(key))
|
||||
.noneOf(filterValueArray.map((obj) => obj['notEquals']));
|
||||
}
|
||||
|
||||
// Remove the first item to use as the initial where clause and then reduce the remaining ones
|
||||
const [firstOperation, firstFilterValue] = Object.entries(
|
||||
filterValueArray[0],
|
||||
)[0];
|
||||
|
||||
// Not all are filters are equals or notEquals,
|
||||
// manually build the initial where clause and resulting collections
|
||||
return filterValueArray.slice(1).reduce(
|
||||
(result, currObj) => {
|
||||
const [operation, value] = Object.entries(currObj)[0];
|
||||
|
||||
return buildIndexedDbCollection({
|
||||
key: getKey(key),
|
||||
value,
|
||||
collection: result,
|
||||
operation,
|
||||
logicalOperator,
|
||||
});
|
||||
},
|
||||
buildIndexedDbWhere({
|
||||
table,
|
||||
key: getKey(key),
|
||||
operation: firstOperation,
|
||||
value: firstFilterValue,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the initial where clause for indexed DB to filter on. Note that this makes
|
||||
* the first filter important as that will do the real filtering from indexed DB while
|
||||
* any subsequent filter has to be done in memory.
|
||||
*/
|
||||
const buildIndexedDbWhere = ({ table, key, value, operation }) => {
|
||||
const baseWhereClause = table.where(key);
|
||||
switch (operation) {
|
||||
case 'contains':
|
||||
// We return an error due to not being able to support contains on first filter due to performance.
|
||||
throw new Error('Contains is not supported as the first filter.');
|
||||
case 'gt':
|
||||
return baseWhereClause.above(value);
|
||||
case 'gte':
|
||||
return baseWhereClause.aboveOrEqual(value);
|
||||
case 'lt':
|
||||
return baseWhereClause.below(value);
|
||||
case 'lte':
|
||||
return baseWhereClause.belowOrEqual(value);
|
||||
case 'notEquals':
|
||||
return baseWhereClause.notEqual(value);
|
||||
default:
|
||||
return baseWhereClause.equals(value);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Continues building the queries for indexed DB to filter on. Note that these are not true
|
||||
* indexed DB queries and in fact just standard JS filters for logical "and" queries and parallel queries
|
||||
* to indexed DB for logical "or" queries that get deduped.
|
||||
*/
|
||||
const buildIndexedDbCollection = ({
|
||||
key,
|
||||
value,
|
||||
collection,
|
||||
operation,
|
||||
logicalOperator,
|
||||
}) => {
|
||||
if (logicalOperator === 'and') {
|
||||
switch (operation) {
|
||||
case 'contains':
|
||||
return collection.and((record) => get(record, key)?.includes(value));
|
||||
case 'gt':
|
||||
return collection.and((record) => get(record, key) > value);
|
||||
case 'gte':
|
||||
return collection.and((record) => get(record, key) >= value);
|
||||
case 'lt':
|
||||
return collection.and((record) => get(record, key) < value);
|
||||
case 'lte':
|
||||
return collection.and((record) => get(record, key) <= value);
|
||||
case 'notEquals':
|
||||
return collection.and((record) => get(record, key) !== value);
|
||||
default:
|
||||
return collection.and((record) => get(record, key) === value);
|
||||
}
|
||||
}
|
||||
|
||||
switch (operation) {
|
||||
case 'contains':
|
||||
// We return an error due to not being able to support contains on this filter due to performance.
|
||||
throw new Error(
|
||||
'Contains is not supported as a filter option with "or" operator.',
|
||||
);
|
||||
case 'gt':
|
||||
return collection.or(key).above(value);
|
||||
case 'gte':
|
||||
return collection.or(key).aboveOrEqual(value);
|
||||
case 'lt':
|
||||
return collection.or(key).below(value);
|
||||
case 'lte':
|
||||
return collection.or(key).belowOrEqual(value);
|
||||
case 'notEquals':
|
||||
return collection.or(key).notEqual(value);
|
||||
default:
|
||||
return collection.or(key).equals(value);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Translates the fields passed in from the UI for resources to the keys indexed DB knows them as.
|
||||
* Usually it's the path of the actual normalized model.
|
||||
*/
|
||||
const getKey = (key) => {
|
||||
switch (key) {
|
||||
case 'scope_id':
|
||||
return `attributes.scope.${key}`;
|
||||
case 'id':
|
||||
return key;
|
||||
default:
|
||||
return `attributes.${key}`;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Cleans the object to remove any keys that have a value of an empty array. This makes
|
||||
* it easier so we don't have to worry about empty arrays getting queried in indexed db.
|
||||
*/
|
||||
const cleanObject = (obj) => {
|
||||
if (!obj) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return Object.entries(obj).reduce((result, [key, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length > 0) {
|
||||
result[key] = value;
|
||||
}
|
||||
} else if (value.values.length > 0) {
|
||||
result[key] = value;
|
||||
}
|
||||
|
||||
return result;
|
||||
}, {});
|
||||
};
|
||||
|
||||
/**
|
||||
* Makes the search object that is expected when a user searches. Uses
|
||||
* the indexes we setup in indexed db.
|
||||
*/
|
||||
const getSearchObject = (text, resource) => {
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
text,
|
||||
fields: modelIndexes[resource]
|
||||
.split(', ')
|
||||
.map((index) => index.replace('&', '')),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,408 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { modelMapping } from 'api/services/sqlite';
|
||||
import { typeOf } from '@ember/utils';
|
||||
import { underscore } from '@ember/string';
|
||||
import { assert } from '@ember/debug';
|
||||
|
||||
/**
|
||||
* Takes a POJO representing a filter query and builds a SQL query.
|
||||
*
|
||||
* @example
|
||||
* const filter = generateSQLExpressions(type, {
|
||||
* search: generated',
|
||||
* filters: {
|
||||
* scope_id: {
|
||||
* logicalOperator: 'and',
|
||||
* values: [{ notEquals: 'scope1' }, { notEquals: 'scope2' }],
|
||||
* },
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function generateSQLExpressions(
|
||||
resource,
|
||||
query = {},
|
||||
{ page, pageSize, select } = {},
|
||||
) {
|
||||
let { search, filters, sort = {} } = query;
|
||||
const conditions = [];
|
||||
const parameters = [];
|
||||
const tableName = underscore(resource);
|
||||
const joins = [];
|
||||
|
||||
addFilterConditions({ filters, parameters, conditions, joins, tableName });
|
||||
addSearchConditions({ search, resource, tableName, parameters, conditions });
|
||||
|
||||
const selectClause = constructSelectClause(select, tableName);
|
||||
const joinClause = constructJoinClause(joins);
|
||||
const orderByClause = constructOrderByClause(resource, tableName, sort);
|
||||
const whereClause = conditions.length ? `WHERE ${and(conditions)}` : '';
|
||||
|
||||
const paginationClause = page && pageSize ? `LIMIT ? OFFSET ?` : '';
|
||||
if (paginationClause) {
|
||||
parameters.push(pageSize, (page - 1) * pageSize);
|
||||
}
|
||||
|
||||
return {
|
||||
// Replace any empty newlines or leading whitespace on each line to be consistent with formatting
|
||||
// This is mainly to help us read and test the generated SQL as it has no effect on the actual SQL execution.
|
||||
sql: `
|
||||
${selectClause}
|
||||
${joinClause}
|
||||
${whereClause}
|
||||
${orderByClause}
|
||||
${paginationClause}`
|
||||
.replace(/^\s+/gm, '')
|
||||
.trim(),
|
||||
parameters,
|
||||
};
|
||||
}
|
||||
|
||||
function addFilterConditions({
|
||||
filters,
|
||||
parameters,
|
||||
conditions,
|
||||
joins,
|
||||
tableName,
|
||||
}) {
|
||||
if (!filters) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [key, filterArrayOrObject] of Object.entries(filters)) {
|
||||
// Filter can be an array or an object with a `values` property
|
||||
const filterValueArray = Array.isArray(filterArrayOrObject)
|
||||
? filterArrayOrObject
|
||||
: filterArrayOrObject.values;
|
||||
|
||||
if (!filterValueArray || !filterValueArray.length || key === 'joins') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// When attribute conditions are a series of equals/notEquals
|
||||
// replace with in/notIn to avoid hitting the sqlite maximum
|
||||
// expression tree depth.
|
||||
const firstOperator = Object.keys(filterValueArray[0])[0];
|
||||
const allOperatorsEqual = filterValueArray
|
||||
.flatMap((item) => Object.keys(item))
|
||||
.every((op) => op === firstOperator);
|
||||
if (
|
||||
filterValueArray.length > 1 &&
|
||||
(firstOperator === 'equals' || firstOperator === 'notEquals') &&
|
||||
allOperatorsEqual
|
||||
) {
|
||||
const operation = firstOperator === 'equals' ? 'in' : 'notIn';
|
||||
const nullOperator =
|
||||
firstOperator === 'equals' ? 'IS NULL' : 'IS NOT NULL';
|
||||
const values = filterValueArray
|
||||
.filter(
|
||||
(filterObjValue) =>
|
||||
filterObjValue && Object.values(filterObjValue)[0] !== null,
|
||||
)
|
||||
.map((filterObjValue) => {
|
||||
let value = Object.values(filterObjValue)[0];
|
||||
if (typeOf(value) === 'date') {
|
||||
value = value.toISOString();
|
||||
}
|
||||
parameters.push(value);
|
||||
return value;
|
||||
});
|
||||
|
||||
// Add a check for null values as an IN clause will mistakenly use `=` for null values.
|
||||
// We'll add an extra OR clause to handle nulls separately.
|
||||
const isNullValue = filterValueArray.some(
|
||||
(filterObjValue) => Object.values(filterObjValue)[0] === null,
|
||||
);
|
||||
|
||||
const filterCondition = `"${tableName}".${key}${OPERATORS[operation](values)}${isNullValue ? ` OR "${tableName}".${key} ${nullOperator}` : ''}`;
|
||||
conditions.push(parenthetical(filterCondition));
|
||||
continue;
|
||||
}
|
||||
|
||||
const filterConditions = filterValueArray
|
||||
.filter((f) => f)
|
||||
.map((filterObjValue) => {
|
||||
let [operation, value] = Object.entries(filterObjValue)[0];
|
||||
|
||||
// Handle null values: convert equals/notEquals to IS NULL or IS NOT NULL
|
||||
if (value === null) {
|
||||
if (operation === 'equals') {
|
||||
return `"${tableName}".${key} IS NULL`;
|
||||
}
|
||||
if (operation === 'notEquals') {
|
||||
return `"${tableName}".${key} IS NOT NULL`;
|
||||
}
|
||||
}
|
||||
|
||||
// SQLite needs to be working with ISO strings
|
||||
if (typeOf(value) === 'date') {
|
||||
value = value.toISOString();
|
||||
}
|
||||
|
||||
// Handle LIKE operator separately
|
||||
if (operation === 'contains') {
|
||||
parameters.push(`%${value}%`);
|
||||
} else {
|
||||
parameters.push(value);
|
||||
}
|
||||
|
||||
return `"${tableName}".${key}${OPERATORS[operation]}`;
|
||||
});
|
||||
|
||||
const { logicalOperator } = filterArrayOrObject;
|
||||
// Default to `or` if we don't have a logical operator
|
||||
conditions.push(
|
||||
parenthetical(
|
||||
logicalOperator === 'and'
|
||||
? and(filterConditions)
|
||||
: or(filterConditions),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (filters.joins?.length > 0) {
|
||||
const tableNameIndex = {};
|
||||
|
||||
filters.joins.forEach((join) => {
|
||||
const {
|
||||
resource,
|
||||
query,
|
||||
joinFrom = 'id',
|
||||
joinOn,
|
||||
joinType = 'INNER',
|
||||
} = join;
|
||||
const joinTableName = underscore(resource);
|
||||
tableNameIndex[resource] ??= 1;
|
||||
// Alias in the possible scenario we join the same table more than once
|
||||
const alias = `${joinTableName}${tableNameIndex[resource]}`;
|
||||
|
||||
joins.push({
|
||||
type: joinType,
|
||||
table: joinTableName,
|
||||
alias,
|
||||
condition: `"${tableName}".${joinFrom} = ${alias}.${joinOn}`,
|
||||
});
|
||||
|
||||
// Add the conditions from the join query
|
||||
if (query?.filters) {
|
||||
addFilterConditions({
|
||||
filters: query.filters,
|
||||
parameters,
|
||||
conditions,
|
||||
tableName: alias,
|
||||
});
|
||||
}
|
||||
|
||||
tableNameIndex[resource]++;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function addSearchConditions({
|
||||
search,
|
||||
resource,
|
||||
tableName,
|
||||
parameters,
|
||||
conditions,
|
||||
}) {
|
||||
if (!search || !modelMapping[resource]) {
|
||||
return;
|
||||
}
|
||||
const searchSelect = search?.select ?? 'rowid';
|
||||
const searchSql = `SELECT ${searchSelect} FROM ${tableName}_fts WHERE ${tableName}_fts MATCH ?`;
|
||||
const getParameter = (fields, text) =>
|
||||
fields?.length > 0
|
||||
? or(fields.map((field) => `${field}:"${text}"*`))
|
||||
: `"${text}"*`;
|
||||
|
||||
// Use the special prefix indicator "*" for full-text search
|
||||
if (typeOf(search) === 'object') {
|
||||
if (!search.text) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parameter = getParameter(search.fields, search.text);
|
||||
parameters.push(parameter);
|
||||
} else {
|
||||
parameters.push(`"${search}"*`);
|
||||
}
|
||||
|
||||
// If there are extra related searches on other tables, add them too
|
||||
if (search?.relatedSearches?.length > 0) {
|
||||
const relatedSearchQueries = [];
|
||||
|
||||
search.relatedSearches.forEach((relatedSearch) => {
|
||||
const { resource: relatedResource, fields, join } = relatedSearch;
|
||||
const relatedTableName = underscore(relatedResource);
|
||||
|
||||
const parameter = getParameter(fields, search.text);
|
||||
parameters.push(parameter);
|
||||
|
||||
// Build the related FTS query with optional join
|
||||
let relatedQuery = [];
|
||||
relatedQuery.push(
|
||||
`SELECT "${tableName}".${searchSelect} FROM ${relatedTableName}_fts`,
|
||||
);
|
||||
|
||||
if (join) {
|
||||
const { joinFrom = 'id', joinOn } = join;
|
||||
relatedQuery.push(
|
||||
`JOIN "${tableName}" ON "${tableName}".${joinFrom} = ${relatedResource}_fts.${joinOn}`,
|
||||
);
|
||||
}
|
||||
relatedQuery.push(`WHERE ${relatedTableName}_fts MATCH ?`);
|
||||
|
||||
relatedSearchQueries.push(relatedQuery.join(' '));
|
||||
});
|
||||
|
||||
// Add the original search first since we've already added the original parameter first
|
||||
const conditionStatement = [searchSql, ...relatedSearchQueries].join(
|
||||
'\nUNION ',
|
||||
);
|
||||
conditions.push(
|
||||
`"${tableName}".${searchSelect} IN (${conditionStatement})`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Use a subquery to match against the FTS table with rowids as SQLite is
|
||||
// much more efficient with FTS queries when using rowids or MATCH (or both).
|
||||
// We could have also used a join here but a subquery is simpler.
|
||||
conditions.push(`"${tableName}".${searchSelect} IN (${searchSql})`);
|
||||
}
|
||||
|
||||
function constructSelectClause(select = [{ field: '*' }], tableName) {
|
||||
const distinctColumns = select.filter(({ isDistinct }) => isDistinct);
|
||||
const nonDistinctColumns = select.filter(({ isDistinct }) => !isDistinct);
|
||||
|
||||
const buildColumnExpression = ({ field, isCount, alias, isDistinct }) => {
|
||||
let column = field === '*' ? field : `"${tableName}".${field}`;
|
||||
|
||||
// If there's just distinct with nothing else, this will be handled separately
|
||||
// and we will just return the column back as is
|
||||
if (isCount && isDistinct) {
|
||||
column = `count(DISTINCT ${column})`;
|
||||
} else if (isCount) {
|
||||
column = `count(${column})`;
|
||||
}
|
||||
|
||||
if (alias) {
|
||||
column = `${column} as ${alias}`;
|
||||
}
|
||||
|
||||
return column;
|
||||
};
|
||||
|
||||
let selectColumns;
|
||||
|
||||
if (distinctColumns.length > 0) {
|
||||
// Check if any columns also have COUNT (or other aggregate function in the future)
|
||||
const hasAggregateDistinct = distinctColumns.some((col) => col.isCount);
|
||||
|
||||
if (hasAggregateDistinct) {
|
||||
selectColumns = distinctColumns.map(buildColumnExpression).join(', ');
|
||||
|
||||
// Add back in the non distinct columns
|
||||
if (nonDistinctColumns.length > 0) {
|
||||
const regularPart = nonDistinctColumns
|
||||
.map(buildColumnExpression)
|
||||
.join(', ');
|
||||
selectColumns = `${selectColumns}, ${regularPart}`;
|
||||
}
|
||||
} else {
|
||||
assert(
|
||||
'Can not combine non-distincts with multi column distincts',
|
||||
nonDistinctColumns.length === 0,
|
||||
);
|
||||
|
||||
// Only do multi column DISTINCT with no aggregate functions
|
||||
selectColumns = `DISTINCT ${distinctColumns.map(buildColumnExpression).join(', ')}`;
|
||||
}
|
||||
} else {
|
||||
selectColumns = select.map(buildColumnExpression).join(', ');
|
||||
}
|
||||
|
||||
return `SELECT ${selectColumns} FROM "${tableName}"`;
|
||||
}
|
||||
|
||||
function constructJoinClause(joins) {
|
||||
if (!joins || joins.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return joins
|
||||
.map(
|
||||
({ type, table, alias, condition }) =>
|
||||
`${type} JOIN "${table}" ${alias} ON ${condition}`,
|
||||
)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function constructOrderByClause(resource, tableName, sort) {
|
||||
const defaultOrderByClause = `ORDER BY "${tableName}".created_time DESC`;
|
||||
|
||||
const { attributes, customSort, direction, isCoalesced } = sort;
|
||||
const sortDirection = direction === 'desc' ? 'DESC' : 'ASC';
|
||||
const attributesWithTableName = attributes?.map(
|
||||
(attribute) => `"${tableName}".${attribute}`,
|
||||
);
|
||||
|
||||
// We have to check if the attributes are valid for the resource
|
||||
// as we can't use parameterized queries for ORDER BY
|
||||
const validAttributes = Object.keys(modelMapping[resource] ?? {});
|
||||
if (attributes?.some((attr) => !validAttributes.includes(attr))) {
|
||||
return modelMapping[resource]?.created_time ? defaultOrderByClause : '';
|
||||
}
|
||||
|
||||
if (customSort?.attributeMap) {
|
||||
const whenClauses = Object.keys(customSort.attributeMap).reduce(
|
||||
(acc, key) => {
|
||||
return acc + `WHEN '${key}' THEN '${customSort.attributeMap[key]}' `;
|
||||
},
|
||||
'',
|
||||
);
|
||||
return `ORDER BY CASE ${attributesWithTableName.join(', ')} ${whenClauses}END ${sortDirection}`;
|
||||
} else if (attributes?.length > 0) {
|
||||
const commaSeparatedVals = attributesWithTableName.join(', ');
|
||||
|
||||
// In places where `collate nocase` is used, it is to ensure case is ignored on the initial sort.
|
||||
// Then, a sort on the same condition is performed to ensure upper-case strings are given preference in a tie.
|
||||
if (isCoalesced) {
|
||||
return `ORDER BY COALESCE(${commaSeparatedVals}) COLLATE NOCASE ${sortDirection}, COALESCE(${commaSeparatedVals}) ${sortDirection}`;
|
||||
}
|
||||
|
||||
const attributesWithNoCollate = attributes
|
||||
.map((attr) => `"${tableName}".${attr} COLLATE NOCASE ${sortDirection}`)
|
||||
.join(', ');
|
||||
const attributesWithDirection = attributes
|
||||
.map((attr) => `"${tableName}".${attr} ${sortDirection}`)
|
||||
.join(', ');
|
||||
return `ORDER BY ${attributesWithNoCollate}, ${attributesWithDirection}`;
|
||||
} else if (modelMapping[resource]?.created_time) {
|
||||
return defaultOrderByClause;
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// Comparison Operators
|
||||
const OPERATORS = {
|
||||
equals: ' = ?',
|
||||
notEquals: ' != ?',
|
||||
gt: ' > ?',
|
||||
gte: ' >= ?',
|
||||
lt: ' < ?',
|
||||
lte: ' <= ?',
|
||||
contains: ' LIKE ?',
|
||||
in: (values) => ` IN (${values.map(() => '?').join(', ')})`,
|
||||
notIn: (values) => ` NOT IN (${values.map(() => '?').join(', ')})`,
|
||||
};
|
||||
|
||||
// Logical Operators
|
||||
const and = (clauses) => clauses.join(' AND ');
|
||||
const or = (clauses) => clauses.join(' OR ');
|
||||
const parenthetical = (expression) => (expression ? `(${expression})` : '');
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { PWBWorker } from 'promise-worker-bi';
|
||||
import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
|
||||
import sharedServiceInit from './utils/shared-service';
|
||||
import {
|
||||
CLEAR_DB,
|
||||
CREATE_TABLES,
|
||||
DELETE_STATEMENT,
|
||||
INSERT_STATEMENTS,
|
||||
} from './utils/schema';
|
||||
|
||||
// Production builds error out when using top level awaits so we'll just use an async IIFE
|
||||
(async () => {
|
||||
/** @type {import('@sqlite.org/sqlite-wasm').OpfsSAHPoolDatabase} */
|
||||
let db;
|
||||
/** @type {import('@sqlite.org/sqlite-wasm').SAHPoolUtil} */
|
||||
let poolUtil;
|
||||
/** @type {import('@sqlite.org/sqlite-wasm').Sqlite3Static} */
|
||||
let sqlite3;
|
||||
|
||||
const { promise: ready, resolve: readyResolve } = Promise.withResolvers();
|
||||
|
||||
// Maximum number of host parameters for SQLite prepared statements.
|
||||
// See "Maximum Number Of Host Parameters In A Single SQL Statement" in
|
||||
// https://www.sqlite.org/limits.html
|
||||
const MAX_HOST_PARAMETERS = 32766;
|
||||
const SCHEMA_VERSION = 2;
|
||||
|
||||
// Some browsers do not allow calling getDirectory in private browsing modes even
|
||||
// if we're in a secure context. This will cause the SQLite setup to fail so we should
|
||||
// check ahead of time to see if we're able to call the API.
|
||||
let canCallGetDirectory;
|
||||
try {
|
||||
canCallGetDirectory = Boolean(await navigator.storage.getDirectory());
|
||||
} catch {
|
||||
canCallGetDirectory = false;
|
||||
}
|
||||
|
||||
const isSecure = self.isSecureContext && canCallGetDirectory;
|
||||
|
||||
// Error code for sqlite corruption errors.
|
||||
// See https://sqlite.org/rescode.html#corrupt
|
||||
const SQLITE_CORRUPT = 11;
|
||||
|
||||
const methods = {
|
||||
initializeSQLite: async () => {
|
||||
sqlite3 = await sqlite3InitModule({
|
||||
print: console.log,
|
||||
printErr: console.error,
|
||||
});
|
||||
const dbName = await promiseWorker.postMessage({
|
||||
method: 'getDatabaseName',
|
||||
});
|
||||
|
||||
function setupDb() {
|
||||
const [row] = db.exec('PRAGMA user_version;', { rowMode: 'object' });
|
||||
// Check if we're on a newer schema version and clear the database if so
|
||||
if (SCHEMA_VERSION > row.user_version) {
|
||||
db.exec(CLEAR_DB);
|
||||
}
|
||||
|
||||
db.exec(CREATE_TABLES(SCHEMA_VERSION));
|
||||
// Force an analyze on initial connection
|
||||
db.exec('PRAGMA optimize=0x10002;');
|
||||
}
|
||||
|
||||
// Instantiate an in memory DB if we're not in a secure context as OPFS is unavailable otherwise
|
||||
if (!isSecure) {
|
||||
db = new sqlite3.oo1.DB(':memory:');
|
||||
setupDb();
|
||||
return;
|
||||
}
|
||||
|
||||
let done = false;
|
||||
while (!done) {
|
||||
try {
|
||||
poolUtil = await sqlite3.installOpfsSAHPoolVfs();
|
||||
db = new poolUtil.OpfsSAHPoolDb(dbName);
|
||||
|
||||
setupDb();
|
||||
done = true;
|
||||
} catch (/** @type {import('@sqlite.org/sqlite-wasm').SQLite3Error} */ err) {
|
||||
// If we get a SQLite3Error and the pool is at capacity, we'll assume the error is
|
||||
// because we hit the cap so we add more capacity and see if that solves it.
|
||||
if (
|
||||
err.name === 'SQLite3Error' &&
|
||||
poolUtil.getCapacity() === poolUtil.getFileCount()
|
||||
) {
|
||||
await poolUtil.addCapacity(1);
|
||||
|
||||
// If we have more than 20 files, that might indicate a problem so let's just
|
||||
// remove the VFS and wipe all the client data across users and restart.
|
||||
if (poolUtil.getCapacity() > 20) {
|
||||
await poolUtil.removeVfs();
|
||||
}
|
||||
} else {
|
||||
console.error('SQLite initialization error:', err);
|
||||
|
||||
// We might have gotten ourselves into a bad state which corrupted the DB so clear it out and retry
|
||||
if (err?.resultCode === SQLITE_CORRUPT) {
|
||||
poolUtil.unlink(`/${dbName}`);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
analyzeDatabase: () => {
|
||||
db.exec('PRAGMA optimize');
|
||||
},
|
||||
clearDatabase: () => {
|
||||
db.exec(CLEAR_DB);
|
||||
db.exec(CREATE_TABLES(SCHEMA_VERSION));
|
||||
},
|
||||
deleteDatabase: () => {
|
||||
if (!db) {
|
||||
throw new Error('No database was initialized');
|
||||
}
|
||||
|
||||
const name = db.dbFilename();
|
||||
db.close();
|
||||
db = null;
|
||||
poolUtil.unlink(`/${name}`);
|
||||
},
|
||||
downloadDatabase: () => {
|
||||
return sqlite3.capi.sqlite3_js_db_export(db);
|
||||
},
|
||||
fetchResource: ({ sql, parameters = [] }) => {
|
||||
try {
|
||||
return db.exec({
|
||||
sql,
|
||||
bind: parameters,
|
||||
rowMode: 'object',
|
||||
});
|
||||
} catch (err) {
|
||||
// TODO: Add error handling to rest of methods and properly return the error back to caller
|
||||
console.error('Fetch resource error:', err, sql, parameters);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
insertResource: ({ resource, items, modelMapping }) => {
|
||||
if (items.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const numberOfParameters = items.reduce((acc, item) => {
|
||||
return acc + item.length;
|
||||
}, 0);
|
||||
|
||||
// We want to use as many VALUES as part of the insert statement as it's quicker
|
||||
// than doing multiple inserts. If we have too many rows to insert, we'll manually
|
||||
// chunk them up to avoid hitting the maximum number of host parameters
|
||||
// and execute these separate insert statements in a transaction
|
||||
if (numberOfParameters > MAX_HOST_PARAMETERS) {
|
||||
const chunkSize = Math.floor(MAX_HOST_PARAMETERS / items[0].length);
|
||||
|
||||
return db.transaction(() => {
|
||||
const results = [];
|
||||
for (let i = 0; i < items.length; i += chunkSize) {
|
||||
const chunk = items.slice(i, i + chunkSize);
|
||||
const result = db.exec({
|
||||
sql: INSERT_STATEMENTS(resource, chunk, modelMapping),
|
||||
bind: chunk.flat(),
|
||||
rowMode: 'object',
|
||||
});
|
||||
results.push(result);
|
||||
}
|
||||
return results;
|
||||
});
|
||||
}
|
||||
|
||||
db.exec({
|
||||
sql: INSERT_STATEMENTS(resource, items, modelMapping),
|
||||
bind: items.flat(),
|
||||
rowMode: 'object',
|
||||
});
|
||||
},
|
||||
deleteResource: ({ resource, ids }) => {
|
||||
// Check if we have too many parameters for the deletion and chunk if so
|
||||
if (ids?.length > MAX_HOST_PARAMETERS) {
|
||||
return db.transaction(() => {
|
||||
const results = [];
|
||||
for (let i = 0; i < ids.length; i += MAX_HOST_PARAMETERS) {
|
||||
const chunk = ids.slice(i, i + MAX_HOST_PARAMETERS);
|
||||
const result = db.exec({
|
||||
sql: DELETE_STATEMENT(resource, chunk),
|
||||
bind: chunk,
|
||||
rowMode: 'object',
|
||||
});
|
||||
results.push(result);
|
||||
}
|
||||
return results;
|
||||
});
|
||||
}
|
||||
|
||||
return db.exec({
|
||||
sql: DELETE_STATEMENT(resource, ids),
|
||||
bind: ids,
|
||||
rowMode: 'object',
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const promiseWorker = new PWBWorker();
|
||||
promiseWorker.register(async ({ method, payload }) => {
|
||||
if (!sharedService) {
|
||||
await ready;
|
||||
return methods[method](payload);
|
||||
}
|
||||
|
||||
await sharedService.status.ready;
|
||||
return sharedService.proxy[method](payload);
|
||||
});
|
||||
|
||||
const initializeOnProviderChange = async (isProvider) => {
|
||||
if (!isProvider) {
|
||||
return;
|
||||
}
|
||||
// Initialize the SQLite database when we become a provider
|
||||
await sharedService.proxy.initializeSQLite();
|
||||
};
|
||||
|
||||
// Only use the shared service in a secure context as weblocks aren't available otherwise
|
||||
let sharedService;
|
||||
if (isSecure) {
|
||||
sharedService = sharedServiceInit(
|
||||
'sqlite',
|
||||
methods,
|
||||
initializeOnProviderChange,
|
||||
);
|
||||
} else {
|
||||
await methods.initializeSQLite();
|
||||
readyResolve();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,480 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { underscore } from '@ember/string';
|
||||
|
||||
export const CLEAR_DB = `
|
||||
PRAGMA writable_schema = 1;
|
||||
DELETE FROM sqlite_master;
|
||||
PRAGMA writable_schema = 0;
|
||||
VACUUM;
|
||||
PRAGMA integrity_check;`;
|
||||
|
||||
const createTargetTables = `
|
||||
CREATE TABLE IF NOT EXISTS target (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
address TEXT,
|
||||
scope_id TEXT NOT NULL,
|
||||
created_time TEXT NOT NULL,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_target_scope_id_created_time ON target(scope_id, created_time DESC);
|
||||
|
||||
-- Create a content FTS table that references the original table
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS target_fts USING fts5(
|
||||
id,
|
||||
type,
|
||||
name,
|
||||
description,
|
||||
address,
|
||||
scope_id,
|
||||
created_time,
|
||||
content='target',
|
||||
);
|
||||
|
||||
-- Create triggers to keep the FTS table in sync with the target table
|
||||
-- Note: The FTS table does not have an update trigger, as it's updated only on
|
||||
-- INSERT and DELETE as we do an INSERT OR REPLACE INTO on the table
|
||||
-- which replaces the row and doesn't execute an UPDATE trigger.
|
||||
CREATE TRIGGER IF NOT EXISTS target_ai AFTER INSERT ON target BEGIN
|
||||
INSERT INTO target_fts(
|
||||
rowid, id, type, name, description, address, scope_id, created_time
|
||||
) VALUES (
|
||||
new.rowid, new.id, new.type, new.name, new.description, new.address, new.scope_id, new.created_time
|
||||
);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS target_ad AFTER DELETE ON target BEGIN
|
||||
INSERT INTO target_fts(target_fts, rowid, id, type, name, description, address, scope_id, created_time)
|
||||
VALUES('delete', old.rowid, old.id, old.type, old.name, old.description, old.address, old.scope_id, old.created_time);
|
||||
END;`;
|
||||
|
||||
const createAliasTables = `
|
||||
CREATE TABLE IF NOT EXISTS alias (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT,
|
||||
description TEXT,
|
||||
destination_id TEXT,
|
||||
value TEXT,
|
||||
scope_id TEXT NOT NULL,
|
||||
created_time TEXT NOT NULL,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_alias_created_time ON alias(created_time DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_alias_destination_id ON alias(destination_id);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS alias_fts USING fts5(
|
||||
id,
|
||||
type,
|
||||
name,
|
||||
description,
|
||||
destination_id,
|
||||
value,
|
||||
scope_id,
|
||||
created_time,
|
||||
content='alias',
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS alias_ai AFTER INSERT ON alias BEGIN
|
||||
INSERT INTO alias_fts(
|
||||
rowid, id, type, name, description, destination_id, value, scope_id, created_time
|
||||
) VALUES (
|
||||
new.rowid, new.id, new.type, new.name, new.description, new.destination_id, new.value, new.scope_id, new.created_time
|
||||
);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS alias_ad AFTER DELETE ON alias BEGIN
|
||||
INSERT INTO alias_fts(alias_fts, rowid, id, type, name, description, destination_id, value, scope_id, created_time)
|
||||
VALUES('delete', old.rowid, old.id, old.type, old.name, old.description, old.destination_id, old.value, old.scope_id, old.created_time);
|
||||
END;`;
|
||||
|
||||
const createGroupTables = `
|
||||
CREATE TABLE IF NOT EXISTS "group" (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
name TEXT,
|
||||
description TEXT,
|
||||
scope_id TEXT NOT NULL,
|
||||
created_time TEXT NOT NULL,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_group_scope_id_created_time ON "group"(scope_id, created_time DESC);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS group_fts USING fts5(
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
scope_id,
|
||||
created_time,
|
||||
content='group',
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS group_ai AFTER INSERT ON "group" BEGIN
|
||||
INSERT INTO group_fts(
|
||||
rowid, id, name, description, scope_id, created_time
|
||||
) VALUES (
|
||||
new.rowid, new.id, new.name, new.description, new.scope_id, new.created_time
|
||||
);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS group_ad AFTER DELETE ON "group" BEGIN
|
||||
INSERT INTO group_fts(group_fts, rowid, id, name, description, scope_id, created_time)
|
||||
VALUES('delete', old.rowid, old.id, old.name, old.description, old.scope_id, old.created_time);
|
||||
END;`;
|
||||
|
||||
const createRoleTables = `
|
||||
CREATE TABLE IF NOT EXISTS role (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
name TEXT,
|
||||
description TEXT,
|
||||
scope_id TEXT NOT NULL,
|
||||
created_time TEXT NOT NULL,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_role_scope_id_created_time ON role(scope_id, created_time DESC);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS role_fts USING fts5(
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
scope_id,
|
||||
created_time,
|
||||
content='role',
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS role_ai AFTER INSERT ON role BEGIN
|
||||
INSERT INTO role_fts(
|
||||
rowid, id, name, description, scope_id, created_time
|
||||
) VALUES (
|
||||
new.rowid, new.id, new.name, new.description, new.scope_id, new.created_time
|
||||
);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS role_ad AFTER DELETE ON role BEGIN
|
||||
INSERT INTO role_fts(role_fts, rowid, id, name, description, scope_id, created_time)
|
||||
VALUES('delete', old.rowid, old.id, old.name, old.description, old.scope_id, old.created_time);
|
||||
END;`;
|
||||
|
||||
const createUserTables = `
|
||||
CREATE TABLE IF NOT EXISTS user (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
name TEXT,
|
||||
description TEXT,
|
||||
scope_id TEXT NOT NULL,
|
||||
created_time TEXT NOT NULL,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_scope_id_created_time ON user(scope_id, created_time DESC);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS user_fts USING fts5(
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
scope_id,
|
||||
created_time,
|
||||
content='user',
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS user_ai AFTER INSERT ON user BEGIN
|
||||
INSERT INTO user_fts(
|
||||
rowid, id, name, description, scope_id, created_time
|
||||
) VALUES (
|
||||
new.rowid, new.id, new.name, new.description, new.scope_id, new.created_time
|
||||
);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS user_ad AFTER DELETE ON user BEGIN
|
||||
INSERT INTO user_fts(user_fts, rowid, id, name, description, scope_id, created_time)
|
||||
VALUES('delete', old.rowid, old.id, old.name, old.description, old.scope_id, old.created_time);
|
||||
END;`;
|
||||
|
||||
const createCredentialStoreTables = `
|
||||
CREATE TABLE IF NOT EXISTS credential_store (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT,
|
||||
description TEXT,
|
||||
scope_id TEXT NOT NULL,
|
||||
created_time TEXT NOT NULL,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_credential_store_scope_id_created_time ON credential_store(scope_id, created_time DESC);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS credential_store_fts USING fts5(
|
||||
id,
|
||||
type,
|
||||
name,
|
||||
description,
|
||||
scope_id,
|
||||
created_time,
|
||||
content='credential_store',
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS credential_store_ai AFTER INSERT ON credential_store BEGIN
|
||||
INSERT INTO credential_store_fts(
|
||||
rowid, id, type, name, description, scope_id, created_time
|
||||
) VALUES (
|
||||
new.rowid, new.id, new.type, new.name, new.description, new.scope_id, new.created_time
|
||||
);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS credential_store_ad AFTER DELETE ON credential_store BEGIN
|
||||
INSERT INTO credential_store_fts(credential_store_fts, rowid, id, type, name, description, scope_id, created_time)
|
||||
VALUES('delete', old.rowid, old.id, old.type, old.name, old.description, old.scope_id, old.created_time);
|
||||
END;`;
|
||||
|
||||
const createScopeTables = `
|
||||
CREATE TABLE IF NOT EXISTS scope (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT,
|
||||
description TEXT,
|
||||
scope_id TEXT NOT NULL,
|
||||
created_time TEXT NOT NULL,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_scope_scope_id_created_time ON scope(scope_id, created_time DESC);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS scope_fts USING fts5(
|
||||
id,
|
||||
type,
|
||||
name,
|
||||
description,
|
||||
scope_id,
|
||||
created_time,
|
||||
content='scope',
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS scope_ai AFTER INSERT ON scope BEGIN
|
||||
INSERT INTO scope_fts(
|
||||
rowid, id, type, name, description, scope_id, created_time
|
||||
) VALUES (
|
||||
new.rowid, new.id, new.type, new.name, new.description, new.scope_id, new.created_time
|
||||
);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS scope_ad AFTER DELETE ON scope BEGIN
|
||||
INSERT INTO scope_fts(scope_fts, rowid, id, type, name, description, scope_id, created_time)
|
||||
VALUES('delete', old.rowid, old.id, old.type, old.name, old.description, old.scope_id, old.created_time);
|
||||
END;`;
|
||||
|
||||
const createAuthMethodTables = `
|
||||
CREATE TABLE IF NOT EXISTS auth_method (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT,
|
||||
description TEXT,
|
||||
is_primary INT,
|
||||
scope_id TEXT NOT NULL,
|
||||
created_time TEXT NOT NULL,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_auth_method_scope_id_created_time ON auth_method(scope_id, created_time DESC);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS auth_method_fts USING fts5(
|
||||
id,
|
||||
type,
|
||||
name,
|
||||
description,
|
||||
is_primary,
|
||||
scope_id,
|
||||
created_time,
|
||||
content='auth_method',
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS auth_method_ai AFTER INSERT ON auth_method BEGIN
|
||||
INSERT INTO auth_method_fts(
|
||||
rowid, id, type, name, description, is_primary, scope_id, created_time
|
||||
) VALUES (
|
||||
new.rowid, new.id, new.type, new.name, new.description, new.is_primary, new.scope_id, new.created_time
|
||||
);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS auth_method_ad AFTER DELETE ON auth_method BEGIN
|
||||
INSERT INTO auth_method_fts(auth_method_fts, rowid, id, type, name, description, is_primary, scope_id, created_time)
|
||||
VALUES('delete', old.rowid, old.id, old.type, old.name, old.description, old.is_primary, old.scope_id, old.created_time);
|
||||
END;`;
|
||||
|
||||
const createHostCatalogTables = `
|
||||
CREATE TABLE IF NOT EXISTS host_catalog (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT,
|
||||
description TEXT,
|
||||
plugin_name TEXT,
|
||||
scope_id TEXT NOT NULL,
|
||||
created_time TEXT NOT NULL,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_host_catalog_scope_id_created_time ON host_catalog(scope_id, created_time DESC);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS host_catalog_fts USING fts5(
|
||||
id,
|
||||
type,
|
||||
name,
|
||||
description,
|
||||
plugin_name,
|
||||
scope_id,
|
||||
created_time,
|
||||
content='host_catalog',
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS host_catalog_ai AFTER INSERT ON host_catalog BEGIN
|
||||
INSERT INTO host_catalog_fts(
|
||||
rowid, id, type, name, description, plugin_name, scope_id, created_time
|
||||
) VALUES (
|
||||
new.rowid, new.id, new.type, new.name, new.description, new.plugin_name, new.scope_id, new.created_time
|
||||
);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS host_catalog_ad AFTER DELETE ON host_catalog BEGIN
|
||||
INSERT INTO host_catalog_fts(host_catalog_fts, rowid, id, type, name, description, plugin_name, scope_id, created_time)
|
||||
VALUES('delete', old.rowid, old.id, old.type, old.name, old.description, old.plugin_name, old.scope_id, old.created_time);
|
||||
END;`;
|
||||
|
||||
const createSessionRecordingTables = `
|
||||
CREATE TABLE IF NOT EXISTS session_recording (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
state TEXT,
|
||||
start_time TEXT,
|
||||
end_time TEXT,
|
||||
duration TEXT,
|
||||
scope_id TEXT NOT NULL,
|
||||
user_id TEXT,
|
||||
user_name TEXT,
|
||||
target_id TEXT,
|
||||
target_name TEXT,
|
||||
target_scope_id TEXT,
|
||||
target_scope_name TEXT,
|
||||
target_scope_parent_scope_id TEXT,
|
||||
created_time TEXT NOT NULL,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_session_recording_created_time ON session_recording(created_time DESC);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS session_recording_fts USING fts5(
|
||||
id,
|
||||
type,
|
||||
state,
|
||||
start_time,
|
||||
end_time,
|
||||
duration,
|
||||
scope_id,
|
||||
user_id,
|
||||
user_name,
|
||||
target_id,
|
||||
target_name,
|
||||
target_scope_id,
|
||||
target_scope_name,
|
||||
target_scope_parent_scope_id,
|
||||
created_time,
|
||||
content='session_recording',
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS session_recording_ai AFTER INSERT ON session_recording BEGIN
|
||||
INSERT INTO session_recording_fts(
|
||||
rowid, id, type, state, start_time, end_time, duration, scope_id, user_id, user_name, target_id, target_name, target_scope_id, target_scope_name, target_scope_parent_scope_id, created_time
|
||||
) VALUES (
|
||||
new.rowid, new.id, new.type, new.state, new.start_time, new.end_time, new.duration, new.scope_id, new.user_id, new.user_name, new.target_id, new.target_name, new.target_scope_id, new.target_scope_name, new.target_scope_parent_scope_id, new.created_time
|
||||
);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS session_recording_ad AFTER DELETE ON session_recording BEGIN
|
||||
INSERT INTO session_recording_fts(session_recording_fts, rowid, id, type, state, start_time, end_time, duration, scope_id, user_id, user_name, target_id, target_name, target_scope_id, target_scope_name, target_scope_parent_scope_id, created_time)
|
||||
VALUES('delete', old.rowid, old.id, old.type, old.state, old.start_time, old.end_time, old.duration, old.scope_id, old.user_id, old.user_name, old.target_id, old.target_name, old.target_scope_id, old.target_scope_name, old.target_scope_parent_scope_id, old.created_time);
|
||||
END;`;
|
||||
|
||||
const createSessionTables = `
|
||||
CREATE TABLE IF NOT EXISTS session (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
type TEXT,
|
||||
status TEXT,
|
||||
endpoint TEXT,
|
||||
target_id TEXT,
|
||||
user_id TEXT,
|
||||
scope_id TEXT NOT NULL,
|
||||
created_time TEXT NOT NULL,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_session_scope_id_created_time ON session(scope_id, created_time DESC);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS session_fts USING fts5(
|
||||
id,
|
||||
type,
|
||||
status,
|
||||
endpoint,
|
||||
target_id,
|
||||
user_id,
|
||||
scope_id,
|
||||
created_time,
|
||||
content='session',
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS session_ai AFTER INSERT ON session BEGIN
|
||||
INSERT INTO session_fts(
|
||||
rowid, id, type, status, endpoint, target_id, user_id, scope_id, created_time
|
||||
) VALUES (
|
||||
new.rowid, new.id, new.type, new.status, new.endpoint, new.target_id, new.user_id, new.scope_id, new.created_time
|
||||
);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS session_ad AFTER DELETE ON session BEGIN
|
||||
INSERT INTO session_fts(session_fts, rowid, id, type, status, endpoint, target_id, user_id, scope_id, created_time)
|
||||
VALUES('delete', old.rowid, old.id, old.type, old.status, old.endpoint, old.target_id, old.user_id, old.scope_id, old.created_time);
|
||||
END;`;
|
||||
|
||||
export const CREATE_TABLES = (version) => `
|
||||
BEGIN;
|
||||
|
||||
-- This ensures multiple triggers can activate in the scenario of using REPLACE INTO
|
||||
PRAGMA recursive_triggers = true;
|
||||
PRAGMA user_version = ${version};
|
||||
|
||||
CREATE TABLE IF NOT EXISTS token (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
token TEXT NOT NULL
|
||||
);
|
||||
|
||||
${createTargetTables}
|
||||
${createAliasTables}
|
||||
${createGroupTables}
|
||||
${createRoleTables}
|
||||
${createUserTables}
|
||||
${createCredentialStoreTables}
|
||||
${createScopeTables}
|
||||
${createAuthMethodTables}
|
||||
${createHostCatalogTables}
|
||||
${createSessionRecordingTables}
|
||||
${createSessionTables}
|
||||
|
||||
COMMIT;`;
|
||||
|
||||
export const INSERT_STATEMENTS = (resource, items, modelMapping) => {
|
||||
if (resource === 'token') {
|
||||
return `REPLACE INTO token (id, token) VALUES ${items.map(() => '(?, ?)').join(', ')};`;
|
||||
}
|
||||
|
||||
if (!modelMapping?.[resource]) {
|
||||
throw new Error(`modelMapping is required for ${resource} insertions.`);
|
||||
}
|
||||
|
||||
const columns = Object.keys(modelMapping[resource]);
|
||||
// Extra column for the data column
|
||||
const numColumns = Array(columns.length + 1);
|
||||
const placeholders = `(${numColumns.fill('?').join(', ')})`;
|
||||
|
||||
return `REPLACE INTO "${underscore(resource)}" (${columns.join(', ')}, data) VALUES ${items.map(() => placeholders).join(', ')};`;
|
||||
};
|
||||
|
||||
export const DELETE_STATEMENT = (resource, ids) => {
|
||||
const baseDeleteStatement = `DELETE FROM "${underscore(resource)}"`;
|
||||
return ids?.length > 0
|
||||
? `${baseDeleteStatement} WHERE id IN (${ids.map(() => '?').join(',')})`
|
||||
: baseDeleteStatement;
|
||||
};
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
let clientChannel = null;
|
||||
let commonChannel = null;
|
||||
const requestsInFlight = new Map();
|
||||
|
||||
export default (serviceName, target, onProviderChange) => {
|
||||
commonChannel = new BroadcastChannel(
|
||||
`shared-service-common-channel-${serviceName}`,
|
||||
);
|
||||
|
||||
let { promise: ready, resolve: readyResolve } = Promise.withResolvers();
|
||||
const status = {
|
||||
ready,
|
||||
isServiceProvider: false,
|
||||
};
|
||||
|
||||
const proxy = getProxy(target, status);
|
||||
|
||||
// This lock is used to ensure that only one tab can become the provider with an indefinite lock.
|
||||
navigator.locks.request(serviceName, { mode: 'exclusive' }, async () => {
|
||||
// If a client was promoted to provider, reset the ready status and resolver
|
||||
if (readyResolve === null) {
|
||||
const { promise: newReady, resolve: newResolve } =
|
||||
Promise.withResolvers();
|
||||
status.ready = newReady;
|
||||
readyResolve = newResolve;
|
||||
}
|
||||
|
||||
status.isServiceProvider = true;
|
||||
await onBecomeProvider({
|
||||
serviceName,
|
||||
onProviderChange,
|
||||
target,
|
||||
status,
|
||||
});
|
||||
readyResolve();
|
||||
readyResolve = null;
|
||||
await new Promise(() => {});
|
||||
});
|
||||
|
||||
(async () => {
|
||||
let providerExists = false;
|
||||
|
||||
// Make sure a lock was acquired which means we have a provider
|
||||
while (!providerExists) {
|
||||
const locks = await navigator.locks.query();
|
||||
providerExists = locks.held?.some((lock) => lock.name === serviceName);
|
||||
}
|
||||
|
||||
if (!status.isServiceProvider) {
|
||||
await onNotProvider({
|
||||
serviceName,
|
||||
onProviderChange,
|
||||
status,
|
||||
});
|
||||
readyResolve();
|
||||
readyResolve = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return { proxy, status };
|
||||
};
|
||||
|
||||
const getClientBroadcastChannelName = (clientId, serviceName) =>
|
||||
`${clientId}-${serviceName}`;
|
||||
|
||||
// Generate a random ID for the tab to use as a lock name to hold indefinitely until the tab is closed
|
||||
const getClientId = () => {
|
||||
const clientId = uuidv4();
|
||||
|
||||
// Keep the lock until this context is destroyed (which means the tab was closed).
|
||||
navigator.locks.request(clientId, { mode: 'exclusive' }, async () => {
|
||||
await new Promise(() => {});
|
||||
});
|
||||
|
||||
return clientId;
|
||||
};
|
||||
|
||||
// Sends a message to the provider to call the method on behalf of a client.
|
||||
const requestFromProvider = (uuid, resolve, reject, method, args) => {
|
||||
const onRequestListener = getOnRequestListener(uuid, resolve, reject);
|
||||
|
||||
// Listen for a response to know we finished and remove from the list of requests in flight
|
||||
clientChannel?.addEventListener('message', onRequestListener);
|
||||
clientChannel?.postMessage({
|
||||
id: uuid,
|
||||
type: 'request',
|
||||
method,
|
||||
args,
|
||||
});
|
||||
};
|
||||
|
||||
// Return the listener used for responding to requests. Only listens for response messages.
|
||||
const getOnRequestListener = (uuid, resolve, reject) => {
|
||||
const listener = ({ data }) => {
|
||||
const { id, type } = data;
|
||||
if (id !== uuid || type === 'request') {
|
||||
return;
|
||||
}
|
||||
|
||||
requestsInFlight.delete(uuid);
|
||||
clientChannel?.removeEventListener('message', listener);
|
||||
|
||||
if (data.error) {
|
||||
reject(data.error);
|
||||
}
|
||||
|
||||
resolve(data.result);
|
||||
};
|
||||
|
||||
return listener;
|
||||
};
|
||||
|
||||
// When we become a client instead of a provider we do two things:
|
||||
// 1. Register ourselves with the provider and wait for a response that says we registered.
|
||||
// 2. Setup an event listener to know when a provider has changed.
|
||||
const onNotProvider = async ({ serviceName, onProviderChange, status }) => {
|
||||
const clientId = getClientId();
|
||||
clientChannel = new BroadcastChannel(
|
||||
getClientBroadcastChannelName(clientId, serviceName),
|
||||
);
|
||||
|
||||
const register = async () =>
|
||||
new Promise((resolve) => {
|
||||
const onRegisteredListener = ({ data }) => {
|
||||
if (data.clientId === clientId && data.type === 'registered') {
|
||||
commonChannel.removeEventListener('message', onRegisteredListener);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
// Add an event listener to check for the registration response
|
||||
commonChannel.addEventListener('message', onRegisteredListener);
|
||||
commonChannel.postMessage({ type: 'register', clientId });
|
||||
});
|
||||
|
||||
commonChannel.addEventListener('message', async ({ data }) => {
|
||||
// Check if this was a provider change. If so and we're not the new provider, re-register.
|
||||
if (data.type === 'providerChange' && !status.isServiceProvider) {
|
||||
await onProviderChange?.(status.isServiceProvider);
|
||||
await register();
|
||||
|
||||
// If there were requests in flight when the provider was changed,
|
||||
// we need to resend them to the new provider.
|
||||
if (requestsInFlight.size > 0) {
|
||||
requestsInFlight.forEach(({ method, args, resolve, reject }, uuid) => {
|
||||
requestFromProvider(uuid, resolve, reject, method, args);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await register();
|
||||
};
|
||||
|
||||
// When we become the provider, we do two things:
|
||||
// 1. Set an event listener to check for clients trying to register with us. As part of registration,
|
||||
// we also setup a listener with the individual client to listen for requests.
|
||||
// 2. Send a message to all tabs that we've now become the provider
|
||||
const onBecomeProvider = async ({
|
||||
serviceName,
|
||||
onProviderChange,
|
||||
target,
|
||||
status,
|
||||
}) => {
|
||||
// Set an event listener for other tabs to register with the provider tab
|
||||
commonChannel.addEventListener('message', async (event) => {
|
||||
const { clientId, type } = event.data;
|
||||
if (type !== 'register') {
|
||||
return;
|
||||
}
|
||||
|
||||
const clientChannel = new BroadcastChannel(
|
||||
getClientBroadcastChannelName(clientId, serviceName),
|
||||
);
|
||||
|
||||
// Acquire another lock that immediately is waiting for the
|
||||
// initial lock on the client tab to be resolved to handle cleanup.
|
||||
navigator.locks.request(clientId, { mode: 'exclusive' }, async () => {
|
||||
// The client tab was closed, clean up.
|
||||
clientChannel.close();
|
||||
});
|
||||
|
||||
// Setup an event listener for communication between the provider and subscriber to respond to requests
|
||||
clientChannel.addEventListener('message', async ({ data }) => {
|
||||
if (data.type === 'response') {
|
||||
return;
|
||||
}
|
||||
const { method, args, id } = data;
|
||||
|
||||
let result, error;
|
||||
try {
|
||||
result = await target[method](...args);
|
||||
} catch (e) {
|
||||
// Error objects won't be properly serialized, we need to reconstruct an error like object
|
||||
error =
|
||||
e instanceof Error
|
||||
? Object.fromEntries(
|
||||
Object.getOwnPropertyNames(e).map((k) => [k, e[k]]),
|
||||
)
|
||||
: e;
|
||||
}
|
||||
|
||||
clientChannel.postMessage({
|
||||
id,
|
||||
type: 'response',
|
||||
result,
|
||||
error,
|
||||
method,
|
||||
});
|
||||
});
|
||||
|
||||
// Send back a message to the client that we finished registering
|
||||
commonChannel.postMessage({ type: 'registered', clientId, serviceName });
|
||||
});
|
||||
|
||||
await onProviderChange?.(status.isServiceProvider);
|
||||
commonChannel.postMessage({ type: 'providerChange', serviceName });
|
||||
|
||||
// If we became the new provider and there were requests in flight, request it directly again.
|
||||
if (requestsInFlight.size > 0) {
|
||||
await Promise.all(
|
||||
Array.from(
|
||||
requestsInFlight,
|
||||
async ([uuid, { method, args, resolve, reject }]) => {
|
||||
try {
|
||||
const result = await target[method](...args);
|
||||
resolve(result);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
} finally {
|
||||
requestsInFlight.delete(uuid);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Returns a proxy that we will have all caller methods go through.
|
||||
// If we're the provider, we just call the method directly. If we're not the provider, we'll
|
||||
// send the request through our established client channel to have the provider respond with the data.
|
||||
const getProxy = (target, status) =>
|
||||
new Proxy(target, {
|
||||
get: (target, method) => {
|
||||
if (method === 'then' || method === 'catch' || method === 'finally') {
|
||||
// Return undefined for these methods to allow promise chaining to work correctly
|
||||
return;
|
||||
}
|
||||
|
||||
return async (...args) => {
|
||||
if (status.isServiceProvider) {
|
||||
return await target[method](...args);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const uuid = uuidv4();
|
||||
requestFromProvider(uuid, resolve, reject, method, args);
|
||||
requestsInFlight.set(uuid, { method, args, resolve, reject });
|
||||
});
|
||||
};
|
||||
},
|
||||
});
|
||||
+1
-2
@@ -3,5 +3,4 @@
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
@import 'cover';
|
||||
@import 'dialog';
|
||||
export { default } from 'api/services/db';
|
||||
+1
-1
@@ -3,4 +3,4 @@
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
@forward 'button';
|
||||
export { default } from 'api/services/sqlite';
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
// Ember-data no longer automatically installs BooleanTransform for apps.
|
||||
export { BooleanTransform as default } from '@ember-data/serializer/transform';
|
||||
@@ -3,4 +3,4 @@
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
export { default, initialize } from 'core/initializers/deprecations';
|
||||
export { DateTransform as default } from '@ember-data/serializer/transform';
|
||||
@@ -3,4 +3,4 @@
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
export { default } from 'rose/components/rose/dialog';
|
||||
export { NumberTransform as default } from '@ember-data/serializer/transform';
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
// Ember-data no longer automatically installs StringTransform for apps.
|
||||
export { StringTransform as default } from '@ember-data/serializer/transform';
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
const Plugin = require('broccoli-plugin');
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const esbuild = require('esbuild');
|
||||
|
||||
module.exports = class BuildWorkers extends Plugin {
|
||||
build() {
|
||||
this.inputPaths.forEach((inputPath) => {
|
||||
this.#buildWorkers(inputPath);
|
||||
});
|
||||
}
|
||||
|
||||
#buildWorkers(inputPath) {
|
||||
const workers = this.#getWorkers(inputPath);
|
||||
|
||||
const workerBuilder = this.#configureWorkerBuilder({
|
||||
isProduction: this.isProduction,
|
||||
buildDir: this.outputPath,
|
||||
});
|
||||
|
||||
Object.entries(workers).map(workerBuilder);
|
||||
}
|
||||
|
||||
#getWorkers(inputPath) {
|
||||
let workers = {};
|
||||
let dir = fs.readdirSync(inputPath);
|
||||
|
||||
dir
|
||||
.filter((name) => name.endsWith('-worker.js'))
|
||||
.forEach((name) => {
|
||||
workers[name] = path.join(inputPath, name);
|
||||
});
|
||||
|
||||
return workers;
|
||||
}
|
||||
|
||||
#configureWorkerBuilder({ isProduction, buildDir }) {
|
||||
return ([name, entryPath]) => {
|
||||
esbuild.buildSync({
|
||||
loader: { '.js': 'js' },
|
||||
entryPoints: [entryPath],
|
||||
bundle: true,
|
||||
outfile: path.join(buildDir, name),
|
||||
format: 'esm',
|
||||
minify: isProduction,
|
||||
sourcemap: !isProduction,
|
||||
});
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -6,7 +6,6 @@
|
||||
'use strict';
|
||||
|
||||
const EmberAddon = require('ember-cli/lib/broccoli/ember-addon');
|
||||
const { maybeEmbroider } = require('@embroider/test-setup');
|
||||
|
||||
module.exports = function (defaults) {
|
||||
const app = new EmberAddon(defaults, {
|
||||
@@ -19,6 +18,8 @@ module.exports = function (defaults) {
|
||||
This build file does *not* influence how the addon or the app using it
|
||||
behave. You most likely want to be modifying `./index.js` or app's build file
|
||||
*/
|
||||
|
||||
const { maybeEmbroider } = require('@embroider/test-setup');
|
||||
return maybeEmbroider(app, {
|
||||
skipBabel: [
|
||||
{
|
||||
|
||||
@@ -5,6 +5,69 @@
|
||||
|
||||
'use strict';
|
||||
|
||||
const { WatchedDir } = require('broccoli-source');
|
||||
const funnel = require('broccoli-funnel');
|
||||
const merge = require('broccoli-merge-trees');
|
||||
const path = require('path');
|
||||
|
||||
const buildWorkers = require('./broccoli-plugins/build-workers');
|
||||
|
||||
const workerRoot = path.join(__dirname, 'addon', 'workers');
|
||||
|
||||
module.exports = {
|
||||
name: require('./package').name,
|
||||
|
||||
options: {
|
||||
// Don't automatically import sqlite, we need to manually control how it gets used
|
||||
autoImport: {
|
||||
exclude: ['@sqlite.org/sqlite-wasm'],
|
||||
},
|
||||
},
|
||||
|
||||
treeForAddon() {
|
||||
// Exclude anything in the workers folder from being bundled in the final
|
||||
// build as we're manually bundling the files ourselves below.
|
||||
const tree = this._super.treeForAddon.apply(this, arguments);
|
||||
return funnel(tree, {
|
||||
exclude: ['api/workers/**/*'],
|
||||
});
|
||||
},
|
||||
|
||||
treeForPublic(tree) {
|
||||
const env = this.parent?.app?.env ?? 'production';
|
||||
const { enableSqlite } = this.parent?.app?.options[this.name] ?? {};
|
||||
|
||||
// If sqlite is not enabled and we're not running a test directly in the addon,
|
||||
// we don't need to build the workers
|
||||
if (!enableSqlite && this.app?.env !== 'test') {
|
||||
return tree;
|
||||
}
|
||||
|
||||
// We need to process and build our workers separately from ember as these
|
||||
// workers are not part of the app and are not getting properly bundled otherwise
|
||||
let workerTree = new WatchedDir(workerRoot);
|
||||
workerTree = new buildWorkers([workerTree]);
|
||||
workerTree.isProduction = env === 'production';
|
||||
|
||||
const workers = funnel(workerTree, {
|
||||
destDir: 'workers',
|
||||
});
|
||||
|
||||
// Also include the sqlite wasm file directly to be placed next to the workers
|
||||
const sqliteWasmDirectoryPath = path.resolve(
|
||||
require.resolve('@sqlite.org/sqlite-wasm/sqlite3.wasm'),
|
||||
'..',
|
||||
);
|
||||
const wasmFile = funnel(sqliteWasmDirectoryPath, {
|
||||
files: ['sqlite3.wasm'],
|
||||
destDir: 'workers',
|
||||
});
|
||||
|
||||
const trees = [workers, wasmFile];
|
||||
if (tree) {
|
||||
trees.push(tree);
|
||||
}
|
||||
|
||||
return merge(trees);
|
||||
},
|
||||
};
|
||||
@@ -70,11 +70,16 @@ function routes() {
|
||||
let resultSet;
|
||||
// Default parent scope is global
|
||||
if (!scope_id) scope_id = 'global';
|
||||
// We do not return global scope to mimic our API.
|
||||
if (recursive && scope_id === 'global') {
|
||||
resultSet = scopes.all();
|
||||
resultSet = scopes.where((scope) => {
|
||||
return scope.scope ? scope.type !== 'global' : false;
|
||||
});
|
||||
} else {
|
||||
resultSet = scopes.where((scope) => {
|
||||
return scope.scope ? scope.scope.id === scope_id : false;
|
||||
return scope.scope
|
||||
? scope.scope.id === scope_id && scope.type !== 'global'
|
||||
: false;
|
||||
});
|
||||
}
|
||||
const filteredResultSet = resultSet.filter(makeBooleanFilter(filter));
|
||||
@@ -537,10 +542,11 @@ function routes() {
|
||||
session.update({
|
||||
status: pickRandomStatusString(),
|
||||
});
|
||||
if (session.id.split('-').pop() % 2)
|
||||
if (session.id.split('-').pop() % 2) {
|
||||
session.update({
|
||||
userId: 'authenticateduser',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
if (recursive && scope_id === 'global') {
|
||||
|
||||
@@ -50,7 +50,8 @@ export default factory.extend({
|
||||
}
|
||||
case TYPE_AUTH_METHOD_OIDC:
|
||||
return {
|
||||
issuer: faker.internet.ip(),
|
||||
issuer:
|
||||
Math.random() > 0.5 ? faker.internet.ip() : faker.internet.ipv6(),
|
||||
subject: 'sub',
|
||||
email: faker.internet.email(),
|
||||
full_name: faker.person.fullName(),
|
||||
|
||||
@@ -10,6 +10,7 @@ import generateId from '../helpers/id';
|
||||
import {
|
||||
TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC,
|
||||
TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE,
|
||||
TYPE_CREDENTIAL_LIBRARY_VAULT_LDAP,
|
||||
TYPES_CREDENTIAL_LIBRARY,
|
||||
options,
|
||||
} from 'api/models/credential-library';
|
||||
@@ -45,6 +46,10 @@ export default factory.extend({
|
||||
key_bits: faker.number.int(999),
|
||||
path: faker.system.directoryPath(),
|
||||
};
|
||||
case TYPE_CREDENTIAL_LIBRARY_VAULT_LDAP:
|
||||
return {
|
||||
path: faker.system.directoryPath(),
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -39,7 +39,8 @@ export default factory.extend({
|
||||
switch (this.type) {
|
||||
case 'vault':
|
||||
return {
|
||||
address: faker.internet.ip(),
|
||||
address:
|
||||
Math.random() > 0.5 ? faker.internet.ip() : faker.internet.ipv6(),
|
||||
namespace: faker.word.words(1),
|
||||
ca_cert: 'ca-cert-123',
|
||||
tls_server_name: faker.internet.domainName(),
|
||||
@@ -57,14 +58,14 @@ export default factory.extend({
|
||||
const { scope, type } = credentialStore;
|
||||
switch (type) {
|
||||
case 'vault':
|
||||
server.createList('credential-library', 2, {
|
||||
server.createList('credential-library', 3, {
|
||||
scope,
|
||||
credentialStore,
|
||||
});
|
||||
break;
|
||||
case 'static':
|
||||
default:
|
||||
server.createList('credential', 3, {
|
||||
server.createList('credential', 5, {
|
||||
scope,
|
||||
credentialStore,
|
||||
});
|
||||
|
||||
@@ -7,19 +7,31 @@ import factory from '../generated/factories/credential';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import permissions from '../helpers/permissions';
|
||||
import generatedId from '../helpers/id';
|
||||
import {
|
||||
TYPES_CREDENTIAL,
|
||||
TYPE_CREDENTIAL_SSH_PRIVATE_KEY,
|
||||
TYPE_CREDENTIAL_USERNAME_PASSWORD,
|
||||
TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN,
|
||||
TYPE_CREDENTIAL_JSON,
|
||||
TYPE_CREDENTIAL_PASSWORD,
|
||||
} from 'api/models/credential';
|
||||
|
||||
const types = ['username_password', 'ssh_private_key', 'json'];
|
||||
const types = [...TYPES_CREDENTIAL];
|
||||
|
||||
export default factory.extend({
|
||||
type: (i) => types[i % types.length],
|
||||
id() {
|
||||
switch (this.type) {
|
||||
case 'ssh_private_key':
|
||||
case TYPE_CREDENTIAL_SSH_PRIVATE_KEY:
|
||||
return generatedId('credspk_');
|
||||
case 'username_password':
|
||||
case TYPE_CREDENTIAL_USERNAME_PASSWORD:
|
||||
return generatedId('credup_');
|
||||
case 'json':
|
||||
case TYPE_CREDENTIAL_JSON:
|
||||
return generatedId('credjson_');
|
||||
case TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN:
|
||||
return generatedId('credupd_');
|
||||
case TYPE_CREDENTIAL_PASSWORD:
|
||||
return generatedId('credp_');
|
||||
}
|
||||
},
|
||||
authorized_actions: () =>
|
||||
@@ -30,8 +42,21 @@ export default factory.extend({
|
||||
'delete',
|
||||
],
|
||||
attributes() {
|
||||
return {
|
||||
username: faker.internet.userName(),
|
||||
};
|
||||
switch (this.type) {
|
||||
case TYPE_CREDENTIAL_USERNAME_PASSWORD:
|
||||
return { username: faker.internet.userName() };
|
||||
|
||||
case TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN:
|
||||
return {
|
||||
username: faker.internet.userName(),
|
||||
domain: faker.internet.domainName(),
|
||||
};
|
||||
|
||||
case TYPE_CREDENTIAL_SSH_PRIVATE_KEY:
|
||||
return { username: faker.internet.userName() };
|
||||
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
},
|
||||
});
|
||||
Loaded 100 of 1467 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user