Terraform: organizations, teams, projects, keys and memberships

llmgw_org, llmgw_team and llmgw_project rename and move in place;
llmgw_key holds the key in a sensitive secret and is made again when
revoked or expired outside Terraform; llmgw_membership puts a person in
the tree. Tried with OpenTofu against the fake admin API, drift and
import included.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilker ManapandClaude Opus 5.5 committed 2026-10-09 06:42:07 +02:00
1 parent 90e0942481
commit 15a209e652
11 files changed
+1361 -24

No files matched your search

+4 -2
View File
@@ -571,8 +571,10 @@ portal is not done — the page shows; the console and ctl change.
apply, empty plan, in-place change and destroy against a real `llmgw apply, empty plan, in-place change and destroy against a real `llmgw
server` run by hand. Organizations, teams, projects, keys and server` run by hand. Organizations, teams, projects, keys and
memberships are in the admin API since 2026-10-08 (`/api/v1/orgs`, memberships are in the admin API since 2026-10-08 (`/api/v1/orgs`,
`teams`, `projects`, `keys`, `users/{id}/memberships`); accounts are `teams`, `projects`, `keys`, `users/{id}/memberships`) and in the
still made by sign-up, invitation or OIDC/SCIM. Left: a model's definition beyond provider (`llmgw_org`, `llmgw_team`, `llmgw_project`, `llmgw_key`,
`llmgw_membership`); accounts are still made by sign-up, invitation or
OIDC/SCIM. Left: a model's definition beyond
scaling, class, owner and adapters is changed by replacing it (the API scaling, class, owner and adapters is changed by replacing it (the API
has no edit, and `resources` is not used yet); `canary`, `slo`, has no edit, and `resources` is not used yet); `canary`, `slo`,
`scaling.schedule` and `prewarm`, `guard` role and `origin` are not in `scaling.schedule` and `prewarm`, `guard` role and `origin` are not in
+3 -1
View File
@@ -61,7 +61,9 @@ APIs change between minors without a deprecation period.
`PATCH` to rename or move, `DELETE` for what is empty; `/api/v1/keys` to `PATCH` to rename or move, `DELETE` for what is empty; `/api/v1/keys` to
issue (the key is in the answer, once), list and revoke; `/api/v1/users` issue (the key is in the answer, once), list and revoke; `/api/v1/users`
and `/api/v1/users/{id}/memberships`. They were only `llmgw org`, `keys` and `/api/v1/users/{id}/memberships`. They were only `llmgw org`, `keys`
and `users` on a control plane before. and `users` on a control plane before. The Terraform provider manages them
as `llmgw_org`, `llmgw_team`, `llmgw_project`, `llmgw_key` (the key in a
sensitive `secret`) and `llmgw_membership`.
### Changed ### Changed
+15 -6
View File
@@ -15,13 +15,17 @@ API is reached and what it asks of a request.
| `llmgw_model` | A model added to the catalog at runtime, as `llmgw ctl model add` adds one. Scaling, class, owner and adapters change in place; any other change to the definition replaces the model, which stops its replicas while it is added again. | | `llmgw_model` | A model added to the catalog at runtime, as `llmgw ctl model add` adds one. Scaling, class, owner and adapters change in place; any other change to the definition replaces the model, which stops its replicas while it is added again. |
| `llmgw_quota` | A capacity quota on an organization, team or project: a guarantee, a limit, or both, in GPUs, CPU cores and memory. | | `llmgw_quota` | A capacity quota on an organization, team or project: a guarantee, a limit, or both, in GPUs, CPU cores and memory. |
| `llmgw_guard_attachment` | A guardrail policy attached to an organization, team, project, key or person. The policies are defined in `server.yaml`'s `guard:` section; this says where they apply. | | `llmgw_guard_attachment` | A guardrail policy attached to an organization, team, project, key or person. The policies are defined in `server.yaml`'s `guard:` section; this says where they apply. |
| `llmgw_org`, `llmgw_team`, `llmgw_project` | The tenancy tree. A team names its organization and a project its team, by path or ID; a new name renames in place, a new parent moves it with everything under it. Only something empty is removed, and a project that has ever had a key is never empty (the revoked key stays for usage history): rename it rather than destroy it. |
| `llmgw_key` | An API key in a project, for a person in a project they belong to, or a person's own. The key is in `secret` (sensitive) from the apply that made it; the gateway keeps only a hash. Any change makes a new key, and one revoked or expired outside Terraform is made again. Destroying it revokes it. |
| `llmgw_membership` | A person in an organization, team or project, as member or manager. People are not made here: they sign up, are invited, or come from the identity provider. Taking a membership away revokes their keys in projects they no longer reach. |
| `data.llmgw_models` | Every model in the catalog, with its replicas now. | | `data.llmgw_models` | Every model in the catalog, with its replicas now. |
| `data.llmgw_nodes` | Every node, with its cards and state now. | | `data.llmgw_nodes` | Every node, with its cards and state now. |
Not here, because the admin API does not serve them: organizations, teams, Not here: people's accounts (sign-up, invitation, OIDC/SCIM, and `llmgw
projects, people and API keys. Those live in the store and are managed with users` to approve, disable or delete), and key lifetime and spending limits
`llmgw org`, `llmgw users` and `llmgw keys` on the control plane. A quota or (`llmgw keys ttl`, `llmgw keys limits`). A quota or an attachment names a
an attachment names a team that already exists, by its path or its ID. part of the tree by its path or its ID; refer to an `llmgw_team`'s `id` to
have Terraform make the team first.
Models declared in the control plane's `server.yaml` are not managed here Models declared in the control plane's `server.yaml` are not managed here
either: the cluster refuses to remove or change them through the API, since either: the cluster refuses to remove or change them through the API, since
@@ -107,8 +111,13 @@ with `version = "0.1.0"` beside `source` in `required_providers`.
found again by the ID, so renaming the team in the store does not lose it. found again by the ID, so renaming the team in the store does not lose it.
- **Import** works for quotas, by `team:ID` as `GET /api/v1/quotas` lists it - **Import** works for quotas, by `team:ID` as `GET /api/v1/quotas` lists it
(the `scope` is then that ID; write it so in the configuration, or the next (the `scope` is then that ID; write it so in the configuration, or the next
plan replaces the quota with itself), and for attachments, by plan replaces the quota with itself), for attachments, by
`scope:SCOPE-ID:policy` as their `id` shows. Models cannot be imported yet. `scope:SCOPE-ID:policy` as their `id` shows, for organizations, teams,
projects and keys by their IDs (an imported key has no `secret`), and for
memberships by `USER-ID/scope:SCOPE-ID`. Models cannot be imported yet.
- **A project's `path` follows a move of its team at the next refresh**: the
project did not change, so the apply that moved the team does not rewrite
it.
## Developing it ## Developing it
+27
View File
@@ -66,6 +66,33 @@ resource "llmgw_model" "llama_70b" {
fallbacks = [llmgw_model.qwen_small.name] fallbacks = [llmgw_model.qwen_small.name]
} }
# A team and a project in Acme, a key for its CI, and a person in the team.
resource "llmgw_team" "search" {
name = "search"
org = "Acme"
}
resource "llmgw_project" "ranking" {
name = "ranking"
team = llmgw_team.search.id
}
resource "llmgw_key" "ranking_ci" {
name = "ci"
project = llmgw_project.ranking.id
expires_in = "2160h"
}
resource "llmgw_membership" "ayse" {
user = "ayse@example.com"
target = "team:${llmgw_team.search.id}"
}
output "ranking_ci_key" {
value = llmgw_key.ranking_ci.secret
sensitive = true
}
# The ml team may hold at most eight GPUs, and is always given two. # The ml team may hold at most eight GPUs, and is always given two.
resource "llmgw_quota" "ml" { resource "llmgw_quota" "ml" {
scope = "team:Acme/ml" scope = "team:Acme/ml"
@@ -40,6 +40,22 @@ var Endpoints = []string{
"GET /api/v1/guard", "GET /api/v1/guard",
"POST /api/v1/guard/attachments", "POST /api/v1/guard/attachments",
"DELETE /api/v1/guard/attachments", "DELETE /api/v1/guard/attachments",
"GET /api/v1/orgs",
"POST /api/v1/orgs",
"PATCH /api/v1/orgs/{id}",
"DELETE /api/v1/orgs/{id}",
"POST /api/v1/teams",
"PATCH /api/v1/teams/{id}",
"DELETE /api/v1/teams/{id}",
"POST /api/v1/projects",
"PATCH /api/v1/projects/{id}",
"DELETE /api/v1/projects/{id}",
"POST /api/v1/keys",
"GET /api/v1/keys/{id}",
"DELETE /api/v1/keys/{id}",
"GET /api/v1/users/{id}",
"POST /api/v1/users/{id}/memberships",
"DELETE /api/v1/users/{id}/memberships",
} }
// Client is a connection to one control plane's admin API. // Client is a connection to one control plane's admin API.
@@ -436,3 +452,166 @@ func (c *Client) Attach(ctx context.Context, target, policy string) (Attachment,
func (c *Client) Detach(ctx context.Context, target, policy string) error { func (c *Client) Detach(ctx context.Context, target, policy string) error {
return c.do(ctx, "DELETE /api/v1/guard/attachments", nil, GuardAttachmentRequest{Target: target, Policy: policy}, nil) return c.do(ctx, "DELETE /api/v1/guard/attachments", nil, GuardAttachmentRequest{Target: target, Policy: policy}, nil)
} }
// --- the tenancy tree, keys and memberships ---
// OrgView is an organization with its teams.
type OrgView struct {
ID string `json:"id"`
Name string `json:"name"`
Teams []TeamView `json:"teams"`
}
// TeamView is a team with its projects.
type TeamView struct {
ID string `json:"id"`
Name string `json:"name"`
Org string `json:"org"`
Projects []ProjectView `json:"projects"`
}
// ProjectView is a project.
type ProjectView struct {
ID string `json:"id"`
Name string `json:"name"`
Team string `json:"team"`
Path string `json:"path"`
}
// TenancyCreateRequest adds an organization, a team or a project.
type TenancyCreateRequest struct {
Name string `json:"name"`
In string `json:"in,omitempty"`
}
// TenancyChangeRequest renames or moves one.
type TenancyChangeRequest struct {
Name string `json:"name,omitempty"`
In string `json:"in,omitempty"`
}
// TenancyItem is one added or changed, by ID.
type TenancyItem struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
In string `json:"in,omitempty"`
}
// Levels of the tree, as the API's paths name them.
const (
LevelOrg = "orgs"
LevelTeam = "teams"
LevelProject = "projects"
)
// Tree reads every organization, team and project.
func (c *Client) Tree(ctx context.Context) ([]OrgView, error) {
var out []OrgView
return out, c.do(ctx, "GET /api/v1/orgs", nil, nil, &out)
}
// Create adds an organization, team or project.
func (c *Client) Create(ctx context.Context, level string, in TenancyCreateRequest) (TenancyItem, error) {
var out TenancyItem
return out, c.do(ctx, "POST /api/v1/"+level, nil, in, &out)
}
// Change renames an organization, team or project, or moves a team or a
// project.
func (c *Client) Change(ctx context.Context, level, id string, in TenancyChangeRequest) (TenancyItem, error) {
var out TenancyItem
return out, c.do(ctx, "PATCH /api/v1/"+level+"/{id}", []string{id}, in, &out)
}
// Remove removes an organization, team or project with nothing in it.
func (c *Client) Remove(ctx context.Context, level, id string) error {
return c.do(ctx, "DELETE /api/v1/"+level+"/{id}", []string{id}, nil, nil)
}
// KeyView is a key, never the key itself.
type KeyView struct {
ID string `json:"id"`
Name string `json:"name"`
Prefix string `json:"prefix"`
Project string `json:"project,omitempty"`
User string `json:"user,omitempty"`
Band string `json:"band,omitempty"`
RoutingProfile string `json:"routing_profile,omitempty"`
State string `json:"state"`
ExpiresAt time.Time `json:"expires_at,omitzero"`
}
// KeyCreateRequest issues a key.
type KeyCreateRequest struct {
Name string `json:"name"`
Project string `json:"project,omitempty"`
User string `json:"user,omitempty"`
Band string `json:"band,omitempty"`
RoutingProfile string `json:"routing_profile,omitempty"`
ExpiresIn string `json:"expires_in,omitempty"`
}
// KeyCreated is a new key with the key itself, given this once.
type KeyCreated struct {
ID string `json:"id"`
Prefix string `json:"prefix"`
Project string `json:"project,omitempty"`
User string `json:"user,omitempty"`
State string `json:"state"`
ExpiresAt time.Time `json:"expires_at,omitzero"`
Secret string `json:"secret"`
}
// CreateKey issues a key.
func (c *Client) CreateKey(ctx context.Context, in KeyCreateRequest) (KeyCreated, error) {
var out KeyCreated
return out, c.do(ctx, "POST /api/v1/keys", nil, in, &out)
}
// Key reads a key.
func (c *Client) Key(ctx context.Context, id string) (KeyView, error) {
var out KeyView
return out, c.do(ctx, "GET /api/v1/keys/{id}", []string{id}, nil, &out)
}
// RevokeKey stops a key working.
func (c *Client) RevokeKey(ctx context.Context, id string) error {
return c.do(ctx, "DELETE /api/v1/keys/{id}", []string{id}, nil, nil)
}
// UserView is a person with where they are a member.
type UserView struct {
ID string `json:"id"`
Email string `json:"email"`
Memberships []MembershipView `json:"memberships"`
}
// MembershipView is one place a person belongs, target as scope:ID.
type MembershipView struct {
Target string `json:"target"`
Role string `json:"role"`
}
// MembershipRequest names where a person goes or leaves.
type MembershipRequest struct {
Target string `json:"target"`
Role string `json:"role,omitempty"`
}
// User reads a person, by ID or email.
func (c *Client) User(ctx context.Context, id string) (UserView, error) {
var out UserView
return out, c.do(ctx, "GET /api/v1/users/{id}", []string{id}, nil, &out)
}
// Join puts a person in an organization, team or project, or changes their
// role there.
func (c *Client) Join(ctx context.Context, user string, in MembershipRequest) (UserView, error) {
var out UserView
return out, c.do(ctx, "POST /api/v1/users/{id}/memberships", []string{user}, in, &out)
}
// Leave takes a person out.
func (c *Client) Leave(ctx context.Context, user, target string) error {
return c.do(ctx, "DELETE /api/v1/users/{id}/memberships", []string{user}, MembershipRequest{Target: target}, nil)
}
@@ -101,17 +101,32 @@ var bodies = []struct {
{"GET /api/v1/guard", nil, "", GuardView{}, "GuardView"}, {"GET /api/v1/guard", nil, "", GuardView{}, "GuardView"},
{"POST /api/v1/guard/attachments", GuardAttachmentRequest{}, "GuardAttachmentRequest", Attachment{}, "guard.Attachment"}, {"POST /api/v1/guard/attachments", GuardAttachmentRequest{}, "GuardAttachmentRequest", Attachment{}, "guard.Attachment"},
{"DELETE /api/v1/guard/attachments", GuardAttachmentRequest{}, "GuardAttachmentRequest", nil, ""}, {"DELETE /api/v1/guard/attachments", GuardAttachmentRequest{}, "GuardAttachmentRequest", nil, ""},
{"GET /api/v1/orgs", nil, "", OrgView{}, "OrgView"},
{"POST /api/v1/orgs", TenancyCreateRequest{}, "TenancyCreateRequest", TenancyItem{}, "TenancyItem"},
{"PATCH /api/v1/orgs/{id}", TenancyChangeRequest{}, "TenancyChangeRequest", TenancyItem{}, "TenancyItem"},
{"POST /api/v1/teams", TenancyCreateRequest{}, "TenancyCreateRequest", TenancyItem{}, "TenancyItem"},
{"PATCH /api/v1/teams/{id}", TenancyChangeRequest{}, "TenancyChangeRequest", TenancyItem{}, "TenancyItem"},
{"POST /api/v1/projects", TenancyCreateRequest{}, "TenancyCreateRequest", TenancyItem{}, "TenancyItem"},
{"PATCH /api/v1/projects/{id}", TenancyChangeRequest{}, "TenancyChangeRequest", TenancyItem{}, "TenancyItem"},
{"POST /api/v1/keys", KeyCreateRequest{}, "KeyCreateRequest", KeyCreated{}, "KeyCreated"},
{"GET /api/v1/keys/{id}", nil, "", KeyView{}, "KeyView"},
{"GET /api/v1/users/{id}", nil, "", UserView{}, "UserView"},
{"POST /api/v1/users/{id}/memberships", MembershipRequest{}, "MembershipRequest", UserView{}, "UserView"},
{"DELETE /api/v1/users/{id}/memberships", MembershipRequest{}, "MembershipRequest", UserView{}, "UserView"},
} }
// nested are the client's types inside those bodies, and their schemas. // nested are the client's types inside those bodies, and their schemas.
var nested = map[reflect.Type]string{ var nested = map[reflect.Type]string{
reflect.TypeOf(Profile{}): "catalog.Profile", reflect.TypeOf(Profile{}): "catalog.Profile",
reflect.TypeOf(Scaling{}): "catalog.Scaling", reflect.TypeOf(Scaling{}): "catalog.Scaling",
reflect.TypeOf(External{}): "catalog.External", reflect.TypeOf(External{}): "catalog.External",
reflect.TypeOf(Adapter{}): "catalog.Adapter", reflect.TypeOf(Adapter{}): "catalog.Adapter",
reflect.TypeOf(Capabilities{}): "catalog.Capabilities", reflect.TypeOf(Capabilities{}): "catalog.Capabilities",
reflect.TypeOf(Resources{}): "store.Resources", reflect.TypeOf(Resources{}): "store.Resources",
reflect.TypeOf(Attachment{}): "GuardAttachmentView", reflect.TypeOf(Attachment{}): "GuardAttachmentView",
reflect.TypeOf(TeamView{}): "TeamView",
reflect.TypeOf(ProjectView{}): "ProjectView",
reflect.TypeOf(MembershipView{}): "MembershipView",
} }
func TestEveryBodyTheClientSendsAndReadsMatchesTheDocument(t *testing.T) { func TestEveryBodyTheClientSendsAndReadsMatchesTheDocument(t *testing.T) {
@@ -32,24 +32,33 @@ type API struct {
attachments []client.Attachment attachments []client.Attachment
nodes []client.NodeView nodes []client.NodeView
actors []string actors []string
// scopes maps scope:ID to the path people know it by. // scopes maps scope:ID to the path people know it by, rebuilt from
// the tree whenever it changes.
scopes map[string]string scopes map[string]string
tenancy
} }
// New builds a fake with a small tenancy tree — Acme, Acme/ml, // New builds a fake with a small tenancy tree — Acme, Acme/ml,
// Acme/ml/training — two guard policies and the nodes given. // Acme/ml/training — one person, ayse@example.com (u1), two guard policies
// and the nodes given.
func New(token string, nodes ...client.NodeView) *API { func New(token string, nodes ...client.NodeView) *API {
return &API{ a := &API{
Token: token, Token: token,
models: map[string]client.Model{}, models: map[string]client.Model{},
quotas: map[string]client.QuotaView{}, quotas: map[string]client.QuotaView{},
nodes: nodes, nodes: nodes,
scopes: map[string]string{ tenancy: tenancy{
"org:o1": "Acme", levels: map[string][]node{
"team:t1": "Acme / ml", client.LevelOrg: {{ID: "o1", Name: "Acme"}},
"project:p1": "Acme/ml/training", client.LevelTeam: {{ID: "t1", Name: "ml", In: "o1"}},
client.LevelProject: {{ID: "p1", Name: "training", In: "t1"}},
},
keys: map[string]client.KeyView{},
users: map[string]*client.UserView{"u1": {ID: "u1", Email: "ayse@example.com", Memberships: []client.MembershipView{}}},
}, },
} }
a.rebuild()
return a
} }
// Actors are who each change said it was made by, in order. // Actors are who each change said it was made by, in order.
@@ -153,6 +162,7 @@ func (a *API) mux() *http.ServeMux {
mux.HandleFunc("GET /api/v1/guard", a.guard) mux.HandleFunc("GET /api/v1/guard", a.guard)
mux.HandleFunc("POST /api/v1/guard/attachments", a.attach(true)) mux.HandleFunc("POST /api/v1/guard/attachments", a.attach(true))
mux.HandleFunc("DELETE /api/v1/guard/attachments", a.attach(false)) mux.HandleFunc("DELETE /api/v1/guard/attachments", a.attach(false))
a.tenancyRoutes(mux)
return mux return mux
} }
@@ -0,0 +1,393 @@
package fakeapi
import (
"cmp"
"encoding/json"
"fmt"
"net/http"
"slices"
"strings"
"time"
"gitea.mynodes.xyz/ilker/llmgw/terraform-provider-llmgw/internal/client"
)
// node is an organization, team or project; In is its parent's ID.
type node struct{ ID, Name, In string }
// tenancy is the tree, the keys and the people.
type tenancy struct {
levels map[string][]node
keys map[string]client.KeyView
users map[string]*client.UserView
seq int
}
// parent is the level a level's nodes are in, and the scope it is named by.
var parent = map[string]string{client.LevelTeam: client.LevelOrg, client.LevelProject: client.LevelTeam}
var scopeOf = map[string]string{client.LevelOrg: "org", client.LevelTeam: "team", client.LevelProject: "project"}
func (a *API) find(level, id string) (node, int) {
for i, n := range a.levels[level] {
if n.ID == id {
return n, i
}
}
return node{}, -1
}
// rebuild names every part of the tree as the real API does.
func (a *API) rebuild() {
a.scopes = map[string]string{}
for _, o := range a.levels[client.LevelOrg] {
a.scopes["org:"+o.ID] = o.Name
}
for _, t := range a.levels[client.LevelTeam] {
o, _ := a.find(client.LevelOrg, t.In)
a.scopes["team:"+t.ID] = o.Name + " / " + t.Name
}
for _, p := range a.levels[client.LevelProject] {
t, _ := a.find(client.LevelTeam, p.In)
o, _ := a.find(client.LevelOrg, t.In)
a.scopes["project:"+p.ID] = o.Name + "/" + t.Name + "/" + p.Name
}
}
// resolveAt reads a level's node by ID or path.
func (a *API) resolveAt(level, ref string) (string, error) {
key, err := a.resolve(scopeOf[level] + ":" + ref)
if err != nil {
return "", err
}
_, id, _ := strings.Cut(key, ":")
return id, nil
}
// RenameBehindTerraform renames a part of the tree as someone at a terminal
// would.
func (a *API) RenameBehindTerraform(level, id, name string) {
a.mu.Lock()
defer a.mu.Unlock()
if _, i := a.find(level, id); i >= 0 {
a.levels[level][i].Name = name
a.rebuild()
}
}
// RevokeBehindTerraform revokes a key as someone at a terminal would.
func (a *API) RevokeBehindTerraform(id string) {
a.mu.Lock()
defer a.mu.Unlock()
k := a.keys[id]
k.State = "revoked"
a.keys[id] = k
}
// Keys are the keys as the fake holds them.
func (a *API) Keys() map[string]client.KeyView {
a.mu.Lock()
defer a.mu.Unlock()
out := map[string]client.KeyView{}
for id, k := range a.keys {
out[id] = k
}
return out
}
// Memberships are where a person is, as the fake holds it.
func (a *API) Memberships(user string) []client.MembershipView {
a.mu.Lock()
defer a.mu.Unlock()
return slices.Clone(a.users[user].Memberships)
}
// Has says whether a part of the tree is there, by ID.
func (a *API) Has(level, id string) bool {
a.mu.Lock()
defer a.mu.Unlock()
_, i := a.find(level, id)
return i >= 0
}
func (a *API) tenancyRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/v1/orgs", a.tree)
for _, level := range []string{client.LevelOrg, client.LevelTeam, client.LevelProject} {
mux.HandleFunc("POST /api/v1/"+level, a.create(level))
mux.HandleFunc("PATCH /api/v1/"+level+"/{id}", a.changeNode(level))
mux.HandleFunc("DELETE /api/v1/"+level+"/{id}", a.removeNode(level))
}
mux.HandleFunc("POST /api/v1/keys", a.createKey)
mux.HandleFunc("GET /api/v1/keys/{id}", a.getKey)
mux.HandleFunc("DELETE /api/v1/keys/{id}", a.revokeKey)
mux.HandleFunc("GET /api/v1/users/{id}", a.getUser)
mux.HandleFunc("POST /api/v1/users/{id}/memberships", a.membership(true))
mux.HandleFunc("DELETE /api/v1/users/{id}/memberships", a.membership(false))
}
func (a *API) tree(w http.ResponseWriter, _ *http.Request) {
a.mu.Lock()
defer a.mu.Unlock()
out := []client.OrgView{}
for _, o := range a.levels[client.LevelOrg] {
ov := client.OrgView{ID: o.ID, Name: o.Name, Teams: []client.TeamView{}}
for _, t := range a.levels[client.LevelTeam] {
if t.In != o.ID {
continue
}
tv := client.TeamView{ID: t.ID, Name: t.Name, Org: o.ID, Projects: []client.ProjectView{}}
for _, p := range a.levels[client.LevelProject] {
if p.In == t.ID {
tv.Projects = append(tv.Projects, client.ProjectView{ID: p.ID, Name: p.Name, Team: t.ID,
Path: a.scopes["project:"+p.ID]})
}
}
ov.Teams = append(ov.Teams, tv)
}
out = append(out, ov)
}
writeJSON(w, http.StatusOK, out)
}
func (a *API) taken(level, in, name, except string) bool {
for _, n := range a.levels[level] {
if n.In == in && n.Name == name && n.ID != except {
return true
}
}
return false
}
func (a *API) create(level string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req client.TenancyCreateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" {
writeError(w, http.StatusBadRequest, "a name is needed")
return
}
a.mu.Lock()
defer a.mu.Unlock()
n := node{Name: req.Name}
if p, ok := parent[level]; ok {
id, err := a.resolveAt(p, req.In)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
n.In = id
}
if a.taken(level, n.In, n.Name, "") {
writeError(w, http.StatusConflict, "there is already one named "+n.Name+" there")
return
}
a.seq++
n.ID = fmt.Sprintf("%s-%d", scopeOf[level], a.seq)
a.levels[level] = append(a.levels[level], n)
a.rebuild()
writeJSON(w, http.StatusCreated, client.TenancyItem{ID: n.ID, Name: n.Name, In: n.In})
}
}
func (a *API) changeNode(level string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req client.TenancyChangeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
a.mu.Lock()
defer a.mu.Unlock()
id, err := a.resolveAt(level, r.PathValue("id"))
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
n, i := a.find(level, id)
out := client.TenancyItem{ID: id}
if req.In != "" {
p, ok := parent[level]
if !ok {
writeError(w, http.StatusBadRequest, "an organization is in nothing")
return
}
if n.In, err = a.resolveAt(p, req.In); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
out.In = n.In
}
if req.Name != "" {
n.Name, out.Name = req.Name, req.Name
}
if a.taken(level, n.In, n.Name, id) {
writeError(w, http.StatusConflict, "there is already one named "+n.Name+" there")
return
}
a.levels[level][i] = n
a.rebuild()
writeJSON(w, http.StatusOK, out)
}
}
func (a *API) removeNode(level string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
a.mu.Lock()
defer a.mu.Unlock()
id, err := a.resolveAt(level, r.PathValue("id"))
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
for child, p := range parent {
if p != level {
continue
}
for _, c := range a.levels[child] {
if c.In == id {
writeError(w, http.StatusConflict, "not empty")
return
}
}
}
for _, k := range a.keys {
if k.Project == id {
writeError(w, http.StatusConflict, "not empty: it still has a key")
return
}
}
_, i := a.find(level, id)
a.levels[level] = slices.Delete(a.levels[level], i, i+1)
a.rebuild()
writeJSON(w, http.StatusOK, map[string]any{"id": id, "deleted": true})
}
}
func (a *API) createKey(w http.ResponseWriter, r *http.Request) {
var req client.KeyCreateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" {
writeError(w, http.StatusBadRequest, "a key needs a name")
return
}
a.mu.Lock()
defer a.mu.Unlock()
k := client.KeyView{Name: req.Name, Band: req.Band, RoutingProfile: req.RoutingProfile, State: "active"}
if req.Project != "" {
id, err := a.resolveAt(client.LevelProject, req.Project)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
k.Project = id
}
if req.User != "" {
u, ok := a.user(req.User)
if !ok {
writeError(w, http.StatusNotFound, "no person "+req.User)
return
}
if k.Project != "" && !slices.ContainsFunc(u.Memberships, func(m client.MembershipView) bool {
return m.Target == "project:"+k.Project
}) {
writeError(w, http.StatusConflict, u.Email+" is not a member there")
return
}
k.User = u.ID
}
if req.ExpiresIn != "" {
d, err := time.ParseDuration(req.ExpiresIn)
if err != nil || d <= 0 {
writeError(w, http.StatusBadRequest, "expires_in is not a duration")
return
}
k.ExpiresAt = time.Now().Add(d).UTC().Truncate(time.Second)
}
a.seq++
k.ID = fmt.Sprintf("key-%d", a.seq)
secret := fmt.Sprintf("sk-llmgw-fake%08d", a.seq)
k.Prefix = secret[:12]
a.keys[k.ID] = k
writeJSON(w, http.StatusCreated, client.KeyCreated{ID: k.ID, Prefix: k.Prefix, Project: k.Project, User: k.User,
State: k.State, ExpiresAt: k.ExpiresAt, Secret: secret})
}
func (a *API) getKey(w http.ResponseWriter, r *http.Request) {
a.mu.Lock()
defer a.mu.Unlock()
k, ok := a.keys[r.PathValue("id")]
if !ok {
writeError(w, http.StatusNotFound, "no key "+r.PathValue("id"))
return
}
writeJSON(w, http.StatusOK, k)
}
func (a *API) revokeKey(w http.ResponseWriter, r *http.Request) {
a.mu.Lock()
defer a.mu.Unlock()
k, ok := a.keys[r.PathValue("id")]
if !ok {
writeError(w, http.StatusNotFound, "no key "+r.PathValue("id"))
return
}
k.State = "revoked"
a.keys[k.ID] = k
writeJSON(w, http.StatusOK, map[string]any{"id": k.ID, "revoked": true})
}
func (a *API) user(ref string) (*client.UserView, bool) {
if u, ok := a.users[ref]; ok {
return u, true
}
for _, u := range a.users {
if strings.EqualFold(u.Email, ref) {
return u, true
}
}
return nil, false
}
func (a *API) getUser(w http.ResponseWriter, r *http.Request) {
a.mu.Lock()
defer a.mu.Unlock()
u, ok := a.user(r.PathValue("id"))
if !ok {
writeError(w, http.StatusNotFound, "no person "+r.PathValue("id"))
return
}
writeJSON(w, http.StatusOK, u)
}
func (a *API) membership(join bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req client.MembershipRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
a.mu.Lock()
defer a.mu.Unlock()
u, ok := a.user(r.PathValue("id"))
if !ok {
writeError(w, http.StatusNotFound, "no person "+r.PathValue("id"))
return
}
key, err := a.resolve(req.Target)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
i := slices.IndexFunc(u.Memberships, func(m client.MembershipView) bool { return m.Target == key })
switch {
case join && i >= 0:
u.Memberships[i].Role = cmp.Or(req.Role, "member")
case join:
u.Memberships = append(u.Memberships, client.MembershipView{Target: key, Role: cmp.Or(req.Role, "member")})
case i < 0:
writeError(w, http.StatusNotFound, u.Email+" is not a member there")
return
default:
u.Memberships = slices.Delete(u.Memberships, i, i+1)
}
writeJSON(w, http.StatusOK, u)
}
}
@@ -104,7 +104,8 @@ func (p *llmgwProvider) Configure(ctx context.Context, req provider.ConfigureReq
} }
func (p *llmgwProvider) Resources(context.Context) []func() resource.Resource { func (p *llmgwProvider) Resources(context.Context) []func() resource.Resource {
return []func() resource.Resource{newModelResource, newQuotaResource, newGuardAttachmentResource} return []func() resource.Resource{newModelResource, newQuotaResource, newGuardAttachmentResource,
newOrgResource, newTeamResource, newProjectResource, newKeyResource, newMembershipResource}
} }
func (p *llmgwProvider) DataSources(context.Context) []func() datasource.DataSource { func (p *llmgwProvider) DataSources(context.Context) []func() datasource.DataSource {
@@ -0,0 +1,532 @@
package provider
import (
"context"
"fmt"
"strings"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/tfsdk"
"github.com/hashicorp/terraform-plugin-framework/types"
"gitea.mynodes.xyz/ilker/llmgw/terraform-provider-llmgw/internal/client"
)
// Organizations, teams and projects: one resource each, written once. A
// team names the organization it is in and a project the team, by path or
// ID; changing that moves it, with everything under it, rather than
// replacing it, as `llmgw org move` does.
type tenancyResource struct {
c *client.Client
// level is the API's path for it; kind the resource's name; parent the
// attribute naming what it is in, empty for an organization.
level, kind, parent string
}
func newOrgResource() resource.Resource {
return &tenancyResource{level: client.LevelOrg, kind: "org"}
}
func newTeamResource() resource.Resource {
return &tenancyResource{level: client.LevelTeam, kind: "team", parent: "org"}
}
func newProjectResource() resource.Resource {
return &tenancyResource{level: client.LevelProject, kind: "project", parent: "team"}
}
func (r *tenancyResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_" + r.kind
}
func (r *tenancyResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
keep := []planmodifier.String{stringplanmodifier.UseStateForUnknown()}
attrs := map[string]schema.Attribute{
"id": schema.StringAttribute{Computed: true, PlanModifiers: keep,
Description: "The " + r.kind + "'s ID. Import by it."},
"name": schema.StringAttribute{Required: true,
Description: "Its name; changing it renames it, keeping its ID and everything under it."},
}
desc := "An organization: the top of the tenancy tree. Removed only when it has no teams."
switch r.kind {
case "team":
desc = "A team in an organization. Removed only when it has no projects."
attrs["org"] = schema.StringAttribute{Required: true,
Description: "The organization it is in, by name or ID; changing it moves the team and its projects."}
attrs["org_id"] = schema.StringAttribute{Computed: true, Description: "The organization's ID."}
case "project":
desc = "A project in a team: where keys, people and spending meet. Removed only when it has no people and no collections, " +
"and has never had a key: a revoked key stays, because usage refers to it. Rename such a project rather than destroying it."
attrs["team"] = schema.StringAttribute{Required: true,
Description: "The team it is in, as ORG/TEAM or its ID; changing it moves the project."}
attrs["team_id"] = schema.StringAttribute{Computed: true, Description: "The team's ID."}
attrs["path"] = schema.StringAttribute{Computed: true, Description: "ORG/TEAM/PROJECT, by name."}
}
resp.Schema = schema.Schema{Description: desc, Attributes: attrs}
}
func (r *tenancyResource) Configure(_ context.Context, req resource.ConfigureRequest, _ *resource.ConfigureResponse) {
if c, ok := clientFrom(req.ProviderData); ok {
r.c = c
}
}
// found is a node as the tree has it.
type found struct{ name, in, path string }
// lookup finds a node of this resource's level in the tree by ID.
func (r *tenancyResource) lookup(ctx context.Context, id string) (found, bool, error) {
tree, err := r.c.Tree(ctx)
if err != nil {
return found{}, false, err
}
for _, o := range tree {
if r.level == client.LevelOrg && o.ID == id {
return found{name: o.Name}, true, nil
}
for _, t := range o.Teams {
if r.level == client.LevelTeam && t.ID == id {
return found{name: t.Name, in: o.ID}, true, nil
}
for _, p := range t.Projects {
if r.level == client.LevelProject && p.ID == id {
return found{name: p.Name, in: t.ID, path: p.Path}, true, nil
}
}
}
}
return found{}, false, nil
}
// set writes what the API says into the state: the computed attributes,
// and, when the API has moved on from what the state said, the configured
// ones too, so the next plan shows the difference.
func (r *tenancyResource) set(ctx context.Context, st *tfsdk.State, id string, f found, fromAPI bool) {
st.SetAttribute(ctx, path.Root("id"), types.StringValue(id))
if fromAPI {
st.SetAttribute(ctx, path.Root("name"), types.StringValue(f.name))
}
if r.parent == "" {
return
}
var was types.String
st.GetAttribute(ctx, path.Root(r.parent+"_id"), &was)
if fromAPI && was.ValueString() != f.in {
st.SetAttribute(ctx, path.Root(r.parent), types.StringValue(f.in))
}
st.SetAttribute(ctx, path.Root(r.parent+"_id"), types.StringValue(f.in))
if r.level == client.LevelProject {
st.SetAttribute(ctx, path.Root("path"), types.StringValue(f.path))
}
}
func (r *tenancyResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var name, in types.String
resp.Diagnostics.Append(req.Plan.GetAttribute(ctx, path.Root("name"), &name)...)
if r.parent != "" {
resp.Diagnostics.Append(req.Plan.GetAttribute(ctx, path.Root(r.parent), &in)...)
}
if resp.Diagnostics.HasError() {
return
}
item, err := r.c.Create(ctx, r.level, client.TenancyCreateRequest{Name: name.ValueString(), In: in.ValueString()})
if err != nil {
resp.Diagnostics.AddError("Adding "+r.kind+" "+name.ValueString(), err.Error())
return
}
f, ok, err := r.lookup(ctx, item.ID)
if err != nil || !ok {
resp.Diagnostics.AddError("Reading back "+r.kind+" "+name.ValueString(), fmt.Sprint("not in the tree after adding it ", err))
return
}
resp.State.Raw = req.Plan.Raw
r.set(ctx, &resp.State, item.ID, f, false)
}
func (r *tenancyResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var id types.String
resp.Diagnostics.Append(req.State.GetAttribute(ctx, path.Root("id"), &id)...)
if resp.Diagnostics.HasError() {
return
}
f, ok, err := r.lookup(ctx, id.ValueString())
if err != nil {
resp.Diagnostics.AddError("Reading the tenancy tree", err.Error())
return
}
if !ok {
resp.State.RemoveResource(ctx)
return
}
r.set(ctx, &resp.State, id.ValueString(), f, true)
}
func (r *tenancyResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var id, name, wasName, in, wasIn types.String
resp.Diagnostics.Append(req.State.GetAttribute(ctx, path.Root("id"), &id)...)
resp.Diagnostics.Append(req.Plan.GetAttribute(ctx, path.Root("name"), &name)...)
resp.Diagnostics.Append(req.State.GetAttribute(ctx, path.Root("name"), &wasName)...)
if r.parent != "" {
resp.Diagnostics.Append(req.Plan.GetAttribute(ctx, path.Root(r.parent), &in)...)
resp.Diagnostics.Append(req.State.GetAttribute(ctx, path.Root(r.parent), &wasIn)...)
}
if resp.Diagnostics.HasError() {
return
}
var change client.TenancyChangeRequest
if !name.Equal(wasName) {
change.Name = name.ValueString()
}
if r.parent != "" && !in.Equal(wasIn) {
change.In = in.ValueString()
}
if change.Name != "" || change.In != "" {
if _, err := r.c.Change(ctx, r.level, id.ValueString(), change); err != nil {
resp.Diagnostics.AddError("Changing "+r.kind+" "+wasName.ValueString(), err.Error())
return
}
}
f, ok, err := r.lookup(ctx, id.ValueString())
if err != nil || !ok {
resp.Diagnostics.AddError("Reading back "+r.kind+" "+name.ValueString(), fmt.Sprint("not in the tree after changing it ", err))
return
}
resp.State.Raw = req.Plan.Raw
r.set(ctx, &resp.State, id.ValueString(), f, false)
}
func (r *tenancyResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var id, name types.String
resp.Diagnostics.Append(req.State.GetAttribute(ctx, path.Root("id"), &id)...)
resp.Diagnostics.Append(req.State.GetAttribute(ctx, path.Root("name"), &name)...)
if resp.Diagnostics.HasError() {
return
}
if err := r.c.Remove(ctx, r.level, id.ValueString()); err != nil && !client.IsNotFound(err) {
resp.Diagnostics.AddError("Removing "+r.kind+" "+name.ValueString(), err.Error())
}
}
func (r *tenancyResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
}
// --- keys ---
// The key resource is an API key. Its secret is known only to the apply
// that made it, and kept in the state, marked sensitive: the gateway keeps
// only a hash. A key revoked or expired outside Terraform is made again.
type keyResource struct{ c *client.Client }
func newKeyResource() resource.Resource { return &keyResource{} }
type keyModel struct {
ID types.String `tfsdk:"id"`
Name types.String `tfsdk:"name"`
Project types.String `tfsdk:"project"`
User types.String `tfsdk:"user"`
Band types.String `tfsdk:"band"`
RoutingProfile types.String `tfsdk:"routing_profile"`
ExpiresIn types.String `tfsdk:"expires_in"`
Secret types.String `tfsdk:"secret"`
Prefix types.String `tfsdk:"prefix"`
ExpiresAt types.String `tfsdk:"expires_at"`
ProjectID types.String `tfsdk:"project_id"`
UserID types.String `tfsdk:"user_id"`
}
func (r *keyResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_key"
}
func (r *keyResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
replace := []planmodifier.String{stringplanmodifier.RequiresReplace()}
keep := []planmodifier.String{stringplanmodifier.UseStateForUnknown()}
resp.Schema = schema.Schema{
Description: "An API key: a project's, a person's in a project they are a member of, or a person's own. " +
"Every change makes a new key; one revoked or expired outside Terraform is made again on the next apply.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{Computed: true, PlanModifiers: keep,
Description: "The key's ID, not the key. Import by it; an imported key has no secret."},
"name": schema.StringAttribute{Required: true, PlanModifiers: replace},
"project": schema.StringAttribute{Optional: true, PlanModifiers: replace,
Description: "The project it belongs to, as ORG/TEAM/PROJECT or its ID."},
"user": schema.StringAttribute{Optional: true, PlanModifiers: replace,
Description: "The person it belongs to, by ID or email; with a project, they must be a member of it."},
"band": schema.StringAttribute{Optional: true, PlanModifiers: replace,
Description: "Admission band; inherited when unset."},
"routing_profile": schema.StringAttribute{Optional: true, PlanModifiers: replace,
Description: "Routing profile; inherited when unset."},
"expires_in": schema.StringAttribute{Optional: true, PlanModifiers: replace,
Description: "Lifetime, such as 720h; never, unless a scope above it limits how long keys live."},
"secret": schema.StringAttribute{Computed: true, Sensitive: true, PlanModifiers: keep,
Description: "The key itself."},
"prefix": schema.StringAttribute{Computed: true, PlanModifiers: keep, Description: "Its first characters, as listings show it."},
"expires_at": schema.StringAttribute{Computed: true, PlanModifiers: keep, Description: "When it stops working, RFC 3339; empty for never."},
"project_id": schema.StringAttribute{Computed: true, PlanModifiers: keep},
"user_id": schema.StringAttribute{Computed: true, PlanModifiers: keep},
},
}
}
func (r *keyResource) Configure(_ context.Context, req resource.ConfigureRequest, _ *resource.ConfigureResponse) {
if c, ok := clientFrom(req.ProviderData); ok {
r.c = c
}
}
func (r *keyResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan keyModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
k, err := r.c.CreateKey(ctx, client.KeyCreateRequest{Name: plan.Name.ValueString(), Project: plan.Project.ValueString(),
User: plan.User.ValueString(), Band: plan.Band.ValueString(), RoutingProfile: plan.RoutingProfile.ValueString(),
ExpiresIn: plan.ExpiresIn.ValueString()})
if err != nil {
resp.Diagnostics.AddError("Issuing key "+plan.Name.ValueString(), err.Error())
return
}
plan.ID, plan.Secret, plan.Prefix = types.StringValue(k.ID), types.StringValue(k.Secret), types.StringValue(k.Prefix)
plan.ProjectID, plan.UserID = types.StringValue(k.Project), types.StringValue(k.User)
plan.ExpiresAt = types.StringValue("")
if !k.ExpiresAt.IsZero() {
plan.ExpiresAt = types.StringValue(k.ExpiresAt.Format("2006-01-02T15:04:05Z07:00"))
}
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}
func (r *keyResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var st keyModel
resp.Diagnostics.Append(req.State.Get(ctx, &st)...)
if resp.Diagnostics.HasError() {
return
}
k, err := r.c.Key(ctx, st.ID.ValueString())
if client.IsNotFound(err) || err == nil && k.State != "active" {
resp.State.RemoveResource(ctx)
return
}
if err != nil {
resp.Diagnostics.AddError("Reading key "+st.ID.ValueString(), err.Error())
return
}
if st.Name.IsNull() {
// Imported: what it was made with comes from the API.
st.Name, st.Project, st.User = types.StringValue(k.Name), strOrNull(k.Project), strOrNull(k.User)
st.Band, st.RoutingProfile = strOrNull(k.Band), strOrNull(k.RoutingProfile)
st.Prefix, st.ProjectID, st.UserID = types.StringValue(k.Prefix), types.StringValue(k.Project), types.StringValue(k.User)
st.ExpiresAt = types.StringValue("")
if !k.ExpiresAt.IsZero() {
st.ExpiresAt = types.StringValue(k.ExpiresAt.Format("2006-01-02T15:04:05Z07:00"))
}
}
resp.Diagnostics.Append(resp.State.Set(ctx, &st)...)
}
// Update is never called: every attribute replaces the key.
func (r *keyResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan keyModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}
func (r *keyResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var st keyModel
resp.Diagnostics.Append(req.State.Get(ctx, &st)...)
if resp.Diagnostics.HasError() {
return
}
if err := r.c.RevokeKey(ctx, st.ID.ValueString()); err != nil && !client.IsNotFound(err) {
resp.Diagnostics.AddError("Revoking key "+st.Name.ValueString(), err.Error())
}
}
func (r *keyResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
}
// --- memberships ---
// The membership resource puts a person in an organization, team or
// project. People themselves are not made here: they sign up, are invited
// or come from the identity provider.
type membershipResource struct{ c *client.Client }
func newMembershipResource() resource.Resource { return &membershipResource{} }
type membershipModel struct {
ID types.String `tfsdk:"id"`
User types.String `tfsdk:"user"`
Target types.String `tfsdk:"target"`
Role types.String `tfsdk:"role"`
UserID types.String `tfsdk:"user_id"`
// Scope is the target resolved, scope:ID.
Scope types.String `tfsdk:"scope"`
}
func (r *membershipResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_membership"
}
func (r *membershipResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
replace := []planmodifier.String{stringplanmodifier.RequiresReplace()}
keep := []planmodifier.String{stringplanmodifier.UseStateForUnknown()}
resp.Schema = schema.Schema{
Description: "A person's membership of an organization, team or project. Taking it away revokes their keys in projects they no longer reach.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{Computed: true, PlanModifiers: keep,
Description: "USER-ID/scope:SCOPE-ID. Import by it."},
"user": schema.StringAttribute{Required: true, PlanModifiers: replace,
Description: "The person, by ID or email."},
"target": schema.StringAttribute{Required: true, PlanModifiers: replace,
Description: "org:NAME, team:ORG/TEAM or project:ORG/TEAM/PROJECT, or the ID after the colon."},
"role": schema.StringAttribute{Optional: true,
Description: "member or manager; member when unset."},
"user_id": schema.StringAttribute{Computed: true, PlanModifiers: keep},
"scope": schema.StringAttribute{Computed: true, PlanModifiers: keep, Description: "The target as scope:ID."},
},
}
}
func (r *membershipResource) Configure(_ context.Context, req resource.ConfigureRequest, _ *resource.ConfigureResponse) {
if c, ok := clientFrom(req.ProviderData); ok {
r.c = c
}
}
// resolveTarget is a target as scope:ID, read from the tree.
func resolveTarget(ctx context.Context, c *client.Client, target string) (string, error) {
kind, what, ok := strings.Cut(strings.TrimSpace(target), ":")
if !ok || what == "" {
return "", fmt.Errorf("%q is not org:, team: or project: and a name or ID", target)
}
tree, err := c.Tree(ctx)
if err != nil {
return "", err
}
same := func(a, b string) bool { return strings.EqualFold(strings.TrimSpace(a), strings.TrimSpace(b)) }
for _, o := range tree {
if kind == "org" && (o.ID == what || same(o.Name, what)) {
return "org:" + o.ID, nil
}
for _, t := range o.Teams {
if kind == "team" && (t.ID == what || same(o.Name+"/"+t.Name, what)) {
return "team:" + t.ID, nil
}
for _, p := range t.Projects {
if kind == "project" && (p.ID == what || same(p.Path, what)) {
return "project:" + p.ID, nil
}
}
}
}
return "", fmt.Errorf("there is no %s %q", kind, what)
}
func roleOf(v types.String) string {
if v.IsNull() || v.ValueString() == "" {
return "member"
}
return v.ValueString()
}
func (r *membershipResource) join(ctx context.Context, m *membershipModel) error {
scope := m.Scope.ValueString()
if m.Scope.IsNull() || m.Scope.IsUnknown() {
var err error
if scope, err = resolveTarget(ctx, r.c, m.Target.ValueString()); err != nil {
return err
}
}
u, err := r.c.Join(ctx, m.User.ValueString(), client.MembershipRequest{Target: scope, Role: roleOf(m.Role)})
if err != nil {
return err
}
m.ID, m.UserID, m.Scope = types.StringValue(u.ID+"/"+scope), types.StringValue(u.ID), types.StringValue(scope)
return nil
}
func (r *membershipResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan membershipModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
if err := r.join(ctx, &plan); err != nil {
resp.Diagnostics.AddError("Putting "+plan.User.ValueString()+" in "+plan.Target.ValueString(), err.Error())
return
}
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}
func (r *membershipResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var st membershipModel
resp.Diagnostics.Append(req.State.Get(ctx, &st)...)
if resp.Diagnostics.HasError() {
return
}
u, err := r.c.User(ctx, st.UserID.ValueString())
if client.IsNotFound(err) {
resp.State.RemoveResource(ctx)
return
}
if err != nil {
resp.Diagnostics.AddError("Reading "+st.User.ValueString(), err.Error())
return
}
for _, m := range u.Memberships {
if m.Target == st.Scope.ValueString() {
// Unset stays unset while the role is the default.
if !(st.Role.IsNull() && m.Role == "member") {
st.Role = types.StringValue(m.Role)
}
resp.Diagnostics.Append(resp.State.Set(ctx, &st)...)
return
}
}
resp.State.RemoveResource(ctx)
}
func (r *membershipResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan membershipModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
if err := r.join(ctx, &plan); err != nil {
resp.Diagnostics.AddError("Changing "+plan.User.ValueString()+"'s role in "+plan.Target.ValueString(), err.Error())
return
}
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}
func (r *membershipResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var st membershipModel
resp.Diagnostics.Append(req.State.Get(ctx, &st)...)
if resp.Diagnostics.HasError() {
return
}
if err := r.c.Leave(ctx, st.UserID.ValueString(), st.Scope.ValueString()); err != nil && !client.IsNotFound(err) {
resp.Diagnostics.AddError("Taking "+st.User.ValueString()+" out of "+st.Target.ValueString(), err.Error())
}
}
func (r *membershipResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
user, scope, ok := strings.Cut(req.ID, "/")
if !ok || !strings.Contains(scope, ":") {
resp.Diagnostics.AddError("Not a membership ID", fmt.Sprintf("%q is not USER-ID/scope:SCOPE-ID, as id shows it", req.ID))
return
}
st := membershipModel{ID: types.StringValue(req.ID), User: types.StringValue(user), Target: types.StringValue(scope),
Role: types.StringNull(), UserID: types.StringValue(user), Scope: types.StringValue(scope)}
resp.Diagnostics.Append(resp.State.Set(ctx, &st)...)
}
@@ -0,0 +1,167 @@
package provider
import (
"fmt"
"strings"
"testing"
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
"github.com/hashicorp/terraform-plugin-testing/plancheck"
"github.com/hashicorp/terraform-plugin-testing/terraform"
"gitea.mynodes.xyz/ilker/llmgw/terraform-provider-llmgw/internal/client"
)
func tenancyConfig(project, team, role string) string {
roleLine := ""
if role != "" {
roleLine = fmt.Sprintf(" role = %q\n", role)
}
return fmt.Sprintf(`
resource "llmgw_org" "globex" {
name = "Globex"
}
resource "llmgw_team" "ops" {
name = "ops"
org = %s
}
resource "llmgw_project" "web" {
name = %q
team = llmgw_team.ops.id
}
resource "llmgw_membership" "ayse" {
user = "ayse@example.com"
target = "project:${llmgw_project.web.id}"
%s}
# Keys in the project that is not destroyed: one that ever had a key stays.
resource "llmgw_key" "ci" {
name = "ci"
project = "Acme/ml/training"
expires_in = "720h"
}
`, team, project, roleLine)
}
func TestAccTheTreeKeysAndMembershipsAreManaged(t *testing.T) {
api, cfg := fake(t)
var team, project string
resource.Test(t, resource.TestCase{
ProtoV6ProviderFactories: factories,
CheckDestroy: func(*terraform.State) error {
if api.Has(client.LevelOrg, "org-1") || api.Has(client.LevelTeam, team) || api.Has(client.LevelProject, project) {
return fmt.Errorf("a part of the tree is left")
}
for _, k := range api.Keys() {
if k.State != "revoked" {
return fmt.Errorf("key %s is %s", k.ID, k.State)
}
}
if m := api.Memberships("u1"); len(m) != 0 {
return fmt.Errorf("memberships left: %+v", m)
}
return nil
},
Steps: []resource.TestStep{
{
Config: cfg + tenancyConfig("web", "llmgw_org.globex.name", ""),
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr("llmgw_project.web", "path", "Globex/ops/web"),
resource.TestCheckResourceAttrPair("llmgw_team.ops", "org_id", "llmgw_org.globex", "id"),
func(s *terraform.State) error {
got := s.RootModule().Resources["llmgw_membership.ayse"].Primary.Attributes["scope"]
if want := "project:" + s.RootModule().Resources["llmgw_project.web"].Primary.ID; got != want {
return fmt.Errorf("scope %q, not %q", got, want)
}
return nil
},
resource.TestCheckResourceAttr("llmgw_membership.ayse", "user_id", "u1"),
resource.TestCheckResourceAttr("llmgw_key.ci", "project_id", "p1"),
resource.TestCheckResourceAttrWith("llmgw_key.ci", "secret", func(v string) error {
if !strings.HasPrefix(v, "sk-llmgw-") {
return fmt.Errorf("secret %q", v)
}
return nil
}),
resource.TestCheckResourceAttrWith("llmgw_key.ci", "expires_at", func(v string) error {
if v == "" {
return fmt.Errorf("no expiry")
}
return nil
}),
func(s *terraform.State) error {
team = s.RootModule().Resources["llmgw_team.ops"].Primary.ID
project = s.RootModule().Resources["llmgw_project.web"].Primary.ID
return nil
},
),
},
{
// Renamed and the role changed, in place.
Config: cfg + tenancyConfig("site", "llmgw_org.globex.name", "manager"),
ConfigPlanChecks: resource.ConfigPlanChecks{PreApply: []plancheck.PlanCheck{
plancheck.ExpectResourceAction("llmgw_project.web", plancheck.ResourceActionUpdate),
plancheck.ExpectResourceAction("llmgw_membership.ayse", plancheck.ResourceActionUpdate),
plancheck.ExpectResourceAction("llmgw_key.ci", plancheck.ResourceActionNoop),
}},
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr("llmgw_project.web", "path", "Globex/ops/site"),
func(*terraform.State) error {
if m := api.Memberships("u1"); len(m) != 1 || m[0].Role != "manager" {
return fmt.Errorf("memberships %+v", m)
}
return nil
},
),
},
{
// Moved to another organization with its project.
Config: cfg + tenancyConfig("site", `"Acme"`, "manager"),
ConfigPlanChecks: resource.ConfigPlanChecks{PreApply: []plancheck.PlanCheck{
plancheck.ExpectResourceAction("llmgw_team.ops", plancheck.ResourceActionUpdate),
}},
// The project's path follows at the next refresh: the
// project itself did not change.
Check: resource.TestCheckResourceAttr("llmgw_team.ops", "org_id", "o1"),
},
{
// Renamed behind Terraform, and the key revoked: the name is
// put back, the key made again.
PreConfig: func() {
api.RenameBehindTerraform(client.LevelProject, project, "renamed")
for id, k := range api.Keys() {
if k.Name == "ci" && k.State == "active" {
api.RevokeBehindTerraform(id)
}
}
},
Config: cfg + tenancyConfig("site", `"Acme"`, "manager"),
ConfigPlanChecks: resource.ConfigPlanChecks{PreApply: []plancheck.PlanCheck{
plancheck.ExpectResourceAction("llmgw_project.web", plancheck.ResourceActionUpdate),
plancheck.ExpectResourceAction("llmgw_key.ci", plancheck.ResourceActionCreate),
}},
Check: resource.TestCheckResourceAttr("llmgw_project.web", "path", "Acme/ops/site"),
},
{
ResourceName: "llmgw_org.globex",
ImportState: true,
ImportStateVerify: true,
},
{
ResourceName: "llmgw_membership.ayse",
ImportState: true,
ImportStateVerify: true,
ImportStateVerifyIgnore: []string{"user", "target"},
},
{
ResourceName: "llmgw_key.ci",
ImportState: true,
ImportStateVerify: true,
ImportStateVerifyIgnore: []string{"secret", "expires_in", "project"},
},
},
})
}