Compare commits

...

6 Commits

Author SHA1 Message Date
Jeff McCune
2df843bc98 (#175) Link the generated platform to holos server
When the user generates a platform, we need to know the platform ID it's
linked to in the holos server.  If there is no platform with the same
name, the `holos generate platform` command should error out.

This is necessary because the first thing we want to show is pushing an
updated form to `holos server`.  To update the web ui the CLI needs to
know the platform ID to update.

This patch modifies the generate command to obtain a list of platforms
for the org and verify the generated name matches one of the platforms
  that already exists.

A future patch could have the `generate platform` command call the
`holos.platform.v1alpha1.PlatformService.CreatePlatform` method if the
platform isn't found.

Results:

```sh
holos generate platform bare
```

```txt
4:15PM INF generate.go:77 wrote platform.metadata.json version=0.77.1 platform_id=018f826d-85a8-751f-96d0-0d2bf70df909 path=/home/jeff/holos/platform.metadata.json
4:15PM INF generate.go:89 generated platform bare version=0.77.1 platform_id=018f826d-85a8-751f-96d0-0d2bf70df909 path=/home/jeff/holos
```

```sh
cat platform.metadata.json
```

```json
{
  "id": "018f826d-85a8-751f-96d0-0d2bf70df909",
  "name": "bare",
  "display_name": "Bare Platform"
}
```
2024-05-16 16:18:38 -07:00
Jeff McCune
be4d2c29a5 (#175) Log info message when generating a platform
holos generate platform bare
    2:11PM INF generate.go:55 generated platform bare version=0.77.1 path=/home/jeff/holos
2024-05-16 14:26:51 -07:00
Jeff McCune
8ce88bf491 (#175) Fix goreleaser
Buf was being automatically updated in the pipeline.
2024-05-16 14:00:37 -07:00
Jeff McCune
b05571a595 (#175) Go tidy and update package.json
For goreleaser
2024-05-16 13:41:47 -07:00
Jeff McCune
4edfc71d68 (#175) Log the grpc procedure at info level
This patch logs the service and rpc method of every request at Info
level.  The error code and message is also logged.  This gives a good
indication of what rpc methods are being called and by whom.
2024-05-16 11:43:20 -07:00
Jeff McCune
3049694a0a (#175) holos register user
This patch adds a `holos register user` command.  Given an authenticated
id token and no other record of the user in the database, the cli tool
use the API to:

 1. User is registered in `holos server`
 2. User is linked to one Holos Organization.
 3. Holos Organization has the `bare` platform.
 4. Holos Organization has the `reference` platform.
 5. Ensure `~/.holos/client-context.json` contains the user id and an
    org id.

The `holos.ClientContext` struct is intended as a light weight way to
save and load the current organization id to the file system for further
API calls.

The assumption is most users will have only one single org.  We can add
a more complicated config context system like kubectl uses if and when
we need it.
2024-05-16 10:51:40 -07:00
35 changed files with 2547 additions and 1130 deletions

View File

@@ -54,6 +54,9 @@ jobs:
- name: List keys
run: gpg -K
- name: Git diff
run: git diff
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v5
with:

View File

@@ -113,7 +113,7 @@ snapshot: ## Go release snapshot
.PHONY: buf
buf: ## buf generate
cd service && buf mod update
cd service && buf dep update
buf generate
.PHONY: tools

4
go.mod
View File

@@ -6,6 +6,7 @@ require (
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.33.0-20240401165935-b983156c5e99.1
connectrpc.com/connect v1.16.0
connectrpc.com/grpcreflect v1.2.0
connectrpc.com/otelconnect v0.7.0
connectrpc.com/validate v0.1.0
cuelang.org/go v0.8.0
entgo.io/ent v0.13.1
@@ -30,6 +31,7 @@ require (
github.com/stretchr/testify v1.9.0
golang.org/x/net v0.24.0
golang.org/x/tools v0.20.0
google.golang.org/genproto/googleapis/rpc v0.0.0-20240325203815-454cdb8f5daa
google.golang.org/protobuf v1.33.1-0.20240408130810-98873a205002
honnef.co/go/tools v0.4.7
k8s.io/api v0.29.2
@@ -44,7 +46,6 @@ require (
ariga.io/atlas v0.19.1-0.20240203083654-5948b60a8e43 // indirect
cloud.google.com/go/compute v1.23.3 // indirect
cloud.google.com/go/compute/metadata v0.2.3 // indirect
connectrpc.com/otelconnect v0.7.0 // indirect
cuelabs.dev/go/oci/ociregistry v0.0.0-20240314152124-224736b49f2e // indirect
github.com/AlecAivazis/survey/v2 v2.3.7 // indirect
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect
@@ -248,7 +249,6 @@ require (
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/genproto v0.0.0-20240123012728-ef4313101c80 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20240325203815-454cdb8f5daa // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240325203815-454cdb8f5daa // indirect
google.golang.org/grpc v1.62.1 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect

View File

@@ -5,6 +5,7 @@ import (
"strings"
"github.com/holos-run/holos/internal/cli/command"
"github.com/holos-run/holos/internal/client"
"github.com/holos-run/holos/internal/errors"
"github.com/holos-run/holos/internal/generate"
"github.com/holos-run/holos/internal/holos"
@@ -31,8 +32,11 @@ func NewPlatform(cfg *holos.Config) *cobra.Command {
cmd.Args = cobra.ExactArgs(1)
cmd.RunE = func(cmd *cobra.Command, args []string) error {
ctx := cmd.Root().Context()
clientContext := holos.NewClientContext(ctx)
client := client.New(client.NewConfig(cfg))
for _, name := range args {
if err := generate.GeneratePlatform(ctx, name); err != nil {
if err := generate.GeneratePlatform(ctx, client, clientContext.OrgID, name); err != nil {
return errors.Wrap(err)
}
}

View File

@@ -5,9 +5,11 @@ import (
"fmt"
"log/slog"
"connectrpc.com/connect"
cue "cuelang.org/go/cue/errors"
"github.com/holos-run/holos/internal/errors"
"github.com/holos-run/holos/internal/holos"
"google.golang.org/genproto/googleapis/rpc/errdetails"
)
// MakeMain makes a main function for the cli or tests.
@@ -25,7 +27,8 @@ func MakeMain(options ...holos.Option) func() int {
// HandleError is the top level error handler that unwraps and logs errors.
func HandleError(ctx context.Context, err error, hc *holos.Config) (exitCode int) {
log := hc.NewTopLevelLogger()
// Connect errors have codes, log them.
log := hc.NewTopLevelLogger().With("code", connect.CodeOf(err))
var cueErr cue.Error
var errAt *errors.ErrorAt
const msg = "could not execute"
@@ -39,5 +42,24 @@ func HandleError(ctx context.Context, err error, hc *holos.Config) (exitCode int
msg := cue.Details(cueErr, nil)
_, _ = fmt.Fprint(hc.Stderr(), msg)
}
// connect errors have details and codes.
// Refer to https://connectrpc.com/docs/go/errors
if connectErr := new(connect.Error); errors.As(err, &connectErr) {
for _, detail := range connectErr.Details() {
msg, valueErr := detail.Value()
if valueErr != nil {
log.WarnContext(ctx, "could not decode error detail", "err", err, "type", detail.Type(), "note", "this usually means we don't have the schema for the protobuf message type")
continue
}
if info, ok := msg.(*errdetails.ErrorInfo); ok {
logDetail := log.With("reason", info.GetReason(), "domain", info.GetDomain())
for k, v := range info.GetMetadata() {
logDetail = logDetail.With(k, v)
}
logDetail.ErrorContext(ctx, info.String())
}
}
}
return 1
}

58
internal/cli/push/push.go Normal file
View File

@@ -0,0 +1,58 @@
// Package push pushes resources to the holos api server.
package push
import (
"github.com/holos-run/holos/internal/cli/command"
"github.com/holos-run/holos/internal/client"
"github.com/holos-run/holos/internal/errors"
"github.com/holos-run/holos/internal/holos"
"github.com/holos-run/holos/internal/push"
"github.com/spf13/cobra"
)
func New(cfg *holos.Config) *cobra.Command {
cmd := command.New("push")
cmd.Short = "push resources to holos server"
cmd.Args = cobra.NoArgs
config := client.NewConfig(cfg)
cmd.PersistentFlags().AddGoFlagSet(config.ClientFlagSet())
cmd.PersistentFlags().AddGoFlagSet(config.TokenFlagSet())
cmd.AddCommand(NewPlatform(config))
return cmd
}
func NewPlatform(cfg *client.Config) *cobra.Command {
cmd := command.New("platform")
cmd.Short = "push platform resources to holos server"
cmd.Args = cobra.NoArgs
cmd.AddCommand(NewPlatformForm(cfg))
// cmd.AddCommand(NewPlatformModel(cfg))
return cmd
}
func NewPlatformForm(cfg *client.Config) *cobra.Command {
cmd := command.New("form")
cmd.Short = "push platform form to holos server"
cmd.Args = cobra.MinimumNArgs(1)
cmd.RunE = func(cmd *cobra.Command, args []string) error {
ctx := cmd.Root().Context()
if ctx == nil {
return errors.Wrap(errors.New("cannot execute: no context"))
}
for _, name := range args {
if err := push.PlatformForm(ctx, name); err != nil {
return errors.Wrap(err)
}
}
return nil
}
return cmd
}

View File

@@ -0,0 +1,36 @@
// Package register provides user registration via the command line.
package register
import (
"github.com/holos-run/holos/internal/cli/command"
"github.com/holos-run/holos/internal/client"
"github.com/holos-run/holos/internal/holos"
"github.com/holos-run/holos/internal/register"
"github.com/spf13/cobra"
)
// New returns a new register command.
func New(cfg *holos.Config) *cobra.Command {
cmd := command.New("register")
cmd.Short = "register with holos server"
cmd.Args = cobra.NoArgs
config := client.NewConfig(cfg)
cmd.PersistentFlags().AddGoFlagSet(config.ClientFlagSet())
cmd.PersistentFlags().AddGoFlagSet(config.TokenFlagSet())
cmd.AddCommand(NewUser(config))
return cmd
}
// NewUser returns a command to register a user with holos server.
func NewUser(cfg *client.Config) *cobra.Command {
cmd := command.New("user")
cmd.Short = "user registration workflow"
cmd.RunE = func(cmd *cobra.Command, args []string) error {
ctx := cmd.Root().Context()
return register.User(ctx, cfg)
}
return cmd
}

View File

@@ -20,6 +20,8 @@ import (
"github.com/holos-run/holos/internal/cli/login"
"github.com/holos-run/holos/internal/cli/logout"
"github.com/holos-run/holos/internal/cli/preflight"
"github.com/holos-run/holos/internal/cli/push"
"github.com/holos-run/holos/internal/cli/register"
"github.com/holos-run/holos/internal/cli/render"
"github.com/holos-run/holos/internal/cli/rpc"
"github.com/holos-run/holos/internal/cli/token"
@@ -68,6 +70,8 @@ func New(cfg *holos.Config) *cobra.Command {
rootCmd.AddCommand(token.New(cfg))
rootCmd.AddCommand(rpc.New(cfg))
rootCmd.AddCommand(generate.New(cfg))
rootCmd.AddCommand(register.New(cfg))
rootCmd.AddCommand(push.New(cfg))
// Maybe not needed?
rootCmd.AddCommand(txtar.New(cfg))

View File

@@ -45,7 +45,7 @@ func NewPlatformModel(cfg *Config) *cobra.Command {
cmd := command.New("platform-model")
cmd.Short = "get the platform model"
cmd.RunE = func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
ctx := cmd.Root().Context()
log := logger.FromContext(ctx)
// client := platformconnect.NewPlatformServiceClient(token.NewClient(cfg.token), cfg.client.Server())
client := platformconnect.NewPlatformServiceClient(token.NewClient(cfg.token), cfg.client.Server())

51
internal/client/client.go Normal file
View File

@@ -0,0 +1,51 @@
// Package client provides configuration and convenience methods for making API calls to the holos server.
package client
import (
"context"
"errors"
"connectrpc.com/connect"
"github.com/holos-run/holos/internal/token"
"github.com/holos-run/holos/service/gen/holos/organization/v1alpha1/organizationconnect"
platform "github.com/holos-run/holos/service/gen/holos/platform/v1alpha1"
"github.com/holos-run/holos/service/gen/holos/platform/v1alpha1/platformconnect"
"github.com/holos-run/holos/service/gen/holos/user/v1alpha1/userconnect"
"google.golang.org/protobuf/types/known/fieldmaskpb"
)
func New(cfg *Config) *Client {
t := token.NewClient(cfg.Token())
s := cfg.Client().Server()
return &Client{
cfg: cfg,
usrSvc: userconnect.NewUserServiceClient(t, s),
orgSvc: organizationconnect.NewOrganizationServiceClient(t, s),
pltSvc: platformconnect.NewPlatformServiceClient(t, s),
}
}
// Client provides convenience methods for making API calls to the holos server.
type Client struct {
cfg *Config
usrSvc userconnect.UserServiceClient
pltSvc platformconnect.PlatformServiceClient
orgSvc organizationconnect.OrganizationServiceClient
}
func (c *Client) Platforms(ctx context.Context, orgID string) ([]*platform.Platform, error) {
if c == nil {
return nil, errors.New("no service client")
}
req := &platform.ListPlatformsRequest{
OrgId: orgID,
FieldMask: &fieldmaskpb.FieldMask{
Paths: []string{"id", "name", "displayName"},
},
}
resp, err := c.pltSvc.ListPlatforms(ctx, connect.NewRequest(req))
if err != nil {
return nil, err
}
return resp.Msg.GetPlatforms(), nil
}

51
internal/client/config.go Normal file
View File

@@ -0,0 +1,51 @@
// Package client provides client configuration for the holos cli.
package client
import (
"flag"
"github.com/holos-run/holos/internal/holos"
"github.com/holos-run/holos/internal/token"
)
type Config struct {
holos *holos.Config
client *holos.ClientConfig
token *token.Config
}
func (c *Config) ClientFlagSet() *flag.FlagSet {
if c == nil {
return nil
}
return c.client.FlagSet()
}
func (c *Config) TokenFlagSet() *flag.FlagSet {
if c == nil {
return nil
}
return c.token.FlagSet()
}
func (c *Config) Token() *token.Config {
if c == nil {
return nil
}
return c.token
}
func (c *Config) Client() *holos.ClientConfig {
if c == nil {
return nil
}
return c.client
}
func NewConfig(cfg *holos.Config) *Config {
return &Config{
holos: cfg,
client: holos.NewClientConfig(),
token: token.NewConfig(),
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -40,7 +40,7 @@
"@angular-eslint/template-parser": "17.3.0",
"@angular/cli": "^17.3.4",
"@angular/compiler-cli": "^17.3.0",
"@bufbuild/buf": "^1.31.0",
"@bufbuild/buf": "^1.32.0",
"@bufbuild/protoc-gen-es": "^1.9.0",
"@connectrpc/protoc-gen-connect-es": "^1.4.0",
"@connectrpc/protoc-gen-connect-query": "^1.4.0",

View File

@@ -3,7 +3,7 @@
/* eslint-disable */
// @ts-nocheck
import { CreateUserRequest, CreateUserResponse, GetUserRequest, GetUserResponse } from "./user_service_pb.js";
import { CreateUserRequest, CreateUserResponse, GetUserRequest, GetUserResponse, RegisterUserRequest, RegisterUserResponse } from "./user_service_pb.js";
import { MethodKind } from "@bufbuild/protobuf";
/**
@@ -36,6 +36,17 @@ export const UserService = {
O: GetUserResponse,
kind: MethodKind.Unary,
},
/**
* Register an user and initialize an organization, bare platform, and reference platform.
*
* @generated from rpc holos.user.v1alpha1.UserService.RegisterUser
*/
registerUser: {
name: "RegisterUser",
I: RegisterUserRequest,
O: RegisterUserResponse,
kind: MethodKind.Unary,
},
}
} as const;

View File

@@ -7,6 +7,7 @@ import type { BinaryReadOptions, FieldList, JsonReadOptions, JsonValue, PartialM
import { FieldMask, Message, proto3 } from "@bufbuild/protobuf";
import { User } from "./user_pb.js";
import { UserRef } from "../../object/v1alpha1/object_pb.js";
import { Organization } from "../../organization/v1alpha1/organization_pb.js";
/**
* Create a User from the oidc id token claims or the provided user. Each one
@@ -172,3 +173,118 @@ export class GetUserResponse extends Message<GetUserResponse> {
}
}
/**
* Register a User from the oidc id token claims or the provided user. Each one
* of subject, email, and user id must be globally unique.
*
* @generated from message holos.user.v1alpha1.RegisterUserRequest
*/
export class RegisterUserRequest extends Message<RegisterUserRequest> {
/**
* User resource to create. If absent, the server populates User fields with
* the oidc id token claims of the authenticated request.
* NOTE: The server may ignore this request field and register the user solely
* from authenticated identity claims.
*
* @generated from field: optional holos.user.v1alpha1.User user = 1;
*/
user?: User;
/**
* Mask of the user fields to include in the response.
*
* @generated from field: optional google.protobuf.FieldMask user_mask = 2;
*/
userMask?: FieldMask;
/**
* Organization resource to create. If absent, the server generates an
* organization based on the user fields.
* NOTE: The server may ignore this request field and register the
* organization solely from authenticated identity claims.
*
* @generated from field: optional holos.organization.v1alpha1.Organization organization = 3;
*/
organization?: Organization;
/**
* Mask of the organization fields to include in the response.
*
* @generated from field: optional google.protobuf.FieldMask organization_mask = 4;
*/
organizationMask?: FieldMask;
constructor(data?: PartialMessage<RegisterUserRequest>) {
super();
proto3.util.initPartial(data, this);
}
static readonly runtime: typeof proto3 = proto3;
static readonly typeName = "holos.user.v1alpha1.RegisterUserRequest";
static readonly fields: FieldList = proto3.util.newFieldList(() => [
{ no: 1, name: "user", kind: "message", T: User, opt: true },
{ no: 2, name: "user_mask", kind: "message", T: FieldMask, opt: true },
{ no: 3, name: "organization", kind: "message", T: Organization, opt: true },
{ no: 4, name: "organization_mask", kind: "message", T: FieldMask, opt: true },
]);
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): RegisterUserRequest {
return new RegisterUserRequest().fromBinary(bytes, options);
}
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): RegisterUserRequest {
return new RegisterUserRequest().fromJson(jsonValue, options);
}
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): RegisterUserRequest {
return new RegisterUserRequest().fromJsonString(jsonString, options);
}
static equals(a: RegisterUserRequest | PlainMessage<RegisterUserRequest> | undefined, b: RegisterUserRequest | PlainMessage<RegisterUserRequest> | undefined): boolean {
return proto3.util.equals(RegisterUserRequest, a, b);
}
}
/**
* @generated from message holos.user.v1alpha1.RegisterUserResponse
*/
export class RegisterUserResponse extends Message<RegisterUserResponse> {
/**
* @generated from field: holos.user.v1alpha1.User user = 1;
*/
user?: User;
/**
* @generated from field: holos.organization.v1alpha1.Organization organization = 2;
*/
organization?: Organization;
constructor(data?: PartialMessage<RegisterUserResponse>) {
super();
proto3.util.initPartial(data, this);
}
static readonly runtime: typeof proto3 = proto3;
static readonly typeName = "holos.user.v1alpha1.RegisterUserResponse";
static readonly fields: FieldList = proto3.util.newFieldList(() => [
{ no: 1, name: "user", kind: "message", T: User },
{ no: 2, name: "organization", kind: "message", T: Organization },
]);
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): RegisterUserResponse {
return new RegisterUserResponse().fromBinary(bytes, options);
}
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): RegisterUserResponse {
return new RegisterUserResponse().fromJson(jsonValue, options);
}
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): RegisterUserResponse {
return new RegisterUserResponse().fromJsonString(jsonString, options);
}
static equals(a: RegisterUserResponse | PlainMessage<RegisterUserResponse> | undefined, b: RegisterUserResponse | PlainMessage<RegisterUserResponse> | undefined): boolean {
return proto3.util.equals(RegisterUserResponse, a, b);
}
}

View File

@@ -3,13 +3,16 @@ package generate
import (
"context"
"embed"
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
"github.com/holos-run/holos/internal/client"
"github.com/holos-run/holos/internal/errors"
"github.com/holos-run/holos/internal/server/middleware/logger"
platform "github.com/holos-run/holos/service/gen/holos/platform/v1alpha1"
)
//go:embed all:platforms
@@ -35,13 +38,46 @@ func Platforms() []string {
// GeneratePlatform writes the cue code for a platform to the local working
// directory.
func GeneratePlatform(ctx context.Context, name string) error {
func GeneratePlatform(ctx context.Context, rpc *client.Client, orgID string, name string) error {
log := logger.FromContext(ctx)
// Check for a valid platform
platformPath := filepath.Join(root, name)
if !dirExists(platforms, platformPath) {
return errors.Wrap(fmt.Errorf("cannot generate: have: [%s] want: %+v", name, Platforms()))
}
// Link the local platform the SaaS platform ID.
rpcPlatforms, err := rpc.Platforms(ctx, orgID)
if err != nil {
return errors.Wrap(err)
}
var rpcPlatform *platform.Platform
for _, p := range rpcPlatforms {
if p.GetName() == name {
rpcPlatform = p
break
}
}
if rpcPlatform == nil {
return errors.Wrap(errors.New("cannot generate: platform not found in the holos server"))
}
// Write the platform data.
data, err := json.MarshalIndent(rpcPlatform, "", " ")
if err != nil {
return errors.Wrap(err)
}
if len(data) > 0 {
data = append(data, '\n')
}
log = log.With("platform_id", rpcPlatform.GetId())
path := "platform.metadata.json"
if err := os.WriteFile(path, data, 0644); err != nil {
return errors.Wrap(fmt.Errorf("could not write platform metadata: %w", err))
}
log.InfoContext(ctx, "wrote "+path, "path", filepath.Join(getCwd(ctx), path))
// Copy the cue.mod directory
if err := copyEmbedFS(ctx, platforms, filepath.Join(root, "cue.mod"), "cue.mod"); err != nil {
return errors.Wrap(err)
@@ -52,6 +88,8 @@ func GeneratePlatform(ctx context.Context, name string) error {
return errors.Wrap(err)
}
log.InfoContext(ctx, "generated platform "+name, "path", getCwd(ctx))
return nil
}
@@ -95,3 +133,17 @@ func copyEmbedFS(ctx context.Context, srcFS embed.FS, srcPath, dstPath string) e
return nil
})
}
func getCwd(ctx context.Context) string {
cwd, err := os.Getwd()
if err != nil {
logger.FromContext(ctx).WarnContext(ctx, "could not get working directory", "err", err)
return "."
}
abs, err := filepath.Abs(cwd)
if err != nil {
logger.FromContext(ctx).WarnContext(ctx, "could not get absolute path", "err", err)
return cwd
}
return abs
}

View File

@@ -1,6 +1,6 @@
package forms
import v1 "github.com/holos-run/holos/v1alpha1"
import v1 "github.com/holos-run/holos/api/v1alpha1"
// Provides a concrete v1.#Form
FormBuilder.Output
@@ -24,22 +24,12 @@ let FormBuilder = v1.#FormBuilder & {
required: true
}
validation: messages: {
pattern: "It must be 3 to 30 lowercase letters, digits, or hyphens. It must start with a letter. Trailing hyphens are prohibited."
pattern: "It must be \(props.minLength) to \(props.maxLength) lowercase letters, digits, or hyphens. It must start with a letter. Trailing hyphens are prohibited."
minLength: "Must be at least \(props.minLength) characters"
maxLength: "Must be at most \(props.maxLength) characters"
}
}
// platform.spec.config.user.sections.org.fields.domain
domain: {
type: "input"
props: {
label: "Domain"
placeholder: "example.com"
minLength: 3
maxLength: 100
description: "DNS domain, e.g. 'example.com'"
required: true
}
}
// platform.spec.config.user.sections.org.fields.displayName
displayName: {
type: "input"
@@ -51,16 +41,6 @@ let FormBuilder = v1.#FormBuilder & {
required: true
}
}
// platform.spec.config.user.sections.org.fields.contactEmail
contactEmail: {
type: "input"
props: {
label: "Contact Email"
placeholder: "platform-team@example.com"
description: "Technical contact email address"
required: true
}
}
}
}
@@ -160,7 +140,9 @@ let FormBuilder = v1.#FormBuilder & {
required: true
}
validation: messages: {
pattern: "It must be 3 to 30 lowercase letters, digits, or hyphens. It must start with a letter. Trailing hyphens are prohibited."
pattern: "It must be \(props.minLength) to \(props.maxLength) lowercase letters, digits, or hyphens. It must start with a letter. Trailing hyphens are prohibited."
minLength: "Must be at least \(props.minLength) characters."
maxLength: "Must be at most \(props.maxLength) characters."
}
}
@@ -252,32 +234,6 @@ let FormBuilder = v1.#FormBuilder & {
}
}
}
Sections: backups: {
displayName: "Backups"
description: "Configure platform level data backup settings. Requires AWS."
fieldConfigs: {
s3bucket: {
// https://formly.dev/docs/api/ui/material/input
type: "select"
props: {
label: "S3 Bucket Region"
description: "Select the S3 Bucket Region."
multiple: true
options: AWSRegions
}
expressions: {
// Disable the control if AWS is not selected.
"props.disabled": "!" + AWSSelected
// Required if AWS is selected.
"props.required": AWSSelected
// Change the label depending on AWS
"props.description": AWSSelected + " ? '\(props.description)' : 'Enable AWS in the Cloud Provider section to configure backups.'"
}
}
}
}
}
let GCPRegions = [

View File

@@ -1 +1 @@
module: "github.com/holos-run/holos/internal/platforms/bare"
module: "user.holos.run/platform"

View File

@@ -0,0 +1,4 @@
package holos
// #ClusterName is the --cluster-name flag value provided by the holos cli.
#ClusterName: string @tag(cluster, type=string)

View File

@@ -0,0 +1,25 @@
package holos
import "encoding/yaml"
import v1 "github.com/holos-run/holos/api/v1alpha1"
let PLATFORM = {message: "TODO: Load the platform from the API."}
// Provide a BuildPlan to the holos cli to render k8s api objects.
v1.#BuildPlan & {
spec: components: resources: platformConfigmap: {
metadata: name: "platform-configmap"
apiObjectMap: OBJECTS.apiObjectMap
}
}
// OBJECTS represents the kubernetes api objects to manage.
let OBJECTS = v1.#APIObjects & {
apiObjects: ConfigMap: platform: {
metadata: {
name: "platform"
namespace: "default"
}
data: platform: yaml.Marshal(PLATFORM)
}
}

View File

@@ -0,0 +1,358 @@
package forms
import v1 "github.com/holos-run/holos/v1alpha1"
// Provides a concrete v1.#Form
FormBuilder.Output
let FormBuilder = v1.#FormBuilder & {
Sections: org: {
displayName: "Organization"
description: "Organization config values are used to derive more specific configuration values throughout the platform."
fieldConfigs: {
// platform.spec.config.user.sections.org.fields.name
name: {
type: "input"
props: {
label: "Name"
// placeholder: "example" placeholder cannot be used with validation?
description: "DNS label, e.g. 'example'"
pattern: "^[a-z]([0-9a-z]|-){1,28}[0-9a-z]$"
minLength: 3
maxLength: 30
required: true
}
validation: messages: {
pattern: "It must be 3 to 30 lowercase letters, digits, or hyphens. It must start with a letter. Trailing hyphens are prohibited."
}
}
// platform.spec.config.user.sections.org.fields.domain
domain: {
type: "input"
props: {
label: "Domain"
placeholder: "example.com"
minLength: 3
maxLength: 100
description: "DNS domain, e.g. 'example.com'"
required: true
}
}
// platform.spec.config.user.sections.org.fields.displayName
displayName: {
type: "input"
props: {
label: "Display Name"
placeholder: "Example Organization"
description: "Display name, e.g. 'Example Organization'"
maxLength: 100
required: true
}
}
// platform.spec.config.user.sections.org.fields.contactEmail
contactEmail: {
type: "input"
props: {
label: "Contact Email"
placeholder: "platform-team@example.com"
description: "Technical contact email address"
required: true
}
}
}
}
Sections: cloud: {
displayName: "Cloud Providers"
description: "Select the services that provide resources for the platform."
fieldConfigs: {
providers: {
// https://formly.dev/docs/api/ui/material/select/
type: "select"
props: {
label: "Select Providers"
description: "Select the cloud providers the platform builds upon."
multiple: true
selectAllOption: "Select All"
options: [
{value: "aws", label: "Amazon Web Services"},
{value: "gcp", label: "Google Cloud Platform"},
{value: "azure", label: "Microsoft Azure"},
{value: "cloudflare", label: "Cloudflare"},
{value: "github", label: "GitHub"},
{value: "ois", label: "Open Infrastructure Services"},
{value: "onprem", label: "On Premises", disabled: true},
]
}
}
}
}
Sections: aws: {
displayName: "Amazon Web Services"
description: "Provide the information necessary for Holos to manage AWS resources to provide the platform."
expressions: hide: "!\(AWSSelected)"
fieldConfigs: {
primaryRoleARN: {
// https://formly.dev/docs/api/ui/material/input
type: "input"
props: {
label: "Holos Admin Role ARN"
description: "Enter the AWS Role ARN Holos will use to bootstrap resources. For example, arn:aws:iam::123456789012:role/HolosAdminAccess"
pattern: "^arn:.*"
minLength: 4
required: true
}
validation: messages: {
pattern: "Must be a valid ARN. Refer to https://docs.aws.amazon.com/IAM/latest/UserGuide/reference-arns.html"
}
}
regions: {
// https://formly.dev/docs/api/ui/material/select/
type: "select"
props: {
label: "Select Regions"
description: "Select the AWS regions this platform operates in."
multiple: true
required: true
selectAllOption: "Select All"
options: AWSRegions
}
}
}
}
Sections: gcp: {
displayName: "Google Cloud Platform"
description: "Use this form to configure platform level GCP settings."
expressions: hide: "!\(GCPSelected)"
fieldConfigs: {
regions: {
// https://formly.dev/docs/api/ui/material/select/
type: "select"
props: {
label: "Select Regions"
description: "Select the GCP regions this platform operates in."
multiple: true
selectAllOption: "Select All"
// gcloud compute regions list --format=json | jq '.[] | {value: .name, label: .description}' regions.json | jq -s | cue export --out cue
options: GCPRegions
}
}
gcpProjectID: {
// https://formly.dev/docs/api/ui/material/input
type: "input"
props: {
label: "Project ID"
description: "Enter the project id where the provisioner cluster resides."
pattern: "^[a-z]([0-9a-z]|-){1,28}[0-9a-z]$"
minLength: 6
maxLength: 30
required: true
}
validation: messages: {
pattern: "It must be 3 to 30 lowercase letters, digits, or hyphens. It must start with a letter. Trailing hyphens are prohibited."
}
}
gcpProjectNumber: {
// https://formly.dev/docs/api/ui/material/input
type: "input"
props: {
label: "Project Number"
// note type number here
type: "number"
description: "Enter the project number where the provisioner cluster resides."
pattern: "^[0-9]+$"
required: true
}
validation: messages: {
pattern: "Must be a valid project number."
}
}
provisionerCABundle: {
type: "input"
props: {
label: "Provisioner CA Bundle"
description: "Enter the provisioner cluster ca bundle. kubectl config view --minify --flatten -ojsonpath='{.clusters[0].cluster.certificate-authority-data}'"
pattern: "^[0-9a-zA-Z]+=*$"
required: true
}
validation: messages: {
pattern: "Must be a base64 encoded pem encoded certificate bundle."
}
}
provisionerURL: {
type: "input"
props: {
label: "Provisioner URL"
description: "Enter the URL of the provisioner cluster API endpoint. kubectl config view --minify --flatten -ojsonpath='{.clusters[0].cluster.server}'"
pattern: "^https://.*$"
required: true
}
validation: messages: {
pattern: "Must be a https:// URL."
}
}
}
}
Sections: cloudflare: {
displayName: "Cloudflare"
description: "Cloudflare is primarily used for DNS automation."
expressions: hide: "!" + CloudflareSelected
fieldConfigs: {
email: {
// https://formly.dev/docs/api/ui/material/input
type: "input"
props: {
label: "Account Email"
description: "Enter the Cloudflare email address to manage DNS"
minLength: 3
required: true
}
}
}
}
Sections: github: {
displayName: "GitHub"
description: "GitHub is primarily used to host Git repositories and execute Actions workflows."
expressions: hide: "!\(GitHubSelected)"
fieldConfigs: {
primaryOrg: {
// https://formly.dev/docs/api/ui/material/input
type: "input"
props: {
label: "Organization"
description: "Enter the primary GitHub organization associed with the platform."
pattern: "^(?!-)(?!.*--)([a-zA-Z0-9]|-){1,39}$"
minLength: 1
maxLength: 39
required: true
}
validation: messages: {
pattern: "All characters must be either a hyphen or alphanumeric. Cannot start with a hyphen. Cannot include consecutive hyphens."
}
}
}
}
Sections: backups: {
displayName: "Backups"
description: "Configure platform level data backup settings. Requires AWS."
fieldConfigs: {
s3bucket: {
// https://formly.dev/docs/api/ui/material/input
type: "select"
props: {
label: "S3 Bucket Region"
description: "Select the S3 Bucket Region."
multiple: true
options: AWSRegions
}
expressions: {
// Disable the control if AWS is not selected.
"props.disabled": "!" + AWSSelected
// Required if AWS is selected.
"props.required": AWSSelected
// Change the label depending on AWS
"props.description": AWSSelected + " ? '\(props.description)' : 'Enable AWS in the Cloud Provider section to configure backups.'"
}
}
}
}
}
let GCPRegions = [
{value: "africa-south1", label: "africa-south1"},
{value: "asia-east1", label: "asia-east1"},
{value: "asia-east2", label: "asia-east2"},
{value: "asia-northeast1", label: "asia-northeast1"},
{value: "asia-northeast2", label: "asia-northeast2"},
{value: "asia-northeast3", label: "asia-northeast3"},
{value: "asia-south1", label: "asia-south1"},
{value: "asia-south2", label: "asia-south2"},
{value: "asia-southeast1", label: "asia-southeast1"},
{value: "asia-southeast2", label: "asia-southeast2"},
{value: "australia-southeast1", label: "australia-southeast1"},
{value: "australia-southeast2", label: "australia-southeast2"},
{value: "europe-central2", label: "europe-central2"},
{value: "europe-north1", label: "europe-north1"},
{value: "europe-southwest1", label: "europe-southwest1"},
{value: "europe-west1", label: "europe-west1"},
{value: "europe-west10", label: "europe-west10"},
{value: "europe-west12", label: "europe-west12"},
{value: "europe-west2", label: "europe-west2"},
{value: "europe-west3", label: "europe-west3"},
{value: "europe-west4", label: "europe-west4"},
{value: "europe-west6", label: "europe-west6"},
{value: "europe-west8", label: "europe-west8"},
{value: "europe-west9", label: "europe-west9"},
{value: "me-central1", label: "me-central1"},
{value: "me-central2", label: "me-central2"},
{value: "me-west1", label: "me-west1"},
{value: "northamerica-northeast1", label: "northamerica-northeast1"},
{value: "northamerica-northeast2", label: "northamerica-northeast2"},
{value: "southamerica-east1", label: "southamerica-east1"},
{value: "southamerica-west1", label: "southamerica-west1"},
{value: "us-central1", label: "us-central1"},
{value: "us-east1", label: "us-east1"},
{value: "us-east4", label: "us-east4"},
{value: "us-east5", label: "us-east5"},
{value: "us-south1", label: "us-south1"},
{value: "us-west1", label: "us-west1"},
{value: "us-west2", label: "us-west2"},
{value: "us-west3", label: "us-west3"},
{value: "us-west4", label: "us-west4"},
]
let AWSRegions = [
{value: "us-east-1", label: "N. Virginia (us-east-1)"},
{value: "us-east-2", label: "Ohio (us-east-2)"},
{value: "us-west-1", label: "N. California (us-west-1)"},
{value: "us-west-2", label: "Oregon (us-west-2)"},
{value: "us-gov-west1", label: "US GovCloud West (us-gov-west1)"},
{value: "us-gov-east1", label: "US GovCloud East (us-gov-east1)"},
{value: "ca-central-1", label: "Canada (ca-central-1)"},
{value: "eu-north-1", label: "Stockholm (eu-north-1)"},
{value: "eu-west-1", label: "Ireland (eu-west-1)"},
{value: "eu-west-2", label: "London (eu-west-2)"},
{value: "eu-west-3", label: "Paris (eu-west-3)"},
{value: "eu-central-1", label: "Frankfurt (eu-central-1)"},
{value: "eu-south-1", label: "Milan (eu-south-1)"},
{value: "af-south-1", label: "Cape Town (af-south-1)"},
{value: "ap-northeast-1", label: "Tokyo (ap-northeast-1)"},
{value: "ap-northeast-2", label: "Seoul (ap-northeast-2)"},
{value: "ap-northeast-3", label: "Osaka (ap-northeast-3)"},
{value: "ap-southeast-1", label: "Singapore (ap-southeast-1)"},
{value: "ap-southeast-2", label: "Sydney (ap-southeast-2)"},
{value: "ap-east-1", label: "Hong Kong (ap-east-1)"},
{value: "ap-south-1", label: "Mumbai (ap-south-1)"},
{value: "me-south-1", label: "Bahrain (me-south-1)"},
{value: "sa-east-1", label: "São Paulo (sa-east-1)"},
{value: "cn-north-1", label: "Bejing (cn-north-1)"},
{value: "cn-northwest-1", label: "Ningxia (cn-northwest-1)"},
{value: "ap-southeast-3", label: "Jakarta (ap-southeast-3)"},
]
let AWSSelected = "formState.model.cloud?.providers?.includes(\"aws\")"
let GCPSelected = "formState.model.cloud?.providers?.includes(\"gcp\")"
let GitHubSelected = "formState.model.cloud?.providers?.includes(\"github\")"
let CloudflareSelected = "formState.model.cloud?.providers?.includes(\"cloudflare\")"

