Skip to content

คู่มือแปลง Trantech VM เป็น Docker Container #23905

@sirocco128

Description

@sirocco128

Description

คู่มือแปลง Trantech VM เป็น Docker Container

สารบัญ

  1. ภาพรวมระบบ
  2. เตรียมความพร้อม
  3. Backup ข้อมูล
  4. สร้าง Docker Images
  5. Docker Compose Setup
  6. Testing
  7. Deployment
  8. Monitoring
  9. Rollback Plan

ภาพรวมระบบ

ระบบปัจจุบัน (VM-based)

Server 1 (10.2.1.83) - Admin Portal
├── Node.js API (Port 8081) - /root/trantech-api
├── Angular Frontend (Port 3000) - /root/trantech-web
└── Nginx Reverse Proxy (Port 81)

Server 2 (10.2.1.82) - Customer Portal
├── Node.js API (Port 81) - /root/trantech-customer-api
├── Angular Frontend - /root/trantech-customer-web
└── Nginx Reverse Proxy (Port 80)

Server 3 (10.2.1.177) - Document Server
└── Upload API (Port 80)

Server 4 (10.2.1.100) - Database
└── MySQL (Port 3306)

ระบบใหม่ (Docker-based)

Docker Host
├── trantech-admin-api (Container)
├── trantech-admin-web (Container)
├── trantech-customer-api (Container)
├── trantech-customer-web (Container)
├── trantech-document-api (Container)
├── nginx-proxy (Container)
└── mysql (Container - Optional)

เตรียมความพร้อม

1. ติดตั้ง Docker

# ติดตั้ง Docker Engine
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh

# ติดตั้ง Docker Compose
sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose

# เช็คเวอร์ชัน
docker --version
docker-compose --version

# Start Docker service
sudo systemctl start docker
sudo systemctl enable docker

2. สร้าง Working Directory

# สร้าง directory structure
mkdir -p /opt/trantech-docker/{admin-api,admin-web,customer-api,customer-web,document-api,nginx,mysql}
cd /opt/trantech-docker

Backup ข้อมูล

1. Backup Database

# สร้าง backup directory
mkdir -p /backup/trantech/$(date +%Y%m%d)

# Backup MySQL database
mysqldump -h 10.2.1.100 -u root -p trantech > /backup/trantech/$(date +%Y%m%d)/trantech_db.sql

# Compress backup
gzip /backup/trantech/$(date +%Y%m%d)/trantech_db.sql

2. Backup Application Code

# Admin API & Web
ssh 10.2.1.83 "tar czf /tmp/admin-backup.tar.gz /root/trantech-api /root/trantech-web"
scp 10.2.1.83:/tmp/admin-backup.tar.gz /backup/trantech/$(date +%Y%m%d)/

# Customer API & Web
ssh 10.2.1.82 "tar czf /tmp/customer-backup.tar.gz /root/trantech-customer-api /root/trantech-customer-web"
scp 10.2.1.82:/tmp/customer-backup.tar.gz /backup/trantech/$(date +%Y%m%d)/

# Document API
ssh 10.2.1.177 "tar czf /tmp/document-backup.tar.gz /root/trantech-document-management"
scp 10.2.1.177:/tmp/document-backup.tar.gz /backup/trantech/$(date +%Y%m%d)/

3. Backup Nginx Configs

# Backup nginx configs
ssh 10.2.1.83 "tar czf /tmp/nginx-admin.tar.gz /etc/nginx"
scp 10.2.1.83:/tmp/nginx-admin.tar.gz /backup/trantech/$(date +%Y%m%d)/

ssh 10.2.1.82 "tar czf /tmp/nginx-customer.tar.gz /etc/nginx"
scp 10.2.1.82:/tmp/nginx-customer.tar.gz /backup/trantech/$(date +%Y%m%d)/

4. Backup PM2 Configs

# Backup PM2 ecosystem files
ssh 10.2.1.83 "pm2 save"
scp 10.2.1.83:/root/.pm2/dump.pm2 /backup/trantech/$(date +%Y%m%d)/admin-pm2.json

ssh 10.2.1.82 "pm2 save"
scp 10.2.1.82:/root/.pm2/dump.pm2 /backup/trantech/$(date +%Y%m%d)/customer-pm2.json

สร้าง Docker Images

1. Customer API Dockerfile

cat > /opt/trantech-docker/customer-api/Dockerfile << 'EOF'
FROM node:14-alpine

