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:
1 parent
90e0942481
commit
15a209e652
11 files changed
+1361
-24
No files matched your search
+4
-2
@@ -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
@@ -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,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
|
||||
|
||||
|
||||
@@ -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"},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user