View File

@@ -0,0 +1,7 @@
Example Platform
| Folder | Description |
| - | - |
| forms | Contains Platform and Project form and model definitions |
| platform | Contains the Platform resource that defines how to render the configuration for all Platform Components |
| components | Contains BuildPlan resources which define how to render individual Platform Components |

View File

@@ -0,0 +1,81 @@
package holos
import (
"context"
"encoding/json"
"log/slog"
"os"
"path/filepath"
"github.com/holos-run/holos/internal/logger"
"k8s.io/client-go/util/homedir"
)
func NewClientContext(ctx context.Context) *ClientContext {
cc := &ClientContext{}
if cc.Exists() {
if err := cc.Load(ctx); err != nil {
logger.FromContext(ctx).WarnContext(ctx, "could not load client context", "err", err)
return nil
}
}
return cc
}
// ClientContext represents the context the holos api is working in. Used to
// store and recall values from the filesystem.
type ClientContext struct {
// OrgID is the organization id of the current context.
OrgID string `json:"org_id"`
// UserID is the user id of the current context.
UserID string `json:"user_id"`
}
func (cc *ClientContext) Save(ctx context.Context) error {
log := logger.FromContext(ctx)
config := cc.configFile()
data, err := json.MarshalIndent(cc, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(config, data, 0644); err != nil {
return err
}
log.DebugContext(ctx, "saved", "path", config, "bytes", len(data))
return nil
}
func (cc *ClientContext) Load(ctx context.Context) error {
log := logger.FromContext(ctx)
config := cc.configFile()
data, err := os.ReadFile(config)
if err != nil {
return err
}
if err := json.Unmarshal(data, cc); err != nil {
return err
}
log.DebugContext(ctx, "loaded", "path", config, "bytes", len(data))
return nil
}
// Exists returns true if the client context file exists.
func (cc *ClientContext) Exists() bool {
_, err := os.Stat(cc.configFile())
if os.IsNotExist(err) {
return false
}
return err == nil
}
func (cc *ClientContext) configFile() string {
config := "client-context.json"
if home := homedir.HomeDir(); home != "" {
dir := filepath.Join(home, ".holos")
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
slog.Warn("could not mkdir", "path", dir, "err", err)
}
config = filepath.Join(home, ".holos", config)
}
return config
}

