Initial Phoenix Sankofa Cloud setup

- Complete project structure with Next.js frontend
- GraphQL API backend with Apollo Server
- Portal application with NextAuth
- Crossplane Proxmox provider
- GitOps configurations
- CI/CD pipelines
- Testing infrastructure (Vitest, Jest, Go tests)
- Error handling and monitoring
- Security hardening
- UI component library
- Documentation
This commit is contained in:
defiQUG
2025-11-28 12:54:33 -08:00
commit 6f28146ac3
229 changed files with 43136 additions and 0 deletions

12
api/src/context.ts Normal file
View File

@@ -0,0 +1,12 @@
import { FastifyRequest } from 'fastify'
import { Context } from './types/context'
import { getDb } from './db'
export async function createContext(request: FastifyRequest): Promise<Context> {
return {
request,
user: (request as any).user, // Set by auth middleware
db: getDb(),
}
}

32
api/src/db/index.ts Normal file
View File

@@ -0,0 +1,32 @@
import { Pool } from 'pg'
let pool: Pool | null = null
export function getDb(): Pool {
if (!pool) {
pool = new Pool({
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432', 10),
database: process.env.DB_NAME || 'sankofa',
user: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || 'postgres',
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
})
pool.on('error', (err) => {
console.error('Unexpected error on idle client', err)
})
}
return pool
}
export async function closeDb(): Promise<void> {
if (pool) {
await pool.end()
pool = null
}
}

64
api/src/db/schema.sql Normal file
View File