# Set working directory
WORKDIR /app

# Install dependencies first (for better caching)
COPY package*.json ./
RUN npm install --legacy-peer-deps --production

# Copy source code
COPY . .

# Build TypeScript
RUN npm run build

# Create uploads directory
RUN mkdir -p /app/uploads

# Expose port
EXPOSE 3000

# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s \
  CMD node -e "require('http').get('http://localhost:3000/', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"

# Start application
CMD ["node", "app/app.js"]
EOF

2. Admin API Dockerfile

cat > /opt/trantech-docker/admin-api/Dockerfile << 'EOF'
FROM node:14-alpine

WORKDIR /app

# Copy package files
COPY package*.json ./

# Install dependencies
RUN npm install --production

# Copy source code
COPY . .

# Expose port
EXPOSE 8081

# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s \
  CMD node -e "require('http').get('http://localhost:8081/', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"

# Start application
CMD ["node", "app.js"]
EOF

3. Customer Web Dockerfile

cat > /opt/trantech-docker/customer-web/Dockerfile << 'EOF'
FROM node:14-alpine AS builder

WORKDIR /app

# Copy package files
COPY package*.json ./

# Install dependencies
RUN npm install --legacy-peer-deps

# Copy source code
COPY . .

# Build Angular app
RUN npm run build-prod

# Production stage
FROM nginx:alpine

# Copy built app
COPY --from=builder /app/dist /usr/share/nginx/html

# Copy nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf

# Expose port
EXPOSE 80

# Health check
HEALTHCHECK --interval=30s --timeout=3s \
  CMD wget --quiet --tries=1 --spider http://localhost/ || exit 1

# Start nginx
CMD ["nginx", "-g", "daemon off;"]
EOF

4. Nginx Config สำหรับ Customer Web

cat > /opt/trantech-docker/customer-web/nginx.conf << 'EOF'
server {
    listen 80;
    server_name _;
    
    root /usr/share/nginx/html;
    index index.html;
    
    # Gzip compression
    gzip on;
    gzip_vary on;
    gzip_min_length 1024;
    gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json;
    
    # Angular routes
    location / {
        try_files $uri $uri/ /index.html;
    }
    
    # Cache static assets
    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
}
EOF

5. Document API Dockerfile

cat > /opt/trantech-docker/document-api/Dockerfile << 'EOF'
FROM node:14-alpine

WORKDIR /app

# Copy package files
COPY package*.json ./

# Install dependencies
RUN npm install --production

# Copy source code
COPY . .

# Create uploads directory
RUN mkdir -p /app/uploaded

# Expose port
EXPOSE 80

# Health check
HEALTHCHECK --interval=30s --timeout=3s \
  CMD wget --quiet --tries=1 --spider http://localhost/ || exit 1

# Start application
CMD ["node", "server.js"]
EOF

Docker Compose Setup

1. สร้าง docker-compose.yml

cat > /opt/trantech-docker/docker-compose.yml << 'EOF'
version: '3.8'