8
internal/push/push.go Normal file
View File

@@ -0,0 +1,8 @@
// Package push pushes resources to the holos api server.
package push
import "context"
func PlatformForm(ctx context.Context, path string) error {
return nil
}

View File

@@ -0,0 +1,106 @@
package register
import (
"context"
"connectrpc.com/connect"
"github.com/holos-run/holos/internal/client"
"github.com/holos-run/holos/internal/errors"
"github.com/holos-run/holos/internal/holos"
"github.com/holos-run/holos/internal/server/middleware/logger"
"github.com/holos-run/holos/internal/token"
org "github.com/holos-run/holos/service/gen/holos/organization/v1alpha1"
"github.com/holos-run/holos/service/gen/holos/organization/v1alpha1/organizationconnect"
user "github.com/holos-run/holos/service/gen/holos/user/v1alpha1"
"github.com/holos-run/holos/service/gen/holos/user/v1alpha1/userconnect"
)
// User registers the user with the holos server.
func User(ctx context.Context, cfg *client.Config) error {
log := logger.FromContext(ctx)
client := userconnect.NewUserServiceClient(token.NewClient(cfg.Token()), cfg.Client().Server())
var err error
var u *user.User
var o *org.Organization
cc := &holos.ClientContext{}
u, err = getUser(ctx, client)
if err != nil {
if connect.CodeOf(err) != connect.CodeNotFound {
return errors.Wrap(err)
}
if u, o, err = registerUser(ctx, client); err != nil {
return errors.Wrap(err)
}
// Save the registration context
cc.OrgID = o.GetOrgId()
cc.UserID = u.GetId()
if err := cc.Save(ctx); err != nil {
return errors.Wrap(err)
}
log.InfoContext(ctx, "created user", "email", u.GetEmail(), "id", u.GetId())
}
if cc.Exists() {
if err := cc.Load(ctx); err != nil {
return errors.Wrap(err)
}
}
// Ensure the current user id gets saved.
cc.UserID = u.GetId()
// Ensure an org ID gets saved.
if cc.OrgID == "" {
org, err := getOrg(ctx, cfg)
if err != nil {
return errors.Wrap(err)
}
cc.OrgID = org.GetOrgId()
}
// One last save, we know we have the user id and org id at this point.
if err := cc.Save(ctx); err != nil {
return errors.Wrap(err)
}
log.InfoContext(ctx, "user", "email", u.GetEmail(), "user_id", cc.UserID, "org_id", cc.OrgID)
return nil
}
func getUser(ctx context.Context, client userconnect.UserServiceClient) (*user.User, error) {
req := connect.NewRequest(&user.GetUserRequest{})
resp, err := client.GetUser(ctx, req)
if err != nil {
return nil, errors.Wrap(err)
}
return resp.Msg.GetUser(), nil
}
// getOrg returns the first organization returned from the ListOrganizations rpc
// method.
func getOrg(ctx context.Context, cfg *client.Config) (*org.Organization, error) {
client := organizationconnect.NewOrganizationServiceClient(token.NewClient(cfg.Token()), cfg.Client().Server())
req := connect.NewRequest(&org.ListOrganizationsRequest{})
resp, err := client.ListOrganizations(ctx, req)
if err != nil {
return nil, errors.Wrap(err)
}
orgs := resp.Msg.GetOrganizations()
if len(orgs) == 0 {
return nil, nil
} else {
return orgs[0], nil
}
}
func registerUser(ctx context.Context, client userconnect.UserServiceClient) (*user.User, *org.Organization, error) {
req := connect.NewRequest(&user.RegisterUserRequest{})
resp, err := client.RegisterUser(ctx, req)
if err != nil {
return nil, nil, errors.Wrap(err)
}
return resp.Msg.GetUser(), resp.Msg.GetOrganization(), nil
}