@@ -0,0 +1,64 @@
-- Phoenix Sankofa Cloud Database Schema
-- Enable UUID extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Users table
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
email VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(50) NOT NULL DEFAULT 'USER' CHECK (role IN ('ADMIN', 'USER', 'VIEWER')),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Sites table
CREATE TABLE IF NOT EXISTS sites (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(255) NOT NULL,
region VARCHAR(255) NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE' CHECK (status IN ('ACTIVE', 'INACTIVE', 'MAINTENANCE')),
metadata JSONB,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Resources table
CREATE TABLE IF NOT EXISTS resources (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(255) NOT NULL,
type VARCHAR(50) NOT NULL CHECK (type IN ('VM', 'CONTAINER', 'STORAGE', 'NETWORK')),
status VARCHAR(50) NOT NULL DEFAULT 'PENDING' CHECK (status IN ('PENDING', 'PROVISIONING', 'RUNNING', 'STOPPED', 'ERROR', 'DELETING')),
site_id UUID NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
metadata JSONB,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_resources_site_id ON resources(site_id);
CREATE INDEX IF NOT EXISTS idx_resources_type ON resources(type);
CREATE INDEX IF NOT EXISTS idx_resources_status ON resources(status);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
-- Update timestamp trigger function
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
-- Triggers
CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_sites_updated_at BEFORE UPDATE ON sites
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_resources_updated_at BEFORE UPDATE ON resources
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();

View File

@@ -0,0 +1,39 @@
import { FastifyRequest, FastifyReply } from 'fastify'
import jwt from 'jsonwebtoken'
import { User } from '../types/context'
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production'
export async function authMiddleware(
request: FastifyRequest,
_reply: FastifyReply
) {
// Skip auth for health check and GraphQL introspection
if (request.url === '/health' || request.method === 'OPTIONS') {
return
}
// Get token from Authorization header
const authHeader = request.headers.authorization
if (!authHeader || !authHeader.startsWith('Bearer ')) {
// Allow unauthenticated requests - GraphQL will handle auth per query/mutation
return
}
const token = authHeader.substring(7)
try {
const decoded = jwt.verify(token, JWT_SECRET) as any
// Attach user to request
;(request as any).user = {
id: decoded.id,
email: decoded.email,
name: decoded.name,
role: decoded.role,
} as User
} catch (error) {
// Invalid token - let GraphQL resolvers handle it
return
}
}

9
api/src/schema/index.ts Normal file
View File

@@ -0,0 +1,9 @@
import { makeExecutableSchema } from '@graphql-tools/schema'
import { typeDefs } from './typeDefs'
import { resolvers } from './resolvers'
export const schema = makeExecutableSchema({
typeDefs,
resolvers,
})

181
api/src/schema/resolvers.ts Normal file
View File

@@ -0,0 +1,181 @@
import { GraphQLError } from 'graphql'
import { Context } from '../types/context'
import * as resourceService from '../services/resource'
import * as siteService from '../services/site'
import * as userService from '../services/user'
import * as authService from '../services/auth'
export const resolvers = {
Query: {
health: () => ({
status: 'ok',
timestamp: new Date(),
version: process.env.npm_package_version || '1.0.0',
}),
resources: async (_: unknown, args: { filter?: any }, context: Context) => {
if (!context.user) {
throw new GraphQLError('Authentication required', {
extensions: { code: 'UNAUTHENTICATED' },
})
}
return resourceService.getResources(context, args.filter)
},
resource: async (_: unknown, args: { id: string }, context: Context) => {
if (!context.user) {
throw new GraphQLError('Authentication required', {
extensions: { code: 'UNAUTHENTICATED' },
})
}
return resourceService.getResource(context, args.id)
},
sites: async (_: unknown, __: unknown, context: Context) => {
if (!context.user) {
throw new GraphQLError('Authentication required', {
extensions: { code: 'UNAUTHENTICATED' },
})
}
return siteService.getSites(context)
},
site: async (_: unknown, args: { id: string }, context: Context) => {
if (!context.user) {
throw new GraphQLError('Authentication required', {
extensions: { code: 'UNAUTHENTICATED' },
})
}
return siteService.getSite(context, args.id)
},
me: async (_: unknown, __: unknown, context: Context) => {
if (!context.user) {
throw new GraphQLError('Authentication required', {
extensions: { code: 'UNAUTHENTICATED' },
})
}
return context.user
},
users: async (_: unknown, __: unknown, context: Context) => {
if (!context.user || context.user.role !== 'ADMIN') {
throw new GraphQLError('Admin access required', {
extensions: { code: 'FORBIDDEN' },
})
}
return userService.getUsers(context)
},
user: async (_: unknown, args: { id: string }, context: Context) => {
if (!context.user || context.user.role !== 'ADMIN') {
throw new GraphQLError('Admin access required', {
extensions: { code: 'FORBIDDEN' },
})
}
return userService.getUser(context, args.id)
},
},
Mutation: {
login: async (_: unknown, args: { email: string; password: string }) => {
return authService.login(args.email, args.password)
},
logout: async (_: unknown, __: unknown, _context: Context) => {
// In a real implementation, invalidate the token
return true
},
createResource: async (
_: unknown,
args: { input: any },
context: Context
) => {
if (!context.user) {
throw new GraphQLError('Authentication required', {
extensions: { code: 'UNAUTHENTICATED' },
})
}
return resourceService.createResource(context, args.input)
},
updateResource: async (
_: unknown,
args: { id: string; input: any },
context: Context
) => {
if (!context.user) {
throw new GraphQLError('Authentication required', {
extensions: { code: 'UNAUTHENTICATED' },
})
}
return resourceService.updateResource(context, args.id, args.input)
},
deleteResource: async (
_: unknown,
args: { id: string },
context: Context
) => {
if (!context.user) {
throw new GraphQLError('Authentication required', {
extensions: { code: 'UNAUTHENTICATED' },
})
}
return resourceService.deleteResource(context, args.id)
},
createUser: async (
_: unknown,
args: { input: any },
context: Context
) => {
if (!context.user || context.user.role !== 'ADMIN') {
throw new GraphQLError('Admin access required', {
extensions: { code: 'FORBIDDEN' },
})
}
return userService.createUser(context, args.input)
},
updateUser: async (
_: unknown,
args: { id: string; input: any },
context: Context
) => {
if (!context.user || context.user.role !== 'ADMIN') {
throw new GraphQLError('Admin access required', {
extensions: { code: 'FORBIDDEN' },
})
}
return userService.updateUser(context, args.id, args.input)
},
deleteUser: async (
_: unknown,
args: { id: string },
context: Context
) => {
if (!context.user || context.user.role !== 'ADMIN') {
throw new GraphQLError('Admin access required', {
extensions: { code: 'FORBIDDEN' },
})
}
return userService.deleteUser(context, args.id)
},
},
Resource: {
site: async (resource: any, __: unknown, context: Context) => {
return siteService.getSite(context, resource.siteId)
},
},
Site: {
resources: async (site: any, __: unknown, context: Context) => {
return resourceService.getResources(context, { siteId: site.id })
},
},
}

146
api/src/schema/typeDefs.ts Normal file
View File

@@ -0,0 +1,146 @@
import { gql } from 'graphql-tag'
export const typeDefs = gql`
scalar DateTime
scalar JSON
type Query {
# Health check
health: HealthStatus
# Resources
resources(filter: ResourceFilter): [Resource!]!
resource(id: ID!): Resource
# Sites
sites: [Site!]!
site(id: ID!): Site
# Users
me: User
users: [User!]!
user(id: ID!): User
}
type Mutation {
# Authentication
login(email: String!, password: String!): AuthPayload!
logout: Boolean!
# Resources
createResource(input: CreateResourceInput!): Resource!
updateResource(id: ID!, input: UpdateResourceInput!): Resource!
deleteResource(id: ID!): Boolean!
# Users
createUser(input: CreateUserInput!): User!
updateUser(id: ID!, input: UpdateUserInput!): User!
deleteUser(id: ID!): Boolean!
}
type Subscription {
resourceUpdated(id: ID!): Resource!
resourceCreated: Resource!
resourceDeleted(id: ID!): ID!
}
type HealthStatus {
status: String!
timestamp: DateTime!
version: String!
}
type Resource {
id: ID!
name: String!
type: ResourceType!
status: ResourceStatus!
site: Site!
metadata: JSON
createdAt: DateTime!
updatedAt: DateTime!
}
type Site {
id: ID!
name: String!
region: String!
status: SiteStatus!
resources: [Resource!]!
createdAt: DateTime!
updatedAt: DateTime!
}
type User {
id: ID!
email: String!
name: String!
role: UserRole!
createdAt: DateTime!
updatedAt: DateTime!
}
type AuthPayload {
token: String!
user: User!
}
enum ResourceType {
VM
CONTAINER
STORAGE
NETWORK
}
enum ResourceStatus {
PENDING
PROVISIONING
RUNNING
STOPPED
ERROR
DELETING
}
enum SiteStatus {
ACTIVE
INACTIVE
MAINTENANCE
}
enum UserRole {
ADMIN
USER
VIEWER
}
input ResourceFilter {
type: ResourceType
status: ResourceStatus
siteId: ID
}
input CreateResourceInput {
name: String!
type: ResourceType!
siteId: ID!
metadata: JSON
}
input UpdateResourceInput {
name: String
metadata: JSON
}
input CreateUserInput {
email: String!
name: String!
password: String!
role: UserRole
}
input UpdateUserInput {
name: String
role: UserRole
}
`

52
api/src/server.ts Normal file
View File

@@ -0,0 +1,52 @@
import 'dotenv/config'
import Fastify from 'fastify'
import { ApolloServer } from '@apollo/server'
import { fastifyApolloDrainPlugin, fastifyApolloHandler } from '@as-integrations/fastify'
import { schema } from './schema'
import { createContext } from './context'
import { authMiddleware } from './middleware/auth'
const fastify = Fastify({
logger: true,
})
// Register authentication middleware
fastify.addHook('onRequest', authMiddleware)
// Create Apollo Server
const apolloServer = new ApolloServer({
schema,
plugins: [fastifyApolloDrainPlugin(fastify)],
})
async function startServer() {
try {
// Start Apollo Server
await apolloServer.start()
// Register GraphQL route
fastify.post('/graphql', async (request, reply) => {
return fastifyApolloHandler(apolloServer, {
context: async () => createContext(request),
})(request, reply)
})
// Health check endpoint
fastify.get('/health', async () => {
return { status: 'ok', timestamp: new Date().toISOString() }
})
// Start Fastify server
const port = parseInt(process.env.PORT || '4000', 10)
const host = process.env.HOST || '0.0.0.0'
await fastify.listen({ port, host })
console.log(`🚀 Server ready at http://${host}:${port}/graphql`)
} catch (err) {
fastify.log.error(err)
process.exit(1)
}
}
startServer()

55
api/src/services/auth.ts Normal file
View File

@@ -0,0 +1,55 @@
import jwt from 'jsonwebtoken'
import bcrypt from 'bcryptjs'
import { getDb } from '../db'
import { User } from '../types/context'
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production'
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d'
export interface AuthPayload {
token: string
user: User
}
export async function login(email: string, password: string): Promise<AuthPayload> {
const db = getDb()
const result = await db.query(
'SELECT id, email, name, password_hash, role, created_at, updated_at FROM users WHERE email = $1',
[email]
)
if (result.rows.length === 0) {
throw new Error('Invalid email or password')
}
const user = result.rows[0]
const isValid = await bcrypt.compare(password, user.password_hash)
if (!isValid) {
throw new Error('Invalid email or password')
}
const token = jwt.sign(
{
id: user.id,
email: user.email,
name: user.name,
role: user.role,
},
JWT_SECRET,
{ expiresIn: JWT_EXPIRES_IN }
)
return {
token,
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
createdAt: user.created_at,
updatedAt: user.updated_at,
},
}
}

View File

@@ -0,0 +1,105 @@
import { Context } from '../types/context'
export async function getResources(context: Context, filter?: any) {
const db = context.db
let query = 'SELECT * FROM resources WHERE 1=1'
const params: any[] = []
let paramCount = 1
if (filter?.type) {
query += ` AND type = $${paramCount}`
params.push(filter.type)
paramCount++
}
if (filter?.status) {
query += ` AND status = $${paramCount}`
params.push(filter.status)
paramCount++
}
if (filter?.siteId) {
query += ` AND site_id = $${paramCount}`
params.push(filter.siteId)
paramCount++
}
query += ' ORDER BY created_at DESC'
const result = await db.query(query, params)
return result.rows.map(mapResource)
}
export async function getResource(context: Context, id: string) {
const db = context.db
const result = await db.query('SELECT * FROM resources WHERE id = $1', [id])
if (result.rows.length === 0) {
throw new Error('Resource not found')
}
return mapResource(result.rows[0])
}
export async function createResource(context: Context, input: any) {
const db = context.db
const result = await db.query(
`INSERT INTO resources (name, type, status, site_id, metadata)
VALUES ($1, $2, $3, $4, $5)
RETURNING *`,
[input.name, input.type, 'PENDING', input.siteId, JSON.stringify(input.metadata || {})]
)
return mapResource(result.rows[0])
}
export async function updateResource(context: Context, id: string, input: any) {
const db = context.db
const updates: string[] = []
const params: any[] = []
let paramCount = 1
if (input.name !== undefined) {
updates.push(`name = $${paramCount}`)
params.push(input.name)
paramCount++
}
if (input.metadata !== undefined) {
updates.push(`metadata = $${paramCount}`)
params.push(JSON.stringify(input.metadata))
paramCount++
}
if (updates.length === 0) {
return getResource(context, id)
}
params.push(id)
const result = await db.query(
`UPDATE resources SET ${updates.join(', ')} WHERE id = $${paramCount} RETURNING *`,
params
)
return mapResource(result.rows[0])
}
export async function deleteResource(context: Context, id: string) {
const db = context.db
await db.query('DELETE FROM resources WHERE id = $1', [id])
return true
}
function mapResource(row: any) {
return {
id: row.id,
name: row.name,
type: row.type,
status: row.status,
siteId: row.site_id,
metadata: row.metadata || {},
createdAt: row.created_at,
updatedAt: row.updated_at,
}
}

31
api/src/services/site.ts Normal file
View File

@@ -0,0 +1,31 @@
import { Context } from '../types/context'
export async function getSites(context: Context) {
const db = context.db
const result = await db.query('SELECT * FROM sites ORDER BY created_at DESC')
return result.rows.map(mapSite)
}
export async function getSite(context: Context, id: string) {
const db = context.db
const result = await db.query('SELECT * FROM sites WHERE id = $1', [id])
if (result.rows.length === 0) {
throw new Error('Site not found')
}
return mapSite(result.rows[0])
}
function mapSite(row: any) {
return {
id: row.id,
name: row.name,
region: row.region,
status: row.status,
metadata: row.metadata || {},
createdAt: row.created_at,
updatedAt: row.updated_at,
}
}

88
api/src/services/user.ts Normal file
View File

@@ -0,0 +1,88 @@
import bcrypt from 'bcryptjs'
import { Context } from '../types/context'
export async function getUsers(context: Context) {
const db = context.db
const result = await db.query(
'SELECT id, email, name, role, created_at, updated_at FROM users ORDER BY created_at DESC'
)
return result.rows.map(mapUser)
}
export async function getUser(context: Context, id: string) {
const db = context.db
const result = await db.query(
'SELECT id, email, name, role, created_at, updated_at FROM users WHERE id = $1',
[id]
)
if (result.rows.length === 0) {
throw new Error('User not found')
}
return mapUser(result.rows[0])
}
export async function createUser(context: Context, input: any) {
const db = context.db
const passwordHash = await bcrypt.hash(input.password, 10)
const result = await db.query(
`INSERT INTO users (email, name, password_hash, role)
VALUES ($1, $2, $3, $4)
RETURNING id, email, name, role, created_at, updated_at`,
[input.email, input.name, passwordHash, input.role || 'USER']
)
return mapUser(result.rows[0])
}
export async function updateUser(context: Context, id: string, input: any) {
const db = context.db
const updates: string[] = []
const params: any[] = []
let paramCount = 1
if (input.name !== undefined) {
updates.push(`name = $${paramCount}`)
params.push(input.name)
paramCount++
}
if (input.role !== undefined) {
updates.push(`role = $${paramCount}`)
params.push(input.role)
paramCount++
}
if (updates.length === 0) {
return getUser(context, id)
}
params.push(id)
const result = await db.query(
`UPDATE users SET ${updates.join(', ')} WHERE id = $${paramCount}
RETURNING id, email, name, role, created_at, updated_at`,
params
)
return mapUser(result.rows[0])
}
export async function deleteUser(context: Context, id: string) {
const db = context.db
await db.query('DELETE FROM users WHERE id = $1', [id])
return true
}
function mapUser(row: any) {
return {
id: row.id,
email: row.email,
name: row.name,
role: row.role,
createdAt: row.created_at,
updatedAt: row.updated_at,
}
}

17
api/src/types/context.ts Normal file
View File

@@ -0,0 +1,17 @@
import { FastifyRequest } from 'fastify'
export interface User {
id: string
email: string
name: string
role: 'ADMIN' | 'USER' | 'VIEWER'
createdAt: Date
updatedAt: Date
}
export interface Context {
request: FastifyRequest
user?: User
db: any // Database connection - will be typed properly later
}