From 15a209e652a7d909be0bcacca3f7e7247e9798be Mon Sep 17 00:00:00 2001 From: Ilker Manap Date: Fri, 9 Oct 2026 06:42:07 +0200 Subject: [PATCH] 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) --- BACKLOG.md | 6 +- CHANGELOG.md | 4 +- terraform-provider-llmgw/README.md | 21 +- terraform-provider-llmgw/examples/main.tf | 27 + .../internal/client/client.go | 179 ++++++ .../internal/client/contract_test.go | 29 +- .../internal/fakeapi/fakeapi.go | 24 +- .../internal/fakeapi/tenancy.go | 393 +++++++++++++ .../internal/provider/provider.go | 3 +- .../internal/provider/resource_tenancy.go | 532 ++++++++++++++++++ .../internal/provider/tenancy_test.go | 167 ++++++ 11 files changed, 1361 insertions(+), 24 deletions(-) create mode 100644 terraform-provider-llmgw/internal/fakeapi/tenancy.go create mode 100644 terraform-provider-llmgw/internal/provider/resource_tenancy.go create mode 100644 terraform-provider-llmgw/internal/provider/tenancy_test.go diff --git a/BACKLOG.md b/BACKLOG.md index 0cae8a2..81191fa 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 994aa9d..cc46bf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/terraform-provider-llmgw/README.md b/terraform-provider-llmgw/README.md index a46ca7e..bad006c 100644 --- a/terraform-provider-llmgw/README.md +++ b/terraform-provider-llmgw/README.md @@ -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 diff --git a/terraform-provider-llmgw/examples/main.tf b/terraform-provider-llmgw/examples/main.tf index 4132aae..f581187 100644 --- a/terraform-provider-llmgw/examples/main.tf +++ b/terraform-provider-llmgw/examples/main.tf @@ -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" diff --git a/terraform-provider-llmgw/internal/client/client.go b/terraform-provider-llmgw/internal/client/client.go index 2d3ff0e..6f03768 100644 --- a/terraform-provider-llmgw/internal/client/client.go +++ b/terraform-provider-llmgw/internal/client/client.go @@ -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) +} diff --git a/terraform-provider-llmgw/internal/client/contract_test.go b/terraform-provider-llmgw/internal/client/contract_test.go index 3d61f5c..db78d0a 100644 --- a/terraform-provider-llmgw/internal/client/contract_test.go +++ b/terraform-provider-llmgw/internal/client/contract_test.go @@ -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) { diff --git a/terraform-provider-llmgw/internal/fakeapi/fakeapi.go b/terraform-provider-llmgw/internal/fakeapi/fakeapi.go index 7718ebb..5ee2318 100644 --- a/terraform-provider-llmgw/internal/fakeapi/fakeapi.go +++ b/terraform-provider-llmgw/internal/fakeapi/fakeapi.go @@ -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 } diff --git a/terraform-provider-llmgw/internal/fakeapi/tenancy.go b/terraform-provider-llmgw/internal/fakeapi/tenancy.go new file mode 100644 index 0000000..5d888eb --- /dev/null +++ b/terraform-provider-llmgw/internal/fakeapi/tenancy.go @@ -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) + } +} diff --git a/terraform-provider-llmgw/internal/provider/provider.go b/terraform-provider-llmgw/internal/provider/provider.go index 77f2752..f5cec48 100644 --- a/terraform-provider-llmgw/internal/provider/provider.go +++ b/terraform-provider-llmgw/internal/provider/provider.go @@ -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 { diff --git a/terraform-provider-llmgw/internal/provider/resource_tenancy.go b/terraform-provider-llmgw/internal/provider/resource_tenancy.go new file mode 100644 index 0000000..d97798a --- /dev/null +++ b/terraform-provider-llmgw/internal/provider/resource_tenancy.go @@ -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)...) +} diff --git a/terraform-provider-llmgw/internal/provider/tenancy_test.go b/terraform-provider-llmgw/internal/provider/tenancy_test.go new file mode 100644 index 0000000..33afd18 --- /dev/null +++ b/terraform-provider-llmgw/internal/provider/tenancy_test.go @@ -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"}, + }, + }, + }) +}