services:
  # MySQL Database
  mysql:
    image: mysql:5.7
    container_name: trantech-mysql
    environment:
      MYSQL_ROOT_PASSWORD: your_root_password
      MYSQL_DATABASE: trantech
      MYSQL_USER: trantech_user
      MYSQL_PASSWORD: your_password
    volumes:
      - mysql-data:/var/lib/mysql
      - ./mysql/init.sql:/docker-entrypoint-initdb.d/init.sql
    ports:
      - "3306:3306"
    networks:
      - trantech-network
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      timeout: 5s
      retries: 5

  # Customer API
  customer-api:
    build: ./customer-api
    container_name: trantech-customer-api
    environment:
      - NODE_ENV=production
      - DB_HOST=mysql
      - DB_PORT=3306
      - DB_NAME=trantech
      - DB_USER=trantech_user
      - DB_PASSWORD=your_password
      - JWT_SECRET=your_jwt_secret
      - PORT=3000
    ports:
      - "3001:3000"
    volumes:
      - customer-uploads:/app/uploads
    depends_on:
      mysql:
        condition: service_healthy
    networks:
      - trantech-network
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s

  # Customer Web
  customer-web:
    build: ./customer-web
    container_name: trantech-customer-web
    ports:
      - "3002:80"
    depends_on:
      - customer-api
    networks:
      - trantech-network
    restart: unless-stopped

  # Admin API
  admin-api:
    build: ./admin-api
    container_name: trantech-admin-api
    environment:
      - NODE_ENV=production
      - DB_HOST=mysql
      - DB_PORT=3306
      - DB_NAME=trantech
      - DB_USER=trantech_user
      - DB_PASSWORD=your_password
      - JWT_SECRET=your_jwt_secret
      - PORT=8081
    ports:
      - "8081:8081"
    depends_on:
      mysql:
        condition: service_healthy
    networks:
      - trantech-network
    restart: unless-stopped

  # Admin Web
  admin-web:
    build: ./admin-web
    container_name: trantech-admin-web
    ports:
      - "3003:80"
    depends_on:
      - admin-api
    networks:
      - trantech-network
    restart: unless-stopped

  # Document API
  document-api:
    build: ./document-api
    container_name: trantech-document-api
    ports:
      - "3004:80"
    volumes:
      - document-uploads:/app/uploaded
    networks:
      - trantech-network
    restart: unless-stopped

  # Nginx Reverse Proxy
  nginx:
    image: nginx:alpine
    container_name: trantech-nginx
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - ./nginx/ssl:/etc/nginx/ssl:ro
      - ./nginx/logs:/var/log/nginx
    depends_on:
      - customer-api
      - customer-web
      - admin-api
      - admin-web
      - document-api
    networks:
      - trantech-network
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "nginx", "-t"]
      interval: 30s
      timeout: 10s
      retries: 3

volumes:
  mysql-data:
    driver: local
  customer-uploads:
    driver: local
  document-uploads:
    driver: local

networks:
  trantech-network:
    driver: bridge
    ipam:
      config:
        - subnet: 172.20.0.0/16
EOF

2. สร้าง Nginx Reverse Proxy Config

mkdir -p /opt/trantech-docker/nginx/conf.d

