diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml new file mode 100644 index 0000000..440cd0b --- /dev/null +++ b/.github/workflows/terraform.yml @@ -0,0 +1,49 @@ +name: Terraform CI/CD + +on: + pull_request: + branches: [ main ] + push: + branches: [ main ] + +permissions: + contents: read + id-token: write # Needed for OIDC auth to AWS + +jobs: + terraform: + name: Terraform Pipeline + runs-on: ubuntu-latest + + env: + AWS_REGION: us-east-2 + TF_VERSION: 1.13.3 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_version: ${{ env.AWS_REGION }} + + - name: Terraform Format Check + run: terraform fmt -check -recursive + + - name: Terraform Init + run: terraform init + + - name: Terraform Validate + run: terraform validate -no-color + + - name: Terraform Plan + if: github.event_name == 'pull_request' + run: terraform plan -no-color -input=false \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8765b74 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +lambda_venv \ No newline at end of file diff --git a/Readme1.md b/Readme1.md new file mode 100644 index 0000000..1084dbe --- /dev/null +++ b/Readme1.md @@ -0,0 +1,87 @@ +# Serverless ETL Pipeline with Terraform + +This project provisions a fully serverless data ingestion and analytics system on AWS using Terraform. +It creates the following components: + +* Amazon S3 – to store uploaded CSV files +* AWS Lambda – triggered by S3 PUT events, aggregates sales data and inserts into PostgreSQL +* Amazon RDS (PostgreSQL) – stores aggregated sales results +* Amazon API Gateway – exposes a REST endpoint to retrieve the aggregated data +* Networking (VPC, Subnets, Security Groups) – ensures resources are deployed in private subnets +* IAM Roles & Policies – provide secure and least-privilege access between services + +## Diagram + +![Diagram](images/image.png) + + +## Folder Structure + +``` +lambda.zip/ # lambda.zip(it can be versioned for future changes) +├── main.tf # Root module to call all submodules +├── provider.tf # AWS provider configuration and region setup +├── lambda_iam_role.tf # Iam role with perssions for lambda +├── modules/ +│ ├── api_gateway/ # Module API Gateway setup and Lambda integration +│ │ ├── main.tf +│ │ └── variables.tf +│ ├── lambda_s3/ # Module Lambda function +│ │ ├── main.tf +│ │ ├── outputs.tf +│ │ └── variables.tf +│ ├── network/ # Module VPC, subnets, route tables, and NAT gateway setup +│ │ ├── main.tf +│ │ ├── outputs.tf +│ │ └── variables.tf +│ ├── rds/ # Module RDS instance and subnet group +│ │ ├── main.tf +│ │ ├── outputs.tf +│ │ └── variables.tf +│ └── security/ # Module Security groups +│ ├── main.tf +│ ├── outputs.tf +│ └── variables.tf +``` + +# Module and folders* Overview +| Module/Folder | Description | +|:-------:| :----------:| +|network | Creates a VPC with public and private subnets across 2 Availability Zones. | +| security | Manages security groups for Lambda and RDS access.| +| lambda_s3 | Builds and deploys the Lambda function from an S3 zip package.| +| rds | Deploys a PostgreSQL RDS instance in private subnets.| +| api_gateway | Configures an API Gateway to expose the Lambda endpoint.| +| lambda* | Configurates the lambda function and its requirements| +| data* | Contains a csv file for testing and sql script to create the table.| + +## Package Lambda Function + +Create virtual environment with the corresponding engine python3.11 for this case +``` +python3.11 -m venv venv +source venv/bin/activate +``` + +Install requirements +``` +cd lambda +pip install requirements.txt -t . +``` + +Create deployment package +``` +zip -r9 ../lambda.zip . +cd .. +``` + +## Testing + +Upload file: +``` +aws s3 cp sales.csv s3://my-private-bucket-terraform-test-nanlab1/ +``` + +Calling API gateway: + +![API](images/api.png) diff --git a/data/sales.csv b/data/sales.csv new file mode 100644 index 0000000..46a43f0 --- /dev/null +++ b/data/sales.csv @@ -0,0 +1,10 @@ +date,product,quantity,price +2025-10-01,ProductA,10,15.5 +2025-10-01,ProductB,5,20.0 +2025-10-01,ProductB,8,30.0 +2025-10-02,ProductA,8,15.5 +2025-10-02,ProductA,8,15.8 +2025-10-02,ProductC,12,30.0 +2025-10-03,ProductB,7,20.0 +2025-10-03,ProductC,10,80.0 +2025-10-03,ProductC,3,30.0 \ No newline at end of file diff --git a/data/sales_table.sql b/data/sales_table.sql new file mode 100644 index 0000000..47bf9a8 --- /dev/null +++ b/data/sales_table.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS public.sales_agg ( + sale_date DATE NOT NULL, + product TEXT NOT NULL, + total_quantity INT NOT NULL, + total_amount NUMERIC(10,2) NOT NULL, + PRIMARY KEY (sale_date, product) +); diff --git a/images/api.png b/images/api.png new file mode 100644 index 0000000..1a0f3bf Binary files /dev/null and b/images/api.png differ diff --git a/images/image.png b/images/image.png new file mode 100644 index 0000000..68cd035 Binary files /dev/null and b/images/image.png differ diff --git a/lambda.zip b/lambda.zip new file mode 100644 index 0000000..565454d Binary files /dev/null and b/lambda.zip differ diff --git a/lambda/lambda_function.py b/lambda/lambda_function.py new file mode 100644 index 0000000..0c0fea3 --- /dev/null +++ b/lambda/lambda_function.py @@ -0,0 +1,99 @@ +import json +import io +import csv +import pg8000.native +import boto3 +import os +from collections import defaultdict + +# Environment variables set in Lambda +DB_HOST = os.environ["DB_HOST"] +DB_NAME = os.environ["DB_NAME"] +DB_USER = os.environ["DB_USER"] +DB_PASSWORD = os.environ["DB_PASSWORD"] +DB_PORT = int(os.environ.get("DB_PORT", 5432)) + +s3_client = boto3.client("s3") + +def handler(event, context): + try: + # Check if this Lambda is invoked via S3 or API Gateway + if "Records" in event: # S3 trigger + for record in event["Records"]: + bucket = record["s3"]["bucket"]["name"] + key = record["s3"]["object"]["key"] + + response = s3_client.get_object(Bucket=bucket, Key=key) + file_content = response["Body"].read().decode("utf-8") + + reader = csv.DictReader(io.StringIO(file_content)) + aggregation = defaultdict(lambda: {"total_quantity": 0, "total_amount": 0.0}) + + print(f"Aggrr {aggregation}") + for row in reader: + sale_date = row["date"] + product = row["product"] + quantity = int(row["quantity"]) + amount = float(row["price"]) + key_agg = (sale_date, product) + aggregation[key_agg]["total_quantity"] += quantity + aggregation[key_agg]["total_amount"] += amount*quantity + + # Connect to Postgres + conn = pg8000.native.Connection( + host=DB_HOST, + database=DB_NAME, + user=DB_USER, + password=DB_PASSWORD, + port=DB_PORT + ) + + # Insert/Update aggregated data + for (sale_date, product), values in aggregation.items(): + conn.run( + """ + INSERT INTO sales_agg (sale_date, product, total_quantity, total_amount) + VALUES (:sale_date, :product, :total_quantity, :total_amount) + ON CONFLICT (sale_date, product) + DO UPDATE SET + total_quantity = sales_agg.total_quantity + EXCLUDED.total_quantity, + total_amount = sales_agg.total_amount + EXCLUDED.total_amount; + """, + sale_date=sale_date, + product=product, + total_quantity=values["total_quantity"], + total_amount=values["total_amount"] + ) + + conn.close() + return {"statusCode": 200, "body": "CSV processed successfully."} + + else: # API Gateway trigger + conn = pg8000.native.Connection( + host=DB_HOST, + database=DB_NAME, + user=DB_USER, + password=DB_PASSWORD, + port=DB_PORT + ) + result = conn.run("SELECT * FROM sales_agg;") + columns = [desc[0] for desc in conn.run("SELECT column_name FROM information_schema.columns WHERE table_name='sales_agg'")] + + # Map tuples to dicts + res = [] + for row in result: + res.append({columns[0]: str(row[0]), + columns[1]: str(row[1]), + columns[2]: str(row[2]), + columns[3]: str(row[3])}) + + conn.close() + + return { + "statusCode": 200, + "headers": {"Content-Type": "application/json"}, + "body": json.dumps(res) + } + + except Exception as e: + return {"statusCode": 500, "body": str(e)} \ No newline at end of file diff --git a/lambda/requirements.txt b/lambda/requirements.txt new file mode 100644 index 0000000..a63f237 --- /dev/null +++ b/lambda/requirements.txt @@ -0,0 +1 @@ +pg8000 \ No newline at end of file diff --git a/lambda_iam_role.tf b/lambda_iam_role.tf new file mode 100644 index 0000000..dc2db68 --- /dev/null +++ b/lambda_iam_role.tf @@ -0,0 +1,52 @@ +# IAM Role for Lambda +resource "aws_iam_role" "lambda_exec" { + name = "lambda_exec_role" + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { Service = "lambda.amazonaws.com" } + }] + }) +} + +resource "aws_iam_role_policy_attachment" "lambda_basic" { + role = aws_iam_role.lambda_exec.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" +} + +resource "aws_iam_role_policy_attachment" "lambda_s3" { + role = aws_iam_role.lambda_exec.name + policy_arn = "arn:aws:iam::aws:policy/AmazonS3FullAccess" +} + +# Inline policy for VPC and RDS permissions +resource "aws_iam_role_policy" "lambda_vpc_access" { + name = "lambda-vpc-access" + role = aws_iam_role.lambda_exec.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "NetworkInterfaceManagement" + Effect = "Allow" + Action = [ + "ec2:CreateNetworkInterface", + "ec2:DescribeNetworkInterfaces", + "ec2:DeleteNetworkInterface" + ] + Resource = "*" + }, + { + Sid = "RDSConnection" + Effect = "Allow" + Action = [ + "rds-db:connect" + ] + Resource = "*" + } + ] + }) +} \ No newline at end of file diff --git a/main.tf b/main.tf new file mode 100644 index 0000000..a9c873b --- /dev/null +++ b/main.tf @@ -0,0 +1,79 @@ +# VPC, Subnets, NAT +module "network" { + source = "./modules/network" + vpc_cidr = "10.0.0.0/16" + public_a_subnet_cidr = "10.0.1.0/24" + public_b_subnet_cidr = "10.0.2.0/24" + private_a_subnet_cidr = "10.0.3.0/24" + private_b_subnet_cidr = "10.0.4.0/24" + az_1 = "us-east-2a" + az_2 = "us-east-2b" + tag_name = "vpc-test" +} + +# Security Groups +module "security" { + source = "./modules/security" + vpc_id = module.network.vpc_id +} + +# s3 +resource "aws_s3_bucket" "my_bucket" { + bucket = "my-private-bucket-terraform-test-nanlab1" +} + +# Lambda +module "lambda_s3" { + source = "./modules/lambda_s3" + lambda_function_name = "s3-to-postgres" + lambda_filename = "lambda.zip" + lambda_handler = "lambda_function.handler" + lambda_runtime = "python3.11" + lambda_iam_role = aws_iam_role.lambda_exec.arn + private_subnet_id = module.network.private_a_subnet_id + lambda_sg_id = module.security.lambda_sg_id + rds_address = module.rds.rds_endpoint + lambda_env_vars = { + DB_HOST = module.rds.rds_endpoint + DB_NAME = "testdb" + DB_USER = "test" + DB_PASSWORD = "test1234" + DB_PORT = "5432" + } +} + +# Allow S3 to invoke the Lambda function +resource "aws_lambda_permission" "allow_s3_invoke" { + statement_id = "AllowExecutionFromS3Bucket" + action = "lambda:InvokeFunction" + function_name = module.lambda_s3.lambda_name + principal = "s3.amazonaws.com" + source_arn = aws_s3_bucket.my_bucket.arn +} + +resource "aws_s3_bucket_notification" "bucket_notify" { + bucket = aws_s3_bucket.my_bucket.id + + lambda_function { + lambda_function_arn = module.lambda_s3.lambda_arn + events = ["s3:ObjectCreated:*"] + } + + depends_on = [module.lambda_s3] +} + +# RDS +module "rds" { + source = "./modules/rds" + private_subnet_ids = [module.network.private_a_subnet_id, module.network.private_b_subnet_id] + rds_sg_id = module.security.rds_sg_id + rds_tag_name = "rds-postgres" +} + + +# API Gateway +module "api_gateway" { + source = "./modules/api_gateway" + lambda_arn = module.lambda_s3.lambda_arn + lambda_name = module.lambda_s3.lambda_name +} \ No newline at end of file diff --git a/modules/.DS_Store b/modules/.DS_Store new file mode 100644 index 0000000..e7b08f9 Binary files /dev/null and b/modules/.DS_Store differ diff --git a/modules/api_gateway/main.tf b/modules/api_gateway/main.tf new file mode 100644 index 0000000..5e255ce --- /dev/null +++ b/modules/api_gateway/main.tf @@ -0,0 +1,31 @@ +resource "aws_apigatewayv2_api" "api" { + name = "rds-api" + protocol_type = "HTTP" +} + +resource "aws_apigatewayv2_integration" "lambda_integration" { + api_id = aws_apigatewayv2_api.api.id + integration_type = "AWS_PROXY" + integration_uri = var.lambda_arn + integration_method = "POST" + payload_format_version = "2.0" +} + +resource "aws_apigatewayv2_route" "get_products" { + api_id = aws_apigatewayv2_api.api.id + route_key = "GET /products" + target = "integrations/${aws_apigatewayv2_integration.lambda_integration.id}" +} + +resource "aws_apigatewayv2_stage" "default" { + api_id = aws_apigatewayv2_api.api.id + name = "$default" + auto_deploy = true +} + +resource "aws_lambda_permission" "apigw_lambda" { + statement_id = "AllowAPIGatewayInvoke" + action = "lambda:InvokeFunction" + function_name = var.lambda_name + principal = "apigateway.amazonaws.com" +} diff --git a/modules/api_gateway/variables.tf b/modules/api_gateway/variables.tf new file mode 100644 index 0000000..90f3e58 --- /dev/null +++ b/modules/api_gateway/variables.tf @@ -0,0 +1,2 @@ +variable "lambda_arn" {} +variable "lambda_name" {} \ No newline at end of file diff --git a/modules/lambda_s3/main.tf b/modules/lambda_s3/main.tf new file mode 100644 index 0000000..72a5b7f --- /dev/null +++ b/modules/lambda_s3/main.tf @@ -0,0 +1,16 @@ +resource "aws_lambda_function" "s3_to_postgres" { + filename = var.lambda_filename + function_name = var.lambda_function_name + handler = var.lambda_handler + runtime = var.lambda_runtime + role = var.lambda_iam_role + + vpc_config { + subnet_ids = [var.private_subnet_id] + security_group_ids = [var.lambda_sg_id] + } + + environment { + variables = var.lambda_env_vars + } +} \ No newline at end of file diff --git a/modules/lambda_s3/outputs.tf b/modules/lambda_s3/outputs.tf new file mode 100644 index 0000000..d7146f3 --- /dev/null +++ b/modules/lambda_s3/outputs.tf @@ -0,0 +1,7 @@ +output "lambda_arn" { + value = aws_lambda_function.s3_to_postgres.arn +} + +output "lambda_name" { + value = aws_lambda_function.s3_to_postgres.function_name +} \ No newline at end of file diff --git a/modules/lambda_s3/variables.tf b/modules/lambda_s3/variables.tf new file mode 100644 index 0000000..698a103 --- /dev/null +++ b/modules/lambda_s3/variables.tf @@ -0,0 +1,9 @@ +variable "private_subnet_id" {} +variable "lambda_sg_id" {} +variable "rds_address" {} +variable "lambda_filename" {default = "lambda.zip"} +variable "lambda_function_name" {default = "s3-to-postgres"} +variable "lambda_handler" {default = "lambda_function.handler"} +variable "lambda_runtime" {default = "python3.11"} +variable "lambda_iam_role" {} +variable "lambda_env_vars" {} \ No newline at end of file diff --git a/modules/network/main.tf b/modules/network/main.tf new file mode 100644 index 0000000..a574b20 --- /dev/null +++ b/modules/network/main.tf @@ -0,0 +1,101 @@ +resource "aws_vpc" "this" { + cidr_block = var.vpc_cidr + tags = { + Name = var.tag_name + } +} + +resource "aws_subnet" "public_a" { + vpc_id = aws_vpc.this.id + cidr_block = var.public_a_subnet_cidr + map_public_ip_on_launch = true + availability_zone = var.az_1 + tags = { + Name = "${var.tag_name}-subnet-public-a" + } +} + +resource "aws_subnet" "public_b" { + vpc_id = aws_vpc.this.id + cidr_block = var.public_b_subnet_cidr + map_public_ip_on_launch = true + availability_zone = var.az_2 + tags = { + Name = "${var.tag_name}-subnet-public-b" + } +} + + +resource "aws_subnet" "private_a" { + vpc_id = aws_vpc.this.id + cidr_block = var.private_a_subnet_cidr + availability_zone = var.az_1 + tags = { + Name = "${var.tag_name}-subnet-private-a" + } +} + +resource "aws_subnet" "private_b" { + vpc_id = aws_vpc.this.id + cidr_block = var.private_b_subnet_cidr + availability_zone = var.az_2 + tags = { + Name = "${var.tag_name}-subnet-private-b" + } +} + + +resource "aws_internet_gateway" "igw" { + vpc_id = aws_vpc.this.id + tags = { + Name = "${var.tag_name}-igw" + } +} + +resource "aws_eip" "nat" { + domain = "vpc" +} + +resource "aws_nat_gateway" "nat" { + allocation_id = aws_eip.nat.id + subnet_id = aws_subnet.public_a.id + tags = { + Name = "${var.tag_name}-nat" + } +} + +resource "aws_route_table" "public" { + vpc_id = aws_vpc.this.id + tags = { + Name = "${var.tag_name}-public-route-table" + } +} + +resource "aws_route" "internet_access" { + route_table_id = aws_route_table.public.id + destination_cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.igw.id +} + +resource "aws_route_table_association" "public_assoc" { + subnet_id = aws_subnet.public_a.id + route_table_id = aws_route_table.public.id +} + +resource "aws_route_table" "private" { + vpc_id = aws_vpc.this.id + tags = { + Name = "${var.tag_name}-private-route-table" + } +} + +resource "aws_route" "private_nat" { + route_table_id = aws_route_table.private.id + destination_cidr_block = "0.0.0.0/0" + nat_gateway_id = aws_nat_gateway.nat.id +} + +resource "aws_route_table_association" "private_assoc" { + subnet_id = aws_subnet.private_a.id + route_table_id = aws_route_table.private.id +} diff --git a/modules/network/outputs.tf b/modules/network/outputs.tf new file mode 100644 index 0000000..f90a31e --- /dev/null +++ b/modules/network/outputs.tf @@ -0,0 +1,19 @@ +output "vpc_id" { + value = aws_vpc.this.id +} + +output "public_a_subnet_id" { + value = aws_subnet.public_a.id +} + +output "private_a_subnet_id" { + value = aws_subnet.private_a.id +} + +output "public_b_subnet_id" { + value = aws_subnet.public_b.id +} + +output "private_b_subnet_id" { + value = aws_subnet.private_b.id +} \ No newline at end of file diff --git a/modules/network/variables.tf b/modules/network/variables.tf new file mode 100644 index 0000000..2c985b9 --- /dev/null +++ b/modules/network/variables.tf @@ -0,0 +1,8 @@ +variable "vpc_cidr" {} +variable "public_a_subnet_cidr" {} +variable "public_b_subnet_cidr" {} +variable "private_a_subnet_cidr" {} +variable "private_b_subnet_cidr" {} +variable "az_1" {} +variable "az_2" {} +variable "tag_name" {} \ No newline at end of file diff --git a/modules/rds/main.tf b/modules/rds/main.tf new file mode 100644 index 0000000..3ae4ff6 --- /dev/null +++ b/modules/rds/main.tf @@ -0,0 +1,23 @@ +resource "aws_db_subnet_group" "this" { + name = "rds-subnet-group" + subnet_ids = var.private_subnet_ids + tags = { + Name = "${var.rds_tag_name}-subnet-group" + } +} + +resource "aws_db_instance" "postgres" { + allocated_storage = 20 + engine = var.rds_engine + engine_version = var.rds_engine_version + instance_class = var.rds_instance_class + db_name = var.rds_db + username = var.rds_db_user + password = var.rds_db_pass + db_subnet_group_name = aws_db_subnet_group.this.name + vpc_security_group_ids = [var.rds_sg_id] + skip_final_snapshot = true + tags = { + Name = "${var.rds_tag_name}" + } +} \ No newline at end of file diff --git a/modules/rds/outputs.tf b/modules/rds/outputs.tf new file mode 100644 index 0000000..9044a6c --- /dev/null +++ b/modules/rds/outputs.tf @@ -0,0 +1,3 @@ +output "rds_endpoint" { + value = aws_db_instance.postgres.address +} \ No newline at end of file diff --git a/modules/rds/variables.tf b/modules/rds/variables.tf new file mode 100644 index 0000000..c752d7b --- /dev/null +++ b/modules/rds/variables.tf @@ -0,0 +1,9 @@ +variable "private_subnet_ids" {} +variable "rds_sg_id" {} +variable "rds_engine" {default = "postgres"} +variable "rds_engine_version" {default = "15.13"} +variable "rds_instance_class" {default = "db.t3.micro"} +variable "rds_db" {default = "testdb"} +variable "rds_db_user" {default = "test"} +variable "rds_db_pass" {default = "test1234"} +variable "rds_tag_name" {default = "rds-postgres"} \ No newline at end of file diff --git a/modules/security/main.tf b/modules/security/main.tf new file mode 100644 index 0000000..f597eda --- /dev/null +++ b/modules/security/main.tf @@ -0,0 +1,34 @@ +# Lambda SG +resource "aws_security_group" "lambda_sg" { + name = "lambda-sg" + vpc_id = var.vpc_id + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } +} + +# RDS SG +resource "aws_security_group" "rds_sg" { + name = "rds-sg" + vpc_id = var.vpc_id + + ingress { + from_port = 5432 + to_port = 5432 + protocol = "tcp" + security_groups = [aws_security_group.lambda_sg.id] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } +} + + diff --git a/modules/security/outputs.tf b/modules/security/outputs.tf new file mode 100644 index 0000000..27a35d9 --- /dev/null +++ b/modules/security/outputs.tf @@ -0,0 +1,7 @@ +output "lambda_sg_id" { + value = aws_security_group.lambda_sg.id +} + +output "rds_sg_id" { + value = aws_security_group.rds_sg.id +} \ No newline at end of file diff --git a/modules/security/variables.tf b/modules/security/variables.tf new file mode 100644 index 0000000..b7bf843 --- /dev/null +++ b/modules/security/variables.tf @@ -0,0 +1 @@ +variable "vpc_id" {} \ No newline at end of file diff --git a/provider.tf b/provider.tf new file mode 100644 index 0000000..e95298a --- /dev/null +++ b/provider.tf @@ -0,0 +1,12 @@ +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } +} + +provider "aws" { + region = "us-east-2" +} \ No newline at end of file