@homeflare/alchemy 0.51.0 → 0.53.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/grafana/folder-permission.d.ts +76 -0
- package/dist/grafana/folder-permission.d.ts.map +1 -0
- package/dist/grafana/index.d.ts +3 -1
- package/dist/grafana/index.d.ts.map +1 -1
- package/dist/grafana/index.js +133 -46
- package/dist/grafana/index.js.map +7 -5
- package/dist/grafana/providers.d.ts +1 -1
- package/dist/grafana/providers.d.ts.map +1 -1
- package/dist/grafana/provisioned.d.ts +1 -1
- package/dist/grafana/provisioned.d.ts.map +1 -1
- package/dist/grafana/team.d.ts +64 -0
- package/dist/grafana/team.d.ts.map +1 -0
- package/dist/linux/container-env-quote.d.ts +39 -0
- package/dist/linux/container-env-quote.d.ts.map +1 -0
- package/dist/linux/container-form.d.ts +14 -13
- package/dist/linux/container-form.d.ts.map +1 -1
- package/dist/linux/container-paths.d.ts +17 -0
- package/dist/linux/container-paths.d.ts.map +1 -0
- package/dist/linux/index.js +41 -8
- package/dist/linux/index.js.map +6 -4
- package/dist/litellm/fake-marketplace-litellm.d.ts +33 -0
- package/dist/litellm/fake-marketplace-litellm.d.ts.map +1 -0
- package/dist/litellm/index.d.ts +1 -0
- package/dist/litellm/index.d.ts.map +1 -1
- package/dist/litellm/index.js +530 -329
- package/dist/litellm/index.js.map +7 -4
- package/dist/litellm/marketplace-form.d.ts +21 -0
- package/dist/litellm/marketplace-form.d.ts.map +1 -0
- package/dist/litellm/marketplace-operations.d.ts +27 -0
- package/dist/litellm/marketplace-operations.d.ts.map +1 -0
- package/dist/litellm/marketplace-plugin.d.ts +57 -0
- package/dist/litellm/marketplace-plugin.d.ts.map +1 -0
- package/dist/litellm/marketplace-types.d.ts +55 -0
- package/dist/litellm/marketplace-types.d.ts.map +1 -0
- package/dist/litellm/providers.d.ts +1 -1
- package/dist/litellm/providers.d.ts.map +1 -1
- package/dist/unifi/index.js.map +2 -2
- package/dist/unifi/network.d.ts +2 -1
- package/dist/unifi/network.d.ts.map +1 -1
- package/docs/litellm-marketplace.md +59 -0
- package/docs/litellm.md +1 -0
- package/docs/unifi.md +11 -10
- package/package.json +1 -1
- package/src/grafana/folder-permission.test.ts +107 -0
- package/src/grafana/folder-permission.ts +128 -0
- package/src/grafana/index.ts +10 -1
- package/src/grafana/providers.ts +4 -0
- package/src/grafana/provisioned.ts +1 -1
- package/src/grafana/team.test.ts +108 -0
- package/src/grafana/team.ts +105 -0
- package/src/linux/container-env-chain.test.ts +135 -0
- package/src/linux/container-env-live.test.ts +115 -0
- package/src/linux/container-env-oracle.ts +109 -0
- package/src/linux/container-env-quadlet-oracle.ts +174 -0
- package/src/linux/container-env-quote.test.ts +131 -0
- package/src/linux/container-env-quote.ts +126 -0
- package/src/linux/container-fixture.test.ts +3 -1
- package/src/linux/container-form.ts +32 -28
- package/src/linux/container-paths.ts +21 -0
- package/src/litellm/fake-marketplace-litellm.test.ts +51 -0
- package/src/litellm/fake-marketplace-litellm.ts +134 -0
- package/src/litellm/index.ts +8 -0
- package/src/litellm/marketplace-form.ts +160 -0
- package/src/litellm/marketplace-operations.ts +76 -0
- package/src/litellm/marketplace-plugin-replace.test.ts +68 -0
- package/src/litellm/marketplace-plugin.test.ts +181 -0
- package/src/litellm/marketplace-plugin.ts +156 -0
- package/src/litellm/marketplace-types.ts +53 -0
- package/src/litellm/providers.ts +2 -0
- package/src/litellm/registry-exports.test.ts +6 -0
- package/src/unifi/network-scope.test.ts +21 -0
- package/src/unifi/network.ts +2 -1
- package/src/unifi/write-op-reference.test.ts +3 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homeflare/alchemy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.53.0",
|
|
4
4
|
"description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy, and a verifier that proves adoptions are no-ops.",
|
|
5
5
|
"homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/alchemy#readme",
|
|
6
6
|
"bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Grafana.FolderPermission` against a fake Grafana: only DIRECT team entries count, writes go through
|
|
3
|
+
* the per-team endpoint (never the whole-list replace), missing team fails loudly, delete sets "".
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, test } from 'bun:test';
|
|
6
|
+
import type { GrafanaOpContext } from '@distilled.cloud/grafana/Protocol';
|
|
7
|
+
import * as Effect from 'effect/Effect';
|
|
8
|
+
import { fakeFailure, fakeGrafana, fakeGrafanaLayer } from './fake-grafana.ts';
|
|
9
|
+
import { spec } from './folder-permission.ts';
|
|
10
|
+
import { grafanaOperations } from './resource.ts';
|
|
11
|
+
|
|
12
|
+
const TEAMS = '/api/teams/search';
|
|
13
|
+
const PERMS = '/api/access-control/folders/village-grafana';
|
|
14
|
+
const SET = '/api/access-control/folders/village-grafana/teams/7';
|
|
15
|
+
const teamRow = {
|
|
16
|
+
id: 7,
|
|
17
|
+
uid: 'aex7',
|
|
18
|
+
name: 'village-grafana',
|
|
19
|
+
isProvisioned: false,
|
|
20
|
+
memberCount: 0,
|
|
21
|
+
orgId: 1,
|
|
22
|
+
};
|
|
23
|
+
const props = {
|
|
24
|
+
folderUid: 'village-grafana',
|
|
25
|
+
teamName: 'village-grafana',
|
|
26
|
+
permission: 'Edit' as const,
|
|
27
|
+
};
|
|
28
|
+
const run = <A, E>(
|
|
29
|
+
fake: ReturnType<typeof fakeGrafana>,
|
|
30
|
+
effect: Effect.Effect<A, E, GrafanaOpContext>,
|
|
31
|
+
): Promise<A> => Effect.runPromise(effect.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))));
|
|
32
|
+
|
|
33
|
+
const grafanaWith = (entries: () => unknown[], teams = [teamRow]) =>
|
|
34
|
+
fakeGrafana((m, url) => {
|
|
35
|
+
if (m === 'GET' && url.pathname === TEAMS)
|
|
36
|
+
return Response.json({ teams, totalCount: teams.length });
|
|
37
|
+
if (m === 'GET' && url.pathname === PERMS) return Response.json(entries());
|
|
38
|
+
if (m === 'POST' && url.pathname === SET)
|
|
39
|
+
return Response.json({ message: 'Permission updated' });
|
|
40
|
+
return fakeFailure(404, `unexpected ${m} ${url.pathname}`);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('Grafana.FolderPermission', () => {
|
|
44
|
+
test('an inherited entry for the team does not count as live', async () => {
|
|
45
|
+
const fake = grafanaWith(() => [
|
|
46
|
+
{ teamId: 7, permission: 'Edit', isInherited: true },
|
|
47
|
+
{ builtInRole: 'Admin', permission: 'Admin' },
|
|
48
|
+
]);
|
|
49
|
+
expect(await run(fake, spec.fetchLive(props))).toBeUndefined();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('a matching direct entry adopts with no write', async () => {
|
|
53
|
+
const fake = grafanaWith(() => [
|
|
54
|
+
{ builtInRole: 'Viewer', permission: 'View', isInherited: true },
|
|
55
|
+
{ teamId: 7, team: 'village-grafana', permission: 'Edit', isInherited: false },
|
|
56
|
+
]);
|
|
57
|
+
const out = await run(fake, grafanaOperations(spec).reconcile(props));
|
|
58
|
+
expect(out).toEqual({
|
|
59
|
+
folderUid: 'village-grafana',
|
|
60
|
+
teamId: 7,
|
|
61
|
+
teamName: 'village-grafana',
|
|
62
|
+
permission: 'Edit',
|
|
63
|
+
});
|
|
64
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('create writes only through the per-team endpoint', async () => {
|
|
68
|
+
const state = { set: false };
|
|
69
|
+
const fake = fakeGrafana((m, url) => {
|
|
70
|
+
if (m === 'GET' && url.pathname === TEAMS)
|
|
71
|
+
return Response.json({ teams: [teamRow], totalCount: 1 });
|
|
72
|
+
if (m === 'GET' && url.pathname === PERMS)
|
|
73
|
+
return Response.json(
|
|
74
|
+
state.set ? [{ teamId: 7, permission: 'Edit', isInherited: false }] : [],
|
|
75
|
+
);
|
|
76
|
+
if (m === 'POST' && url.pathname === SET) {
|
|
77
|
+
state.set = true;
|
|
78
|
+
return Response.json({ message: 'Permission updated' });
|
|
79
|
+
}
|
|
80
|
+
return fakeFailure(404, `unexpected ${m} ${url.pathname}`);
|
|
81
|
+
});
|
|
82
|
+
await run(fake, grafanaOperations(spec).reconcile(props));
|
|
83
|
+
expect(fake.seen.filter((s) => s.method === 'POST')).toEqual([{ method: 'POST', path: SET }]);
|
|
84
|
+
expect(fake.bodies.filter((b) => b !== undefined)).toEqual([{ permission: 'Edit' }]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('a permission drift (View live, Edit declared) updates', async () => {
|
|
88
|
+
const fake = grafanaWith(() => [{ teamId: 7, permission: 'View', isInherited: false }]);
|
|
89
|
+
await run(fake, Effect.flip(grafanaOperations(spec).reconcile(props))).catch(() => undefined);
|
|
90
|
+
expect(fake.seen.some((s) => s.method === 'POST' && s.path === SET)).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('a missing team fails loudly, never folds to absent', async () => {
|
|
94
|
+
const fake = grafanaWith(() => [], []);
|
|
95
|
+
const failure = await run(fake, Effect.flip(spec.fetchLive(props)));
|
|
96
|
+
expect((failure as { _tag: string })._tag).toBe('GrafanaTeamNotFoundError');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('delete sets the empty permission, and is a no-op when already absent', async () => {
|
|
100
|
+
const live = grafanaWith(() => [{ teamId: 7, permission: 'Edit', isInherited: false }]);
|
|
101
|
+
await run(live, grafanaOperations(spec).destroy(props));
|
|
102
|
+
expect(live.bodies.filter((b) => b !== undefined)).toEqual([{ permission: '' }]);
|
|
103
|
+
const gone = grafanaWith(() => []);
|
|
104
|
+
await run(gone, grafanaOperations(spec).destroy(props));
|
|
105
|
+
expect(gone.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Grafana.FolderPermission` — ONE team's DIRECT permission on ONE folder.
|
|
3
|
+
*
|
|
4
|
+
* Walked against `@distilled.cloud/grafana` 0.2.1 (`@homeflare/distilled-grafana`):
|
|
5
|
+
* `getResourcePermissions` (`GET /access-control/folders/{uid}`) and `setResourcePermissionsForTeam`
|
|
6
|
+
* (`POST /access-control/folders/{uid}/teams/{teamId}`).
|
|
7
|
+
*
|
|
8
|
+
* ★ ADDITIVE BY CONSTRUCTION. The per-team endpoint touches only that team's managed entry; the
|
|
9
|
+
* folder's other entries (built-in roles, admin users, other teams, anything inherited from a parent
|
|
10
|
+
* folder) are never read into a write and never changed. The whole-list `setResourcePermissions`
|
|
11
|
+
* would replace them all — deliberately not used.
|
|
12
|
+
* ★ ONLY DIRECT ENTRIES COUNT. An entry inherited from a parent folder (`isInherited`) is not this
|
|
13
|
+
* resource's object: it can't be changed here, so it never satisfies a declaration either.
|
|
14
|
+
* ★ The team is named, not numbered. Team ids differ per instance, so `teamName` is resolved through
|
|
15
|
+
* `findTeamByName` on every read and write; a missing team fails loudly with
|
|
16
|
+
* `GrafanaTeamNotFoundError` (declare the `Grafana.Team` first) instead of folding to "absent".
|
|
17
|
+
* ★ delete = set the team's permission to "" (Grafana's documented way to remove a managed entry),
|
|
18
|
+
* idempotent: an already-absent entry is a no-op (S11).
|
|
19
|
+
*/
|
|
20
|
+
import { Resource } from 'alchemy';
|
|
21
|
+
import * as Provider from 'alchemy/Provider';
|
|
22
|
+
import * as grafana from '@distilled.cloud/grafana';
|
|
23
|
+
import * as Data from 'effect/Data';
|
|
24
|
+
import * as Effect from 'effect/Effect';
|
|
25
|
+
import { type GrafanaSpec, grafanaHandlers } from './resource.ts';
|
|
26
|
+
import { findTeamByName } from './team.ts';
|
|
27
|
+
|
|
28
|
+
export type FolderPermissionLevel = 'View' | 'Edit' | 'Admin';
|
|
29
|
+
|
|
30
|
+
export interface FolderPermissionProps {
|
|
31
|
+
folderUid: string;
|
|
32
|
+
teamName: string;
|
|
33
|
+
permission: FolderPermissionLevel;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface FolderPermissionAttributes {
|
|
37
|
+
folderUid: string;
|
|
38
|
+
teamId: number;
|
|
39
|
+
teamName: string;
|
|
40
|
+
permission: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface GrafanaFolderPermission extends Resource<
|
|
44
|
+
'Grafana.FolderPermission',
|
|
45
|
+
FolderPermissionProps,
|
|
46
|
+
FolderPermissionAttributes,
|
|
47
|
+
never
|
|
48
|
+
> {}
|
|
49
|
+
|
|
50
|
+
export const GrafanaFolderPermission = Resource<GrafanaFolderPermission>(
|
|
51
|
+
'Grafana.FolderPermission',
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
export class GrafanaTeamNotFoundError extends Data.TaggedError('GrafanaTeamNotFoundError')<{
|
|
55
|
+
readonly message: string;
|
|
56
|
+
}> {}
|
|
57
|
+
|
|
58
|
+
interface LivePermission {
|
|
59
|
+
readonly teamId: number;
|
|
60
|
+
readonly permission: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const resolveTeamId = (teamName: string) =>
|
|
64
|
+
findTeamByName(teamName).pipe(
|
|
65
|
+
Effect.flatMap((team) =>
|
|
66
|
+
team === undefined
|
|
67
|
+
? Effect.fail(
|
|
68
|
+
new GrafanaTeamNotFoundError({
|
|
69
|
+
message: `Grafana.FolderPermission: team '${teamName}' does not exist. Declare it with Grafana.Team first.`,
|
|
70
|
+
}),
|
|
71
|
+
)
|
|
72
|
+
: Effect.succeed(team.id),
|
|
73
|
+
),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const setTeam = (folderUid: string, teamId: number, permission: string) =>
|
|
77
|
+
grafana.setResourcePermissionsForTeam({
|
|
78
|
+
resource: 'folders',
|
|
79
|
+
resourceID: folderUid,
|
|
80
|
+
teamID: teamId,
|
|
81
|
+
permission,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
export const spec: GrafanaSpec<
|
|
85
|
+
FolderPermissionProps,
|
|
86
|
+
LivePermission,
|
|
87
|
+
FolderPermissionAttributes,
|
|
88
|
+
| grafana.SearchTeamsError
|
|
89
|
+
| grafana.GetResourcePermissionsError
|
|
90
|
+
| grafana.SetResourcePermissionsForTeamError
|
|
91
|
+
| GrafanaTeamNotFoundError
|
|
92
|
+
> = {
|
|
93
|
+
attributes: (live, props) => ({
|
|
94
|
+
folderUid: props.folderUid,
|
|
95
|
+
permission: live.permission,
|
|
96
|
+
teamId: live.teamId,
|
|
97
|
+
teamName: props.teamName,
|
|
98
|
+
}),
|
|
99
|
+
create: (props) =>
|
|
100
|
+
resolveTeamId(props.teamName).pipe(
|
|
101
|
+
Effect.flatMap((teamId) => setTeam(props.folderUid, teamId, props.permission)),
|
|
102
|
+
),
|
|
103
|
+
destroy: (props, live) => setTeam(props.folderUid, live.teamId, ''),
|
|
104
|
+
fetchLive: (props) =>
|
|
105
|
+
Effect.gen(function* () {
|
|
106
|
+
const teamId = yield* resolveTeamId(props.teamName);
|
|
107
|
+
const entries = yield* grafana.getResourcePermissions({
|
|
108
|
+
resource: 'folders',
|
|
109
|
+
resourceID: props.folderUid,
|
|
110
|
+
});
|
|
111
|
+
const direct = (entries ?? []).find(
|
|
112
|
+
(entry) => entry.teamId === teamId && entry.isInherited !== true,
|
|
113
|
+
);
|
|
114
|
+
return direct === undefined
|
|
115
|
+
? undefined
|
|
116
|
+
: { permission: direct.permission ?? '', teamId: teamId };
|
|
117
|
+
}),
|
|
118
|
+
matches: (attributes, props) => attributes.permission === props.permission,
|
|
119
|
+
update: (props, live) => setTeam(props.folderUid, live.teamId, props.permission),
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export const handlers = grafanaHandlers(spec);
|
|
123
|
+
|
|
124
|
+
export const GrafanaFolderPermissionProvider = () =>
|
|
125
|
+
Provider.effect(
|
|
126
|
+
GrafanaFolderPermission,
|
|
127
|
+
Effect.succeed(GrafanaFolderPermission.Provider.of(handlers)),
|
|
128
|
+
);
|
package/src/grafana/index.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*
|
|
9
9
|
* `Grafana.Datasource`, `Grafana.Folder`, `Grafana.Dashboard`, `Grafana.ContactPoint`,
|
|
10
10
|
* `Grafana.MuteTiming`, `Grafana.MessageTemplate`, `Grafana.AlertRuleGroup` and
|
|
11
|
-
* `Grafana.NotificationPolicy` all ship here — see docs/grafana.md, docs/grafana-folder-dashboard.md,
|
|
11
|
+
* `Grafana.NotificationPolicy`, `Grafana.Team` and `Grafana.FolderPermission` all ship here — see docs/grafana.md, docs/grafana-folder-dashboard.md,
|
|
12
12
|
* docs/grafana-alerting.md, docs/grafana-alerting-rules.md and
|
|
13
13
|
* docs/grafana-notification-policy.md for what shipped.
|
|
14
14
|
*/
|
|
@@ -46,6 +46,15 @@ export {
|
|
|
46
46
|
GrafanaFolderProvider,
|
|
47
47
|
GrafanaFolderReparentError,
|
|
48
48
|
} from './folder.ts';
|
|
49
|
+
export { type TeamAttributes, type TeamProps, GrafanaTeam, GrafanaTeamProvider } from './team.ts';
|
|
50
|
+
export {
|
|
51
|
+
type FolderPermissionAttributes,
|
|
52
|
+
type FolderPermissionLevel,
|
|
53
|
+
type FolderPermissionProps,
|
|
54
|
+
GrafanaFolderPermission,
|
|
55
|
+
GrafanaFolderPermissionProvider,
|
|
56
|
+
GrafanaTeamNotFoundError,
|
|
57
|
+
} from './folder-permission.ts';
|
|
49
58
|
export {
|
|
50
59
|
type ContactPointAttributes,
|
|
51
60
|
type ContactPointProps,
|
package/src/grafana/providers.ts
CHANGED
|
@@ -18,6 +18,8 @@ import { type GrafanaTarget, grafanaCredentials } from './credentials.ts';
|
|
|
18
18
|
import { GrafanaDashboardProvider } from './dashboard.ts';
|
|
19
19
|
import { GrafanaDatasourceProvider } from './datasource.ts';
|
|
20
20
|
import { GrafanaFolderProvider } from './folder.ts';
|
|
21
|
+
import { GrafanaFolderPermissionProvider } from './folder-permission.ts';
|
|
22
|
+
import { GrafanaTeamProvider } from './team.ts';
|
|
21
23
|
import { GrafanaMessageTemplateProvider } from './message-template.ts';
|
|
22
24
|
import { GrafanaMuteTimingProvider } from './mute-timing.ts';
|
|
23
25
|
import { GrafanaNotificationPolicyProvider } from './notification-policy.ts';
|
|
@@ -26,6 +28,8 @@ export const grafanaProviders = (target: GrafanaTarget) =>
|
|
|
26
28
|
Layer.mergeAll(
|
|
27
29
|
GrafanaDatasourceProvider(),
|
|
28
30
|
GrafanaFolderProvider(),
|
|
31
|
+
GrafanaTeamProvider(),
|
|
32
|
+
GrafanaFolderPermissionProvider(),
|
|
29
33
|
GrafanaDashboardProvider(),
|
|
30
34
|
GrafanaContactPointProvider(),
|
|
31
35
|
GrafanaMuteTimingProvider(),
|
|
@@ -29,7 +29,7 @@ export class GrafanaProvisionedObjectError extends Data.TaggedError(
|
|
|
29
29
|
}> {}
|
|
30
30
|
|
|
31
31
|
export const refuseIfProvisioned = (
|
|
32
|
-
kind: 'Grafana.Folder' | 'Grafana.Dashboard',
|
|
32
|
+
kind: 'Grafana.Folder' | 'Grafana.Dashboard' | 'Grafana.Team',
|
|
33
33
|
uid: string,
|
|
34
34
|
owner: string | undefined,
|
|
35
35
|
): Effect.Effect<never, GrafanaProvisionedObjectError> => {
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Grafana.Team` against a fake Grafana through the real `@distilled.cloud/grafana` protocol:
|
|
3
|
+
* exact-name observe (adopt), create body, update only on drift, provisioned refusal, idempotent delete.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, test } from 'bun:test';
|
|
6
|
+
import type { GrafanaOpContext } from '@distilled.cloud/grafana/Protocol';
|
|
7
|
+
import * as Effect from 'effect/Effect';
|
|
8
|
+
import { fakeFailure, fakeGrafana, fakeGrafanaLayer } from './fake-grafana.ts';
|
|
9
|
+
import { grafanaOperations } from './resource.ts';
|
|
10
|
+
import { spec } from './team.ts';
|
|
11
|
+
|
|
12
|
+
const team = (over: Record<string, unknown> = {}) => ({
|
|
13
|
+
id: 7,
|
|
14
|
+
uid: 'aex7',
|
|
15
|
+
name: 'village-grafana',
|
|
16
|
+
email: '',
|
|
17
|
+
isProvisioned: false,
|
|
18
|
+
memberCount: 0,
|
|
19
|
+
orgId: 1,
|
|
20
|
+
...over,
|
|
21
|
+
});
|
|
22
|
+
const search = (teams: unknown[]) =>
|
|
23
|
+
Response.json({ teams, totalCount: teams.length, page: 1, perPage: 100 });
|
|
24
|
+
const run = <A, E>(
|
|
25
|
+
fake: ReturnType<typeof fakeGrafana>,
|
|
26
|
+
effect: Effect.Effect<A, E, GrafanaOpContext>,
|
|
27
|
+
): Promise<A> => Effect.runPromise(effect.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))));
|
|
28
|
+
const props = { name: 'village-grafana' };
|
|
29
|
+
|
|
30
|
+
describe('Grafana.Team', () => {
|
|
31
|
+
test('fetchLive keeps only an exact name match, GET without a body', async () => {
|
|
32
|
+
const fake = fakeGrafana((m, url) =>
|
|
33
|
+
m === 'GET' && url.pathname === '/api/teams/search'
|
|
34
|
+
? search([team({ name: 'village-grafana-old', id: 9 }), team()])
|
|
35
|
+
: fakeFailure(404, 'x'),
|
|
36
|
+
);
|
|
37
|
+
const live = await run(fake, spec.fetchLive(props));
|
|
38
|
+
expect(live?.id).toBe(7);
|
|
39
|
+
expect(fake.bodies[0]).toBeUndefined();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('absent team folds to undefined; 403 propagates', async () => {
|
|
43
|
+
expect(
|
|
44
|
+
await run(
|
|
45
|
+
fakeGrafana(() => search([])),
|
|
46
|
+
spec.fetchLive(props),
|
|
47
|
+
),
|
|
48
|
+
).toBeUndefined();
|
|
49
|
+
const failure = await run(
|
|
50
|
+
fakeGrafana(() => fakeFailure(403, 'forbidden')),
|
|
51
|
+
Effect.flip(spec.fetchLive(props)),
|
|
52
|
+
);
|
|
53
|
+
expect((failure as { _tag: string })._tag).toBe('Forbidden');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('reconcile adopts a matching live team with no write', async () => {
|
|
57
|
+
const fake = fakeGrafana((m) =>
|
|
58
|
+
m === 'GET' ? search([team()]) : fakeFailure(500, 'no write expected'),
|
|
59
|
+
);
|
|
60
|
+
const out = await run(fake, grafanaOperations(spec).reconcile(props));
|
|
61
|
+
expect(out).toMatchObject({ id: 7, name: 'village-grafana' });
|
|
62
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('reconcile creates when absent, sending only declared fields', async () => {
|
|
66
|
+
let created = false;
|
|
67
|
+
const fake = fakeGrafana((m) => {
|
|
68
|
+
if (m === 'POST') {
|
|
69
|
+
created = true;
|
|
70
|
+
return Response.json({ teamId: 7, uid: 'aex7', message: 'Team created' });
|
|
71
|
+
}
|
|
72
|
+
return search(created ? [team()] : []);
|
|
73
|
+
});
|
|
74
|
+
await run(fake, grafanaOperations(spec).reconcile(props));
|
|
75
|
+
expect(fake.bodies.filter((b) => b !== undefined)).toEqual([{ name: 'village-grafana' }]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('email drift updates by team id', async () => {
|
|
79
|
+
let updated = false;
|
|
80
|
+
const fake = fakeGrafana((m) => {
|
|
81
|
+
if (m === 'PUT') {
|
|
82
|
+
updated = true;
|
|
83
|
+
return Response.json({ message: 'Team updated' });
|
|
84
|
+
}
|
|
85
|
+
return search([team({ email: updated ? 'ops@example.com' : '' })]);
|
|
86
|
+
});
|
|
87
|
+
await run(fake, grafanaOperations(spec).reconcile({ ...props, email: 'ops@example.com' }));
|
|
88
|
+
expect(fake.seen.some((s) => s.method === 'PUT' && s.path === '/api/teams/7')).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('a provisioned team refuses update and delete without writing', async () => {
|
|
92
|
+
const fake = fakeGrafana(() => search([team({ isProvisioned: true, email: 'a@b' })]));
|
|
93
|
+
const failure = await run(
|
|
94
|
+
fake,
|
|
95
|
+
Effect.flip(grafanaOperations(spec).reconcile({ ...props, email: 'ops@example.com' })),
|
|
96
|
+
);
|
|
97
|
+
expect((failure as { _tag: string })._tag).toBe('GrafanaProvisionedObjectError');
|
|
98
|
+
const del = await run(fake, Effect.flip(grafanaOperations(spec).destroy(props)));
|
|
99
|
+
expect((del as { _tag: string })._tag).toBe('GrafanaProvisionedObjectError');
|
|
100
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('delete is idempotent when the team is already gone', async () => {
|
|
104
|
+
const fake = fakeGrafana(() => search([]));
|
|
105
|
+
await run(fake, grafanaOperations(spec).destroy(props));
|
|
106
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Grafana.Team` — a Grafana team, keyed by NAME.
|
|
3
|
+
*
|
|
4
|
+
* Walked against `@distilled.cloud/grafana` 0.2.1 (`@homeflare/distilled-grafana`): `searchTeams`,
|
|
5
|
+
* `createTeam`, `updateTeam`, `deleteTeam`. Upstream Alchemy ships no Grafana family (S1); distilled
|
|
6
|
+
* ships the SDK, so every call goes through it (S23).
|
|
7
|
+
*
|
|
8
|
+
* ★ NAME IS THE KEY, NOT `uid`. `CreateTeamRequest` takes only `name`/`email` — there is no way to
|
|
9
|
+
* choose a team uid, and Grafana enforces unique team names per org. `fetchLive` therefore runs
|
|
10
|
+
* `GET /teams/search?name=<name>` (an exact-name filter) and keeps only an exact match; adoption of a
|
|
11
|
+
* team created by hand or by a script is a plain observe (S9/S10).
|
|
12
|
+
* ★ `email` is optional and compared only when declared ("undeclared means don't care", the kit's
|
|
13
|
+
* convention for optional props).
|
|
14
|
+
* ⛔ A provisioned team (`isProvisioned`, e.g. synced from SCIM/LDAP) is refused for update and delete.
|
|
15
|
+
*/
|
|
16
|
+
import { Resource } from 'alchemy';
|
|
17
|
+
import * as Provider from 'alchemy/Provider';
|
|
18
|
+
import * as grafana from '@distilled.cloud/grafana';
|
|
19
|
+
import * as Effect from 'effect/Effect';
|
|
20
|
+
import { type GrafanaProvisionedObjectError, refuseIfProvisioned } from './provisioned.ts';
|
|
21
|
+
import { type GrafanaSpec, grafanaHandlers } from './resource.ts';
|
|
22
|
+
|
|
23
|
+
export interface TeamProps {
|
|
24
|
+
/** Team name — unique per org, and this resource's identity. */
|
|
25
|
+
name: string;
|
|
26
|
+
/** Optional team email; compared only when declared. */
|
|
27
|
+
email?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface TeamAttributes {
|
|
31
|
+
id: number;
|
|
32
|
+
uid: string;
|
|
33
|
+
name: string;
|
|
34
|
+
email: string;
|
|
35
|
+
isProvisioned: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface GrafanaTeam extends Resource<'Grafana.Team', TeamProps, TeamAttributes, never> {}
|
|
39
|
+
|
|
40
|
+
export const GrafanaTeam = Resource<GrafanaTeam>('Grafana.Team');
|
|
41
|
+
|
|
42
|
+
/** Exact-name lookup. `/teams/search?name=` is already exact in Grafana; the filter keeps that true. */
|
|
43
|
+
export const findTeamByName = (name: string) =>
|
|
44
|
+
grafana
|
|
45
|
+
.searchTeams({ name, perpage: 100 })
|
|
46
|
+
.pipe(Effect.map((result) => (result.teams ?? []).find((team) => team.name === name)));
|
|
47
|
+
|
|
48
|
+
export const spec: GrafanaSpec<
|
|
49
|
+
TeamProps,
|
|
50
|
+
grafana.TeamDTO,
|
|
51
|
+
TeamAttributes,
|
|
52
|
+
| grafana.SearchTeamsError
|
|
53
|
+
| grafana.CreateTeamError
|
|
54
|
+
| grafana.UpdateTeamError
|
|
55
|
+
| grafana.DeleteTeamError
|
|
56
|
+
| GrafanaProvisionedObjectError
|
|
57
|
+
> = {
|
|
58
|
+
attributes: (live) => ({
|
|
59
|
+
email: live.email ?? '',
|
|
60
|
+
id: live.id,
|
|
61
|
+
isProvisioned: live.isProvisioned,
|
|
62
|
+
name: live.name,
|
|
63
|
+
uid: live.uid,
|
|
64
|
+
}),
|
|
65
|
+
create: (props) =>
|
|
66
|
+
grafana.createTeam({
|
|
67
|
+
name: props.name,
|
|
68
|
+
...(props.email === undefined ? {} : { email: props.email }),
|
|
69
|
+
}),
|
|
70
|
+
destroy: (props, live) =>
|
|
71
|
+
Effect.gen(function* () {
|
|
72
|
+
if (live.isProvisioned) {
|
|
73
|
+
return yield* refuseIfProvisioned(
|
|
74
|
+
'Grafana.Team',
|
|
75
|
+
props.name,
|
|
76
|
+
'an external team sync (isProvisioned)',
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
yield* grafana.deleteTeam({ team_id: String(live.id) });
|
|
80
|
+
}),
|
|
81
|
+
fetchLive: (props) => findTeamByName(props.name),
|
|
82
|
+
matches: (attributes, props) =>
|
|
83
|
+
attributes.name === props.name &&
|
|
84
|
+
(props.email === undefined || attributes.email === props.email),
|
|
85
|
+
update: (props, live) =>
|
|
86
|
+
Effect.gen(function* () {
|
|
87
|
+
if (live.isProvisioned) {
|
|
88
|
+
return yield* refuseIfProvisioned(
|
|
89
|
+
'Grafana.Team',
|
|
90
|
+
props.name,
|
|
91
|
+
'an external team sync (isProvisioned)',
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
return yield* grafana.updateTeam({
|
|
95
|
+
team_id: String(live.id),
|
|
96
|
+
name: props.name,
|
|
97
|
+
...(props.email === undefined ? {} : { email: props.email }),
|
|
98
|
+
});
|
|
99
|
+
}),
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export const handlers = grafanaHandlers(spec);
|
|
103
|
+
|
|
104
|
+
export const GrafanaTeamProvider = () =>
|
|
105
|
+
Provider.effect(GrafanaTeam, Effect.succeed(GrafanaTeam.Provider.of(handlers)));
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `Environment=` quoting contract, proven against a port of the three parsers a value crosses —
|
|
3
|
+
* container-env-oracle.ts, whose every rule was reproduced with the LIVE podman 5.4.2 system
|
|
4
|
+
* generator and a live systemd 257 unit (2026-10-11/12, `QUADLET_UNIT_DIRS=<dir>
|
|
5
|
+
* /usr/lib/systemd/system-generators/podman-system-generator --dryrun`; artifacts in
|
|
6
|
+
* /tmp/opencode/quadlet-quote/) — not inferred. The port's own header cites each measurement.
|
|
7
|
+
*
|
|
8
|
+
* The TEXT-level contract of the renderer itself (what we emit) is container-env-quote.test.ts;
|
|
9
|
+
* this file is the parser-level proof that what we emit SURVIVES, and that what shipped before
|
|
10
|
+
* did not. container-env-live.test.ts re-validates the port against the real generator wherever
|
|
11
|
+
* one is installed.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, expect, test } from 'bun:test';
|
|
14
|
+
import { environmentLines } from './container-env-quote.ts';
|
|
15
|
+
import { renderContainerFile } from './container-form.ts';
|
|
16
|
+
import { deployed } from './container-env-oracle.ts';
|
|
17
|
+
|
|
18
|
+
// ── The real values this row exists for (homeflare-ct100 commit 53b84c1, grafana.container) ──
|
|
19
|
+
|
|
20
|
+
const VAR = 'GF_AUTH_GENERIC_OAUTH_ROLE_ATTRIBUTE_PATH';
|
|
21
|
+
const ROLE_PATH =
|
|
22
|
+
"(contains(groups[*], 'Admin') || contains(email, 'tim@schenanigans.com')) && 'GrafanaAdmin'";
|
|
23
|
+
const oracleLines = (value: string): string[] => [
|
|
24
|
+
'Environment=SCOPES=openid',
|
|
25
|
+
`Environment=${VAR}=${value}`,
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
describe('the unquoted renderer (what shipped before) corrupts values through the real chain', () => {
|
|
29
|
+
test('the deployed Grafana expression is truncated at its first space', () => {
|
|
30
|
+
const { env } = deployed(oracleLines(ROLE_PATH));
|
|
31
|
+
expect(env[VAR]).toBe('(contains(groups[*],');
|
|
32
|
+
// Everything after the first space produced no variable of its own here — it is simply gone.
|
|
33
|
+
expect(Object.keys(env).sort()).toEqual([VAR, 'SCOPES']);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('a quoted fragment holding `=` becomes a bogus second variable (measured oracle output)', () => {
|
|
37
|
+
const { env } = deployed(
|
|
38
|
+
oracleLines("contains(groups[*], 'cn=admins,ou=people') && is_member('')"),
|
|
39
|
+
);
|
|
40
|
+
expect(env).toEqual({
|
|
41
|
+
[VAR]: 'contains(groups[*],',
|
|
42
|
+
cn: 'admins,ou=people)',
|
|
43
|
+
SCOPES: 'openid',
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('an unknown escape drops every assignment of its line, silently', () => {
|
|
48
|
+
const { env } = deployed(oracleLines('A=1 B=2\\d C=3'));
|
|
49
|
+
expect(env).toEqual({ SCOPES: 'openid' });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('a bare % is consumed as a specifier, never literal data', () => {
|
|
53
|
+
const { env, consumed } = deployed(oracleLines('PCT=50%off'));
|
|
54
|
+
expect(consumed).toContain('%o');
|
|
55
|
+
// ⛔ Assert the VALUE the chain produced, not what it is not: `oracleLines` puts the text
|
|
56
|
+
// under VAR, so an `env.PCT` assertion compares `undefined` and can never fail.
|
|
57
|
+
expect(env[VAR]).toBe('PCT=50ff');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('systemd halves $$: the shipped renderer lets `pa$$word` arrive as `pa$word`', () => {
|
|
61
|
+
// Measured 2026-10-12, live systemd 257 unit: the ExecStart word `pa$$word` (no `:` prefix,
|
|
62
|
+
// so variables ARE substituted) folds to `pa$word` — systemd.service(5) §Command lines.
|
|
63
|
+
const { env, consumed } = deployed(oracleLines('pa$$word'));
|
|
64
|
+
expect(env[VAR]).toBe('pa$word');
|
|
65
|
+
expect(consumed).toEqual([]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('systemd erases braced variables: the shipped renderer lets a braced HOME ref arrive as `ab`', () => {
|
|
69
|
+
// oxlint-disable-next-line no-template-curly-in-string -- `${HOME}` is the measured test data, not a placeholder
|
|
70
|
+
const { env, consumed } = deployed(oracleLines('a${HOME}b'));
|
|
71
|
+
expect(env[VAR]).toBe('ab');
|
|
72
|
+
// oxlint-disable-next-line no-template-curly-in-string -- the consumed record is the literal reference systemd erased
|
|
73
|
+
expect(consumed).toEqual(['${HOME}']);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('the quoted renderer survives the same chain intact', () => {
|
|
78
|
+
test('the deployed Grafana expression round-trips exactly', () => {
|
|
79
|
+
const lines = environmentLines({
|
|
80
|
+
[VAR]: ROLE_PATH,
|
|
81
|
+
GF_AUTH_GENERIC_OAUTH_ROLE_ATTRIBUTE_STRICT: 'true',
|
|
82
|
+
}).map(([key, value]) => `${key}=${value}`);
|
|
83
|
+
const { env, consumed } = deployed(lines);
|
|
84
|
+
expect(env).toEqual({
|
|
85
|
+
[VAR]: ROLE_PATH,
|
|
86
|
+
GF_AUTH_GENERIC_OAUTH_ROLE_ATTRIBUTE_STRICT: 'true',
|
|
87
|
+
});
|
|
88
|
+
expect(consumed).toEqual([]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('escapes, quotes and percent signs each round-trip to the measured generator output', () => {
|
|
92
|
+
const lines = environmentLines({
|
|
93
|
+
BACKSLASH: 'C:\\path',
|
|
94
|
+
PCT: '50%off',
|
|
95
|
+
QUOTED: 'say "hi"',
|
|
96
|
+
}).map(([key, value]) => `${key}=${value}`);
|
|
97
|
+
const { env, consumed } = deployed(lines);
|
|
98
|
+
expect(env).toEqual({ BACKSLASH: 'C:\\path', PCT: '50%off', QUOTED: 'say "hi"' });
|
|
99
|
+
expect(consumed).toEqual([]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('dollars round-trip exactly: bcrypt hashes, braced references and $$ stay data', () => {
|
|
103
|
+
// The live-systemd counterparts of these three arrived corrupted under the shipped renderer
|
|
104
|
+
// (the two tests above); each `$` doubled to `$$` — systemd's literal-dollar escape — makes
|
|
105
|
+
// the fold restore exactly the declared bytes. `$2y$10$abc` is a bcrypt hash prefix: the
|
|
106
|
+
// common real-world `$` value this round trip must hold.
|
|
107
|
+
// oxlint-disable-next-line no-template-curly-in-string -- `${HOME}` is the measured test data, not a placeholder
|
|
108
|
+
const declared = { B: 'a${HOME}b', C: '$2y$10$abc', D: 'pa$$word' };
|
|
109
|
+
const lines = environmentLines(declared).map(([key, value]) => `${key}=${value}`);
|
|
110
|
+
const { env, consumed } = deployed(lines);
|
|
111
|
+
expect(env).toEqual(declared);
|
|
112
|
+
expect(consumed).toEqual([]);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('renderContainerFile wires the quoting in — end to end through the chain', () => {
|
|
117
|
+
const props = {
|
|
118
|
+
name: 'grafana-primary',
|
|
119
|
+
container: {
|
|
120
|
+
environmentFile: ['/etc/grafana-primary/env'],
|
|
121
|
+
environment: { [VAR]: ROLE_PATH, GF_AUTH_GENERIC_OAUTH_ROLE_ATTRIBUTE_STRICT: 'true' },
|
|
122
|
+
image: 'docker.io/grafana/grafana:12.2.1',
|
|
123
|
+
network: 'host',
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
test('what the container would receive equals what was declared', () => {
|
|
128
|
+
const envLines = renderContainerFile(props)
|
|
129
|
+
.split('\n')
|
|
130
|
+
.filter((line) => line.startsWith('Environment='));
|
|
131
|
+
const { env, consumed } = deployed(envLines);
|
|
132
|
+
expect(env).toEqual(props.container.environment);
|
|
133
|
+
expect(consumed).toEqual([]);
|
|
134
|
+
});
|
|
135
|
+
});
|