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
server` run by hand. Organizations, teams, projects, keys and
memberships are in the admin API since 2026-10-08 (`/api/v1/orgs`,
`teams`, `projects`, `keys`, `users/{id}/memberships`); accounts are
still made by sign-up, invitation or OIDC/SCIM. Left: a model's definition beyond
`teams`, `projects`, `keys`, `users/{id}/memberships`) and in the
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
has no edit, and `resources` is not used yet); `canary`, `slo`,
`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
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 `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
+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_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_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_nodes` | Every node, with its cards and state now. |
Not here, because the admin API does not serve them: organizations, teams,
projects, people and API keys. Those live in the store and are managed with
`llmgw org`, `llmgw users` and `llmgw keys` on the control plane. A quota or
an attachment names a team that already exists, by its path or its ID.
Not here: people's accounts (sign-up, invitation, OIDC/SCIM, and `llmgw
users` to approve, disable or delete), and key lifetime and spending limits
(`llmgw keys ttl`, `llmgw keys limits`). A quota or an attachment names a
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
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.
- **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
plan replaces the quota with itself), and for attachments, by
`scope:SCOPE-ID:policy` as their `id` shows. Models cannot be imported yet.
plan replaces the quota with itself), for attachments, by
`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
+27
View File
@@ -66,6 +66,33 @@ resource "llmgw_model" "llama_70b" {
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.
resource "llmgw_quota" "ml" {
scope = "team:Acme/ml"
@@ -40,6 +40,22 @@ var Endpoints = []string{
"GET /api/v1/guard",
"POST /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.
@@ -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 {
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"},
{"POST /api/v1/guard/attachments", GuardAttachmentRequest{}, "GuardAttachmentRequest", Attachment{}, "guard.Attachment"},
{"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.
var nested = map[reflect.Type]string{
reflect.TypeOf(Profile{}): "catalog.Profile",
reflect.TypeOf(Scaling{}): "catalog.Scaling",
reflect.TypeOf(External{}): "catalog.External",
reflect.TypeOf(Adapter{}): "catalog.Adapter",
reflect.TypeOf(Capabilities{}): "catalog.Capabilities",
reflect.TypeOf(Resources{}): "store.Resources",
reflect.TypeOf(Attachment{}): "GuardAttachmentView",
reflect.TypeOf(Profile{}): "catalog.Profile",
reflect.TypeOf(Scaling{}): "catalog.Scaling",
reflect.TypeOf(External{}): "catalog.External",
reflect.TypeOf(Adapter{}): "catalog.Adapter",
reflect.TypeOf(Capabilities{}): "catalog.Capabilities",
reflect.TypeOf(Resources{}): "store.Resources",
reflect.TypeOf(Attachment{}): "GuardAttachmentView",
reflect.TypeOf(TeamView{}): "TeamView",
reflect.TypeOf(ProjectView{}): "ProjectView",
reflect.TypeOf(MembershipView{}): "MembershipView",
}
func TestEveryBodyTheClientSendsAndReadsMatchesTheDocument(t *testing.T) {
@@ -32,24 +32,33 @@ type API struct {
attachments []client.Attachment
nodes []client.NodeView
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
tenancy
}
// 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 {
return &API{
a := &API{
Token: token,
models: map[string]client.Model{},
quotas: map[string]client.QuotaView{},
nodes: nodes,
scopes: map[string]string{
"org:o1": "Acme",
"team:t1": "Acme / ml",
"project:p1": "Acme/ml/training",
tenancy: tenancy{
levels: map[string][]node{
client.LevelOrg: {{ID: "o1", Name: "Acme"}},
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.
@@ -153,6 +162,7 @@ func (a *API) mux() *http.ServeMux {
mux.HandleFunc("GET /api/v1/guard", a.guard)
mux.HandleFunc("POST /api/v1/guard/attachments", a.attach(true))
mux.HandleFunc("DELETE /api/v1/guard/attachments", a.attach(false))
a.tenancyRoutes(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 {
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 {
@@ -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"},
},
},
})
}