View File

@@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"fmt"
"slices"
"strings"
"connectrpc.com/connect"
@@ -20,7 +21,7 @@ import (
fieldmask_utils "github.com/mennanov/fieldmask-utils"
)
const AdminEmail = "jeff@openinfrastructure.co"
var adminEmails = []string{"jeff@openinfrastructure.co", "jeff@ois.run"}
// NewSystemHandler returns a new SystemService implementation.
func NewSystemHandler(db *ent.Client) *SystemHandler {
@@ -37,8 +38,8 @@ func (h *SystemHandler) checkAdmin(ctx context.Context) error {
if err != nil {
return connect.NewError(connect.CodePermissionDenied, errors.Wrap(err))
}
if authnID.Email() != AdminEmail {
err := fmt.Errorf("not an admin:\n\thave (%+v)\n\twant (%+v)", authnID.Email(), AdminEmail)
if !slices.Contains(adminEmails, authnID.Email()) {
err := fmt.Errorf("not an admin:\n\thave (%+v)\n\twant (%+v)", authnID.Email(), strings.Join(adminEmails, ","))
return connect.NewError(connect.CodePermissionDenied, errors.Wrap(err))
}
return nil

View File

@@ -1,18 +1,26 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"connectrpc.com/connect"
"github.com/gofrs/uuid"
"github.com/holos-run/holos/internal/ent"
"github.com/holos-run/holos/internal/ent/user"
"github.com/holos-run/holos/internal/errors"
"github.com/holos-run/holos/internal/logger"
"github.com/holos-run/holos/internal/server/middleware/authn"
"github.com/holos-run/holos/internal/strings"
object "github.com/holos-run/holos/service/gen/holos/object/v1alpha1"
org "github.com/holos-run/holos/service/gen/holos/organization/v1alpha1"
storage "github.com/holos-run/holos/service/gen/holos/storage/v1alpha1"
holos "github.com/holos-run/holos/service/gen/holos/user/v1alpha1"
fieldmask_utils "github.com/mennanov/fieldmask-utils"
"google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/protobuf/types/known/timestamppb"
)
@@ -50,8 +58,12 @@ func (h *UserHandler) GetUser(ctx context.Context, req *connect.Request[holos.Ge
}
func (h *UserHandler) CreateUser(ctx context.Context, req *connect.Request[holos.CreateUserRequest]) (*connect.Response[holos.CreateUserResponse], error) {
_, err := authn.FromContext(ctx)
if err != nil {
return nil, connect.NewError(connect.CodePermissionDenied, errors.Wrap(err))
}
var createdUser *ent.User
var err error
if rpcUser := req.Msg.GetUser(); rpcUser != nil {
createdUser, err = h.createUser(ctx, h.db, rpcUser)
} else {
@@ -67,6 +79,145 @@ func (h *UserHandler) CreateUser(ctx context.Context, req *connect.Request[holos
return res, nil
}
func (h *UserHandler) RegisterUser(ctx context.Context, req *connect.Request[holos.RegisterUserRequest]) (*connect.Response[holos.RegisterUserResponse], error) {
authnID, err := authn.FromContext(ctx)
if err != nil {
return nil, connect.NewError(connect.CodePermissionDenied, errors.Wrap(err))
}
var dbUser *ent.User
var dbOrg *ent.Organization
var rpcUser holos.User
var rpcOrg org.Organization
userMask, err := fieldmask_utils.MaskFromProtoFieldMask(req.Msg.GetUserMask(), strings.PascalCase)
if err != nil {
return nil, connect.NewError(connect.CodeInvalidArgument, errors.Wrap(err))
}
orgMask, err := fieldmask_utils.MaskFromProtoFieldMask(req.Msg.GetOrganizationMask(), strings.PascalCase)
if err != nil {
return nil, connect.NewError(connect.CodeInvalidArgument, errors.Wrap(err))
}
// Server assigns IDs.
userID, err := uuid.NewV7()
if err != nil {
return nil, errors.Wrap(connect.NewError(connect.CodeInternal, err))
}
orgID, err := uuid.NewV7()
if err != nil {
return nil, errors.Wrap(connect.NewError(connect.CodeInternal, err))
}
bareID, err := uuid.NewV7()
if err != nil {
return nil, errors.Wrap(connect.NewError(connect.CodeInternal, err))
}
refID, err := uuid.NewV7()
if err != nil {
return nil, errors.Wrap(connect.NewError(connect.CodeInternal, err))
}
// Perform registration in a single transaction.
if err := WithTx(ctx, h.db, func(tx *ent.Tx) (err error) {
// Create the user
dbUser, err = tx.User.Create().
SetID(userID).
SetEmail(authnID.Email()).
SetIss(authnID.Issuer()).
SetSub(authnID.Subject()).
SetName(authnID.Name()).
Save(ctx)
if err != nil {
if ent.IsConstraintError(err) {
rpcErr := connect.NewError(connect.CodeAlreadyExists, errors.New("user already registered"))
errInfo := &errdetails.ErrorInfo{
Reason: "USER_EXISTS",
Domain: "user.holos.run",
Metadata: map[string]string{"email": authnID.Email()},
}
if detail, detailErr := connect.NewErrorDetail(errInfo); detailErr != nil {
logger.FromContext(ctx).ErrorContext(ctx, detailErr.Error(), "err", detailErr)
} else {
rpcErr.AddDetail(detail)
}
return errors.Wrap(rpcErr)
}
return errors.Wrap(err)
}
// Create the org
dbOrg, err = tx.Organization.Create().
SetID(orgID).
SetName(cleanAndAppendRandom(authnID.Name())).
SetDisplayName(authnID.GivenName() + "'s Org").
SetCreatorID(userID).
SetEditorID(userID).
AddUserIDs(userID).
Save(ctx)
if err != nil {
return errors.Wrap(err)
}
// Create the platforms.
decoder := json.NewDecoder(bytes.NewReader([]byte(BareForm)))
decoder.DisallowUnknownFields()
var form storage.Form
if err := decoder.Decode(&form); err != nil {
return errors.Wrap(err)
}
decoder = json.NewDecoder(bytes.NewReader([]byte(Model)))
decoder.DisallowUnknownFields()
var model storage.Model
if err := decoder.Decode(&model); err != nil {
return errors.Wrap(err)
}
// Add the platforms.
err = tx.Platform.Create().
SetID(bareID).
SetName("bare").
SetDisplayName("Bare Platform").
SetForm(&form).
SetModel(&model).
SetCreatorID(userID).
SetEditorID(userID).
SetOrgID(orgID).
Exec(ctx)
if err != nil {
return errors.Wrap(err)
}
err = tx.Platform.Create().
SetID(refID).
SetName("reference").
SetDisplayName("Holos Reference Platform").
SetForm(&form).
SetModel(&model).
SetCreatorID(userID).
SetEditorID(userID).
SetOrgID(orgID).
Exec(ctx)
if err != nil {
return errors.Wrap(err)
}
return nil
}); err != nil {
return nil, errors.Wrap(connect.NewError(connect.CodeFailedPrecondition, err))
}
if err = fieldmask_utils.StructToStruct(userMask, UserToRPC(dbUser), &rpcUser); err != nil {
return nil, connect.NewError(connect.CodeInvalidArgument, errors.Wrap(err))
}
if err = fieldmask_utils.StructToStruct(orgMask, OrganizationToRPC(dbOrg), &rpcOrg); err != nil {
return nil, connect.NewError(connect.CodeInvalidArgument, errors.Wrap(err))
}
msg := holos.RegisterUserResponse{
User: &rpcUser,
Organization: &rpcOrg,
}
return connect.NewResponse(&msg), nil
}
// UserToRPC returns an *holos.User adapted from *ent.User u.
func UserToRPC(entity *ent.User) *holos.User {
uid := entity.ID.String()

View File

@@ -0,0 +1,33 @@
package interceptor
import (
"context"
"time"
"connectrpc.com/connect"
"github.com/holos-run/holos/internal/server/middleware/logger"
)
func NewLogger() connect.UnaryInterceptorFunc {
interceptor := func(next connect.UnaryFunc) connect.UnaryFunc {
return connect.UnaryFunc(func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
start := time.Now()
rpcLogger := logger.FromContext(ctx).With("procedure", req.Spec().Procedure)
ctx = logger.NewContext(ctx, rpcLogger)
resp, err := next(ctx, req)
go emitLog(ctx, start, err)
return resp, err
})
}
return connect.UnaryInterceptorFunc(interceptor)
}
func emitLog(ctx context.Context, start time.Time, err error) {
log := logger.FromContext(ctx)
if err == nil {
log = log.With("ok", true)
} else {
log = log.With("ok", false, "code", connect.CodeOf(err), "err", err)
}
log.InfoContext(ctx, "response", "duration", time.Since(start))
}

View File

@@ -9,12 +9,14 @@ import (
"connectrpc.com/connect"
"connectrpc.com/grpcreflect"
"connectrpc.com/otelconnect"
"connectrpc.com/validate"
"github.com/holos-run/holos/internal/ent"
"github.com/holos-run/holos/internal/errors"
"github.com/holos-run/holos/internal/frontend"
"github.com/holos-run/holos/internal/holos"
"github.com/holos-run/holos/internal/server/handler"
"github.com/holos-run/holos/internal/server/interceptor"
"github.com/holos-run/holos/internal/server/middleware/authn"
"github.com/holos-run/holos/internal/server/middleware/logger"
"github.com/holos-run/holos/service/gen/holos/organization/v1alpha1/organizationconnect"
@@ -113,7 +115,12 @@ func (s *Server) registerConnectRpc() error {
return errors.Wrap(fmt.Errorf("could not initialize proto validation interceptor: %w", err))
}
opts := connect.WithInterceptors(validator)
otel, err := otelconnect.NewInterceptor()
if err != nil {
return errors.Wrap(err)
}
opts := connect.WithInterceptors(interceptor.NewLogger(), otel, validator)
s.handle(userconnect.NewUserServiceHandler(handler.NewUserHandler(s.db), opts))
s.handle(organizationconnect.NewOrganizationServiceHandler(handler.NewOrganizationHandler(s.db), opts))

View File

@@ -8,6 +8,7 @@ package user
import (
v1alpha1 "github.com/holos-run/holos/service/gen/holos/object/v1alpha1"
v1alpha11 "github.com/holos-run/holos/service/gen/holos/organization/v1alpha1"
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
fieldmaskpb "google.golang.org/protobuf/types/known/fieldmaskpb"
@@ -224,6 +225,144 @@ func (x *GetUserResponse) GetUser() *User {
return nil
}
// Register a User from the oidc id token claims or the provided user. Each one
// of subject, email, and user id must be globally unique.
type RegisterUserRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
// User resource to create. If absent, the server populates User fields with
// the oidc id token claims of the authenticated request.
// NOTE: The server may ignore this request field and register the user solely
// from authenticated identity claims.
User *User `protobuf:"bytes,1,opt,name=user,proto3,oneof" json:"user,omitempty"`
// Mask of the user fields to include in the response.
UserMask *fieldmaskpb.FieldMask `protobuf:"bytes,2,opt,name=user_mask,json=userMask,proto3,oneof" json:"user_mask,omitempty"`
// Organization resource to create. If absent, the server generates an
// organization based on the user fields.
// NOTE: The server may ignore this request field and register the
// organization solely from authenticated identity claims.
Organization *v1alpha11.Organization `protobuf:"bytes,3,opt,name=organization,proto3,oneof" json:"organization,omitempty"`
// Mask of the organization fields to include in the response.
OrganizationMask *fieldmaskpb.FieldMask `protobuf:"bytes,4,opt,name=organization_mask,json=organizationMask,proto3,oneof" json:"organization_mask,omitempty"`
}
func (x *RegisterUserRequest) Reset() {
*x = RegisterUserRequest{}
if protoimpl.UnsafeEnabled {
mi := &file_holos_user_v1alpha1_user_service_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *RegisterUserRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*RegisterUserRequest) ProtoMessage() {}
func (x *RegisterUserRequest) ProtoReflect() protoreflect.Message {
mi := &file_holos_user_v1alpha1_user_service_proto_msgTypes[4]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use RegisterUserRequest.ProtoReflect.Descriptor instead.
func (*RegisterUserRequest) Descriptor() ([]byte, []int) {
return file_holos_user_v1alpha1_user_service_proto_rawDescGZIP(), []int{4}
}
func (x *RegisterUserRequest) GetUser() *User {
if x != nil {
return x.User
}
return nil
}
func (x *RegisterUserRequest) GetUserMask() *fieldmaskpb.FieldMask {
if x != nil {
return x.UserMask
}
return nil
}
func (x *RegisterUserRequest) GetOrganization() *v1alpha11.Organization {
if x != nil {
return x.Organization
}
return nil
}
func (x *RegisterUserRequest) GetOrganizationMask() *fieldmaskpb.FieldMask {
if x != nil {
return x.OrganizationMask
}
return nil
}
type RegisterUserResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
User *User `protobuf:"bytes,1,opt,name=user,proto3" json:"user,omitempty"`
Organization *v1alpha11.Organization `protobuf:"bytes,2,opt,name=organization,proto3" json:"organization,omitempty"`
}
func (x *RegisterUserResponse) Reset() {
*x = RegisterUserResponse{}
if protoimpl.UnsafeEnabled {
mi := &file_holos_user_v1alpha1_user_service_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *RegisterUserResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*RegisterUserResponse) ProtoMessage() {}
func (x *RegisterUserResponse) ProtoReflect() protoreflect.Message {
mi := &file_holos_user_v1alpha1_user_service_proto_msgTypes[5]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use RegisterUserResponse.ProtoReflect.Descriptor instead.
func (*RegisterUserResponse) Descriptor() ([]byte, []int) {
return file_holos_user_v1alpha1_user_service_proto_rawDescGZIP(), []int{5}
}
func (x *RegisterUserResponse) GetUser() *User {
if x != nil {
return x.User
}
return nil
}
func (x *RegisterUserResponse) GetOrganization() *v1alpha11.Organization {
if x != nil {
return x.Organization
}
return nil
}
var File_holos_user_v1alpha1_user_service_proto protoreflect.FileDescriptor
var file_holos_user_v1alpha1_user_service_proto_rawDesc = []byte{
@@ -232,7 +371,10 @@ var file_holos_user_v1alpha1_user_service_proto_rawDesc = []byte{
0x63, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x13, 0x68, 0x6f, 0x6c, 0x6f, 0x73, 0x2e,
0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x1a, 0x1e, 0x68,
0x6f, 0x6c, 0x6f, 0x73, 0x2f, 0x75, 0x73, 0x65, 0x72, 0x2f, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68,
0x61, 0x31, 0x2f, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x20, 0x67,
0x61, 0x31, 0x2f, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x2e, 0x68,
0x6f, 0x6c, 0x6f, 0x73, 0x2f, 0x6f, 0x72, 0x67, 0x61, 0x6e, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f,
0x6e, 0x2f, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2f, 0x6f, 0x72, 0x67, 0x61, 0x6e,
0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x20, 0x67,
0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x66,
0x69, 0x65, 0x6c, 0x64, 0x5f, 0x6d, 0x61, 0x73, 0x6b, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a,
0x22, 0x68, 0x6f, 0x6c, 0x6f, 0x73, 0x2f, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2f, 0x76, 0x31,
@@ -260,24 +402,62 @@ var file_holos_user_v1alpha1_user_service_proto_rawDesc = []byte{
0x6e, 0x73, 0x65, 0x12, 0x2d, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28,
0x0b, 0x32, 0x19, 0x2e, 0x68, 0x6f, 0x6c, 0x6f, 0x73, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76,
0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x52, 0x04, 0x75, 0x73,
0x65, 0x72, 0x32, 0xc6, 0x01, 0x0a, 0x0b, 0x55, 0x73, 0x65, 0x72, 0x53, 0x65, 0x72, 0x76, 0x69,
0x63, 0x65, 0x12, 0x5f, 0x0a, 0x0a, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72,
0x12, 0x26, 0x2e, 0x68, 0x6f, 0x6c, 0x6f, 0x73, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31,
0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x55, 0x73, 0x65,
0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x27, 0x2e, 0x68, 0x6f, 0x6c, 0x6f, 0x73,
0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x43,
0x72, 0x65, 0x61, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
0x65, 0x22, 0x00, 0x12, 0x56, 0x0a, 0x07, 0x47, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x12, 0x23,
0x2e, 0x68, 0x6f, 0x6c, 0x6f, 0x73, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x61, 0x6c,
0x70, 0x68, 0x61, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75,
0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x68, 0x6f, 0x6c, 0x6f, 0x73, 0x2e, 0x75, 0x73, 0x65, 0x72,
0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x55, 0x73, 0x65,
0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x42, 0x41, 0x5a, 0x3f, 0x67,
0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x68, 0x6f, 0x6c, 0x6f, 0x73, 0x2d,
0x72, 0x75, 0x6e, 0x2f, 0x68, 0x6f, 0x6c, 0x6f, 0x73, 0x2f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63,
0x65, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x68, 0x6f, 0x6c, 0x6f, 0x73, 0x2f, 0x75, 0x73, 0x65, 0x72,
0x2f, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x3b, 0x75, 0x73, 0x65, 0x72, 0x62, 0x06,
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
0x65, 0x72, 0x22, 0xe7, 0x02, 0x0a, 0x13, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x55,
0x73, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x32, 0x0a, 0x04, 0x75, 0x73,
0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x68, 0x6f, 0x6c, 0x6f, 0x73,
0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x55,
0x73, 0x65, 0x72, 0x48, 0x00, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x88, 0x01, 0x01, 0x12, 0x3c,
0x0a, 0x09, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x6d, 0x61, 0x73, 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28,
0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f,
0x62, 0x75, 0x66, 0x2e, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x4d, 0x61, 0x73, 0x6b, 0x48, 0x01, 0x52,
0x08, 0x75, 0x73, 0x65, 0x72, 0x4d, 0x61, 0x73, 0x6b, 0x88, 0x01, 0x01, 0x12, 0x52, 0x0a, 0x0c,
0x6f, 0x72, 0x67, 0x61, 0x6e, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01,
0x28, 0x0b, 0x32, 0x29, 0x2e, 0x68, 0x6f, 0x6c, 0x6f, 0x73, 0x2e, 0x6f, 0x72, 0x67, 0x61, 0x6e,
0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31,
0x2e, 0x4f, 0x72, 0x67, 0x61, 0x6e, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x48, 0x02, 0x52,
0x0c, 0x6f, 0x72, 0x67, 0x61, 0x6e, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x88, 0x01, 0x01,
0x12, 0x4c, 0x0a, 0x11, 0x6f, 0x72, 0x67, 0x61, 0x6e, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e,
0x5f, 0x6d, 0x61, 0x73, 0x6b, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f,
0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x46, 0x69,
0x65, 0x6c, 0x64, 0x4d, 0x61, 0x73, 0x6b, 0x48, 0x03, 0x52, 0x10, 0x6f, 0x72, 0x67, 0x61, 0x6e,
0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4d, 0x61, 0x73, 0x6b, 0x88, 0x01, 0x01, 0x42, 0x07,
0x0a, 0x05, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x75, 0x73, 0x65, 0x72,
0x5f, 0x6d, 0x61, 0x73, 0x6b, 0x42, 0x0f, 0x0a, 0x0d, 0x5f, 0x6f, 0x72, 0x67, 0x61, 0x6e, 0x69,
0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x42, 0x14, 0x0a, 0x12, 0x5f, 0x6f, 0x72, 0x67, 0x61, 0x6e,
0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x6d, 0x61, 0x73, 0x6b, 0x22, 0x94, 0x01, 0x0a,
0x14, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x73,
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2d, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x01, 0x20,
0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x68, 0x6f, 0x6c, 0x6f, 0x73, 0x2e, 0x75, 0x73, 0x65, 0x72,
0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x52, 0x04,
0x75, 0x73, 0x65, 0x72, 0x12, 0x4d, 0x0a, 0x0c, 0x6f, 0x72, 0x67, 0x61, 0x6e, 0x69, 0x7a, 0x61,
0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x29, 0x2e, 0x68, 0x6f, 0x6c,
0x6f, 0x73, 0x2e, 0x6f, 0x72, 0x67, 0x61, 0x6e, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2e,
0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x4f, 0x72, 0x67, 0x61, 0x6e, 0x69, 0x7a,
0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x0c, 0x6f, 0x72, 0x67, 0x61, 0x6e, 0x69, 0x7a, 0x61, 0x74,
0x69, 0x6f, 0x6e, 0x32, 0xad, 0x02, 0x0a, 0x0b, 0x55, 0x73, 0x65, 0x72, 0x53, 0x65, 0x72, 0x76,
0x69, 0x63, 0x65, 0x12, 0x5f, 0x0a, 0x0a, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x55, 0x73, 0x65,
0x72, 0x12, 0x26, 0x2e, 0x68, 0x6f, 0x6c, 0x6f, 0x73, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76,
0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x55, 0x73,
0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x27, 0x2e, 0x68, 0x6f, 0x6c, 0x6f,
0x73, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e,
0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
0x73, 0x65, 0x22, 0x00, 0x12, 0x56, 0x0a, 0x07, 0x47, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x12,
0x23, 0x2e, 0x68, 0x6f, 0x6c, 0x6f, 0x73, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x61,
0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x71,
0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x68, 0x6f, 0x6c, 0x6f, 0x73, 0x2e, 0x75, 0x73, 0x65,
0x72, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x55, 0x73,
0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x65, 0x0a, 0x0c,
0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x55, 0x73, 0x65, 0x72, 0x12, 0x28, 0x2e, 0x68,
0x6f, 0x6c, 0x6f, 0x73, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68,
0x61, 0x31, 0x2e, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x55, 0x73, 0x65, 0x72, 0x52,
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x29, 0x2e, 0x68, 0x6f, 0x6c, 0x6f, 0x73, 0x2e, 0x75,
0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x52, 0x65, 0x67,
0x69, 0x73, 0x74, 0x65, 0x72, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
0x65, 0x22, 0x00, 0x42, 0x41, 0x5a, 0x3f, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f,
0x6d, 0x2f, 0x68, 0x6f, 0x6c, 0x6f, 0x73, 0x2d, 0x72, 0x75, 0x6e, 0x2f, 0x68, 0x6f, 0x6c, 0x6f,
0x73, 0x2f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x68, 0x6f,
0x6c, 0x6f, 0x73, 0x2f, 0x75, 0x73, 0x65, 0x72, 0x2f, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61,
0x31, 0x3b, 0x75, 0x73, 0x65, 0x72, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}
var (
@@ -292,31 +472,42 @@ func file_holos_user_v1alpha1_user_service_proto_rawDescGZIP() []byte {
return file_holos_user_v1alpha1_user_service_proto_rawDescData
}
var file_holos_user_v1alpha1_user_service_proto_msgTypes = make([]protoimpl.MessageInfo, 4)
var file_holos_user_v1alpha1_user_service_proto_msgTypes = make([]protoimpl.MessageInfo, 6)
var file_holos_user_v1alpha1_user_service_proto_goTypes = []interface{}{
(*CreateUserRequest)(nil), // 0: holos.user.v1alpha1.CreateUserRequest
(*CreateUserResponse)(nil), // 1: holos.user.v1alpha1.CreateUserResponse
(*GetUserRequest)(nil), // 2: holos.user.v1alpha1.GetUserRequest
(*GetUserResponse)(nil), // 3: holos.user.v1alpha1.GetUserResponse
(*User)(nil), // 4: holos.user.v1alpha1.User
(*v1alpha1.UserRef)(nil), // 5: holos.object.v1alpha1.UserRef
(*fieldmaskpb.FieldMask)(nil), // 6: google.protobuf.FieldMask
(*CreateUserRequest)(nil), // 0: holos.user.v1alpha1.CreateUserRequest
(*CreateUserResponse)(nil), // 1: holos.user.v1alpha1.CreateUserResponse
(*GetUserRequest)(nil), // 2: holos.user.v1alpha1.GetUserRequest
(*GetUserResponse)(nil), // 3: holos.user.v1alpha1.GetUserResponse
(*RegisterUserRequest)(nil), // 4: holos.user.v1alpha1.RegisterUserRequest
(*RegisterUserResponse)(nil), // 5: holos.user.v1alpha1.RegisterUserResponse
(*User)(nil), // 6: holos.user.v1alpha1.User
(*v1alpha1.UserRef)(nil), // 7: holos.object.v1alpha1.UserRef
(*fieldmaskpb.FieldMask)(nil), // 8: google.protobuf.FieldMask
(*v1alpha11.Organization)(nil), // 9: holos.organization.v1alpha1.Organization
}
var file_holos_user_v1alpha1_user_service_proto_depIdxs = []int32{
4, // 0: holos.user.v1alpha1.CreateUserRequest.user:type_name -> holos.user.v1alpha1.User
4, // 1: holos.user.v1alpha1.CreateUserResponse.user:type_name -> holos.user.v1alpha1.User
5, // 2: holos.user.v1alpha1.GetUserRequest.user:type_name -> holos.object.v1alpha1.UserRef
6, // 3: holos.user.v1alpha1.GetUserRequest.field_mask:type_name -> google.protobuf.FieldMask
4, // 4: holos.user.v1alpha1.GetUserResponse.user:type_name -> holos.user.v1alpha1.User
0, // 5: holos.user.v1alpha1.UserService.CreateUser:input_type -> holos.user.v1alpha1.CreateUserRequest
2, // 6: holos.user.v1alpha1.UserService.GetUser:input_type -> holos.user.v1alpha1.GetUserRequest
1, // 7: holos.user.v1alpha1.UserService.CreateUser:output_type -> holos.user.v1alpha1.CreateUserResponse
3, // 8: holos.user.v1alpha1.UserService.GetUser:output_type -> holos.user.v1alpha1.GetUserResponse
7, // [7:9] is the sub-list for method output_type
5, // [5:7] is the sub-list for method input_type
5, // [5:5] is the sub-list for extension type_name
5, // [5:5] is the sub-list for extension extendee
0, // [0:5] is the sub-list for field type_name
6, // 0: holos.user.v1alpha1.CreateUserRequest.user:type_name -> holos.user.v1alpha1.User
6, // 1: holos.user.v1alpha1.CreateUserResponse.user:type_name -> holos.user.v1alpha1.User
7, // 2: holos.user.v1alpha1.GetUserRequest.user:type_name -> holos.object.v1alpha1.UserRef
8, // 3: holos.user.v1alpha1.GetUserRequest.field_mask:type_name -> google.protobuf.FieldMask
6, // 4: holos.user.v1alpha1.GetUserResponse.user:type_name -> holos.user.v1alpha1.User
6, // 5: holos.user.v1alpha1.RegisterUserRequest.user:type_name -> holos.user.v1alpha1.User
8, // 6: holos.user.v1alpha1.RegisterUserRequest.user_mask:type_name -> google.protobuf.FieldMask
9, // 7: holos.user.v1alpha1.RegisterUserRequest.organization:type_name -> holos.organization.v1alpha1.Organization
8, // 8: holos.user.v1alpha1.RegisterUserRequest.organization_mask:type_name -> google.protobuf.FieldMask
6, // 9: holos.user.v1alpha1.RegisterUserResponse.user:type_name -> holos.user.v1alpha1.User
9, // 10: holos.user.v1alpha1.RegisterUserResponse.organization:type_name -> holos.organization.v1alpha1.Organization
0, // 11: holos.user.v1alpha1.UserService.CreateUser:input_type -> holos.user.v1alpha1.CreateUserRequest
2, // 12: holos.user.v1alpha1.UserService.GetUser:input_type -> holos.user.v1alpha1.GetUserRequest
4, // 13: holos.user.v1alpha1.UserService.RegisterUser:input_type -> holos.user.v1alpha1.RegisterUserRequest
1, // 14: holos.user.v1alpha1.UserService.CreateUser:output_type -> holos.user.v1alpha1.CreateUserResponse
3, // 15: holos.user.v1alpha1.UserService.GetUser:output_type -> holos.user.v1alpha1.GetUserResponse
5, // 16: holos.user.v1alpha1.UserService.RegisterUser:output_type -> holos.user.v1alpha1.RegisterUserResponse
14, // [14:17] is the sub-list for method output_type
11, // [11:14] is the sub-list for method input_type
11, // [11:11] is the sub-list for extension type_name
11, // [11:11] is the sub-list for extension extendee
0, // [0:11] is the sub-list for field type_name
}
func init() { file_holos_user_v1alpha1_user_service_proto_init() }
@@ -374,16 +565,41 @@ func file_holos_user_v1alpha1_user_service_proto_init() {
return nil
}
}
file_holos_user_v1alpha1_user_service_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*RegisterUserRequest); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_holos_user_v1alpha1_user_service_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*RegisterUserResponse); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
}
file_holos_user_v1alpha1_user_service_proto_msgTypes[0].OneofWrappers = []interface{}{}
file_holos_user_v1alpha1_user_service_proto_msgTypes[2].OneofWrappers = []interface{}{}
file_holos_user_v1alpha1_user_service_proto_msgTypes[4].OneofWrappers = []interface{}{}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: file_holos_user_v1alpha1_user_service_proto_rawDesc,
NumEnums: 0,
NumMessages: 4,
NumMessages: 6,
NumExtensions: 0,
NumServices: 1,
},

View File

@@ -37,13 +37,17 @@ const (
UserServiceCreateUserProcedure = "/holos.user.v1alpha1.UserService/CreateUser"
// UserServiceGetUserProcedure is the fully-qualified name of the UserService's GetUser RPC.
UserServiceGetUserProcedure = "/holos.user.v1alpha1.UserService/GetUser"
// UserServiceRegisterUserProcedure is the fully-qualified name of the UserService's RegisterUser
// RPC.
UserServiceRegisterUserProcedure = "/holos.user.v1alpha1.UserService/RegisterUser"
)
// These variables are the protoreflect.Descriptor objects for the RPCs defined in this package.
var (
userServiceServiceDescriptor = v1alpha1.File_holos_user_v1alpha1_user_service_proto.Services().ByName("UserService")
userServiceCreateUserMethodDescriptor = userServiceServiceDescriptor.Methods().ByName("CreateUser")
userServiceGetUserMethodDescriptor = userServiceServiceDescriptor.Methods().ByName("GetUser")
userServiceServiceDescriptor = v1alpha1.File_holos_user_v1alpha1_user_service_proto.Services().ByName("UserService")
userServiceCreateUserMethodDescriptor = userServiceServiceDescriptor.Methods().ByName("CreateUser")
userServiceGetUserMethodDescriptor = userServiceServiceDescriptor.Methods().ByName("GetUser")
userServiceRegisterUserMethodDescriptor = userServiceServiceDescriptor.Methods().ByName("RegisterUser")
)
// UserServiceClient is a client for the holos.user.v1alpha1.UserService service.
@@ -52,6 +56,8 @@ type UserServiceClient interface {
CreateUser(context.Context, *connect.Request[v1alpha1.CreateUserRequest]) (*connect.Response[v1alpha1.CreateUserResponse], error)
// Get an existing user by id, email, or subject.
GetUser(context.Context, *connect.Request[v1alpha1.GetUserRequest]) (*connect.Response[v1alpha1.GetUserResponse], error)
// Register an user and initialize an organization, bare platform, and reference platform.
RegisterUser(context.Context, *connect.Request[v1alpha1.RegisterUserRequest]) (*connect.Response[v1alpha1.RegisterUserResponse], error)
}
// NewUserServiceClient constructs a client for the holos.user.v1alpha1.UserService service. By
@@ -76,13 +82,20 @@ func NewUserServiceClient(httpClient connect.HTTPClient, baseURL string, opts ..
connect.WithSchema(userServiceGetUserMethodDescriptor),
connect.WithClientOptions(opts...),
),
registerUser: connect.NewClient[v1alpha1.RegisterUserRequest, v1alpha1.RegisterUserResponse](
httpClient,
baseURL+UserServiceRegisterUserProcedure,
connect.WithSchema(userServiceRegisterUserMethodDescriptor),
connect.WithClientOptions(opts...),
),
}
}
// userServiceClient implements UserServiceClient.
type userServiceClient struct {
createUser *connect.Client[v1alpha1.CreateUserRequest, v1alpha1.CreateUserResponse]
getUser *connect.Client[v1alpha1.GetUserRequest, v1alpha1.GetUserResponse]
createUser *connect.Client[v1alpha1.CreateUserRequest, v1alpha1.CreateUserResponse]
getUser *connect.Client[v1alpha1.GetUserRequest, v1alpha1.GetUserResponse]
registerUser *connect.Client[v1alpha1.RegisterUserRequest, v1alpha1.RegisterUserResponse]
}
// CreateUser calls holos.user.v1alpha1.UserService.CreateUser.
@@ -95,12 +108,19 @@ func (c *userServiceClient) GetUser(ctx context.Context, req *connect.Request[v1
return c.getUser.CallUnary(ctx, req)
}
// RegisterUser calls holos.user.v1alpha1.UserService.RegisterUser.
func (c *userServiceClient) RegisterUser(ctx context.Context, req *connect.Request[v1alpha1.RegisterUserRequest]) (*connect.Response[v1alpha1.RegisterUserResponse], error) {
return c.registerUser.CallUnary(ctx, req)
}
// UserServiceHandler is an implementation of the holos.user.v1alpha1.UserService service.
type UserServiceHandler interface {
// Create a new user from authenticated claims or the provided User resource.
CreateUser(context.Context, *connect.Request[v1alpha1.CreateUserRequest]) (*connect.Response[v1alpha1.CreateUserResponse], error)
// Get an existing user by id, email, or subject.
GetUser(context.Context, *connect.Request[v1alpha1.GetUserRequest]) (*connect.Response[v1alpha1.GetUserResponse], error)
// Register an user and initialize an organization, bare platform, and reference platform.
RegisterUser(context.Context, *connect.Request[v1alpha1.RegisterUserRequest]) (*connect.Response[v1alpha1.RegisterUserResponse], error)
}
// NewUserServiceHandler builds an HTTP handler from the service implementation. It returns the path
@@ -121,12 +141,20 @@ func NewUserServiceHandler(svc UserServiceHandler, opts ...connect.HandlerOption
connect.WithSchema(userServiceGetUserMethodDescriptor),
connect.WithHandlerOptions(opts...),
)
userServiceRegisterUserHandler := connect.NewUnaryHandler(
UserServiceRegisterUserProcedure,
svc.RegisterUser,
connect.WithSchema(userServiceRegisterUserMethodDescriptor),
connect.WithHandlerOptions(opts...),
)
return "/holos.user.v1alpha1.UserService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case UserServiceCreateUserProcedure:
userServiceCreateUserHandler.ServeHTTP(w, r)
case UserServiceGetUserProcedure:
userServiceGetUserHandler.ServeHTTP(w, r)
case UserServiceRegisterUserProcedure:
userServiceRegisterUserHandler.ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
@@ -143,3 +171,7 @@ func (UnimplementedUserServiceHandler) CreateUser(context.Context, *connect.Requ
func (UnimplementedUserServiceHandler) GetUser(context.Context, *connect.Request[v1alpha1.GetUserRequest]) (*connect.Response[v1alpha1.GetUserResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("holos.user.v1alpha1.UserService.GetUser is not implemented"))
}
func (UnimplementedUserServiceHandler) RegisterUser(context.Context, *connect.Request[v1alpha1.RegisterUserRequest]) (*connect.Response[v1alpha1.RegisterUserResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("holos.user.v1alpha1.UserService.RegisterUser is not implemented"))
}

View File

@@ -6,6 +6,7 @@ option go_package = "github.com/holos-run/holos/service/gen/holos/user/v1alpha1;
// git clone https://github.com/bufbuild/protovalidate then add <parent>/protovalidate/proto/protovalidate to your editor proto search path
import "holos/user/v1alpha1/user.proto";
import "holos/organization/v1alpha1/organization.proto";
import "google/protobuf/field_mask.proto";
import "holos/object/v1alpha1/object.proto";
@@ -33,10 +34,36 @@ message GetUserResponse {
User user = 1;
}
// Register a User from the oidc id token claims or the provided user. Each one
// of subject, email, and user id must be globally unique.
message RegisterUserRequest {
// User resource to create. If absent, the server populates User fields with
// the oidc id token claims of the authenticated request.
// NOTE: The server may ignore this request field and register the user solely
// from authenticated identity claims.
optional User user = 1;
// Mask of the user fields to include in the response.
optional google.protobuf.FieldMask user_mask = 2;
// Organization resource to create. If absent, the server generates an
// organization based on the user fields.
// NOTE: The server may ignore this request field and register the
// organization solely from authenticated identity claims.
optional holos.organization.v1alpha1.Organization organization = 3;
// Mask of the organization fields to include in the response.
optional google.protobuf.FieldMask organization_mask = 4;
}
message RegisterUserResponse {
User user = 1;
holos.organization.v1alpha1.Organization organization = 2;
}
// UserService provides CRUD methods for User resources in the system.
service UserService {
// Create a new user from authenticated claims or the provided User resource.
rpc CreateUser(CreateUserRequest) returns (CreateUserResponse) {}
// Get an existing user by id, email, or subject.
rpc GetUser(GetUserRequest) returns (GetUserResponse) {}
// Register an user and initialize an organization, bare platform, and reference platform.
rpc RegisterUser(RegisterUserRequest) returns (RegisterUserResponse) {}
}

View File

@@ -1 +1 @@
76
78

View File

@@ -1 +1 @@
1
0