cat > /opt/trantech-docker/nginx/conf.d/trantech.conf << 'EOF'
# Customer Portal
server {
    listen 80;
    server_name customer.trantech.co.th;
    
    # Frontend
    location / {
        proxy_pass http://customer-web:80;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
    
    # API
    location /api/ {
        proxy_pass http://customer-api:3000/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        
        # CORS
        add_header 'Access-Control-Allow-Origin' '*' always;
        add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
        add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type' always;
        
        if ($request_method = 'OPTIONS') {
            return 204;
        }
    }
}

# Admin Portal
server {
    listen 80;
    server_name admin.trantech.co.th;
    
    # Frontend
    location / {
        proxy_pass http://admin-web:80;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
    
    # API
    location /api/ {
        proxy_pass http://admin-api:8081/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
    
    # Upload form
    location /upload-delivery.html {
        proxy_pass http://document-api:80/upload-delivery.html;
        proxy_set_header Host $host;
    }
}

# API Customer (subdomain)
server {
    listen 80;
    server_name api-customer.trantech.co.th;
    
    location / {
        proxy_pass http://customer-api:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

# Document Upload API
server {
    listen 80;
    server_name docs.trantech.co.th;
    
    location / {
        proxy_pass http://document-api:80;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        
        # File upload settings
        client_max_body_size 100M;
        proxy_read_timeout 300s;
        proxy_connect_timeout 75s;
    }
}
EOF

3. Environment Variables

cat > /opt/trantech-docker/.env << 'EOF'
# MySQL
MYSQL_ROOT_PASSWORD=your_strong_root_password
MYSQL_DATABASE=trantech
MYSQL_USER=trantech_user
MYSQL_PASSWORD=your_strong_password

# JWT
JWT_SECRET=your_very_secret_jwt_key_change_this

# Node Environment
NODE_ENV=production

# Ports
CUSTOMER_API_PORT=3001
CUSTOMER_WEB_PORT=3002
ADMIN_API_PORT=8081
ADMIN_WEB_PORT=3003
DOCUMENT_API_PORT=3004
NGINX_HTTP_PORT=80
NGINX_HTTPS_PORT=443
EOF

# ตั้งค่า permissions
chmod 600 /opt/trantech-docker/.env

Copy Code จาก VM

1. Copy Customer API

# สร้าง directory
mkdir -p /opt/trantech-docker/customer-api

# Copy จาก VM
scp -r 10.2.1.82:/root/trantech-customer-api/* /opt/trantech-docker/customer-api/

# Copy Dockerfile ที่สร้างไว้
cp /opt/trantech-docker/customer-api/Dockerfile /opt/trantech-docker/customer-api/

2. Copy Customer Web

mkdir -p /opt/trantech-docker/customer-web
scp -r 10.2.1.82:/root/trantech-customer-web/* /opt/trantech-docker/customer-web/

3. Copy Admin API

mkdir -p /opt/trantech-docker/admin-api
scp -r 10.2.1.83:/root/trantech-api/* /opt/trantech-docker/admin-api/

4. Copy Admin Web

mkdir -p /opt/trantech-docker/admin-web
scp -r 10.2.1.83:/root/trantech-web/* /opt/trantech-docker/admin-web/

5. Copy Document API

mkdir -p /opt/trantech-docker/document-api
scp -r 10.2.1.177:/root/trantech-document-management/* /opt/trantech-docker/document-api/

Testing

1. Build Images

cd /opt/trantech-docker

# Build แต่ละ service
docker-compose build customer-api
docker-compose build customer-web
docker-compose build admin-api
docker-compose build admin-web
docker-compose build document-api

# หรือ build ทั้งหมดพร้อมกัน
docker-compose build

2. Import Database

# Start MySQL container
docker-compose up -d mysql

# รอให้ MySQL พร้อม
sleep 30

# Import database
docker exec -i trantech-mysql mysql -uroot -pyour_root_password trantech < /backup/trantech/$(date +%Y%m%d)/trantech_db.sql

3. Start Services

# Start ทีละ service เพื่อ debug
docker-compose up -d customer-api
docker-compose logs -f customer-api

# ถ้าทำงานได้ start ต่อ
docker-compose up -d customer-web
docker-compose up -d admin-api
docker-compose up -d admin-web
docker-compose up -d document-api
docker-compose up -d nginx

4. Health Check

# เช็ค containers ทั้งหมด
docker-compose ps

# เช็ค logs
docker-compose logs --tail=50 -f

# Test endpoints
curl http://localhost:3001/  # Customer API
curl http://localhost:8081/  # Admin API
curl http://localhost:3004/  # Document API

# Test ผ่าน Nginx
curl -H "Host: customer.trantech.co.th" http://localhost/
curl -H "Host: admin.trantech.co.th" http://localhost/

5. Test Login

# Test Customer Login
curl -X POST http://localhost:3001/login \
  -H "Content-Type: application/json" \
  -d '{"username":"testtong","password":"Ridersky128"}'

# Test Admin Login
curl -X POST http://localhost:8081/login/check \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"Ridersky128"}'

Deployment

1. Stop VM Services

# หยุด PM2 บน Admin Server
ssh 10.2.1.83 "pm2 stop all"

# หยุด PM2 บน Customer Server
ssh 10.2.1.82 "pm2 stop all"

# หยุด Document Server
ssh 10.2.1.177 "pm2 stop all"

2. Update DNS/Load Balancer

# อัพเดท DNS records ชี้ไปที่ Docker host
# หรือ configure load balancer

# ตัวอย่าง: ถ้าใช้ /etc/hosts
cat >> /etc/hosts << EOF
<docker-host-ip> customer.trantech.co.th
<docker-host-ip> admin.trantech.co.th
<docker-host-ip> api-customer.trantech.co.th
<docker-host-ip> docs.trantech.co.th
EOF

3. Start Production

cd /opt/trantech-docker

# Start ทุก services
docker-compose up -d

# Monitor logs
docker-compose logs -f

4. Verify Production

# เช็ค health
docker-compose ps

# Test URLs
curl https://customer.trantech.co.th/
curl https://admin.trantech.co.th/
curl https://api-customer.trantech.co.th/

# Monitor
watch docker stats

Monitoring

1. Container Monitoring

# ดู container stats
docker stats

# ดู logs แบบ real-time
docker-compose logs -f --tail=100

# ดู logs ของ service เฉพาะ
docker-compose logs -f customer-api

# ดู resource usage
docker system df

2. Setup Log Rotation

cat > /etc/docker/daemon.json << 'EOF'
{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "10m",
    "max-file": "3"
  }
}
EOF

# Restart Docker
systemctl restart docker

3. Backup Script

cat > /opt/trantech-docker/backup.sh << 'EOF'
#!/bin/bash

BACKUP_DIR="/backup/docker/$(date +%Y%m%d_%H%M%S)"
mkdir -p $BACKUP_DIR

# Backup database
docker exec trantech-mysql mysqldump -uroot -pyour_root_password trantech > $BACKUP_DIR/database.sql

# Backup volumes
docker run --rm -v trantech-docker_mysql-data:/data -v $BACKUP_DIR:/backup alpine tar czf /backup/mysql-data.tar.gz -C /data .
docker run --rm -v trantech-docker_customer-uploads:/data -v $BACKUP_DIR:/backup alpine tar czf /backup/customer-uploads.tar.gz -C /data .
docker run --rm -v trantech-docker_document-uploads:/data -v $BACKUP_DIR:/backup alpine tar czf /backup/document-uploads.tar.gz -C /data .

# Backup docker-compose config
cp /opt/trantech-docker/docker-compose.yml $BACKUP_DIR/
cp /opt/trantech-docker/.env $BACKUP_DIR/

echo "Backup completed: $BACKUP_DIR"
EOF

chmod +x /opt/trantech-docker/backup.sh

# เพิ่ม cronjob
crontab -e
# เพิ่มบรรทัด: 0 2 * * * /opt/trantech-docker/backup.sh

Rollback Plan

1. Quick Rollback Script

cat > /opt/trantech-docker/rollback.sh << 'EOF'
#!/bin/bash

echo "Starting rollback..."

# Stop Docker containers
cd /opt/trantech-docker
docker-compose down

# Start VM services
ssh 10.2.1.83 "pm2 start all"
ssh 10.2.1.82 "pm2 start all"
ssh 10.2.1.177 "pm2 start all"

# Restore DNS (if needed)
# ... add your DNS restore commands

echo "Rollback completed!"
EOF

chmod +x /opt/trantech-docker/rollback.sh

2. Restore from Backup

# Restore database
docker exec -i trantech-mysql mysql -uroot -pyour_root_password trantech < /backup/docker/20241231/database.sql

# Restore volumes
docker run --rm -v trantech-docker_mysql-data:/data -v /backup/docker/20241231:/backup alpine tar xzf /backup/mysql-data.tar.gz -C /data

Maintenance Commands

Update Containers

# Pull latest images
docker-compose pull

# Rebuild and restart
docker-compose up -d --build

# Remove old images
docker image prune -a

Scale Services

# Scale customer API to 3 instances
docker-compose up -d --scale customer-api=3

# Update nginx to load balance

Cleanup

# Stop all containers
docker-compose down

# Remove everything including volumes
docker-compose down -v

# Clean up system
docker system prune -a --volumes

Troubleshooting

Container ไม่ขึ้น

# ดู logs
docker-compose logs service-name

# เข้าไปใน container
docker exec -it container-name sh

# เช็ค network
docker network ls
docker network inspect trantech-docker_trantech-network

Database Connection Error

# เช็คว่า MySQL พร้อมหรือยัง
docker exec trantech-mysql mysqladmin ping -h localhost

# เช็ค connection จาก API container
docker exec customer-api ping mysql

Port Conflict

# เช็คว่า port ถูกใช้อยู่หรือไม่
netstat -tulpn | grep :80
netstat -tulpn | grep :3306

# แก้ไขใน docker-compose.yml

Performance Tuning

1. Nginx Optimization

# เพิ่มใน nginx.conf
worker_processes auto;
worker_connections 1024;

# Enable caching
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=my_cache:10m;

2. MySQL Optimization

# เพิ่มใน docker-compose.yml mysql service
command:
  - --max-connections=200
  - --innodb-buffer-pool-size=512M
  - --query-cache-size=32M

3. Node.js Optimization

# เพิ่ม environment variables
NODE_OPTIONS=--max-old-space-size=2048
UV_THREADPOOL_SIZE=128

Security Checklist

  • เปลี่ยน default passwords ทั้งหมด
  • ตั้งค่า firewall
  • Enable SSL/TLS certificates
  • Limit container resources
  • Enable Docker security scanning
  • Regular security updates
  • Backup encryption
  • Access control lists

Support & Resources


หมายเหตุ:

  • ทดสอบบน staging environment ก่อน deploy production
  • สร้าง backup ก่อนทำการ migration ทุกครั้ง
  • เตรียม rollback plan ไว้พร้อมใช้งาน
  • Monitor system อย่างใกล้ชิดหลัง deployment

สร้างโดย: Poramet Ek-un
วันที่: 31 ธันวาคม 2025
เวอร์ชัน: 1.0

Would you like to contribute this guide?

  • Yes

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions